Compare commits
10 Commits
4731d64b4d
...
9393968116
| Author | SHA1 | Date | |
|---|---|---|---|
|
9393968116
|
|||
|
19a2341ddc
|
|||
|
ff11b38c52
|
|||
|
5b9ef2eae1
|
|||
|
40815a3b60
|
|||
|
2ec33ef4ed
|
|||
|
b8922f38be
|
|||
|
2533720014
|
|||
|
5d12d8b0dc
|
|||
|
1728f8dd35
|
3
.env.example
Normal file
3
.env.example
Normal 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
7
.gitignore
vendored
@@ -1,5 +1,8 @@
|
||||
dist
|
||||
.claude
|
||||
repos
|
||||
node_modules
|
||||
package-lock.json
|
||||
repos
|
||||
CLAUDE.md
|
||||
.claude
|
||||
.env
|
||||
/rust/target
|
||||
|
||||
@@ -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 |
BIN
content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/favicons/syu.is.png
Normal file
BIN
content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/favicons/syu.is.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1005 B |
@@ -17,5 +17,5 @@
|
||||
"indexedAt": "2025-09-19T06:17:42.000Z",
|
||||
"followersCount": 1,
|
||||
"followsCount": 1,
|
||||
"postsCount": 74
|
||||
"postsCount": 77
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
68
public/.well-known/lexicon/ai/syui/log/post.json
Normal file
68
public/.well-known/lexicon/ai/syui/log/post.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
/* /index.html 200
|
||||
@@ -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
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
@@ -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
9
public/links.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"links": [
|
||||
{
|
||||
"name": "git",
|
||||
"url": "https://git.syui.ai/ai/log",
|
||||
"icon": "ai"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
public/pkg/icomoon/fonts/icomoon.eot
Normal file
BIN
public/pkg/icomoon/fonts/icomoon.eot
Normal file
Binary file not shown.
34
public/pkg/icomoon/fonts/icomoon.svg
Normal file
34
public/pkg/icomoon/fonts/icomoon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 58 KiB |
BIN
public/pkg/icomoon/fonts/icomoon.ttf
Normal file
BIN
public/pkg/icomoon/fonts/icomoon.ttf
Normal file
Binary file not shown.
BIN
public/pkg/icomoon/fonts/icomoon.woff
Normal file
BIN
public/pkg/icomoon/fonts/icomoon.woff
Normal file
Binary file not shown.
99
public/pkg/icomoon/style.css
Normal file
99
public/pkg/icomoon/style.css
Normal 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";
|
||||
}
|
||||
17
readme.md
17
readme.md
@@ -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
13
rust/Cargo.toml
Normal 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
44
rust/src/auth.rs
Normal 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(())
|
||||
}
|
||||
66
rust/src/commands/login.rs
Normal file
66
rust/src/commands/login.rs
Normal 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
4
rust/src/commands/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod login;
|
||||
pub mod refresh;
|
||||
pub mod sync;
|
||||
pub mod translate;
|
||||
75
rust/src/commands/refresh.rs
Normal file
75
rust/src/commands/refresh.rs
Normal 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
117
rust/src/commands/sync.rs
Normal 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(())
|
||||
}
|
||||
211
rust/src/commands/translate.rs
Normal file
211
rust/src/commands/translate.rs
Normal 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
112
rust/src/main.rs
Normal 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(())
|
||||
}
|
||||
@@ -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,23 +251,24 @@ 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)) {
|
||||
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`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getLocalFaviconPath(did: string, domain: string): string | null {
|
||||
@@ -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>© ${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')
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: () => {}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
`
|
||||
|
||||
109
src/lib/api.ts
109
src/lib/api.ts
@@ -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`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -98,8 +98,12 @@ export async function restoreSession(): Promise<AuthSession | null> {
|
||||
}
|
||||
}
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
19
src/lib/constants.ts
Normal file
19
src/lib/constants.ts
Normal 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
17
src/lib/icons.ts
Normal 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>`,
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
}
|
||||
|
||||
marked.setOptions({
|
||||
breaks: true,
|
||||
gfm: true,
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -5,3 +5,12 @@ export function escapeHtml(str: string): string {
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
export function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('ja-JP', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
339
src/main.ts
339
src/main.ts
@@ -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>© ${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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user