Compare commits

...

10 Commits

Author SHA1 Message Date
9393968116 fix favicon ico 2026-01-17 15:09:26 +09:00
19a2341ddc fix syu.is avatar cid 2026-01-17 13:17:08 +09:00
ff11b38c52 add favicon ico 2026-01-16 22:27:25 +09:00
5b9ef2eae1 add @user 2026-01-16 18:47:00 +09:00
40815a3b60 refact update 2026-01-16 14:10:18 +09:00
2ec33ef4ed fix readme 2026-01-16 13:01:24 +09:00
b8922f38be add translate 2026-01-16 12:55:57 +09:00
2533720014 fix favicon 2026-01-16 11:32:56 +09:00
5d12d8b0dc config color 2026-01-16 01:53:32 +09:00
1728f8dd35 add link 2026-01-16 01:42:31 +09:00
47 changed files with 1912 additions and 181 deletions

3
.env.example Normal file
View File

@@ -0,0 +1,3 @@
AILOG_DIR=~/ai/log
TRANSLATE_URL=http://127.0.0.1:1234/v1
TRANSLATE_MODEL=plamo-2-translate

7
.gitignore vendored
View File

@@ -1,5 +1,8 @@
dist dist
.claude repos
node_modules node_modules
package-lock.json package-lock.json
repos CLAUDE.md
.claude
.env
/rust/target

BIN
ailog.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View File

@@ -1,7 +1,13 @@
{ {
"uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s", "uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s",
"cid": "bafyreidymanu2xk4ftmvfdna3j7ixyijc37s6h3aytstuqgzatgjl4tp7e", "cid": "bafyreielgn743kg5xotfj5x53edl25vkbbd2d6v7s3tydyyjsvczcluyme",
"title": "ailogを作り直した", "title": "ailogを作り直した",
"content": "## ailogとは\n\natprotoと連携するサイトジェネレータ。\n\n## ailogの使い方\n\n```sh\n$ git clone https://git.syui.ai/ai/log\n$ cd log\n$ cat public/config.json\n{\n \"title\": \"syui.ai\",\n \"handle\": \"syui.syui.ai\",\n \"collection\": \"ai.syui.log.post\",\n \"network\": \"syu.is\",\n \"color\": \"#0066cc\",\n \"siteUrl\": \"https://syui.ai\"\n}\n---\n$ npm run dev\n```\n\n## ailogのコンセプト\n\n1. at-browserを基本にする\n2. atproto oauthでログインする\n3. ログインしたアカウントで記事をポストする\n\n## ailogの追加機能\n\n1. atproto recordからjsonをdownloadすると表示速度が上がる(ただし更新はlocalから)\n2. コメントはurlの言及を検索して表示\n\n```sh\n$ npm run fetch\n$ npm run generate\n```", "content": "## ailogとは\n\natprotoと連携するサイトジェネレータ。\n\n## ailogの使い方\n\n```sh\n$ git clone https://git.syui.ai/ai/log\n$ cd log\n$ cat public/config.json\n{\n \"title\": \"syui.ai\",\n \"handle\": \"syui.syui.ai\",\n \"collection\": \"ai.syui.log.post\",\n \"network\": \"syu.is\",\n \"color\": \"#0066cc\",\n \"siteUrl\": \"https://syui.ai\"\n}\n---\n$ npm run dev\n```\n\n## ailogのコンセプト\n\n1. at-browserを基本にする\n2. atproto oauthでログインする\n3. ログインしたアカウントで記事をポストする\n\n## ailogの追加機能\n\n1. atproto recordからjsonをdownloadすると表示速度が上がる(ただし更新はlocalから)\n2. コメントはurlの言及を検索して表示\n\n```sh\n$ npm run fetch\n$ npm run generate\n```",
"createdAt": "2026-01-15T13:59:52.367Z" "createdAt": "2026-01-15T13:59:52.367Z",
"translations": {
"en": {
"title": "recreated ailog",
"content": "## What is ailog?\n\nA site generator that integrates with the atproto framework.\n\n## How to Use ailog\n\n```sh\n$ git clone https://git.syui.ai/ai/log\n$ cd log\n$ cat public/config.json\n{\n \"title\": \"syui.ai\",\n \"handle\": \"syui.syui.ai\",\n \"collection\": \"ai.syui.log.post\",\n \"network\": \"syu.is\",\n \"color\": \"#0066cc\",\n \"siteUrl\": \"https://syui.ai\"\n}\n---\n$ npm run dev\n```\n\n## ailog's Concept\n\n1. Based on at-browser as its foundation\n2. Authentication via atproto oAuth\n3. Post articles using the logged-in account\n\n## Additional Features of ailog\n\n1. Downloading JSON from atproto record improves display speed (though updates still come from local storage)\n2. Comments are displayed by searching for URL mentions\n\n```sh\n$ npm run fetch\n$ npm run generate\n```"
}
}
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1005 B

View File

@@ -17,5 +17,5 @@
"indexedAt": "2025-09-19T06:17:42.000Z", "indexedAt": "2025-09-19T06:17:42.000Z",
"followersCount": 1, "followersCount": 1,
"followsCount": 1, "followsCount": 1,
"postsCount": 74 "postsCount": 77
} }

View File

@@ -4,7 +4,8 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>syui.ai</title> <title>syui.ai</title>
<link rel="icon" href="/favicon.png" type="image/png"> <link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/pkg/icomoon/style.css">
<link rel="stylesheet" href="/src/styles/main.css"> <link rel="stylesheet" href="/src/styles/main.css">
</head> </head>
<body> <body>

View File

@@ -20,13 +20,47 @@
"type": "string", "type": "string",
"maxLength": 1000000, "maxLength": 1000000,
"maxGraphemes": 100000, "maxGraphemes": 100000,
"description": "The content of the post." "description": "The content of the post (markdown)."
}, },
"createdAt": { "createdAt": {
"type": "string", "type": "string",
"format": "datetime", "format": "datetime",
"description": "Client-declared timestamp when this post was originally created." "description": "Client-declared timestamp when this post was originally created."
} },
"lang": {
"type": "string",
"maxLength": 10,
"description": "Language code of the original content (e.g., 'ja', 'en')."
},
"translations": {
"type": "ref",
"ref": "#translationMap",
"description": "Translations of the post in other languages."
}
}
}
},
"translationMap": {
"type": "object",
"description": "Map of language codes to translations.",
"properties": {
"en": { "type": "ref", "ref": "#translation" },
"ja": { "type": "ref", "ref": "#translation" }
}
},
"translation": {
"type": "object",
"description": "A translation of a post.",
"properties": {
"title": {
"type": "string",
"maxLength": 3000,
"maxGraphemes": 300
},
"content": {
"type": "string",
"maxLength": 1000000,
"maxGraphemes": 100000
} }
} }
} }

View File

@@ -0,0 +1,68 @@
{
"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 (markdown)."
},
"createdAt": {
"type": "string",
"format": "datetime",
"description": "Client-declared timestamp when this post was originally created."
},
"lang": {
"type": "string",
"maxLength": 10,
"description": "Language code of the original content (e.g., 'ja', 'en')."
},
"translations": {
"type": "ref",
"ref": "#translationMap",
"description": "Translations of the post in other languages."
}
}
}
},
"translationMap": {
"type": "object",
"description": "Map of language codes to translations.",
"properties": {
"en": { "type": "ref", "ref": "#translation" },
"ja": { "type": "ref", "ref": "#translation" }
}
},
"translation": {
"type": "object",
"description": "A translation of a post.",
"properties": {
"title": {
"type": "string",
"maxLength": 3000,
"maxGraphemes": 300
},
"content": {
"type": "string",
"maxLength": 1000000,
"maxGraphemes": 100000
}
}
}
}
}

View File

@@ -1 +0,0 @@
/* /index.html 200

View File

@@ -3,6 +3,6 @@
"handle": "syui.syui.ai", "handle": "syui.syui.ai",
"collection": "ai.syui.log.post", "collection": "ai.syui.log.post",
"network": "syu.is", "network": "syu.is",
"color": "#0066cc", "color": "#EF454A",
"siteUrl": "https://syui.ai" "siteUrl": "https://syui.ai"
} }

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

View File

@@ -1,22 +1,67 @@
<svg width="77pt" height="77pt" viewBox="0 0 512 512" class="likeButton" > <?xml version="1.0" standalone="no"?>
<circle class="explosion" r="150" cx="250" cy="250"></circle> <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
<g class="particleLayer"> "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<circle fill="#ef454aba" cx="130" cy="126.5" r="12.5"/> <svg version="1.0" xmlns="http://www.w3.org/2000/svg"
<circle fill="#ef454acc" cx="411" cy="313.5" r="12.5"/> width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
<circle fill="#ef454aba" cx="279" cy="86.5" r="12.5"/> preserveAspectRatio="xMidYMid meet">
<circle fill="#ef454aba" cx="155" cy="390.5" r="12.5"/> <metadata>
<circle fill="#ef454aba" cx="89" cy="292.5" r="10.5"/> syui
<circle fill="#ef454aba" cx="414" cy="282.5" r="10.5"/> </metadata>
<circle fill="#ef454a91" cx="115" cy="149.5" r="10.5"/>
<circle fill="#ef454aba" cx="250" cy="80.5" r="10.5"/> <g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
<circle fill="#ef454aba" cx="78" cy="261.5" r="10.5"/> fill="#000000" stroke="none">
<circle fill="#ef454a91" cx="182" cy="402.5" r="10.5"/> <path d="M3660 4460 c-11 -11 -33 -47 -48 -80 l-29 -60 -12 38 c-27 88 -58 92
<circle fill="#ef454aba" cx="401.5" cy="166" r="13"/> -98 11 -35 -70 -73 -159 -73 -169 0 -6 -5 -10 -10 -10 -6 0 -15 -10 -21 -22
<circle fill="#ef454aba" cx="379" cy="141.5" r="10.5"/> -33 -73 -52 -92 -47 -48 2 26 -1 35 -14 38 -16 3 -168 -121 -168 -138 0 -5
<circle fill="#ef454a91" cx="327" cy="397.5" r="10.5"/> -13 -16 -28 -24 -24 -13 -35 -12 -87 0 -221 55 -231 56 -480 56 -219 1 -247
<circle fill="#ef454aba" cx="296" cy="392.5" r="10.5"/> -1 -320 -22 -44 -12 -96 -26 -115 -30 -57 -13 -122 -39 -200 -82 -8 -4 -31
</g> -14 -50 -23 -41 -17 -34 -13 -146 -90 -87 -59 -292 -252 -351 -330 -63 -83
<g transform="translate(0,512) scale(0.1,-0.1)" fill="#000000" class="icon_syui"> -143 -209 -143 -225 0 -10 -7 -23 -15 -30 -8 -7 -15 -17 -15 -22 0 -5 -13 -37
<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"/> -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" fill="#EF454A"/>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

9
public/links.json Normal file
View File

@@ -0,0 +1,9 @@
{
"links": [
{
"name": "git",
"url": "https://git.syui.ai/ai/log",
"icon": "ai"
}
]
}

View File

@@ -1,10 +1,12 @@
{ {
"bsky.social": { "bsky.social": {
"plc": "https://plc.directory", "plc": "https://plc.directory",
"bsky": "https://public.api.bsky.app" "bsky": "https://public.api.bsky.app",
"web": "https://bsky.app"
}, },
"syu.is": { "syu.is": {
"plc": "https://plc.syu.is", "plc": "https://plc.syu.is",
"bsky": "https://bsky.syu.is" "bsky": "https://bsky.syu.is",
"web": "https://syu.is"
} }
} }

Binary file not shown.

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Binary file not shown.

View File

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

View File

@@ -33,3 +33,20 @@ content/
└── 3mch5zca4nj2h.json └── 3mch5zca4nj2h.json
``` ```
## translate
```sh
$ cd rust/
$ cargo build --release
$ ./target/release/ailog
$ cat .env.example
AILOG_DIR=~/ai/log
TRANSLATE_URL=http://127.0.0.1:1234/v1
TRANSLATE_MODEL=plamo-2-translate
```
```sh
$ ailog l syui.ai -p ${PASSWORD} -s bsky.social
$ ailog t ./content/did:plc:uqzpqmrjnptsxezjx4xuh2mn/ai.syui.log.post -f ja -l en
$ ailog s ./content/did:plc:uqzpqmrjnptsxezjx4xuh2mn/ai.syui.log.post/3mch5zca4nj2h.json -c ai.syui.log.post
```

13
rust/Cargo.toml Normal file
View File

@@ -0,0 +1,13 @@
[package]
name = "ailog"
version = "0.1.0"
edition = "2021"
[dependencies]
clap = { version = "4", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.11", features = ["json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
dotenvy = "0.15"
dirs = "5"

44
rust/src/auth.rs Normal file
View File

@@ -0,0 +1,44 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Serialize, Deserialize)]
pub struct AuthConfig {
pub handle: String,
pub did: String,
pub access_jwt: String,
pub refresh_jwt: String,
pub pds: String,
}
pub fn config_dir() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("syui")
.join("ai")
.join("log")
}
pub fn config_path() -> PathBuf {
config_dir().join("config.json")
}
pub fn load_config() -> Option<AuthConfig> {
let path = config_path();
if path.exists() {
let content = fs::read_to_string(&path).ok()?;
serde_json::from_str(&content).ok()
} else {
None
}
}
pub fn save_config(config: &AuthConfig) -> Result<(), Box<dyn std::error::Error>> {
let dir = config_dir();
fs::create_dir_all(&dir)?;
let path = config_path();
let content = serde_json::to_string_pretty(config)?;
fs::write(&path, content)?;
println!("Config saved to: {}", path.display());
Ok(())
}

View File

@@ -0,0 +1,66 @@
use crate::auth::{save_config, AuthConfig};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize)]
struct CreateSessionRequest {
identifier: String,
password: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CreateSessionResponse {
did: String,
handle: String,
access_jwt: String,
refresh_jwt: String,
}
pub async fn run(handle: &str, password: &str, server: &str) -> Result<(), Box<dyn std::error::Error>> {
// Add https:// if no protocol specified
let server = if server.starts_with("http://") || server.starts_with("https://") {
server.to_string()
} else {
format!("https://{}", server)
};
println!("Logging in as {} to {}", handle, server);
let client = reqwest::Client::new();
let url = format!("{}/xrpc/com.atproto.server.createSession", server);
let req = CreateSessionRequest {
identifier: handle.to_string(),
password: password.to_string(),
};
let res = client
.post(&url)
.json(&req)
.send()
.await?;
if !res.status().is_success() {
let status = res.status();
let body = res.text().await?;
return Err(format!("Login failed ({}): {}", status, body).into());
}
let session: CreateSessionResponse = res.json().await?;
let config = AuthConfig {
handle: session.handle.clone(),
did: session.did.clone(),
access_jwt: session.access_jwt,
refresh_jwt: session.refresh_jwt,
pds: server.to_string(),
};
save_config(&config)?;
println!("Logged in successfully!");
println!("DID: {}", session.did);
println!("Handle: {}", session.handle);
Ok(())
}

4
rust/src/commands/mod.rs Normal file
View File

@@ -0,0 +1,4 @@
pub mod login;
pub mod refresh;
pub mod sync;
pub mod translate;

View File

@@ -0,0 +1,75 @@
use crate::auth::{load_config, save_config, AuthConfig};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RefreshSessionResponse {
did: String,
handle: String,
access_jwt: String,
refresh_jwt: String,
}
pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
let config = load_config().ok_or("Not logged in. Run 'ailog l' first.")?;
println!("Refreshing session for {}", config.handle);
let client = reqwest::Client::new();
let url = format!("{}/xrpc/com.atproto.server.refreshSession", config.pds);
let res = client
.post(&url)
.header("Authorization", format!("Bearer {}", config.refresh_jwt))
.send()
.await?;
if !res.status().is_success() {
let status = res.status();
let body = res.text().await?;
return Err(format!("Refresh failed ({}): {}", status, body).into());
}
let session: RefreshSessionResponse = res.json().await?;
let new_config = AuthConfig {
handle: session.handle.clone(),
did: session.did.clone(),
access_jwt: session.access_jwt,
refresh_jwt: session.refresh_jwt,
pds: config.pds,
};
save_config(&new_config)?;
println!("Session refreshed successfully!");
println!("DID: {}", session.did);
Ok(())
}
/// Refresh token if needed and return valid access token
pub async fn get_valid_token() -> Result<String, Box<dyn std::error::Error>> {
let config = load_config().ok_or("Not logged in. Run 'ailog l' first.")?;
// Try to use current token, if it fails, refresh
let client = reqwest::Client::new();
let test_url = format!("{}/xrpc/com.atproto.server.getSession", config.pds);
let res = client
.get(&test_url)
.header("Authorization", format!("Bearer {}", config.access_jwt))
.send()
.await?;
if res.status().is_success() {
return Ok(config.access_jwt);
}
// Token expired, refresh it
println!("Token expired, refreshing...");
run().await?;
let new_config = load_config().ok_or("Failed to load refreshed config")?;
Ok(new_config.access_jwt)
}

117
rust/src/commands/sync.rs Normal file
View File

@@ -0,0 +1,117 @@
use crate::auth::load_config;
use crate::commands::refresh::get_valid_token;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct PutRecordRequest {
repo: String,
collection: String,
rkey: String,
record: serde_json::Value,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct PutRecordResponse {
uri: String,
cid: String,
}
pub async fn run(input: &Path, collection: &str) -> Result<(), Box<dyn std::error::Error>> {
let config = load_config().ok_or("Not logged in. Run 'ailog l' first.")?;
if input.is_dir() {
sync_folder(input, collection).await
} else {
sync_file(input, collection, &config.did, &config.pds).await
}
}
async fn sync_file(
input: &Path,
collection: &str,
did: &str,
pds: &str,
) -> Result<(), Box<dyn std::error::Error>> {
// Extract rkey from filename (e.g., "3abc123.json" -> "3abc123")
let rkey = input
.file_stem()
.and_then(|s| s.to_str())
.ok_or("Invalid filename")?;
println!("Syncing {} -> {}/{}", input.display(), collection, rkey);
// Read and parse JSON
let content = fs::read_to_string(input)?;
let record: serde_json::Value = serde_json::from_str(&content)?;
// Get valid token (auto-refresh if needed)
let token = get_valid_token().await?;
let client = reqwest::Client::new();
let url = format!("{}/xrpc/com.atproto.repo.putRecord", pds);
let req = PutRecordRequest {
repo: did.to_string(),
collection: collection.to_string(),
rkey: rkey.to_string(),
record,
};
let res = client
.post(&url)
.header("Authorization", format!("Bearer {}", token))
.json(&req)
.send()
.await?;
if !res.status().is_success() {
let status = res.status();
let body = res.text().await?;
return Err(format!("Put failed ({}): {}", status, body).into());
}
let result: PutRecordResponse = res.json().await?;
println!(" OK: {}", result.uri);
Ok(())
}
async fn sync_folder(dir: &Path, collection: &str) -> Result<(), Box<dyn std::error::Error>> {
let config = load_config().ok_or("Not logged in. Run 'ailog l' first.")?;
let mut files: Vec<_> = fs::read_dir(dir)?
.filter_map(|e| e.ok())
.filter(|e| {
e.path()
.extension()
.map(|ext| ext == "json")
.unwrap_or(false)
})
.collect();
files.sort_by_key(|e| e.path());
println!("Syncing {} files from {}", files.len(), dir.display());
let mut success = 0;
let mut failed = 0;
for entry in files {
let path = entry.path();
match sync_file(&path, collection, &config.did, &config.pds).await {
Ok(_) => success += 1,
Err(e) => {
eprintln!(" ERROR {}: {}", path.display(), e);
failed += 1;
}
}
}
println!("\nDone: {} success, {} failed", success, failed);
Ok(())
}

View File

@@ -0,0 +1,211 @@
use serde::{Deserialize, Serialize};
use std::env;
use std::fs;
use std::path::Path;
#[derive(Debug, Serialize)]
struct ChatMessage {
role: String,
content: String,
}
#[derive(Debug, Serialize)]
struct ChatRequest {
model: String,
messages: Vec<ChatMessage>,
}
#[derive(Debug, Deserialize)]
struct ChatChoice {
message: ChatMessageResponse,
}
#[derive(Debug, Deserialize)]
struct ChatMessageResponse {
content: String,
}
#[derive(Debug, Deserialize)]
struct ChatResponse {
choices: Vec<ChatChoice>,
}
pub async fn run(input: &Path, from: &str, to: &str) -> Result<(), Box<dyn std::error::Error>> {
if input.is_dir() {
translate_folder(input, from, to).await
} else {
translate_file(input, from, to).await
}
}
async fn translate_text(
client: &reqwest::Client,
url: &str,
model: &str,
text: &str,
from: &str,
to: &str,
) -> Result<String, Box<dyn std::error::Error>> {
let from_lang = lang_name(from);
let to_lang = lang_name(to);
let system_content = "<|plamo:op|>dataset\ntranslation".to_string();
let user_content = format!(
"<|plamo:op|>input lang={}\n{}\n<|plamo:op|>output lang={}",
from_lang, text, to_lang
);
let req = ChatRequest {
model: model.to_string(),
messages: vec![
ChatMessage { role: "system".to_string(), content: system_content },
ChatMessage { role: "user".to_string(), content: user_content },
],
};
let res = client
.post(url)
.json(&req)
.send()
.await?;
if !res.status().is_success() {
let status = res.status();
let body = res.text().await?;
return Err(format!("Translation failed ({}): {}", status, body).into());
}
let chat_res: ChatResponse = res.json().await?;
chat_res.choices
.first()
.map(|c| c.message.content.trim().to_string())
.ok_or_else(|| "No translation result".into())
}
async fn translate_file(input: &Path, from: &str, to: &str) -> Result<(), Box<dyn std::error::Error>> {
let translate_url = env::var("TRANSLATE_URL")
.unwrap_or_else(|_| "http://127.0.0.1:1234/v1".to_string());
let model = env::var("TRANSLATE_MODEL")
.unwrap_or_else(|_| "plamo-2-translate".to_string());
println!("Translating: {}", input.display());
// Read input JSON
let content = fs::read_to_string(input)?;
let mut record: serde_json::Value = serde_json::from_str(&content)?;
// Check if already translated
if record.get("translations")
.and_then(|t| t.get(to))
.is_some()
{
println!(" Skipped (already has {} translation)", to);
return Ok(());
}
let client = reqwest::Client::new();
let url = format!("{}/chat/completions", translate_url);
// Translate title if exists
let translated_title = if let Some(title) = record.get("title").and_then(|v| v.as_str()) {
if !title.is_empty() {
Some(translate_text(&client, &url, &model, title, from, to).await?)
} else {
None
}
} else {
None
};
// Get and translate content
let text = record.get("content")
.and_then(|v| v.as_str())
.ok_or("No 'content' field in JSON")?;
let translated_content = translate_text(&client, &url, &model, text, from, to).await?;
// Add translation to record
let translations = record
.as_object_mut()
.ok_or("Invalid JSON")?
.entry("translations")
.or_insert_with(|| serde_json::json!({}));
let mut translation_entry = serde_json::json!({
"content": translated_content
});
if let Some(title) = translated_title {
translation_entry.as_object_mut().unwrap().insert("title".to_string(), serde_json::json!(title));
}
translations
.as_object_mut()
.ok_or("Invalid translations field")?
.insert(to.to_string(), translation_entry);
// Write back
let output = serde_json::to_string_pretty(&record)?;
fs::write(input, output)?;
println!(" OK");
Ok(())
}
async fn translate_folder(dir: &Path, from: &str, to: &str) -> Result<(), Box<dyn std::error::Error>> {
let mut files: Vec<_> = fs::read_dir(dir)?
.filter_map(|e| e.ok())
.filter(|e| {
e.path()
.extension()
.map(|ext| ext == "json")
.unwrap_or(false)
})
.collect();
files.sort_by_key(|e| e.path());
println!("Translating {} files ({} -> {})", files.len(), from, to);
let mut success = 0;
let mut skipped = 0;
let mut failed = 0;
for entry in files {
let path = entry.path();
match translate_file(&path, from, to).await {
Ok(_) => {
// Check if it was actually translated or skipped
let content = fs::read_to_string(&path)?;
let record: serde_json::Value = serde_json::from_str(&content)?;
if record.get("translations").and_then(|t| t.get(to)).is_some() {
success += 1;
} else {
skipped += 1;
}
}
Err(e) => {
eprintln!(" ERROR {}: {}", path.display(), e);
failed += 1;
}
}
}
println!("\nDone: {} translated, {} skipped, {} failed", success, skipped, failed);
Ok(())
}
fn lang_name(code: &str) -> &str {
match code {
"ja" => "Japanese",
"en" => "English",
"zh" => "Chinese",
"ko" => "Korean",
"fr" => "French",
"de" => "German",
"es" => "Spanish",
_ => code,
}
}

112
rust/src/main.rs Normal file
View File

@@ -0,0 +1,112 @@
use clap::{Parser, Subcommand};
use std::path::PathBuf;
mod auth;
mod commands;
fn load_env() {
// Try AILOG_DIR env var first (may be set externally)
if let Ok(ailog_dir) = std::env::var("AILOG_DIR") {
let env_path = PathBuf::from(&ailog_dir).join(".env");
if env_path.exists() {
let _ = dotenvy::from_path(&env_path);
return;
}
}
// Try current directory
if dotenvy::dotenv().is_ok() {
return;
}
// Try executable directory
if let Ok(exe_path) = std::env::current_exe() {
if let Some(exe_dir) = exe_path.parent() {
let env_path = exe_dir.join(".env");
if env_path.exists() {
let _ = dotenvy::from_path(&env_path);
}
}
}
}
pub fn get_ailog_dir() -> PathBuf {
std::env::var("AILOG_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("."))
}
#[derive(Parser)]
#[command(name = "ailog")]
#[command(about = "AT Protocol blog tool")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Login to PDS (createSession)
#[command(name = "l")]
Login {
/// Handle (e.g., user.bsky.social)
handle: String,
/// Password
#[arg(short, long)]
password: String,
/// PDS server (e.g., bsky.social)
#[arg(short, long, default_value = "bsky.social")]
server: String,
},
/// Refresh session token
#[command(name = "r")]
Refresh,
/// Translate content (file or folder)
#[command(name = "t")]
Translate {
/// Input JSON file or folder containing *.json
input: PathBuf,
/// Source language
#[arg(short, long, default_value = "ja")]
from: String,
/// Target language
#[arg(short = 'l', long, default_value = "en")]
to: String,
},
/// Sync records to PDS (putRecord)
#[command(name = "s")]
Sync {
/// Input JSON file or folder containing *.json
input: PathBuf,
/// Collection NSID (e.g., ai.syui.log.post)
#[arg(short, long)]
collection: String,
},
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
load_env();
let cli = Cli::parse();
match cli.command {
Commands::Login { handle, password, server } => {
commands::login::run(&handle, &password, &server).await?;
}
Commands::Refresh => {
commands::refresh::run().await?;
}
Commands::Translate { input, from, to } => {
commands::translate::run(&input, &from, &to).await?;
}
Commands::Sync { input, collection } => {
commands::sync::run(&input, &collection).await?;
}
}
Ok(())
}

View File

@@ -2,7 +2,9 @@ import * as fs from 'fs'
import * as path from 'path' import * as path from 'path'
import { marked, Renderer } from 'marked' import { marked, Renderer } from 'marked'
import type { AppConfig, Profile, BlogPost, Networks } from '../src/types.ts' import type { AppConfig, Profile, BlogPost, Networks } from '../src/types.ts'
import { escapeHtml } from '../src/lib/utils.ts' import { escapeHtml, formatDate } from '../src/lib/utils.ts'
import { LANG_ICON, BUILTIN_ICONS } from '../src/lib/icons.ts'
import { MAX_SEARCH_LENGTH } from '../src/lib/constants.ts'
// Highlight.js for syntax highlighting (core + common languages only) // Highlight.js for syntax highlighting (core + common languages only)
let hljs: typeof import('highlight.js/lib/core').default let hljs: typeof import('highlight.js/lib/core').default
@@ -70,15 +72,6 @@ function setupMarked() {
}) })
} }
function formatDate(dateStr: string): string {
const date = new Date(dateStr)
return date.toLocaleDateString('ja-JP', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
}
// API functions // API functions
async function resolveHandle(handle: string, bskyUrl: string): Promise<string> { async function resolveHandle(handle: string, bskyUrl: string): Promise<string> {
const res = await fetch(`${bskyUrl}/xrpc/com.atproto.identity.resolveHandle?handle=${handle}`) const res = await fetch(`${bskyUrl}/xrpc/com.atproto.identity.resolveHandle?handle=${handle}`)
@@ -114,6 +107,7 @@ async function listRecordsFromApi(did: string, collection: string, pdsUrl: strin
title: r.value.title as string || 'Untitled', title: r.value.title as string || 'Untitled',
content: r.value.content as string || '', content: r.value.content as string || '',
createdAt: r.value.createdAt as string || new Date().toISOString(), createdAt: r.value.createdAt as string || new Date().toISOString(),
translations: r.value.translations as BlogPost['translations'] || undefined,
})).sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) })).sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
} }
@@ -205,12 +199,14 @@ function getFaviconDir(did: string): string {
async function downloadFavicon(url: string, filepath: string): Promise<boolean> { async function downloadFavicon(url: string, filepath: string): Promise<boolean> {
try { try {
const res = await fetch(url) const res = await fetch(url, { redirect: 'follow' })
if (!res.ok) return false if (!res.ok) return false
const buffer = await res.arrayBuffer() const buffer = await res.arrayBuffer()
if (buffer.byteLength === 0) return false
fs.writeFileSync(filepath, Buffer.from(buffer)) fs.writeFileSync(filepath, Buffer.from(buffer))
return true return true
} catch { } catch (err) {
console.error(`Failed to download ${url}:`, err)
return false return false
} }
} }
@@ -232,8 +228,16 @@ function getServiceDomain(collection: string): string | null {
return null return null
} }
// Common service domains to always download favicons for
const COMMON_SERVICE_DOMAINS = [
'bsky.app',
'atproto.com',
'syui.ai',
'syu.is',
]
function getServiceDomains(collections: string[]): string[] { function getServiceDomains(collections: string[]): string[] {
const domains = new Set<string>() const domains = new Set<string>(COMMON_SERVICE_DOMAINS)
for (const col of collections) { for (const col of collections) {
const domain = getServiceDomain(col) const domain = getServiceDomain(col)
if (domain) domains.add(domain) if (domain) domains.add(domain)
@@ -247,24 +251,25 @@ async function downloadFavicons(did: string, domains: string[]): Promise<void> {
fs.mkdirSync(faviconDir, { recursive: true }) fs.mkdirSync(faviconDir, { recursive: true })
} }
// Known favicon URLs (prefer official sources over Google)
// Others will use Google's favicon API as fallback
const faviconUrls: Record<string, string> = { const faviconUrls: Record<string, string> = {
'bsky.app': 'https://bsky.app/static/favicon-32x32.png', 'bsky.app': 'https://bsky.app/static/favicon-32x32.png',
'syui.ai': 'https://syui.ai/favicon.png', 'syui.ai': 'https://syui.ai/favicon.png',
} }
for (const domain of domains) { for (const domain of domains) {
const url = faviconUrls[domain]
if (!url) continue
const filepath = path.join(faviconDir, `${domain}.png`) const filepath = path.join(faviconDir, `${domain}.png`)
if (!fs.existsSync(filepath)) { if (fs.existsSync(filepath)) continue
// Try known URL first, then fallback to Google's favicon API
const url = faviconUrls[domain] || `https://www.google.com/s2/favicons?domain=${domain}&sz=32`
const ok = await downloadFavicon(url, filepath) const ok = await downloadFavicon(url, filepath)
if (ok) { if (ok) {
console.log(`Downloaded: ${domain}.png`) console.log(`Downloaded: ${domain}.png`)
} }
} }
} }
}
function getLocalFaviconPath(did: string, domain: string): string | null { function getLocalFaviconPath(did: string, domain: string): string | null {
const faviconDir = getFaviconDir(did) const faviconDir = getFaviconDir(did)
@@ -296,7 +301,8 @@ function generateHtml(title: string, content: string, config: AppConfig, assets:
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${escapeHtml(title)} - ${escapeHtml(config.title)}</title> <title>${escapeHtml(title)} - ${escapeHtml(config.title)}</title>
<link rel="icon" href="/favicon.png" type="image/png"> <link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/pkg/icomoon/style.css">
<link rel="stylesheet" href="/assets/${assets.css}"> <link rel="stylesheet" href="/assets/${assets.css}">
${config.color ? `<style>:root { --btn-color: ${config.color}; }</style>` : ''} ${config.color ? `<style>:root { --btn-color: ${config.color}; }</style>` : ''}
</head> </head>
@@ -309,16 +315,20 @@ function generateHtml(title: string, content: string, config: AppConfig, assets:
</html>` </html>`
} }
function generateProfileHtml(profile: Profile): string { function generateProfileHtml(profile: Profile, webUrl?: string): string {
const avatar = profile.avatar const avatar = profile.avatar
? `<img src="${profile.avatar}" class="profile-avatar" alt="">` ? `<img src="${profile.avatar}" class="profile-avatar" alt="">`
: '' : ''
const profileLink = webUrl ? `${webUrl}/profile/${profile.did}` : null
const handleHtml = profileLink
? `<a href="${profileLink}" class="profile-handle-link" target="_blank" rel="noopener">@${escapeHtml(profile.handle)}</a>`
: `@${escapeHtml(profile.handle)}`
return ` return `
<div class="profile"> <div class="profile">
${avatar} ${avatar}
<div class="profile-info"> <div class="profile-info">
<div class="profile-name">${escapeHtml(profile.displayName || profile.handle)}</div> <div class="profile-name">${escapeHtml(profile.displayName || profile.handle)}</div>
<div class="profile-handle">@${escapeHtml(profile.handle)}</div> <div class="profile-handle">${handleHtml}</div>
${profile.description ? `<div class="profile-desc">${escapeHtml(profile.description)}</div>` : ''} ${profile.description ? `<div class="profile-desc">${escapeHtml(profile.description)}</div>` : ''}
</div> </div>
</div> </div>
@@ -389,22 +399,66 @@ function generateTabsHtml(activeTab: 'blog' | 'browser', handle: string): string
` `
} }
function generateLangSelectorHtml(): string {
return `
<div class="lang-selector" id="lang-selector">
<button type="button" class="lang-btn" id="lang-btn" title="Language">
${LANG_ICON}
</button>
<div class="lang-dropdown" id="lang-dropdown">
<div class="lang-option" data-lang="ja">
<span class="lang-name">日本語</span>
<span class="lang-check">✓</span>
</div>
<div class="lang-option selected" data-lang="en">
<span class="lang-name">English</span>
<span class="lang-check">✓</span>
</div>
</div>
</div>
`
}
function generatePostListHtml(posts: BlogPost[]): string { function generatePostListHtml(posts: BlogPost[]): string {
if (posts.length === 0) { if (posts.length === 0) {
return '<p class="no-posts">No posts yet</p>' return '<p class="no-posts">No posts yet</p>'
} }
// Build translations data for titles
const titleTranslations: Record<string, { original: string; translated: string }> = {}
posts.forEach(post => {
const rkey = post.uri.split('/').pop()
if (rkey && post.translations?.en?.title) {
titleTranslations[rkey] = {
original: post.title,
translated: post.translations.en.title
}
}
})
const hasTranslations = Object.keys(titleTranslations).length > 0
const translationScript = hasTranslations
? `<script id="title-translations" type="application/json">${JSON.stringify(titleTranslations)}</script>`
: ''
const items = posts.map(post => { const items = posts.map(post => {
const rkey = post.uri.split('/').pop() const rkey = post.uri.split('/').pop()
// Default to English title if available
const displayTitle = post.translations?.en?.title || post.title
return ` return `
<li class="post-item"> <li class="post-item">
<a href="/post/${rkey}/" class="post-link"> <a href="/post/${rkey}/" class="post-link">
<span class="post-title">${escapeHtml(post.title)}</span> <span class="post-title" data-rkey="${rkey}">${escapeHtml(displayTitle)}</span>
<span class="post-date">${formatDate(post.createdAt)}</span> <span class="post-date">${formatDate(post.createdAt)}</span>
</a> </a>
</li> </li>
` `
}).join('') }).join('')
return `<ul class="post-list">${items}</ul>` return `
<div class="content-header">${generateLangSelectorHtml()}</div>
${translationScript}
<ul class="post-list">${items}</ul>
`
} }
// Map network to app URL for discussion links // Map network to app URL for discussion links
@@ -418,14 +472,20 @@ function getAppUrl(network: string): string {
function generatePostDetailHtml(post: BlogPost, handle: string, collection: string, network: string, siteUrl?: string): string { function generatePostDetailHtml(post: BlogPost, handle: string, collection: string, network: string, siteUrl?: string): string {
const rkey = post.uri.split('/').pop() || '' const rkey = post.uri.split('/').pop() || ''
const jsonUrl = `/at/${handle}/${collection}/${rkey}/` const jsonUrl = `/at/${handle}/${collection}/${rkey}/`
const content = marked.parse(post.content) as string const originalContent = marked.parse(post.content) as string
// Check for English translation
const hasTranslation = post.translations?.en?.content
const translatedContent = hasTranslation ? marked.parse(post.translations!.en.content) as string : ''
// Default to English if translation exists
const displayContent = hasTranslation ? translatedContent : originalContent
// Use siteUrl from config, or construct from handle // Use siteUrl from config, or construct from handle
const baseSiteUrl = siteUrl || `https://${handle}` const baseSiteUrl = siteUrl || `https://${handle}`
const postUrl = `${baseSiteUrl}/post/${rkey}/` const postUrl = `${baseSiteUrl}/post/${rkey}/`
const appUrl = getAppUrl(network) const appUrl = getAppUrl(network)
// Convert to search-friendly format (domain/post/rkey_prefix without https://) // Convert to search-friendly format (domain/post/rkey_prefix without https://)
// Keep total length around 20 chars to avoid URL truncation in posts
const MAX_SEARCH_LENGTH = 20
const urlObj = new URL(postUrl) const urlObj = new URL(postUrl)
const pathParts = urlObj.pathname.split('/').filter(Boolean) const pathParts = urlObj.pathname.split('/').filter(Boolean)
const basePath = urlObj.host + '/' + (pathParts[0] || '') + '/' const basePath = urlObj.host + '/' + (pathParts[0] || '') + '/'
@@ -434,16 +494,32 @@ function generatePostDetailHtml(post: BlogPost, handle: string, collection: stri
const searchQuery = basePath + rkeyPrefix const searchQuery = basePath + rkeyPrefix
const searchUrl = `${appUrl}/search?q=${encodeURIComponent(searchQuery)}` const searchUrl = `${appUrl}/search?q=${encodeURIComponent(searchQuery)}`
// Store translation data in script tag for JS switching
const hasTranslatedTitle = !!post.translations?.en?.title
const translationScript = hasTranslation
? `<script id="translation-data" type="application/json">${JSON.stringify({
original: originalContent,
translated: translatedContent,
originalTitle: post.title,
translatedTitle: post.translations?.en?.title || post.title
})}</script>`
: ''
// Default to English title if available
const displayTitle = post.translations?.en?.title || post.title
return ` return `
<div class="content-header">${generateLangSelectorHtml()}</div>
${translationScript}
<article class="post-detail"> <article class="post-detail">
<header class="post-header"> <header class="post-header">
<h1 class="post-title">${escapeHtml(post.title)}</h1> <h1 class="post-title" id="post-detail-title">${escapeHtml(displayTitle)}</h1>
<div class="post-meta"> <div class="post-meta">
<time class="post-date">${formatDate(post.createdAt)}</time> <time class="post-date">${formatDate(post.createdAt)}</time>
<a href="${jsonUrl}" class="json-btn">json</a> <a href="${jsonUrl}" class="json-btn">json</a>
</div> </div>
</header> </header>
<div class="post-content">${content}</div> <div class="post-content">${displayContent}</div>
</article> </article>
<div class="discussion-section"> <div class="discussion-section">
<a href="${searchUrl}" target="_blank" rel="noopener" class="discuss-link"> <a href="${searchUrl}" target="_blank" rel="noopener" class="discuss-link">
@@ -457,46 +533,90 @@ function generatePostDetailHtml(post: BlogPost, handle: string, collection: stri
` `
} }
function generateFooterHtml(handle: string): string { interface FooterLink {
name: string
url: string
icon?: string
}
function loadLinks(): FooterLink[] {
const linksPath = path.join(process.cwd(), 'public/links.json')
if (!fs.existsSync(linksPath)) return []
try {
const data = JSON.parse(fs.readFileSync(linksPath, 'utf-8'))
return data.links || []
} catch {
return []
}
}
function generateFooterLinksHtml(links: FooterLink[]): string {
if (links.length === 0) return ''
const items = links.map(link => {
let iconHtml = ''
if (link.icon && BUILTIN_ICONS[link.icon]) {
iconHtml = BUILTIN_ICONS[link.icon]
} else {
// Fallback to favicon
const domain = new URL(link.url).hostname
iconHtml = `<img src="https://www.google.com/s2/favicons?domain=${domain}&sz=32" alt="" class="footer-link-favicon">`
}
return `
<a href="${escapeHtml(link.url)}" class="footer-link-item" title="${escapeHtml(link.name)}" target="_blank" rel="noopener">
${iconHtml}
</a>
`
}).join('')
return `
<div class="footer-links">
${items}
</div>
`
}
function generateFooterHtml(handle: string, links: FooterLink[]): string {
const username = handle.split('.')[0] || handle const username = handle.split('.')[0] || handle
return ` return `
${generateFooterLinksHtml(links)}
<footer class="site-footer"> <footer class="site-footer">
<p>&copy; ${username}</p> <p>&copy; ${username}</p>
</footer> </footer>
` `
} }
function generateIndexPageContent(profile: Profile, posts: BlogPost[], config: AppConfig, collections: string[]): string { function generateIndexPageContent(profile: Profile, posts: BlogPost[], config: AppConfig, collections: string[], links: FooterLink[], webUrl?: string): string {
return ` return `
<header id="header">${generateHeaderHtml(config.handle)}</header> <header id="header">${generateHeaderHtml(config.handle)}</header>
<main> <main>
<section id="profile"> <section id="profile">
${generateTabsHtml('blog', config.handle)} ${generateTabsHtml('blog', config.handle)}
${generateProfileHtml(profile)} ${generateProfileHtml(profile, webUrl)}
${generateServicesHtml(profile.did, config.handle, collections)} ${generateServicesHtml(profile.did, config.handle, collections)}
</section> </section>
<section id="content"> <section id="content">
${generatePostListHtml(posts)} ${generatePostListHtml(posts)}
</section> </section>
</main> </main>
${generateFooterHtml(config.handle)} ${generateFooterHtml(config.handle, links)}
` `
} }
function generatePostPageContent(profile: Profile, post: BlogPost, config: AppConfig, collections: string[]): string { function generatePostPageContent(profile: Profile, post: BlogPost, config: AppConfig, collections: string[], links: FooterLink[], webUrl?: string): string {
return ` return `
<header id="header">${generateHeaderHtml(config.handle)}</header> <header id="header">${generateHeaderHtml(config.handle)}</header>
<main> <main>
<section id="profile"> <section id="profile">
${generateTabsHtml('blog', config.handle)} ${generateTabsHtml('blog', config.handle)}
${generateProfileHtml(profile)} ${generateProfileHtml(profile, webUrl)}
${generateServicesHtml(profile.did, config.handle, collections)} ${generateServicesHtml(profile.did, config.handle, collections)}
</section> </section>
<section id="content"> <section id="content">
${generatePostDetailHtml(post, config.handle, config.collection, config.network, config.siteUrl)} ${generatePostDetailHtml(post, config.handle, config.collection, config.network, config.siteUrl)}
</section> </section>
</main> </main>
${generateFooterHtml(config.handle)} ${generateFooterHtml(config.handle, links)}
` `
} }
@@ -588,16 +708,32 @@ async function generate() {
const localPosts = localDid ? loadPostsFromFiles(localDid, config.collection) : [] const localPosts = localDid ? loadPostsFromFiles(localDid, config.collection) : []
console.log(`Found ${localPosts.length} posts from local`) console.log(`Found ${localPosts.length} posts from local`)
// Merge: API is the source of truth // Merge: API is the source of truth for content, but local has translations
// - If post exists in API: always use API (has latest edits) // - If post exists in both: use API data but merge translations from local
// - If post exists in local only: keep if not deleted (for posts beyond API limit) // - If post exists in API only: use API
// - If post exists in local only: keep (for posts beyond API limit)
const localPostMap = new Map<string, BlogPost>()
for (const post of localPosts) {
const rkey = post.uri.split('/').pop()
if (rkey) localPostMap.set(rkey, post)
}
const apiRkeys = new Set(apiPosts.map(p => p.uri.split('/').pop())) const apiRkeys = new Set(apiPosts.map(p => p.uri.split('/').pop()))
// Merge API posts with local translations
const mergedApiPosts = apiPosts.map(apiPost => {
const rkey = apiPost.uri.split('/').pop()
const localPost = rkey ? localPostMap.get(rkey) : undefined
if (localPost?.translations && !apiPost.translations) {
return { ...apiPost, translations: localPost.translations }
}
return apiPost
})
// Local posts that don't exist in API (older posts beyond 100 limit) // Local posts that don't exist in API (older posts beyond 100 limit)
// Note: these might be deleted posts, so we keep them cautiously
const oldLocalPosts = localPosts.filter(p => !apiRkeys.has(p.uri.split('/').pop())) const oldLocalPosts = localPosts.filter(p => !apiRkeys.has(p.uri.split('/').pop()))
posts = [...apiPosts, ...oldLocalPosts].sort((a, b) => posts = [...mergedApiPosts, ...oldLocalPosts].sort((a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
) )
@@ -615,8 +751,14 @@ async function generate() {
// Load collections for services display // Load collections for services display
const collections = loadCollections(did) const collections = loadCollections(did)
// Load footer links
const links = loadLinks()
// Get web URL for profile links
const webUrl = network.web
// Generate index page // Generate index page
const indexContent = generateIndexPageContent(profile, posts, config, collections) const indexContent = generateIndexPageContent(profile, posts, config, collections, links, webUrl)
const indexHtml = generateHtml(config.title, indexContent, config, assets) const indexHtml = generateHtml(config.title, indexContent, config, assets)
fs.writeFileSync(path.join(distDir, 'index.html'), indexHtml) fs.writeFileSync(path.join(distDir, 'index.html'), indexHtml)
console.log('Generated: /index.html') console.log('Generated: /index.html')
@@ -649,7 +791,7 @@ async function generate() {
fs.mkdirSync(postDir, { recursive: true }) fs.mkdirSync(postDir, { recursive: true })
} }
const postContent = generatePostPageContent(profile, post, config, collections) const postContent = generatePostPageContent(profile, post, config, collections, links, webUrl)
const postHtml = generateHtml(post.title, postContent, config, assets) const postHtml = generateHtml(post.title, postContent, config, assets)
fs.writeFileSync(path.join(postDir, 'index.html'), postHtml) fs.writeFileSync(path.join(postDir, 'index.html'), postHtml)
console.log(`Generated: /post/${rkey}/index.html`) console.log(`Generated: /post/${rkey}/index.html`)
@@ -670,14 +812,22 @@ async function generate() {
console.log('Generated: /app.html') console.log('Generated: /app.html')
// Generate _redirects for Cloudflare Pages (SPA routes) // Generate _redirects for Cloudflare Pages (SPA routes)
const redirects = `/app / 301 // Static files (index.html, post/*/index.html) are served automatically
/oauth/* /app.html 200 // Dynamic routes are rewritten to app.html
const redirects = `/oauth/* /app.html 200
/at/* /app.html 200
/new /app.html 200
/app /app.html 200
` `
fs.writeFileSync(path.join(distDir, '_redirects'), redirects) fs.writeFileSync(path.join(distDir, '_redirects'), redirects)
console.log('Generated: /_redirects') console.log('Generated: /_redirects')
// Generate 404.html as SPA fallback for unmatched routes (like /@handle)
fs.writeFileSync(path.join(distDir, '404.html'), spaHtml)
console.log('Generated: /404.html')
// Copy static files // Copy static files
const filesToCopy = ['favicon.png', 'favicon.svg', 'config.json', 'networks.json', 'client-metadata.json'] const filesToCopy = ['favicon.ico', 'favicon.png', 'favicon.svg', 'config.json', 'networks.json', 'client-metadata.json', 'links.json']
for (const file of filesToCopy) { for (const file of filesToCopy) {
const src = path.join(process.cwd(), 'public', file) const src = path.join(process.cwd(), 'public', file)
const dest = path.join(distDir, file) const dest = path.join(distDir, file)
@@ -693,6 +843,13 @@ async function generate() {
fs.cpSync(wellKnownSrc, wellKnownDest, { recursive: true }) fs.cpSync(wellKnownSrc, wellKnownDest, { recursive: true })
} }
// Copy pkg directory (icomoon fonts, etc.)
const pkgSrc = path.join(process.cwd(), 'public/pkg')
const pkgDest = path.join(distDir, 'pkg')
if (fs.existsSync(pkgSrc)) {
fs.cpSync(pkgSrc, pkgDest, { recursive: true })
}
// Copy favicons from content // Copy favicons from content
const faviconSrc = getFaviconDir(did) const faviconSrc = getFaviconDir(did)
const faviconDest = path.join(distDir, 'favicons') const faviconDest = path.join(distDir, 'favicons')

View File

@@ -1,6 +1,17 @@
import { describeRepo, listRecordsRaw, getRecordRaw, fetchLexicon, resolveHandle, getServiceInfo, resolvePds, getPlc } from '../lib/api.js' import { describeRepo, listRecordsRaw, getRecordRaw, fetchLexicon, resolveHandle, getServiceInfo, resolvePds, getPlcForPds } from '../lib/api.js'
import { deleteRecord } from '../lib/auth.js' import { deleteRecord } from '../lib/auth.js'
import { escapeHtml } from '../lib/utils.js' import { escapeHtml } from '../lib/utils.js'
import type { Networks } from '../types.js'
// Cache networks config
let networksConfig: Networks | null = null
async function loadNetworks(): Promise<Networks> {
if (networksConfig) return networksConfig
const res = await fetch('/networks.json')
networksConfig = await res.json()
return networksConfig!
}
function extractRkey(uri: string): string { function extractRkey(uri: string): string {
const parts = uri.split('/') const parts = uri.split('/')
@@ -8,13 +19,15 @@ function extractRkey(uri: string): string {
} }
async function renderServices(did: string, handle: string): Promise<string> { async function renderServices(did: string, handle: string): Promise<string> {
const [collections, pds] = await Promise.all([ const [collections, pds, networks] = await Promise.all([
describeRepo(did), describeRepo(did),
resolvePds(did) resolvePds(did),
loadNetworks()
]) ])
// Server info section // Server info section - use PLC based on PDS
const plcUrl = `${getPlc()}/${did}/log` const plc = getPlcForPds(pds, networks)
const plcUrl = `${plc}/${did}/log`
const serverHtml = ` const serverHtml = `
<div class="server-info"> <div class="server-info">
<h3>Server</h3> <h3>Server</h3>
@@ -36,14 +49,14 @@ async function renderServices(did: string, handle: string): Promise<string> {
} }
// Group by service domain // Group by service domain
const serviceMap = new Map<string, { name: string; favicon: string; count: number }>() const serviceMap = new Map<string, { name: string; favicon: string; faviconFallback: string; count: number }>()
for (const col of collections) { for (const col of collections) {
const info = getServiceInfo(col) const info = getServiceInfo(col)
if (info) { if (info) {
const key = info.domain const key = info.domain
if (!serviceMap.has(key)) { if (!serviceMap.has(key)) {
serviceMap.set(key, { name: info.name, favicon: info.favicon, count: 0 }) serviceMap.set(key, { name: info.name, favicon: info.favicon, faviconFallback: info.faviconFallback, count: 0 })
} }
serviceMap.get(key)!.count++ serviceMap.get(key)!.count++
} }
@@ -53,7 +66,7 @@ async function renderServices(did: string, handle: string): Promise<string> {
return ` return `
<li class="service-list-item"> <li class="service-list-item">
<a href="/at/${handle}/${domain}" class="service-list-link"> <a href="/at/${handle}/${domain}" class="service-list-link">
<img src="${info.favicon}" class="service-list-favicon" alt="" onerror="this.style.display='none'"> <img src="${info.favicon}" class="service-list-favicon" alt="" onerror="this.src='${info.faviconFallback}'; this.onerror=null;">
<span class="service-list-name">${info.name}</span> <span class="service-list-name">${info.name}</span>
<span class="service-list-count">${info.count}</span> <span class="service-list-count">${info.count}</span>
</a> </a>

View File

@@ -13,6 +13,9 @@ export function renderHeader(currentHandle: string, isLoggedIn: boolean, userHan
</svg> </svg>
</button>` </button>`
// Use logged-in user's handle for input if available
const inputHandle = isLoggedIn && userHandle ? userHandle : currentHandle
return ` return `
<div class="header"> <div class="header">
<form class="header-form" id="header-form"> <form class="header-form" id="header-form">
@@ -21,7 +24,7 @@ export function renderHeader(currentHandle: string, isLoggedIn: boolean, userHan
class="header-input" class="header-input"
id="header-input" id="header-input"
placeholder="handle (e.g., syui.ai)" placeholder="handle (e.g., syui.ai)"
value="${currentHandle}" value="${inputHandle}"
> >
<button type="submit" class="header-btn at-btn" title="Browse">@</button> <button type="submit" class="header-btn at-btn" title="Browse">@</button>
${loginBtn} ${loginBtn}
@@ -86,16 +89,3 @@ export function mountHeader(
}) })
} }
} }
// Keep old function for compatibility
export function mountBrowser(
container: HTMLElement,
currentHandle: string,
onSubmit: (handle: string) => void
): void {
mountHeader(container, currentHandle, false, undefined, {
onBrowse: onSubmit,
onLogin: () => {},
onLogout: () => {}
})
}

View File

@@ -1,5 +1,6 @@
import { searchPostsForUrl } from '../lib/api.js' import { searchPostsForUrl } from '../lib/api.js'
import { escapeHtml } from '../lib/utils.js' import { escapeHtml, formatDate } from '../lib/utils.js'
import { MAX_SEARCH_LENGTH, DISCUSSION_POST_LIMIT } from '../lib/constants.js'
// Map network to app URL // Map network to app URL
export function getAppUrl(network: string): string { export function getAppUrl(network: string): string {
@@ -9,15 +10,6 @@ export function getAppUrl(network: string): string {
return 'https://bsky.app' return 'https://bsky.app'
} }
function formatDate(dateStr: string): string {
const date = new Date(dateStr)
return date.toLocaleDateString('ja-JP', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
}
function getPostUrl(uri: string, appUrl: string): string { function getPostUrl(uri: string, appUrl: string): string {
// at://did:plc:xxx/app.bsky.feed.post/rkey -> {appUrl}/profile/did:plc:xxx/post/rkey // at://did:plc:xxx/app.bsky.feed.post/rkey -> {appUrl}/profile/did:plc:xxx/post/rkey
const parts = uri.replace('at://', '').split('/') const parts = uri.replace('at://', '').split('/')
@@ -29,8 +21,6 @@ function getPostUrl(uri: string, appUrl: string): string {
export function renderDiscussionLink(postUrl: string, appUrl: string = 'https://bsky.app'): string { export function renderDiscussionLink(postUrl: string, appUrl: string = 'https://bsky.app'): string {
// Convert full URL to search-friendly format (domain/post/rkey_prefix without https://) // Convert full URL to search-friendly format (domain/post/rkey_prefix without https://)
// Keep total length around 20 chars to avoid URL truncation in posts
const MAX_SEARCH_LENGTH = 20
let searchQuery = postUrl let searchQuery = postUrl
try { try {
const urlObj = new URL(postUrl) const urlObj = new URL(postUrl)
@@ -74,7 +64,7 @@ export async function loadDiscussionPosts(container: HTMLElement, postUrl: strin
return return
} }
const postsHtml = posts.slice(0, 10).map(post => { const postsHtml = posts.slice(0, DISCUSSION_POST_LIMIT).map(post => {
const author = post.author const author = post.author
const avatar = author.avatar || '' const avatar = author.avatar || ''
const displayName = author.displayName || author.handle const displayName = author.displayName || author.handle

View File

@@ -1,19 +1,10 @@
import type { BlogPost } from '../types.js' import type { BlogPost } from '../types.js'
import { putRecord } from '../lib/auth.js' import { putRecord } from '../lib/auth.js'
import { renderMarkdown } from '../lib/markdown.js' import { renderMarkdown } from '../lib/markdown.js'
import { escapeHtml } from '../lib/utils.js' import { escapeHtml, formatDate } from '../lib/utils.js'
import { renderDiscussionLink, loadDiscussionPosts, getAppUrl } from './discussion.js' import { renderDiscussionLink, loadDiscussionPosts, getAppUrl } from './discussion.js'
function formatDate(dateStr: string): string { export function mountPostList(container: HTMLElement, posts: BlogPost[], userHandle?: string): void {
const date = new Date(dateStr)
return date.toLocaleDateString('ja-JP', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
}
export function mountPostList(container: HTMLElement, posts: BlogPost[]): void {
if (posts.length === 0) { if (posts.length === 0) {
container.innerHTML = '<p class="no-posts">No posts yet</p>' container.innerHTML = '<p class="no-posts">No posts yet</p>'
return return
@@ -21,9 +12,11 @@ export function mountPostList(container: HTMLElement, posts: BlogPost[]): void {
const html = posts.map(post => { const html = posts.map(post => {
const rkey = post.uri.split('/').pop() const rkey = post.uri.split('/').pop()
// Use /@handle/post/rkey for user pages, /post/rkey for own blog
const postUrl = userHandle ? `/@${userHandle}/post/${rkey}` : `/post/${rkey}`
return ` return `
<li class="post-item"> <li class="post-item">
<a href="/post/${rkey}" class="post-link"> <a href="${postUrl}" class="post-link">
<span class="post-title">${escapeHtml(post.title)}</span> <span class="post-title">${escapeHtml(post.title)}</span>
<span class="post-date">${formatDate(post.createdAt)}</span> <span class="post-date">${formatDate(post.createdAt)}</span>
</a> </a>

View File

@@ -1,18 +1,23 @@
import type { Profile } from '../types.js' import type { Profile } from '../types.js'
import { escapeHtml } from '../lib/utils.js'
export function renderProfile(profile: Profile): string { export function renderProfile(profile: Profile, webUrl?: string): string {
const profileLink = webUrl ? `${webUrl}/profile/${profile.did}` : null
const handleHtml = profileLink
? `<a href="${escapeHtml(profileLink)}" class="profile-handle-link" target="_blank" rel="noopener">@${escapeHtml(profile.handle)}</a>`
: `@${escapeHtml(profile.handle)}`
return ` return `
<div class="profile"> <div class="profile">
${profile.avatar ? `<img src="${profile.avatar}" alt="avatar" class="profile-avatar">` : ''} ${profile.avatar ? `<img src="${escapeHtml(profile.avatar)}" alt="avatar" class="profile-avatar">` : ''}
<div class="profile-info"> <div class="profile-info">
<h1 class="profile-name">${profile.displayName || profile.handle}</h1> <h1 class="profile-name">${escapeHtml(profile.displayName || profile.handle)}</h1>
<p class="profile-handle">@${profile.handle}</p> <p class="profile-handle">${handleHtml}</p>
${profile.description ? `<p class="profile-desc">${profile.description}</p>` : ''} ${profile.description ? `<p class="profile-desc">${escapeHtml(profile.description)}</p>` : ''}
</div> </div>
</div> </div>
` `
} }
export function mountProfile(container: HTMLElement, profile: Profile): void { export function mountProfile(container: HTMLElement, profile: Profile, webUrl?: string): void {
container.innerHTML = renderProfile(profile) container.innerHTML = renderProfile(profile, webUrl)
} }

View File

@@ -9,14 +9,14 @@ export async function renderServices(handle: string): Promise<string> {
} }
// Group by service // Group by service
const serviceMap = new Map<string, { name: string; favicon: string; collections: string[] }>() const serviceMap = new Map<string, { name: string; favicon: string; faviconFallback: string; collections: string[] }>()
for (const col of collections) { for (const col of collections) {
const info = getServiceInfo(col) const info = getServiceInfo(col)
if (info) { if (info) {
const key = info.domain const key = info.domain
if (!serviceMap.has(key)) { if (!serviceMap.has(key)) {
serviceMap.set(key, { name: info.name, favicon: info.favicon, collections: [] }) serviceMap.set(key, { name: info.name, favicon: info.favicon, faviconFallback: info.faviconFallback, collections: [] })
} }
serviceMap.get(key)!.collections.push(col) serviceMap.get(key)!.collections.push(col)
} }
@@ -27,7 +27,7 @@ export async function renderServices(handle: string): Promise<string> {
return ` return `
<a href="${url}" class="service-item" title="${info.collections.join(', ')}"> <a href="${url}" class="service-item" title="${info.collections.join(', ')}">
<img src="${info.favicon}" class="service-favicon" alt="" onerror="this.style.display='none'"> <img src="${info.favicon}" class="service-favicon" alt="" onerror="this.src='${info.faviconFallback}'; this.onerror=null;">
<span class="service-name">${info.name}</span> <span class="service-name">${info.name}</span>
</a> </a>
` `

View File

@@ -1,5 +1,6 @@
import { AtpAgent } from '@atproto/api' import { AtpAgent } from '@atproto/api'
import type { Profile, BlogPost, NetworkConfig } from '../types.js' import type { Profile, BlogPost, NetworkConfig } from '../types.js'
import { FALLBACK_PLCS, FALLBACK_BSKY_ENDPOINTS, SEARCH_TIMEOUT_MS } from './constants.js'
const agents: Map<string, AtpAgent> = new Map() const agents: Map<string, AtpAgent> = new Map()
@@ -13,6 +14,33 @@ export function getPlc(): string {
return networkConfig?.plc || 'https://plc.directory' return networkConfig?.plc || 'https://plc.directory'
} }
// Get PLC URL based on PDS endpoint
export function getPlcForPds(pds: string, networks: Record<string, { plc: string; bsky: string; web?: string }>): string {
// Check if PDS matches any network
for (const [_key, config] of Object.entries(networks)) {
// Match by domain (e.g., "https://syu.is" or "https://bsky.syu.is")
try {
const pdsHost = new URL(pds).hostname
const bskyHost = new URL(config.bsky).hostname
// Check if PDS host matches network's bsky host
if (pdsHost === bskyHost || pdsHost.endsWith('.' + bskyHost)) {
return config.plc
}
// Also check web host if available
if (config.web) {
const webHost = new URL(config.web).hostname
if (pdsHost === webHost || pdsHost.endsWith('.' + webHost)) {
return config.plc
}
}
} catch {
continue
}
}
// Default to plc.directory
return 'https://plc.directory'
}
function getBsky(): string { function getBsky(): string {
return networkConfig?.bsky || 'https://public.api.bsky.app' return networkConfig?.bsky || 'https://public.api.bsky.app'
} }
@@ -24,18 +52,6 @@ export function getAgent(service: string): AtpAgent {
return agents.get(service)! return agents.get(service)!
} }
// Fallback PLC directories
const FALLBACK_PLCS = [
'https://plc.directory',
'https://plc.syu.is',
]
// Fallback endpoints for handle/profile resolution
const FALLBACK_ENDPOINTS = [
'https://public.api.bsky.app',
'https://bsky.syu.is',
]
export async function resolvePds(did: string): Promise<string> { export async function resolvePds(did: string): Promise<string> {
// Try current PLC first, then fallbacks // Try current PLC first, then fallbacks
const plcs = [getPlc(), ...FALLBACK_PLCS.filter(p => p !== getPlc())] const plcs = [getPlc(), ...FALLBACK_PLCS.filter(p => p !== getPlc())]
@@ -64,7 +80,7 @@ export async function resolveHandle(handle: string): Promise<string> {
return res.data.did return res.data.did
} catch { } catch {
// Try fallback endpoints // Try fallback endpoints
for (const endpoint of FALLBACK_ENDPOINTS) { for (const endpoint of FALLBACK_BSKY_ENDPOINTS) {
if (endpoint === getBsky()) continue // Skip if same as current if (endpoint === getBsky()) continue // Skip if same as current
try { try {
const agent = getAgent(endpoint) const agent = getAgent(endpoint)
@@ -80,19 +96,57 @@ export async function resolveHandle(handle: string): Promise<string> {
export async function getProfile(actor: string): Promise<Profile> { export async function getProfile(actor: string): Promise<Profile> {
// Try current network first // Try current network first
const endpoints = [getBsky(), ...FALLBACK_ENDPOINTS.filter(e => e !== getBsky())] const endpoints = [getBsky(), ...FALLBACK_BSKY_ENDPOINTS.filter(e => e !== getBsky())]
for (const endpoint of endpoints) { for (const endpoint of endpoints) {
try { try {
const agent = getAgent(endpoint) const agent = getAgent(endpoint)
const res = await agent.getProfile({ actor }) const res = await agent.getProfile({ actor })
let avatar = res.data.avatar
let banner = res.data.banner
// If avatar uses cdn.bsky.app but user's PDS is not bsky, fetch from their PDS
const did = res.data.did
if (avatar && avatar.includes('cdn.bsky.app')) {
try {
const pds = await resolvePds(did)
// If PDS is not bsky.social/bsky.network, reconstruct avatar URL from blob
if (pds && !pds.includes('bsky.social') && !pds.includes('bsky.network')) {
const pdsAgent = getAgent(pds)
const profileRecord = await pdsAgent.com.atproto.repo.getRecord({
repo: did,
collection: 'app.bsky.actor.profile',
rkey: 'self',
})
const avatarBlob = (profileRecord.data.value as any)?.avatar
if (avatarBlob?.ref) {
// ref can be either { $link: "..." } or a CID object with toString()
const cid = avatarBlob.ref.$link || avatarBlob.ref.toString?.()
if (cid) {
avatar = `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`
}
}
const bannerBlob = (profileRecord.data.value as any)?.banner
if (bannerBlob?.ref) {
const cid = bannerBlob.ref.$link || bannerBlob.ref.toString?.()
if (cid) {
banner = `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`
}
}
}
} catch {
// Keep original avatar if blob fetch fails
}
}
return { return {
did: res.data.did, did: res.data.did,
handle: res.data.handle, handle: res.data.handle,
displayName: res.data.displayName, displayName: res.data.displayName,
description: res.data.description, description: res.data.description,
avatar: res.data.avatar, avatar,
banner: res.data.banner, banner,
} }
} catch { } catch {
continue continue
@@ -243,8 +297,10 @@ const SERVICE_MAP: Record<string, { domain: string; icon?: string }> = {
// Search Bluesky posts mentioning a URL // Search Bluesky posts mentioning a URL
export async function searchPostsForUrl(url: string): Promise<any[]> { export async function searchPostsForUrl(url: string): Promise<any[]> {
// Search ALL endpoints and merge results (different networks have different indexes) // Only use current network's endpoint - don't cross-search other networks
const endpoints = [getBsky(), ...FALLBACK_ENDPOINTS.filter(e => e !== getBsky())] // This avoids CORS issues with public.api.bsky.app when using different PDS
const currentBsky = getBsky()
const endpoints = [currentBsky]
// Extract search-friendly patterns from URL // Extract search-friendly patterns from URL
// e.g., "https://syui.ai/post/abc123/" -> ["syui.ai/post/abc123", "syui.ai/post"] // e.g., "https://syui.ai/post/abc123/" -> ["syui.ai/post/abc123", "syui.ai/post"]
@@ -270,9 +326,15 @@ export async function searchPostsForUrl(url: string): Promise<any[]> {
const searchPromises = endpoints.flatMap(endpoint => const searchPromises = endpoints.flatMap(endpoint =>
searchQueries.map(async query => { searchQueries.map(async query => {
try { try {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), SEARCH_TIMEOUT_MS)
const res = await fetch( const res = await fetch(
`${endpoint}/xrpc/app.bsky.feed.searchPosts?q=${encodeURIComponent(query)}&limit=20` `${endpoint}/xrpc/app.bsky.feed.searchPosts?q=${encodeURIComponent(query)}&limit=20`,
{ signal: controller.signal }
) )
clearTimeout(timeoutId)
if (!res.ok) return [] if (!res.ok) return []
const data = await res.json() const data = await res.json()
// Filter posts that actually link to the target URL // Filter posts that actually link to the target URL
@@ -282,6 +344,7 @@ export async function searchPostsForUrl(url: string): Promise<any[]> {
return embedUri === url || text.includes(url) || embedUri?.includes(url.replace(/\/$/, '')) return embedUri === url || text.includes(url) || embedUri?.includes(url.replace(/\/$/, ''))
}) })
} catch { } catch {
// Silently fail for CORS/network/timeout errors
return [] return []
} }
}) })
@@ -307,14 +370,15 @@ export async function searchPostsForUrl(url: string): Promise<any[]> {
return allPosts return allPosts
} }
export function getServiceInfo(collection: string): { name: string; domain: string; favicon: string } | null { export function getServiceInfo(collection: string): { name: string; domain: string; favicon: string; faviconFallback: string } | null {
// Try to find matching service prefix // Try to find matching service prefix
for (const [prefix, info] of Object.entries(SERVICE_MAP)) { for (const [prefix, info] of Object.entries(SERVICE_MAP)) {
if (collection.startsWith(prefix)) { if (collection.startsWith(prefix)) {
return { return {
name: info.domain, name: info.domain,
domain: info.domain, domain: info.domain,
favicon: info.icon || `https://www.google.com/s2/favicons?domain=${info.domain}&sz=32` favicon: `/favicons/${info.domain}.png`,
faviconFallback: info.icon || `https://www.google.com/s2/favicons?domain=${info.domain}&sz=32`
} }
} }
} }
@@ -326,7 +390,8 @@ export function getServiceInfo(collection: string): { name: string; domain: stri
return { return {
name: domain, name: domain,
domain: domain, domain: domain,
favicon: `https://www.google.com/s2/favicons?domain=${domain}&sz=32` favicon: `/favicons/${domain}.png`,
faviconFallback: `https://www.google.com/s2/favicons?domain=${domain}&sz=32`
} }
} }

View File

@@ -98,8 +98,12 @@ export async function restoreSession(): Promise<AuthSession | null> {
} }
} }
} catch (err) { } catch (err) {
// Silently fail for CORS/network errors - don't spam console
// Only log if it's not a network error
if (err instanceof Error && !err.message.includes('NetworkError') && !err.message.includes('CORS')) {
console.error('Session restore error:', err) console.error('Session restore error:', err)
} }
}
return null return null
} }

19
src/lib/constants.ts Normal file
View File

@@ -0,0 +1,19 @@
// API limits
export const API_RECORD_LIMIT = 100
export const POST_LIST_LIMIT = 50
export const DISCUSSION_POST_LIMIT = 10
// Search
export const MAX_SEARCH_LENGTH = 20
export const SEARCH_TIMEOUT_MS = 5000
// Fallback endpoints
export const FALLBACK_PLCS = [
'https://plc.directory',
'https://plc.syu.is',
]
export const FALLBACK_BSKY_ENDPOINTS = [
'https://public.api.bsky.app',
'https://bsky.syu.is',
]

17
src/lib/icons.ts Normal file
View File

@@ -0,0 +1,17 @@
// Shared icon definitions
export const LANG_ICON = `<svg viewBox="0 0 640 640" width="20" height="20" fill="currentColor"><path d="M192 64C209.7 64 224 78.3 224 96L224 128L352 128C369.7 128 384 142.3 384 160C384 177.7 369.7 192 352 192L342.4 192L334 215.1C317.6 260.3 292.9 301.6 261.8 337.1C276 345.9 290.8 353.7 306.2 360.6L356.6 383L418.8 243C423.9 231.4 435.4 224 448 224C460.6 224 472.1 231.4 477.2 243L605.2 531C612.4 547.2 605.1 566.1 589 573.2C572.9 580.3 553.9 573.1 546.8 557L526.8 512L369.3 512L349.3 557C342.1 573.2 323.2 580.4 307.1 573.2C291 566 283.7 547.1 290.9 531L330.7 441.5L280.3 419.1C257.3 408.9 235.3 396.7 214.5 382.7C193.2 399.9 169.9 414.9 145 427.4L110.3 444.6C94.5 452.5 75.3 446.1 67.4 430.3C59.5 414.5 65.9 395.3 81.7 387.4L116.2 370.1C132.5 361.9 148 352.4 162.6 341.8C148.8 329.1 135.8 315.4 123.7 300.9L113.6 288.7C102.3 275.1 104.1 254.9 117.7 243.6C131.3 232.3 151.5 234.1 162.8 247.7L173 259.9C184.5 273.8 197.1 286.7 210.4 298.6C237.9 268.2 259.6 232.5 273.9 193.2L274.4 192L64.1 192C46.3 192 32 177.7 32 160C32 142.3 46.3 128 64 128L160 128L160 96C160 78.3 174.3 64 192 64zM448 334.8L397.7 448L498.3 448L448 334.8z"/></svg>`
export const DISCUSS_ICON = `<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.477 2 2 6.477 2 12c0 1.89.525 3.66 1.438 5.168L2.546 20.2A1.5 1.5 0 0 0 4 22h.5l2.83-.892A9.96 9.96 0 0 0 12 22c5.523 0 10-4.477 10-10S17.523 2 12 2z"/></svg>`
export const LOGIN_ICON = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"></path><polyline points="10 17 15 12 10 7"></polyline><line x1="15" y1="12" x2="3" y2="12"></line></svg>`
export const LOGOUT_ICON = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line></svg>`
// Footer link icons
export const BUILTIN_ICONS: Record<string, string> = {
bluesky: `<svg viewBox="0 0 600 530" fill="currentColor"><path d="m135.72 44.03c66.496 49.921 138.02 151.14 164.28 205.46 26.262-54.316 97.782-155.54 164.28-205.46 47.98-36.021 125.72-63.892 125.72 24.795 0 17.712-10.155 148.79-16.111 170.07-20.703 73.984-96.144 92.854-163.25 81.433 117.3 19.964 147.14 86.092 82.697 152.22-122.39 125.59-175.91-31.511-189.63-71.766-2.514-7.3797-3.6904-10.832-3.7077-7.8964-0.0174-2.9357-1.1937 0.51669-3.7077 7.8964-13.72 40.255-67.24 197.36-189.63 71.766-64.444-66.128-34.605-132.26 82.697-152.22-67.108 11.421-142.55-7.4491-163.25-81.433-5.9562-21.282-16.111-152.36-16.111-170.07 0-88.687 77.742-60.816 125.72-24.795z"/></svg>`,
github: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>`,
ai: `<span class="icon-ai"></span>`,
git: `<span class="icon-git"></span>`,
}

View File

@@ -1,5 +1,6 @@
import { marked, Renderer } from 'marked' import { marked, Renderer } from 'marked'
import hljs from 'highlight.js/lib/core' import hljs from 'highlight.js/lib/core'
import { escapeHtml } from './utils.js'
// Import only common languages // Import only common languages
import javascript from 'highlight.js/lib/languages/javascript' import javascript from 'highlight.js/lib/languages/javascript'
@@ -53,13 +54,6 @@ renderer.code = function({ text, lang }: { text: string; lang?: string }) {
return `<pre><code class="hljs">${highlighted}</code></pre>` return `<pre><code class="hljs">${highlighted}</code></pre>`
} }
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}
marked.setOptions({ marked.setOptions({
breaks: true, breaks: true,
gfm: true, gfm: true,

View File

@@ -1,5 +1,5 @@
export interface Route { export interface Route {
type: 'blog' | 'post' | 'browser-services' | 'browser-collections' | 'browser-record' | 'new' type: 'blog' | 'post' | 'browser-services' | 'browser-collections' | 'browser-record' | 'new' | 'user-blog' | 'user-post'
handle?: string handle?: string
collection?: string collection?: string
rkey?: string rkey?: string
@@ -33,6 +33,16 @@ export function parseRoute(pathname: string): Route {
return { type: 'new' } return { type: 'new' }
} }
// /@${handle} - User blog (any user's posts)
// /@${handle}/post/${rkey} - User post detail
if (parts[0].startsWith('@')) {
const handle = parts[0].slice(1) // Remove @ prefix
if (parts[1] === 'post' && parts[2]) {
return { type: 'user-post', handle, rkey: parts[2] }
}
return { type: 'user-blog', handle }
}
// /at/${handle} - Browser services // /at/${handle} - Browser services
// /at/${handle}/${service-or-collection} - Browser collections or records // /at/${handle}/${service-or-collection} - Browser collections or records
// /at/${handle}/${collection}/${rkey} - Browser record detail // /at/${handle}/${collection}/${rkey} - Browser record detail
@@ -75,6 +85,10 @@ export function buildPath(route: Route): string {
return '/new' return '/new'
case 'post': case 'post':
return `/post/${route.rkey}` return `/post/${route.rkey}`
case 'user-blog':
return `/@${route.handle}`
case 'user-post':
return `/@${route.handle}/post/${route.rkey}`
case 'browser-services': case 'browser-services':
return `/at/${route.handle}` return `/at/${route.handle}`
case 'browser-collections': case 'browser-collections':

View File

@@ -5,3 +5,12 @@ export function escapeHtml(str: string): string {
.replace(/>/g, '&gt;') .replace(/>/g, '&gt;')
.replace(/"/g, '&quot;') .replace(/"/g, '&quot;')
} }
export function formatDate(dateStr: string): string {
const date = new Date(dateStr)
return date.toLocaleDateString('ja-JP', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
}

View File

@@ -9,12 +9,14 @@ import { mountPostForm } from './components/postform.js'
import { loadDiscussionPosts } from './components/discussion.js' import { loadDiscussionPosts } from './components/discussion.js'
import { parseRoute, type Route } from './lib/router.js' import { parseRoute, type Route } from './lib/router.js'
import { escapeHtml } from './lib/utils.js' import { escapeHtml } from './lib/utils.js'
import { LANG_ICON, BUILTIN_ICONS } from './lib/icons.js'
import type { AppConfig, Networks } from './types.js' import type { AppConfig, Networks } from './types.js'
let authSession: AuthSession | null = null let authSession: AuthSession | null = null
let config: AppConfig let config: AppConfig
let networks: Networks = {} let networks: Networks = {}
let browserNetwork: string = '' // Network for AT Browser let browserNetwork: string = '' // Network for AT Browser
let currentLang: string = 'en' // Default language for translations
// Browser state // Browser state
let browserMode = false let browserMode = false
@@ -36,16 +38,96 @@ async function loadNetworks(): Promise<Networks> {
return res.json() return res.json()
} }
interface FooterLink {
name: string
url: string
icon?: string
}
let footerLinks: FooterLink[] = []
async function loadLinks(): Promise<FooterLink[]> {
try {
const res = await fetch('/links.json')
if (!res.ok) return []
const data = await res.json()
return data.links || []
} catch {
return []
}
}
function renderFooterLinks(links: FooterLink[]): string {
if (links.length === 0) return ''
const items = links.map(link => {
let iconHtml = ''
if (link.icon && BUILTIN_ICONS[link.icon]) {
iconHtml = BUILTIN_ICONS[link.icon]
} else {
try {
const domain = new URL(link.url).hostname
iconHtml = `<img src="https://www.google.com/s2/favicons?domain=${domain}&sz=32" alt="" class="footer-link-favicon">`
} catch {
iconHtml = ''
}
}
return `
<a href="${escapeHtml(link.url)}" class="footer-link-item" title="${escapeHtml(link.name)}" target="_blank" rel="noopener">
${iconHtml}
</a>
`
}).join('')
return `<div class="footer-links">${items}</div>`
}
function renderFooter(handle: string): string { function renderFooter(handle: string): string {
const parts = handle.split('.') const parts = handle.split('.')
const username = parts[0] || handle const username = parts[0] || handle
return ` return `
${renderFooterLinks(footerLinks)}
<footer class="site-footer"> <footer class="site-footer">
<p>&copy; ${username}</p> <p>&copy; ${username}</p>
</footer> </footer>
` `
} }
// Detect network from handle domain
// e.g., syui.ai → bsky.social, syui.syui.ai → syu.is
function detectNetworkFromHandle(handle: string): string {
const parts = handle.split('.')
if (parts.length >= 2) {
// Get domain part (last 2 parts for most cases)
const domain = parts.slice(-2).join('.')
// Check if domain matches any network key
if (networks[domain]) {
return domain
}
// Check if it's a subdomain of a known network
for (const networkKey of Object.keys(networks)) {
if (handle.endsWith(`.${networkKey}`) || handle.endsWith(networkKey)) {
return networkKey
}
}
}
// Default to bsky.social
return 'bsky.social'
}
function switchNetwork(newNetwork: string): void {
if (newNetwork === browserNetwork) return
browserNetwork = newNetwork
localStorage.setItem('browserNetwork', newNetwork)
const networkConfig = networks[newNetwork]
if (networkConfig) {
setNetworkConfig(networkConfig)
setAuthNetworkConfig(networkConfig)
}
updatePdsSelector()
}
function renderPdsSelector(): string { function renderPdsSelector(): string {
const networkKeys = Object.keys(networks) const networkKeys = Object.keys(networks)
const options = networkKeys.map(key => { const options = networkKeys.map(key => {
@@ -79,10 +161,104 @@ function updatePdsSelector(): void {
}) })
} }
function renderTabs(activeTab: 'blog' | 'browser' | 'new', isLoggedIn: boolean): string { function renderLangSelector(): string {
const langs = [
{ code: 'ja', name: '日本語' },
{ code: 'en', name: 'English' },
]
const options = langs.map(lang => {
const isSelected = lang.code === currentLang
return `<div class="lang-option ${isSelected ? 'selected' : ''}" data-lang="${lang.code}">
<span class="lang-name">${lang.name}</span>
<span class="lang-check">✓</span>
</div>`
}).join('')
return `
<div class="lang-selector" id="lang-selector">
<button type="button" class="lang-btn" id="lang-btn" title="Language">
${LANG_ICON}
</button>
<div class="lang-dropdown" id="lang-dropdown">
${options}
</div>
</div>
`
}
function updateLangSelector(): void {
const dropdown = document.getElementById('lang-dropdown')
if (!dropdown) return
const options = dropdown.querySelectorAll('.lang-option')
options.forEach(opt => {
const el = opt as HTMLElement
const lang = el.dataset.lang
const isSelected = lang === currentLang
el.classList.toggle('selected', isSelected)
})
}
function applyTranslation(): void {
const contentEl = document.querySelector('.post-content')
const titleEl = document.getElementById('post-detail-title')
// Get translation data from script tag
const scriptEl = document.getElementById('translation-data')
if (!scriptEl) return
try {
const data = JSON.parse(scriptEl.textContent || '{}')
// Apply content translation
if (contentEl) {
if (currentLang === 'en' && data.translated) {
contentEl.innerHTML = data.translated
} else if (data.original) {
contentEl.innerHTML = data.original
}
}
// Apply title translation
if (titleEl) {
if (currentLang === 'en' && data.translatedTitle) {
titleEl.textContent = data.translatedTitle
} else if (data.originalTitle) {
titleEl.textContent = data.originalTitle
}
}
} catch {
// Invalid JSON, ignore
}
}
function applyTitleTranslations(): void {
// Get title translations from script tag
const scriptEl = document.getElementById('title-translations')
if (!scriptEl) return
try {
const translations = JSON.parse(scriptEl.textContent || '{}') as Record<string, { original: string; translated: string }>
// Update each post title
document.querySelectorAll('.post-title[data-rkey]').forEach(el => {
const rkey = (el as HTMLElement).dataset.rkey
if (rkey && translations[rkey]) {
const { original, translated } = translations[rkey]
el.textContent = currentLang === 'en' ? translated : original
}
})
} catch {
// Invalid JSON, ignore
}
}
function renderTabs(activeTab: 'blog' | 'browser' | 'new', isLoggedIn: boolean, handle?: string): string {
const browserHandle = handle || config.handle
let tabs = ` let tabs = `
<a href="/" class="tab ${activeTab === 'blog' ? 'active' : ''}" id="blog-tab">Blog</a> <a href="/" class="tab ${activeTab === 'blog' ? 'active' : ''}" id="blog-tab">Blog</a>
<button type="button" class="tab ${activeTab === 'browser' ? 'active' : ''}" id="browser-tab" data-handle="${config.handle}">Browser</button> <button type="button" class="tab ${activeTab === 'browser' ? 'active' : ''}" id="browser-tab" data-handle="${browserHandle}">Browser</button>
` `
if (isLoggedIn) { if (isLoggedIn) {
@@ -101,6 +277,10 @@ function openBrowser(handle: string, service: string | null = null, collection:
if (!contentEl || !tabsEl) return if (!contentEl || !tabsEl) return
// Auto-detect and switch network based on handle
const detectedNetwork = detectNetworkFromHandle(handle)
switchNetwork(detectedNetwork)
// Save current content if not already in browser mode // Save current content if not already in browser mode
if (!browserMode) { if (!browserMode) {
savedContent = { savedContent = {
@@ -415,6 +595,45 @@ function setupEventHandlers(): void {
} }
} }
// Lang button - toggle dropdown
if (target.id === 'lang-btn' || target.closest('#lang-btn')) {
e.preventDefault()
e.stopPropagation()
const dropdown = document.getElementById('lang-dropdown')
if (dropdown) {
dropdown.classList.toggle('show')
}
return
}
// Lang option selection
const langOption = target.closest('.lang-option') as HTMLElement
if (langOption) {
e.preventDefault()
const selectedLang = langOption.dataset.lang
if (selectedLang && selectedLang !== currentLang) {
currentLang = selectedLang
localStorage.setItem('preferredLang', selectedLang)
updateLangSelector()
applyTranslation()
applyTitleTranslations()
}
// Close dropdown
const dropdown = document.getElementById('lang-dropdown')
if (dropdown) {
dropdown.classList.remove('show')
}
return
}
// Close lang dropdown when clicking outside
if (!target.closest('#lang-selector')) {
const dropdown = document.getElementById('lang-dropdown')
if (dropdown) {
dropdown.classList.remove('show')
}
}
// JSON button click (on post detail page) // JSON button click (on post detail page)
const jsonBtn = target.closest('.json-btn') as HTMLAnchorElement const jsonBtn = target.closest('.json-btn') as HTMLAnchorElement
if (jsonBtn) { if (jsonBtn) {
@@ -498,8 +717,10 @@ async function render(): Promise<void> {
const handle = route.handle || config.handle const handle = route.handle || config.handle
// Skip re-rendering for static blog/post pages (but still mount header for login) // Skip re-rendering for static blog/post pages (but still mount header for login)
// Exception: if logged in on blog page, re-render to show user's blog
const isStaticRoute = route.type === 'blog' || route.type === 'post' const isStaticRoute = route.type === 'blog' || route.type === 'post'
if (isStatic && isStaticRoute) { const shouldUseStatic = isStatic && isStaticRoute && !(isLoggedIn && route.type === 'blog')
if (shouldUseStatic) {
// Only mount header for login functionality (pass isStatic=true to skip unnecessary re-render) // Only mount header for login functionality (pass isStatic=true to skip unnecessary re-render)
mountHeader(headerEl, handle, isLoggedIn, authSession?.handle, { mountHeader(headerEl, handle, isLoggedIn, authSession?.handle, {
onBrowse: (newHandle) => { onBrowse: (newHandle) => {
@@ -552,6 +773,32 @@ async function render(): Promise<void> {
// For blog top page, check for new posts from API and merge // For blog top page, check for new posts from API and merge
if (route.type === 'blog') { if (route.type === 'blog') {
refreshPostListFromAPI() refreshPostListFromAPI()
// Add lang selector above post list
const postList = contentEl?.querySelector('.post-list')
if (postList && !document.getElementById('lang-selector')) {
const contentHeader = document.createElement('div')
contentHeader.className = 'content-header'
contentHeader.innerHTML = renderLangSelector()
postList.parentNode?.insertBefore(contentHeader, postList)
}
}
// For post detail page, sync lang selector state and apply translation
if (route.type === 'post') {
// Update lang selector to match current language
updateLangSelector()
// Apply translation based on current language preference
const translationScript = document.getElementById('translation-data')
if (translationScript) {
applyTranslation()
}
}
// For blog index page, sync lang selector state and apply title translations
if (route.type === 'blog') {
updateLangSelector()
applyTitleTranslations()
} }
return // Skip content re-rendering return // Skip content re-rendering
@@ -598,10 +845,11 @@ async function render(): Promise<void> {
case 'post': case 'post':
try { try {
const profile = await getProfile(config.handle) const profile = await getProfile(config.handle)
const webUrl = networks[config.network]?.web
profileEl.innerHTML = renderTabs('blog', isLoggedIn) profileEl.innerHTML = renderTabs('blog', isLoggedIn)
const profileContentEl = document.createElement('div') const profileContentEl = document.createElement('div')
profileEl.appendChild(profileContentEl) profileEl.appendChild(profileContentEl)
mountProfile(profileContentEl, profile) mountProfile(profileContentEl, profile, webUrl)
const servicesHtml = await renderServices(config.handle) const servicesHtml = await renderServices(config.handle)
profileContentEl.insertAdjacentHTML('beforeend', servicesHtml) profileContentEl.insertAdjacentHTML('beforeend', servicesHtml)
@@ -619,20 +867,83 @@ async function render(): Promise<void> {
} }
break break
case 'blog': case 'user-blog':
default: // /@{handle} - Any user's blog
try { try {
const profile = await getProfile(config.handle) const userHandle = route.handle!
profileEl.innerHTML = renderTabs('blog', isLoggedIn) // Auto-detect and switch network based on handle
const detectedNetwork = detectNetworkFromHandle(userHandle)
switchNetwork(detectedNetwork)
const profile = await getProfile(userHandle)
const webUrl = networks[browserNetwork]?.web
profileEl.innerHTML = renderTabs('blog', isLoggedIn, userHandle)
const profileContentEl = document.createElement('div') const profileContentEl = document.createElement('div')
profileEl.appendChild(profileContentEl) profileEl.appendChild(profileContentEl)
mountProfile(profileContentEl, profile) mountProfile(profileContentEl, profile, webUrl)
const servicesHtml = await renderServices(config.handle) const servicesHtml = await renderServices(userHandle)
profileContentEl.insertAdjacentHTML('beforeend', servicesHtml) profileContentEl.insertAdjacentHTML('beforeend', servicesHtml)
const posts = await listRecords(profile.did, config.collection) const posts = await listRecords(profile.did, config.collection)
mountPostList(contentEl, posts) mountPostList(contentEl, posts, userHandle)
} catch (err) {
console.error(err)
contentEl.innerHTML = `<p class="error">Failed to load: ${err}</p>`
}
break
case 'user-post':
// /@{handle}/post/{rkey} - Any user's post detail
try {
const userHandle = route.handle!
// Auto-detect and switch network based on handle
const detectedNetwork = detectNetworkFromHandle(userHandle)
switchNetwork(detectedNetwork)
const profile = await getProfile(userHandle)
const webUrl = networks[browserNetwork]?.web
profileEl.innerHTML = renderTabs('blog', isLoggedIn, userHandle)
const profileContentEl = document.createElement('div')
profileEl.appendChild(profileContentEl)
mountProfile(profileContentEl, profile, webUrl)
const servicesHtml = await renderServices(userHandle)
profileContentEl.insertAdjacentHTML('beforeend', servicesHtml)
const post = await getRecord(profile.did, config.collection, route.rkey!)
if (post) {
const canEdit = isLoggedIn && authSession?.did === profile.did
mountPostDetail(contentEl, post, userHandle, config.collection, canEdit, undefined, browserNetwork)
} else {
contentEl.innerHTML = '<p>Post not found</p>'
}
} catch (err) {
console.error(err)
contentEl.innerHTML = `<p class="error">Failed to load: ${err}</p>`
}
break
case 'blog':
default:
try {
// If logged in, show logged-in user's blog instead of site owner's
const blogHandle = isLoggedIn ? authSession!.handle : config.handle
const detectedNetwork = isLoggedIn ? detectNetworkFromHandle(blogHandle) : config.network
if (isLoggedIn) {
switchNetwork(detectedNetwork)
}
const profile = await getProfile(blogHandle)
const webUrl = networks[detectedNetwork]?.web
profileEl.innerHTML = renderTabs('blog', isLoggedIn, blogHandle)
const profileContentEl = document.createElement('div')
profileEl.appendChild(profileContentEl)
mountProfile(profileContentEl, profile, webUrl)
const servicesHtml = await renderServices(blogHandle)
profileContentEl.insertAdjacentHTML('beforeend', servicesHtml)
const posts = await listRecords(profile.did, config.collection)
// Use handle for post links if logged in user
mountPostList(contentEl, posts, isLoggedIn ? blogHandle : undefined)
} catch (err) { } catch (err) {
console.error(err) console.error(err)
contentEl.innerHTML = `<p class="error">Failed to load: ${err}</p>` contentEl.innerHTML = `<p class="error">Failed to load: ${err}</p>`
@@ -642,9 +953,10 @@ async function render(): Promise<void> {
} }
async function init(): Promise<void> { async function init(): Promise<void> {
const [configData, networksData] = await Promise.all([loadConfig(), loadNetworks()]) const [configData, networksData, linksData] = await Promise.all([loadConfig(), loadNetworks(), loadLinks()])
config = configData config = configData
networks = networksData networks = networksData
footerLinks = linksData
// Set page title // Set page title
document.title = config.title || 'ailog' document.title = config.title || 'ailog'
@@ -657,6 +969,9 @@ async function init(): Promise<void> {
// Initialize browser network from localStorage or default to config.network // Initialize browser network from localStorage or default to config.network
browserNetwork = localStorage.getItem('browserNetwork') || config.network browserNetwork = localStorage.getItem('browserNetwork') || config.network
// Initialize language preference from localStorage (default: en)
currentLang = localStorage.getItem('preferredLang') || 'en'
// Set network config based on selected browser network // Set network config based on selected browser network
const selectedNetworkConfig = networks[browserNetwork] const selectedNetworkConfig = networks[browserNetwork]
if (selectedNetworkConfig) { if (selectedNetworkConfig) {

View File

@@ -95,7 +95,8 @@ body {
} }
.header-btn.at-btn:hover { .header-btn.at-btn:hover {
background: #0052a3; background: var(--btn-color);
filter: brightness(0.85);
} }
.header-btn.login-btn { .header-btn.login-btn {
@@ -217,6 +218,16 @@ body {
margin-bottom: 8px; margin-bottom: 8px;
} }
.profile-handle-link {
color: #666;
text-decoration: none;
}
.profile-handle-link:hover {
color: var(--btn-color);
text-decoration: underline;
}
.profile-desc { .profile-desc {
font-size: 14px; font-size: 14px;
color: #444; color: #444;
@@ -796,9 +807,56 @@ body {
} }
} }
/* Footer Links */
.footer-links {
display: flex;
justify-content: center;
gap: 20px;
margin-top: 40px;
padding: 20px 0;
}
.footer-link-item {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
color: #666;
text-decoration: none;
transition: color 0.2s;
}
.footer-link-item:hover {
color: var(--btn-color);
}
.footer-link-item svg {
width: 24px;
height: 24px;
}
.footer-link-item [class^="icon-"] {
font-size: 24px;
}
.footer-link-favicon {
width: 24px;
height: 24px;
}
@media (prefers-color-scheme: dark) {
.footer-link-item {
color: #888;
}
.footer-link-item:hover {
color: var(--btn-color);
}
}
/* Footer */ /* Footer */
.site-footer { .site-footer {
margin-top: 60px; margin-top: 20px;
padding: 20px 0; padding: 20px 0;
text-align: center; text-align: center;
font-size: 13px; font-size: 13px;
@@ -903,6 +961,125 @@ body {
color: transparent; color: transparent;
} }
/* Language Selector */
.lang-selector {
position: relative;
}
.lang-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
background: #f0f0f0;
border: 1px solid #ddd;
border-radius: 6px;
cursor: pointer;
color: #666;
font-size: 18px;
}
.lang-btn:hover {
background: #e0e0e0;
}
.lang-dropdown {
display: none;
position: absolute;
top: 100%;
right: 0;
margin-top: 4px;
background: #fff;
border: 1px solid #ddd;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
min-width: 140px;
z-index: 100;
overflow: hidden;
}
.lang-dropdown.show {
display: block;
}
.lang-option {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
cursor: pointer;
font-size: 14px;
transition: background 0.15s;
}
.lang-option:hover {
background: #f5f5f5;
}
.lang-option.selected {
background: linear-gradient(135deg, #f0f7ff 0%, #e8f4ff 100%);
}
.lang-name {
color: #333;
font-weight: 500;
}
.lang-check {
width: 18px;
height: 18px;
border-radius: 50%;
border: 2px solid #ccc;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
transition: all 0.2s;
}
.lang-option.selected .lang-check {
background: var(--btn-color);
border-color: var(--btn-color);
color: #fff;
}
.lang-option:not(.selected) .lang-check {
color: transparent;
}
/* Content Header (above post list) */
.content-header {
display: flex;
justify-content: flex-end;
align-items: center;
margin-bottom: 8px;
}
@media (prefers-color-scheme: dark) {
.lang-btn {
background: #2a2a2a;
border-color: #333;
color: #888;
}
.lang-btn:hover {
background: #333;
}
.lang-dropdown {
background: #1a1a1a;
border-color: #333;
}
.lang-option:hover {
background: #2a2a2a;
}
.lang-option.selected {
background: linear-gradient(135deg, #1a2a3a 0%, #1a3040 100%);
}
.lang-name {
color: #e0e0e0;
}
}
/* AT Browser */ /* AT Browser */
.server-info { .server-info {
padding: 16px 0; padding: 16px 0;

View File

@@ -13,11 +13,18 @@ export interface BlogPost {
title: string title: string
content: string content: string
createdAt: string createdAt: string
translations?: {
[lang: string]: {
content: string
title?: string
}
}
} }
export interface NetworkConfig { export interface NetworkConfig {
plc: string plc: string
bsky: string bsky: string
web?: string
} }
export interface AppConfig { export interface AppConfig {