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
.claude
repos
node_modules
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",
"cid": "bafyreidymanu2xk4ftmvfdna3j7ixyijc37s6h3aytstuqgzatgjl4tp7e",
"cid": "bafyreielgn743kg5xotfj5x53edl25vkbbd2d6v7s3tydyyjsvczcluyme",
"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```",
"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",
"followersCount": 1,
"followsCount": 1,
"postsCount": 74
"postsCount": 77
}

View File

@@ -4,7 +4,8 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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">
</head>
<body>

View File

@@ -20,15 +20,49 @@
"type": "string",
"maxLength": 1000000,
"maxGraphemes": 100000,
"description": "The content of the post."
"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

@@ -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",
"collection": "ai.syui.log.post",
"network": "syu.is",
"color": "#0066cc",
"color": "#EF454A",
"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" >
<circle class="explosion" r="150" cx="250" cy="250"></circle>
<g class="particleLayer">
<circle fill="#ef454aba" cx="130" cy="126.5" r="12.5"/>
<circle fill="#ef454acc" cx="411" cy="313.5" r="12.5"/>
<circle fill="#ef454aba" cx="279" cy="86.5" r="12.5"/>
<circle fill="#ef454aba" cx="155" cy="390.5" r="12.5"/>
<circle fill="#ef454aba" cx="89" cy="292.5" r="10.5"/>
<circle fill="#ef454aba" cx="414" cy="282.5" r="10.5"/>
<circle fill="#ef454a91" cx="115" cy="149.5" r="10.5"/>
<circle fill="#ef454aba" cx="250" cy="80.5" r="10.5"/>
<circle fill="#ef454aba" cx="78" cy="261.5" r="10.5"/>
<circle fill="#ef454a91" cx="182" cy="402.5" r="10.5"/>
<circle fill="#ef454aba" cx="401.5" cy="166" r="13"/>
<circle fill="#ef454aba" cx="379" cy="141.5" r="10.5"/>
<circle fill="#ef454a91" cx="327" cy="397.5" r="10.5"/>
<circle fill="#ef454aba" cx="296" cy="392.5" r="10.5"/>
</g>
<g transform="translate(0,512) scale(0.1,-0.1)" fill="#000000" class="icon_syui">
<path class="syui" d="M3660 4460 c-11 -11 -33 -47 -48 -80 l-29 -60 -12 38 c-27 88 -58 92 -98 11 -35 -70 -73 -159 -73 -169 0 -6 -5 -10 -10 -10 -6 0 -15 -10 -21 -22 -33 -73 -52 -92 -47 -48 2 26 -1 35 -14 38 -16 3 -168 -121 -168 -138 0 -5 -13 -16 -28 -24 -24 -13 -35 -12 -87 0 -221 55 -231 56 -480 56 -219 1 -247 -1 -320 -22 -44 -12 -96 -26 -115 -30 -57 -13 -122 -39 -200 -82 -8 -4 -31 -14 -50 -23 -41 -17 -34 -13 -146 -90 -87 -59 -292 -252 -351 -330 -63 -83 -143 -209 -143 -225 0 -10 -7 -23 -15 -30 -8 -7 -15 -17 -15 -22 0 -5 -13 -37 -28 -71 -16 -34 -36 -93 -45 -132 -9 -38 -24 -104 -34 -145 -13 -60 -17 -121 -17 -300 1 -224 1 -225 36 -365 24 -94 53 -175 87 -247 28 -58 51 -108 51 -112 0 -3 13 -24 28 -48 42 -63 46 -79 22 -85 -11 -3 -20 -9 -20 -14 0 -5 -4 -9 -10 -9 -5 0 -22 -11 -37 -25 -16 -13 -75 -59 -133 -100 -58 -42 -113 -82 -123 -90 -9 -8 -22 -15 -27 -15 -6 0 -10 -6 -10 -13 0 -8 -11 -20 -25 -27 -34 -18 -34 -54 0 -48 14 3 25 2 25 -1 0 -3 -43 -31 -95 -61 -52 -30 -95 -58 -95 -62 0 -5 -5 -8 -11 -8 -19 0 -84 -33 -92 -47 -4 -7 -15 -13 -22 -13 -14 0 -17 -4 -19 -32 -1 -8 15 -15 37 -18 l38 -5 -47 -48 c-56 -59 -54 -81 9 -75 30 3 45 0 54 -11 9 -13 16 -14 43 -4 29 11 30 10 18 -5 -7 -9 -19 -23 -25 -30 -7 -7 -13 -20 -13 -29 0 -12 8 -14 38 -9 20 4 57 8 82 9 25 2 54 8 66 15 18 10 23 8 32 -13 17 -38 86 -35 152 6 27 17 50 34 50 38 0 16 62 30 85 19 33 -15 72 -2 89 30 8 15 31 43 51 62 35 34 38 35 118 35 77 0 85 2 126 33 24 17 52 32 61 32 9 0 42 18 73 40 30 22 61 40 69 40 21 0 88 -26 100 -38 7 -7 17 -12 24 -12 7 0 35 -11 62 -25 66 -33 263 -84 387 -101 189 -25 372 -12 574 41 106 27 130 37 261 97 41 20 80 37 85 39 6 2 51 31 100 64 166 111 405 372 489 534 10 20 22 43 27 51 5 8 12 22 15 30 3 8 17 40 31 70 54 115 95 313 108 520 13 200 -43 480 -134 672 -28 58 -51 108 -51 112 0 3 -13 24 -29 48 -15 24 -34 60 -40 80 -19 57 3 142 50 193 10 11 22 49 28 85 6 36 16 67 21 68 18 6 31 53 25 83 -4 18 -17 33 -36 41 -16 7 -29 15 -29 18 1 10 38 50 47 50 5 0 20 11 33 25 18 19 22 31 17 61 -3 20 -14 45 -23 55 -16 18 -16 20 6 44 15 16 21 32 18 49 -3 15 1 34 8 43 32 43 7 73 -46 55 l-30 -11 0 85 c0 74 -2 84 -18 84 -21 0 -53 -33 -103 -104 l-34 -48 -5 74 c-7 102 -35 133 -80 88z m-870 -740 c36 -7 75 -14 88 -16 21 -4 23 -9 16 -37 -3 -18 -14 -43 -24 -57 -10 -14 -20 -35 -24 -46 -4 -12 -16 -32 -27 -45 -12 -13 -37 -49 -56 -79 -20 -30 -52 -73 -72 -96 -53 -60 -114 -133 -156 -189 -21 -27 -44 -54 -52 -58 -7 -4 -13 -14 -13 -22 0 -7 -18 -33 -40 -57 -22 -23 -40 -46 -40 -50 0 -5 -19 -21 -42 -38 -47 -35 -85 -38 -188 -15 -115 25 -173 20 -264 -23 -45 -22 -106 -46 -136 -56 -48 -15 -77 -25 -140 -50 -70 -28 -100 -77 -51 -84 14 -2 34 -10 45 -17 12 -7 53 -16 91 -20 90 -9 131 -22 178 -57 20 -16 52 -35 70 -43 18 -7 40 -22 49 -32 16 -18 15 -22 -24 -88 -23 -39 -47 -74 -53 -80 -7 -5 -23 -26 -36 -45 -26 -39 -92 -113 -207 -232 -4 -4 -37 -36 -73 -71 l-66 -64 -20 41 c-58 119 -105 240 -115 301 -40 244 -35 409 20 595 8 30 21 66 28 80 7 14 24 54 38 89 15 35 35 75 46 89 11 13 20 31 20 38 0 8 3 14 8 14 4 0 16 16 27 36 24 45 221 245 278 281 23 15 44 30 47 33 20 20 138 78 250 123 61 24 167 50 250 61 60 7 302 -1 370 -14z m837 -661 c52 -101 102 -279 106 -379 2 -42 0 -45 -28 -51 -16 -4 -101 -7 -187 -8 -166 -1 -229 10 -271 49 -19 19 -19 19 14 49 22 21 44 31 65 31 41 0 84 34 84 66 0 30 12 55 56 112 19 25 37 65 44 95 11 51 53 111 74 104 6 -2 25 -32 43 -68z m-662 -810 c17 -10 40 -24 53 -30 12 -7 22 -16 22 -20 0 -4 17 -13 38 -19 20 -7 44 -18 52 -24 8 -7 33 -21 55 -31 22 -11 42 -23 45 -26 11 -14 109 -49 164 -58 62 -11 101 -7 126 14 15 14 38 18 78 16 39 -2 26 -41 -49 -146 -78 -109 -85 -118 -186 -219 -61 -61 -239 -189 -281 -203 -17 -5 -73 -29 -104 -44 -187 -92 -605 -103 -791 -21 -42 19 -47 24 -37 41 5 11 28 32 51 48 22 15 51 38 64 51 13 12 28 22 33 22 17 0 242 233 242 250 0 6 5 10 10 10 6 0 10 6 10 14 0 25 50 55 100 62 59 8 56 6 115 83 50 66 74 117 75 162 0 14 7 40 16 57 18 38 52 41 99 11z"/>
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
syui
</metadata>
<g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path 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" fill="#EF454A"/>
</g>
</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": {
"plc": "https://plc.directory",
"bsky": "https://public.api.bsky.app"
"bsky": "https://public.api.bsky.app",
"web": "https://bsky.app"
},
"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
```
## 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 { marked, Renderer } from 'marked'
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)
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
async function resolveHandle(handle: string, bskyUrl: string): Promise<string> {
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',
content: r.value.content as string || '',
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())
}
@@ -205,12 +199,14 @@ function getFaviconDir(did: string): string {
async function downloadFavicon(url: string, filepath: string): Promise<boolean> {
try {
const res = await fetch(url)
const res = await fetch(url, { redirect: 'follow' })
if (!res.ok) return false
const buffer = await res.arrayBuffer()
if (buffer.byteLength === 0) return false
fs.writeFileSync(filepath, Buffer.from(buffer))
return true
} catch {
} catch (err) {
console.error(`Failed to download ${url}:`, err)
return false
}
}
@@ -232,8 +228,16 @@ function getServiceDomain(collection: string): string | 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[] {
const domains = new Set<string>()
const domains = new Set<string>(COMMON_SERVICE_DOMAINS)
for (const col of collections) {
const domain = getServiceDomain(col)
if (domain) domains.add(domain)
@@ -247,21 +251,22 @@ async function downloadFavicons(did: string, domains: string[]): Promise<void> {
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> = {
'bsky.app': 'https://bsky.app/static/favicon-32x32.png',
'syui.ai': 'https://syui.ai/favicon.png',
}
for (const domain of domains) {
const url = faviconUrls[domain]
if (!url) continue
const filepath = path.join(faviconDir, `${domain}.png`)
if (!fs.existsSync(filepath)) {
const ok = await downloadFavicon(url, filepath)
if (ok) {
console.log(`Downloaded: ${domain}.png`)
}
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)
if (ok) {
console.log(`Downloaded: ${domain}.png`)
}
}
}
@@ -296,7 +301,8 @@ function generateHtml(title: string, content: string, config: AppConfig, assets:
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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}">
${config.color ? `<style>:root { --btn-color: ${config.color}; }</style>` : ''}
</head>
@@ -309,16 +315,20 @@ function generateHtml(title: string, content: string, config: AppConfig, assets:
</html>`
}
function generateProfileHtml(profile: Profile): string {
function generateProfileHtml(profile: Profile, webUrl?: string): string {
const avatar = profile.avatar
? `<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 `
<div class="profile">
${avatar}
<div class="profile-info">
<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>` : ''}
</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 {
if (posts.length === 0) {
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 rkey = post.uri.split('/').pop()
// Default to English title if available
const displayTitle = post.translations?.en?.title || post.title
return `
<li class="post-item">
<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>
</a>
</li>
`
}).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
@@ -418,14 +472,20 @@ function getAppUrl(network: string): string {
function generatePostDetailHtml(post: BlogPost, handle: string, collection: string, network: string, siteUrl?: string): string {
const rkey = post.uri.split('/').pop() || ''
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
const baseSiteUrl = siteUrl || `https://${handle}`
const postUrl = `${baseSiteUrl}/post/${rkey}/`
const appUrl = getAppUrl(network)
// 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 pathParts = urlObj.pathname.split('/').filter(Boolean)
const basePath = urlObj.host + '/' + (pathParts[0] || '') + '/'
@@ -434,16 +494,32 @@ function generatePostDetailHtml(post: BlogPost, handle: string, collection: stri
const searchQuery = basePath + rkeyPrefix
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 `
<div class="content-header">${generateLangSelectorHtml()}</div>
${translationScript}
<article class="post-detail">
<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">
<time class="post-date">${formatDate(post.createdAt)}</time>
<a href="${jsonUrl}" class="json-btn">json</a>
</div>
</header>
<div class="post-content">${content}</div>
<div class="post-content">${displayContent}</div>
</article>
<div class="discussion-section">
<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
return `
${generateFooterLinksHtml(links)}
<footer class="site-footer">
<p>&copy; ${username}</p>
</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 `
<header id="header">${generateHeaderHtml(config.handle)}</header>
<main>
<section id="profile">
${generateTabsHtml('blog', config.handle)}
${generateProfileHtml(profile)}
${generateProfileHtml(profile, webUrl)}
${generateServicesHtml(profile.did, config.handle, collections)}
</section>
<section id="content">
${generatePostListHtml(posts)}
</section>
</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 `
<header id="header">${generateHeaderHtml(config.handle)}</header>
<main>
<section id="profile">
${generateTabsHtml('blog', config.handle)}
${generateProfileHtml(profile)}
${generateProfileHtml(profile, webUrl)}
${generateServicesHtml(profile.did, config.handle, collections)}
</section>
<section id="content">
${generatePostDetailHtml(post, config.handle, config.collection, config.network, config.siteUrl)}
</section>
</main>
${generateFooterHtml(config.handle)}
${generateFooterHtml(config.handle, links)}
`
}
@@ -588,16 +708,32 @@ async function generate() {
const localPosts = localDid ? loadPostsFromFiles(localDid, config.collection) : []
console.log(`Found ${localPosts.length} posts from local`)
// Merge: API is the source of truth
// - If post exists in API: always use API (has latest edits)
// - If post exists in local only: keep if not deleted (for posts beyond API limit)
// Merge: API is the source of truth for content, but local has translations
// - If post exists in both: use API data but merge translations from local
// - 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()))
// 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)
// Note: these might be deleted posts, so we keep them cautiously
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()
)
@@ -615,8 +751,14 @@ async function generate() {
// Load collections for services display
const collections = loadCollections(did)
// Load footer links
const links = loadLinks()
// Get web URL for profile links
const webUrl = network.web
// 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)
fs.writeFileSync(path.join(distDir, 'index.html'), indexHtml)
console.log('Generated: /index.html')
@@ -649,7 +791,7 @@ async function generate() {
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)
fs.writeFileSync(path.join(postDir, 'index.html'), postHtml)
console.log(`Generated: /post/${rkey}/index.html`)
@@ -670,14 +812,22 @@ async function generate() {
console.log('Generated: /app.html')
// Generate _redirects for Cloudflare Pages (SPA routes)
const redirects = `/app / 301
/oauth/* /app.html 200
// Static files (index.html, post/*/index.html) are served automatically
// 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)
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
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) {
const src = path.join(process.cwd(), 'public', file)
const dest = path.join(distDir, file)
@@ -693,6 +843,13 @@ async function generate() {
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
const faviconSrc = getFaviconDir(did)
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 { 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 {
const parts = uri.split('/')
@@ -8,13 +19,15 @@ function extractRkey(uri: string): 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),
resolvePds(did)
resolvePds(did),
loadNetworks()
])
// Server info section
const plcUrl = `${getPlc()}/${did}/log`
// Server info section - use PLC based on PDS
const plc = getPlcForPds(pds, networks)
const plcUrl = `${plc}/${did}/log`
const serverHtml = `
<div class="server-info">
<h3>Server</h3>
@@ -36,14 +49,14 @@ async function renderServices(did: string, handle: string): Promise<string> {
}
// 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) {
const info = getServiceInfo(col)
if (info) {
const key = info.domain
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++
}
@@ -53,7 +66,7 @@ async function renderServices(did: string, handle: string): Promise<string> {
return `
<li class="service-list-item">
<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-count">${info.count}</span>
</a>

View File

@@ -13,6 +13,9 @@ export function renderHeader(currentHandle: string, isLoggedIn: boolean, userHan
</svg>
</button>`
// Use logged-in user's handle for input if available
const inputHandle = isLoggedIn && userHandle ? userHandle : currentHandle
return `
<div class="header">
<form class="header-form" id="header-form">
@@ -21,7 +24,7 @@ export function renderHeader(currentHandle: string, isLoggedIn: boolean, userHan
class="header-input"
id="header-input"
placeholder="handle (e.g., syui.ai)"
value="${currentHandle}"
value="${inputHandle}"
>
<button type="submit" class="header-btn at-btn" title="Browse">@</button>
${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 { 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
export function getAppUrl(network: string): string {
@@ -9,15 +10,6 @@ export function getAppUrl(network: string): string {
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 {
// at://did:plc:xxx/app.bsky.feed.post/rkey -> {appUrl}/profile/did:plc:xxx/post/rkey
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 {
// 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
try {
const urlObj = new URL(postUrl)
@@ -74,7 +64,7 @@ export async function loadDiscussionPosts(container: HTMLElement, postUrl: strin
return
}
const postsHtml = posts.slice(0, 10).map(post => {
const postsHtml = posts.slice(0, DISCUSSION_POST_LIMIT).map(post => {
const author = post.author
const avatar = author.avatar || ''
const displayName = author.displayName || author.handle

View File

@@ -1,19 +1,10 @@
import type { BlogPost } from '../types.js'
import { putRecord } from '../lib/auth.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'
function formatDate(dateStr: string): string {
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 {
export function mountPostList(container: HTMLElement, posts: BlogPost[], userHandle?: string): void {
if (posts.length === 0) {
container.innerHTML = '<p class="no-posts">No posts yet</p>'
return
@@ -21,9 +12,11 @@ export function mountPostList(container: HTMLElement, posts: BlogPost[]): void {
const html = posts.map(post => {
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 `
<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-date">${formatDate(post.createdAt)}</span>
</a>

View File

@@ -1,18 +1,23 @@
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 `
<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">
<h1 class="profile-name">${profile.displayName || profile.handle}</h1>
<p class="profile-handle">@${profile.handle}</p>
${profile.description ? `<p class="profile-desc">${profile.description}</p>` : ''}
<h1 class="profile-name">${escapeHtml(profile.displayName || profile.handle)}</h1>
<p class="profile-handle">${handleHtml}</p>
${profile.description ? `<p class="profile-desc">${escapeHtml(profile.description)}</p>` : ''}
</div>
</div>
`
}
export function mountProfile(container: HTMLElement, profile: Profile): void {
container.innerHTML = renderProfile(profile)
export function mountProfile(container: HTMLElement, profile: Profile, webUrl?: string): void {
container.innerHTML = renderProfile(profile, webUrl)
}

View File

@@ -9,14 +9,14 @@ export async function renderServices(handle: string): Promise<string> {
}
// 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) {
const info = getServiceInfo(col)
if (info) {
const key = info.domain
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)
}
@@ -27,7 +27,7 @@ export async function renderServices(handle: string): Promise<string> {
return `
<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>
</a>
`

View File

@@ -1,5 +1,6 @@
import { AtpAgent } from '@atproto/api'
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()
@@ -13,6 +14,33 @@ export function getPlc(): string {
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 {
return networkConfig?.bsky || 'https://public.api.bsky.app'
}
@@ -24,18 +52,6 @@ export function getAgent(service: string): AtpAgent {
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> {
// Try current PLC first, then fallbacks
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
} catch {
// 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
try {
const agent = getAgent(endpoint)
@@ -80,19 +96,57 @@ export async function resolveHandle(handle: string): Promise<string> {
export async function getProfile(actor: string): Promise<Profile> {
// 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) {
try {
const agent = getAgent(endpoint)
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 {
did: res.data.did,
handle: res.data.handle,
displayName: res.data.displayName,
description: res.data.description,
avatar: res.data.avatar,
banner: res.data.banner,
avatar,
banner,
}
} catch {
continue
@@ -243,8 +297,10 @@ const SERVICE_MAP: Record<string, { domain: string; icon?: string }> = {
// Search Bluesky posts mentioning a URL
export async function searchPostsForUrl(url: string): Promise<any[]> {
// Search ALL endpoints and merge results (different networks have different indexes)
const endpoints = [getBsky(), ...FALLBACK_ENDPOINTS.filter(e => e !== getBsky())]
// Only use current network's endpoint - don't cross-search other networks
// 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
// 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 =>
searchQueries.map(async query => {
try {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), SEARCH_TIMEOUT_MS)
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 []
const data = await res.json()
// 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(/\/$/, ''))
})
} catch {
// Silently fail for CORS/network/timeout errors
return []
}
})
@@ -307,14 +370,15 @@ export async function searchPostsForUrl(url: string): Promise<any[]> {
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
for (const [prefix, info] of Object.entries(SERVICE_MAP)) {
if (collection.startsWith(prefix)) {
return {
name: 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 {
name: 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,7 +98,11 @@ export async function restoreSession(): Promise<AuthSession | null> {
}
}
} catch (err) {
console.error('Session restore error:', 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)
}
}
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 hljs from 'highlight.js/lib/core'
import { escapeHtml } from './utils.js'
// Import only common languages
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>`
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}
marked.setOptions({
breaks: true,
gfm: true,

View File

@@ -1,5 +1,5 @@
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
collection?: string
rkey?: string
@@ -33,6 +33,16 @@ export function parseRoute(pathname: string): Route {
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}/${service-or-collection} - Browser collections or records
// /at/${handle}/${collection}/${rkey} - Browser record detail
@@ -75,6 +85,10 @@ export function buildPath(route: Route): string {
return '/new'
case 'post':
return `/post/${route.rkey}`
case 'user-blog':
return `/@${route.handle}`
case 'user-post':
return `/@${route.handle}/post/${route.rkey}`
case 'browser-services':
return `/at/${route.handle}`
case 'browser-collections':

View File

@@ -5,3 +5,12 @@ export function escapeHtml(str: string): string {
.replace(/>/g, '&gt;')
.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 { parseRoute, type Route } from './lib/router.js'
import { escapeHtml } from './lib/utils.js'
import { LANG_ICON, BUILTIN_ICONS } from './lib/icons.js'
import type { AppConfig, Networks } from './types.js'
let authSession: AuthSession | null = null
let config: AppConfig
let networks: Networks = {}
let browserNetwork: string = '' // Network for AT Browser
let currentLang: string = 'en' // Default language for translations
// Browser state
let browserMode = false
@@ -36,16 +38,96 @@ async function loadNetworks(): Promise<Networks> {
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 {
const parts = handle.split('.')
const username = parts[0] || handle
return `
${renderFooterLinks(footerLinks)}
<footer class="site-footer">
<p>&copy; ${username}</p>
</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 {
const networkKeys = Object.keys(networks)
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 = `
<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) {
@@ -101,6 +277,10 @@ function openBrowser(handle: string, service: string | null = null, collection:
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
if (!browserMode) {
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)
const jsonBtn = target.closest('.json-btn') as HTMLAnchorElement
if (jsonBtn) {
@@ -498,8 +717,10 @@ async function render(): Promise<void> {
const handle = route.handle || config.handle
// 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'
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)
mountHeader(headerEl, handle, isLoggedIn, authSession?.handle, {
onBrowse: (newHandle) => {
@@ -552,6 +773,32 @@ async function render(): Promise<void> {
// For blog top page, check for new posts from API and merge
if (route.type === 'blog') {
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
@@ -598,10 +845,11 @@ async function render(): Promise<void> {
case 'post':
try {
const profile = await getProfile(config.handle)
const webUrl = networks[config.network]?.web
profileEl.innerHTML = renderTabs('blog', isLoggedIn)
const profileContentEl = document.createElement('div')
profileEl.appendChild(profileContentEl)
mountProfile(profileContentEl, profile)
mountProfile(profileContentEl, profile, webUrl)
const servicesHtml = await renderServices(config.handle)
profileContentEl.insertAdjacentHTML('beforeend', servicesHtml)
@@ -619,20 +867,83 @@ async function render(): Promise<void> {
}
break
case 'blog':
default:
case 'user-blog':
// /@{handle} - Any user's blog
try {
const profile = await getProfile(config.handle)
profileEl.innerHTML = renderTabs('blog', isLoggedIn)
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)
mountProfile(profileContentEl, profile, webUrl)
const servicesHtml = await renderServices(config.handle)
const servicesHtml = await renderServices(userHandle)
profileContentEl.insertAdjacentHTML('beforeend', servicesHtml)
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) {
console.error(err)
contentEl.innerHTML = `<p class="error">Failed to load: ${err}</p>`
@@ -642,9 +953,10 @@ async function render(): 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
networks = networksData
footerLinks = linksData
// Set page title
document.title = config.title || 'ailog'
@@ -657,6 +969,9 @@ async function init(): Promise<void> {
// Initialize browser network from localStorage or default to 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
const selectedNetworkConfig = networks[browserNetwork]
if (selectedNetworkConfig) {

View File

@@ -95,7 +95,8 @@ body {
}
.header-btn.at-btn:hover {
background: #0052a3;
background: var(--btn-color);
filter: brightness(0.85);
}
.header-btn.login-btn {
@@ -217,6 +218,16 @@ body {
margin-bottom: 8px;
}
.profile-handle-link {
color: #666;
text-decoration: none;
}
.profile-handle-link:hover {
color: var(--btn-color);
text-decoration: underline;
}
.profile-desc {
font-size: 14px;
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 */
.site-footer {
margin-top: 60px;
margin-top: 20px;
padding: 20px 0;
text-align: center;
font-size: 13px;
@@ -903,6 +961,125 @@ body {
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 */
.server-info {
padding: 16px 0;

View File

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