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
|
dist
|
||||||
.claude
|
repos
|
||||||
node_modules
|
node_modules
|
||||||
package-lock.json
|
package-lock.json
|
||||||
repos
|
CLAUDE.md
|
||||||
|
.claude
|
||||||
|
.env
|
||||||
|
/rust/target
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
{
|
{
|
||||||
"uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s",
|
"uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s",
|
||||||
"cid": "bafyreidymanu2xk4ftmvfdna3j7ixyijc37s6h3aytstuqgzatgjl4tp7e",
|
"cid": "bafyreielgn743kg5xotfj5x53edl25vkbbd2d6v7s3tydyyjsvczcluyme",
|
||||||
"title": "ailogを作り直した",
|
"title": "ailogを作り直した",
|
||||||
"content": "## ailogとは\n\natprotoと連携するサイトジェネレータ。\n\n## ailogの使い方\n\n```sh\n$ git clone https://git.syui.ai/ai/log\n$ cd log\n$ cat public/config.json\n{\n \"title\": \"syui.ai\",\n \"handle\": \"syui.syui.ai\",\n \"collection\": \"ai.syui.log.post\",\n \"network\": \"syu.is\",\n \"color\": \"#0066cc\",\n \"siteUrl\": \"https://syui.ai\"\n}\n---\n$ npm run dev\n```\n\n## ailogのコンセプト\n\n1. at-browserを基本にする\n2. atproto oauthでログインする\n3. ログインしたアカウントで記事をポストする\n\n## ailogの追加機能\n\n1. atproto recordからjsonをdownloadすると表示速度が上がる(ただし更新はlocalから)\n2. コメントはurlの言及を検索して表示\n\n```sh\n$ npm run fetch\n$ npm run generate\n```",
|
"content": "## ailogとは\n\natprotoと連携するサイトジェネレータ。\n\n## ailogの使い方\n\n```sh\n$ git clone https://git.syui.ai/ai/log\n$ cd log\n$ cat public/config.json\n{\n \"title\": \"syui.ai\",\n \"handle\": \"syui.syui.ai\",\n \"collection\": \"ai.syui.log.post\",\n \"network\": \"syu.is\",\n \"color\": \"#0066cc\",\n \"siteUrl\": \"https://syui.ai\"\n}\n---\n$ npm run dev\n```\n\n## ailogのコンセプト\n\n1. at-browserを基本にする\n2. atproto oauthでログインする\n3. ログインしたアカウントで記事をポストする\n\n## ailogの追加機能\n\n1. atproto recordからjsonをdownloadすると表示速度が上がる(ただし更新はlocalから)\n2. コメントはurlの言及を検索して表示\n\n```sh\n$ npm run fetch\n$ npm run generate\n```",
|
||||||
"createdAt": "2026-01-15T13:59:52.367Z"
|
"createdAt": "2026-01-15T13:59:52.367Z",
|
||||||
|
"translations": {
|
||||||
|
"en": {
|
||||||
|
"title": "recreated ailog",
|
||||||
|
"content": "## What is ailog?\n\nA site generator that integrates with the atproto framework.\n\n## How to Use ailog\n\n```sh\n$ git clone https://git.syui.ai/ai/log\n$ cd log\n$ cat public/config.json\n{\n \"title\": \"syui.ai\",\n \"handle\": \"syui.syui.ai\",\n \"collection\": \"ai.syui.log.post\",\n \"network\": \"syu.is\",\n \"color\": \"#0066cc\",\n \"siteUrl\": \"https://syui.ai\"\n}\n---\n$ npm run dev\n```\n\n## ailog's Concept\n\n1. Based on at-browser as its foundation\n2. Authentication via atproto oAuth\n3. Post articles using the logged-in account\n\n## Additional Features of ailog\n\n1. Downloading JSON from atproto record improves display speed (though updates still come from local storage)\n2. Comments are displayed by searching for URL mentions\n\n```sh\n$ npm run fetch\n$ npm run generate\n```"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
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",
|
"indexedAt": "2025-09-19T06:17:42.000Z",
|
||||||
"followersCount": 1,
|
"followersCount": 1,
|
||||||
"followsCount": 1,
|
"followsCount": 1,
|
||||||
"postsCount": 74
|
"postsCount": 77
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,8 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>syui.ai</title>
|
<title>syui.ai</title>
|
||||||
<link rel="icon" href="/favicon.png" type="image/png">
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="/pkg/icomoon/style.css">
|
||||||
<link rel="stylesheet" href="/src/styles/main.css">
|
<link rel="stylesheet" href="/src/styles/main.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -20,13 +20,47 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"maxLength": 1000000,
|
"maxLength": 1000000,
|
||||||
"maxGraphemes": 100000,
|
"maxGraphemes": 100000,
|
||||||
"description": "The content of the post."
|
"description": "The content of the post (markdown)."
|
||||||
},
|
},
|
||||||
"createdAt": {
|
"createdAt": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "datetime",
|
"format": "datetime",
|
||||||
"description": "Client-declared timestamp when this post was originally created."
|
"description": "Client-declared timestamp when this post was originally created."
|
||||||
}
|
},
|
||||||
|
"lang": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 10,
|
||||||
|
"description": "Language code of the original content (e.g., 'ja', 'en')."
|
||||||
|
},
|
||||||
|
"translations": {
|
||||||
|
"type": "ref",
|
||||||
|
"ref": "#translationMap",
|
||||||
|
"description": "Translations of the post in other languages."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"translationMap": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Map of language codes to translations.",
|
||||||
|
"properties": {
|
||||||
|
"en": { "type": "ref", "ref": "#translation" },
|
||||||
|
"ja": { "type": "ref", "ref": "#translation" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"translation": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "A translation of a post.",
|
||||||
|
"properties": {
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 3000,
|
||||||
|
"maxGraphemes": 300
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 1000000,
|
||||||
|
"maxGraphemes": 100000
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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",
|
"handle": "syui.syui.ai",
|
||||||
"collection": "ai.syui.log.post",
|
"collection": "ai.syui.log.post",
|
||||||
"network": "syu.is",
|
"network": "syu.is",
|
||||||
"color": "#0066cc",
|
"color": "#EF454A",
|
||||||
"siteUrl": "https://syui.ai"
|
"siteUrl": "https://syui.ai"
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
public/favicon.ico
Normal file
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" >
|
<?xml version="1.0" standalone="no"?>
|
||||||
<circle class="explosion" r="150" cx="250" cy="250"></circle>
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||||
<g class="particleLayer">
|
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||||
<circle fill="#ef454aba" cx="130" cy="126.5" r="12.5"/>
|
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||||
<circle fill="#ef454acc" cx="411" cy="313.5" r="12.5"/>
|
width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
|
||||||
<circle fill="#ef454aba" cx="279" cy="86.5" r="12.5"/>
|
preserveAspectRatio="xMidYMid meet">
|
||||||
<circle fill="#ef454aba" cx="155" cy="390.5" r="12.5"/>
|
<metadata>
|
||||||
<circle fill="#ef454aba" cx="89" cy="292.5" r="10.5"/>
|
syui
|
||||||
<circle fill="#ef454aba" cx="414" cy="282.5" r="10.5"/>
|
</metadata>
|
||||||
<circle fill="#ef454a91" cx="115" cy="149.5" r="10.5"/>
|
|
||||||
<circle fill="#ef454aba" cx="250" cy="80.5" r="10.5"/>
|
<g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
|
||||||
<circle fill="#ef454aba" cx="78" cy="261.5" r="10.5"/>
|
fill="#000000" stroke="none">
|
||||||
<circle fill="#ef454a91" cx="182" cy="402.5" r="10.5"/>
|
<path d="M3660 4460 c-11 -11 -33 -47 -48 -80 l-29 -60 -12 38 c-27 88 -58 92
|
||||||
<circle fill="#ef454aba" cx="401.5" cy="166" r="13"/>
|
-98 11 -35 -70 -73 -159 -73 -169 0 -6 -5 -10 -10 -10 -6 0 -15 -10 -21 -22
|
||||||
<circle fill="#ef454aba" cx="379" cy="141.5" r="10.5"/>
|
-33 -73 -52 -92 -47 -48 2 26 -1 35 -14 38 -16 3 -168 -121 -168 -138 0 -5
|
||||||
<circle fill="#ef454a91" cx="327" cy="397.5" r="10.5"/>
|
-13 -16 -28 -24 -24 -13 -35 -12 -87 0 -221 55 -231 56 -480 56 -219 1 -247
|
||||||
<circle fill="#ef454aba" cx="296" cy="392.5" r="10.5"/>
|
-1 -320 -22 -44 -12 -96 -26 -115 -30 -57 -13 -122 -39 -200 -82 -8 -4 -31
|
||||||
</g>
|
-14 -50 -23 -41 -17 -34 -13 -146 -90 -87 -59 -292 -252 -351 -330 -63 -83
|
||||||
<g transform="translate(0,512) scale(0.1,-0.1)" fill="#000000" class="icon_syui">
|
-143 -209 -143 -225 0 -10 -7 -23 -15 -30 -8 -7 -15 -17 -15 -22 0 -5 -13 -37
|
||||||
<path class="syui" d="M3660 4460 c-11 -11 -33 -47 -48 -80 l-29 -60 -12 38 c-27 88 -58 92 -98 11 -35 -70 -73 -159 -73 -169 0 -6 -5 -10 -10 -10 -6 0 -15 -10 -21 -22 -33 -73 -52 -92 -47 -48 2 26 -1 35 -14 38 -16 3 -168 -121 -168 -138 0 -5 -13 -16 -28 -24 -24 -13 -35 -12 -87 0 -221 55 -231 56 -480 56 -219 1 -247 -1 -320 -22 -44 -12 -96 -26 -115 -30 -57 -13 -122 -39 -200 -82 -8 -4 -31 -14 -50 -23 -41 -17 -34 -13 -146 -90 -87 -59 -292 -252 -351 -330 -63 -83 -143 -209 -143 -225 0 -10 -7 -23 -15 -30 -8 -7 -15 -17 -15 -22 0 -5 -13 -37 -28 -71 -16 -34 -36 -93 -45 -132 -9 -38 -24 -104 -34 -145 -13 -60 -17 -121 -17 -300 1 -224 1 -225 36 -365 24 -94 53 -175 87 -247 28 -58 51 -108 51 -112 0 -3 13 -24 28 -48 42 -63 46 -79 22 -85 -11 -3 -20 -9 -20 -14 0 -5 -4 -9 -10 -9 -5 0 -22 -11 -37 -25 -16 -13 -75 -59 -133 -100 -58 -42 -113 -82 -123 -90 -9 -8 -22 -15 -27 -15 -6 0 -10 -6 -10 -13 0 -8 -11 -20 -25 -27 -34 -18 -34 -54 0 -48 14 3 25 2 25 -1 0 -3 -43 -31 -95 -61 -52 -30 -95 -58 -95 -62 0 -5 -5 -8 -11 -8 -19 0 -84 -33 -92 -47 -4 -7 -15 -13 -22 -13 -14 0 -17 -4 -19 -32 -1 -8 15 -15 37 -18 l38 -5 -47 -48 c-56 -59 -54 -81 9 -75 30 3 45 0 54 -11 9 -13 16 -14 43 -4 29 11 30 10 18 -5 -7 -9 -19 -23 -25 -30 -7 -7 -13 -20 -13 -29 0 -12 8 -14 38 -9 20 4 57 8 82 9 25 2 54 8 66 15 18 10 23 8 32 -13 17 -38 86 -35 152 6 27 17 50 34 50 38 0 16 62 30 85 19 33 -15 72 -2 89 30 8 15 31 43 51 62 35 34 38 35 118 35 77 0 85 2 126 33 24 17 52 32 61 32 9 0 42 18 73 40 30 22 61 40 69 40 21 0 88 -26 100 -38 7 -7 17 -12 24 -12 7 0 35 -11 62 -25 66 -33 263 -84 387 -101 189 -25 372 -12 574 41 106 27 130 37 261 97 41 20 80 37 85 39 6 2 51 31 100 64 166 111 405 372 489 534 10 20 22 43 27 51 5 8 12 22 15 30 3 8 17 40 31 70 54 115 95 313 108 520 13 200 -43 480 -134 672 -28 58 -51 108 -51 112 0 3 -13 24 -29 48 -15 24 -34 60 -40 80 -19 57 3 142 50 193 10 11 22 49 28 85 6 36 16 67 21 68 18 6 31 53 25 83 -4 18 -17 33 -36 41 -16 7 -29 15 -29 18 1 10 38 50 47 50 5 0 20 11 33 25 18 19 22 31 17 61 -3 20 -14 45 -23 55 -16 18 -16 20 6 44 15 16 21 32 18 49 -3 15 1 34 8 43 32 43 7 73 -46 55 l-30 -11 0 85 c0 74 -2 84 -18 84 -21 0 -53 -33 -103 -104 l-34 -48 -5 74 c-7 102 -35 133 -80 88z m-870 -740 c36 -7 75 -14 88 -16 21 -4 23 -9 16 -37 -3 -18 -14 -43 -24 -57 -10 -14 -20 -35 -24 -46 -4 -12 -16 -32 -27 -45 -12 -13 -37 -49 -56 -79 -20 -30 -52 -73 -72 -96 -53 -60 -114 -133 -156 -189 -21 -27 -44 -54 -52 -58 -7 -4 -13 -14 -13 -22 0 -7 -18 -33 -40 -57 -22 -23 -40 -46 -40 -50 0 -5 -19 -21 -42 -38 -47 -35 -85 -38 -188 -15 -115 25 -173 20 -264 -23 -45 -22 -106 -46 -136 -56 -48 -15 -77 -25 -140 -50 -70 -28 -100 -77 -51 -84 14 -2 34 -10 45 -17 12 -7 53 -16 91 -20 90 -9 131 -22 178 -57 20 -16 52 -35 70 -43 18 -7 40 -22 49 -32 16 -18 15 -22 -24 -88 -23 -39 -47 -74 -53 -80 -7 -5 -23 -26 -36 -45 -26 -39 -92 -113 -207 -232 -4 -4 -37 -36 -73 -71 l-66 -64 -20 41 c-58 119 -105 240 -115 301 -40 244 -35 409 20 595 8 30 21 66 28 80 7 14 24 54 38 89 15 35 35 75 46 89 11 13 20 31 20 38 0 8 3 14 8 14 4 0 16 16 27 36 24 45 221 245 278 281 23 15 44 30 47 33 20 20 138 78 250 123 61 24 167 50 250 61 60 7 302 -1 370 -14z m837 -661 c52 -101 102 -279 106 -379 2 -42 0 -45 -28 -51 -16 -4 -101 -7 -187 -8 -166 -1 -229 10 -271 49 -19 19 -19 19 14 49 22 21 44 31 65 31 41 0 84 34 84 66 0 30 12 55 56 112 19 25 37 65 44 95 11 51 53 111 74 104 6 -2 25 -32 43 -68z m-662 -810 c17 -10 40 -24 53 -30 12 -7 22 -16 22 -20 0 -4 17 -13 38 -19 20 -7 44 -18 52 -24 8 -7 33 -21 55 -31 22 -11 42 -23 45 -26 11 -14 109 -49 164 -58 62 -11 101 -7 126 14 15 14 38 18 78 16 39 -2 26 -41 -49 -146 -78 -109 -85 -118 -186 -219 -61 -61 -239 -189 -281 -203 -17 -5 -73 -29 -104 -44 -187 -92 -605 -103 -791 -21 -42 19 -47 24 -37 41 5 11 28 32 51 48 22 15 51 38 64 51 13 12 28 22 33 22 17 0 242 233 242 250 0 6 5 10 10 10 6 0 10 6 10 14 0 25 50 55 100 62 59 8 56 6 115 83 50 66 74 117 75 162 0 14 7 40 16 57 18 38 52 41 99 11z"/>
|
-28 -71 -16 -34 -36 -93 -45 -132 -9 -38 -24 -104 -34 -145 -13 -60 -17 -121
|
||||||
|
-17 -300 1 -224 1 -225 36 -365 24 -94 53 -175 87 -247 28 -58 51 -108 51
|
||||||
|
-112 0 -3 13 -24 28 -48 42 -63 46 -79 22 -85 -11 -3 -20 -9 -20 -14 0 -5 -4
|
||||||
|
-9 -10 -9 -5 0 -22 -11 -37 -25 -16 -13 -75 -59 -133 -100 -58 -42 -113 -82
|
||||||
|
-123 -90 -9 -8 -22 -15 -27 -15 -6 0 -10 -6 -10 -13 0 -8 -11 -20 -25 -27 -34
|
||||||
|
-18 -34 -54 0 -48 14 3 25 2 25 -1 0 -3 -43 -31 -95 -61 -52 -30 -95 -58 -95
|
||||||
|
-62 0 -5 -5 -8 -11 -8 -19 0 -84 -33 -92 -47 -4 -7 -15 -13 -22 -13 -14 0 -17
|
||||||
|
-4 -19 -32 -1 -8 15 -15 37 -18 l38 -5 -47 -48 c-56 -59 -54 -81 9 -75 30 3
|
||||||
|
45 0 54 -11 9 -13 16 -14 43 -4 29 11 30 10 18 -5 -7 -9 -19 -23 -25 -30 -7
|
||||||
|
-7 -13 -20 -13 -29 0 -12 8 -14 38 -9 20 4 57 8 82 9 25 2 54 8 66 15 18 10
|
||||||
|
23 8 32 -13 17 -38 86 -35 152 6 27 17 50 34 50 38 0 16 62 30 85 19 33 -15
|
||||||
|
72 -2 89 30 8 15 31 43 51 62 35 34 38 35 118 35 77 0 85 2 126 33 24 17 52
|
||||||
|
32 61 32 9 0 42 18 73 40 30 22 61 40 69 40 21 0 88 -26 100 -38 7 -7 17 -12
|
||||||
|
24 -12 7 0 35 -11 62 -25 66 -33 263 -84 387 -101 189 -25 372 -12 574 41 106
|
||||||
|
27 130 37 261 97 41 20 80 37 85 39 6 2 51 31 100 64 166 111 405 372 489 534
|
||||||
|
10 20 22 43 27 51 5 8 12 22 15 30 3 8 17 40 31 70 54 115 95 313 108 520 13
|
||||||
|
200 -43 480 -134 672 -28 58 -51 108 -51 112 0 3 -13 24 -29 48 -15 24 -34 60
|
||||||
|
-40 80 -19 57 3 142 50 193 10 11 22 49 28 85 6 36 16 67 21 68 18 6 31 53 25
|
||||||
|
83 -4 18 -17 33 -36 41 -16 7 -29 15 -29 18 1 10 38 50 47 50 5 0 20 11 33 25
|
||||||
|
18 19 22 31 17 61 -3 20 -14 45 -23 55 -16 18 -16 20 6 44 15 16 21 32 18 49
|
||||||
|
-3 15 1 34 8 43 32 43 7 73 -46 55 l-30 -11 0 85 c0 74 -2 84 -18 84 -21 0
|
||||||
|
-53 -33 -103 -104 l-34 -48 -5 74 c-7 102 -35 133 -80 88z m-870 -740 c36 -7
|
||||||
|
75 -14 88 -16 21 -4 23 -9 16 -37 -3 -18 -14 -43 -24 -57 -10 -14 -20 -35 -24
|
||||||
|
-46 -4 -12 -16 -32 -27 -45 -12 -13 -37 -49 -56 -79 -20 -30 -52 -73 -72 -96
|
||||||
|
-53 -60 -114 -133 -156 -189 -21 -27 -44 -54 -52 -58 -7 -4 -13 -14 -13 -22 0
|
||||||
|
-7 -18 -33 -40 -57 -22 -23 -40 -46 -40 -50 0 -5 -19 -21 -42 -38 -47 -35 -85
|
||||||
|
-38 -188 -15 -115 25 -173 20 -264 -23 -45 -22 -106 -46 -136 -56 -48 -15 -77
|
||||||
|
-25 -140 -50 -70 -28 -100 -77 -51 -84 14 -2 34 -10 45 -17 12 -7 53 -16 91
|
||||||
|
-20 90 -9 131 -22 178 -57 20 -16 52 -35 70 -43 18 -7 40 -22 49 -32 16 -18
|
||||||
|
15 -22 -24 -88 -23 -39 -47 -74 -53 -80 -7 -5 -23 -26 -36 -45 -26 -39 -92
|
||||||
|
-113 -207 -232 -4 -4 -37 -36 -73 -71 l-66 -64 -20 41 c-58 119 -105 240 -115
|
||||||
|
301 -40 244 -35 409 20 595 8 30 21 66 28 80 7 14 24 54 38 89 15 35 35 75 46
|
||||||
|
89 11 13 20 31 20 38 0 8 3 14 8 14 4 0 16 16 27 36 24 45 221 245 278 281 23
|
||||||
|
15 44 30 47 33 20 20 138 78 250 123 61 24 167 50 250 61 60 7 302 -1 370 -14z
|
||||||
|
m837 -661 c52 -101 102 -279 106 -379 2 -42 0 -45 -28 -51 -16 -4 -101 -7
|
||||||
|
-187 -8 -166 -1 -229 10 -271 49 -19 19 -19 19 14 49 22 21 44 31 65 31 41 0
|
||||||
|
84 34 84 66 0 30 12 55 56 112 19 25 37 65 44 95 11 51 53 111 74 104 6 -2 25
|
||||||
|
-32 43 -68z m-662 -810 c17 -10 40 -24 53 -30 12 -7 22 -16 22 -20 0 -4 17
|
||||||
|
-13 38 -19 20 -7 44 -18 52 -24 8 -7 33 -21 55 -31 22 -11 42 -23 45 -26 11
|
||||||
|
-14 109 -49 164 -58 62 -11 101 -7 126 14 15 14 38 18 78 16 39 -2 26 -41 -49
|
||||||
|
-146 -78 -109 -85 -118 -186 -219 -61 -61 -239 -189 -281 -203 -17 -5 -73 -29
|
||||||
|
-104 -44 -187 -92 -605 -103 -791 -21 -42 19 -47 24 -37 41 5 11 28 32 51 48
|
||||||
|
22 15 51 38 64 51 13 12 28 22 33 22 17 0 242 233 242 250 0 6 5 10 10 10 6 0
|
||||||
|
10 6 10 14 0 25 50 55 100 62 59 8 56 6 115 83 50 66 74 117 75 162 0 14 7 40
|
||||||
|
16 57 18 38 52 41 99 11z" fill="#EF454A"/>
|
||||||
|
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 4.2 KiB |
9
public/links.json
Normal file
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": {
|
"bsky.social": {
|
||||||
"plc": "https://plc.directory",
|
"plc": "https://plc.directory",
|
||||||
"bsky": "https://public.api.bsky.app"
|
"bsky": "https://public.api.bsky.app",
|
||||||
|
"web": "https://bsky.app"
|
||||||
},
|
},
|
||||||
"syu.is": {
|
"syu.is": {
|
||||||
"plc": "https://plc.syu.is",
|
"plc": "https://plc.syu.is",
|
||||||
"bsky": "https://bsky.syu.is"
|
"bsky": "https://bsky.syu.is",
|
||||||
|
"web": "https://syu.is"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
└── 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 * as path from 'path'
|
||||||
import { marked, Renderer } from 'marked'
|
import { marked, Renderer } from 'marked'
|
||||||
import type { AppConfig, Profile, BlogPost, Networks } from '../src/types.ts'
|
import type { AppConfig, Profile, BlogPost, Networks } from '../src/types.ts'
|
||||||
import { escapeHtml } from '../src/lib/utils.ts'
|
import { escapeHtml, formatDate } from '../src/lib/utils.ts'
|
||||||
|
import { LANG_ICON, BUILTIN_ICONS } from '../src/lib/icons.ts'
|
||||||
|
import { MAX_SEARCH_LENGTH } from '../src/lib/constants.ts'
|
||||||
|
|
||||||
// Highlight.js for syntax highlighting (core + common languages only)
|
// Highlight.js for syntax highlighting (core + common languages only)
|
||||||
let hljs: typeof import('highlight.js/lib/core').default
|
let hljs: typeof import('highlight.js/lib/core').default
|
||||||
@@ -70,15 +72,6 @@ function setupMarked() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(dateStr: string): string {
|
|
||||||
const date = new Date(dateStr)
|
|
||||||
return date.toLocaleDateString('ja-JP', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// API functions
|
// API functions
|
||||||
async function resolveHandle(handle: string, bskyUrl: string): Promise<string> {
|
async function resolveHandle(handle: string, bskyUrl: string): Promise<string> {
|
||||||
const res = await fetch(`${bskyUrl}/xrpc/com.atproto.identity.resolveHandle?handle=${handle}`)
|
const res = await fetch(`${bskyUrl}/xrpc/com.atproto.identity.resolveHandle?handle=${handle}`)
|
||||||
@@ -114,6 +107,7 @@ async function listRecordsFromApi(did: string, collection: string, pdsUrl: strin
|
|||||||
title: r.value.title as string || 'Untitled',
|
title: r.value.title as string || 'Untitled',
|
||||||
content: r.value.content as string || '',
|
content: r.value.content as string || '',
|
||||||
createdAt: r.value.createdAt as string || new Date().toISOString(),
|
createdAt: r.value.createdAt as string || new Date().toISOString(),
|
||||||
|
translations: r.value.translations as BlogPost['translations'] || undefined,
|
||||||
})).sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
})).sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,12 +199,14 @@ function getFaviconDir(did: string): string {
|
|||||||
|
|
||||||
async function downloadFavicon(url: string, filepath: string): Promise<boolean> {
|
async function downloadFavicon(url: string, filepath: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url)
|
const res = await fetch(url, { redirect: 'follow' })
|
||||||
if (!res.ok) return false
|
if (!res.ok) return false
|
||||||
const buffer = await res.arrayBuffer()
|
const buffer = await res.arrayBuffer()
|
||||||
|
if (buffer.byteLength === 0) return false
|
||||||
fs.writeFileSync(filepath, Buffer.from(buffer))
|
fs.writeFileSync(filepath, Buffer.from(buffer))
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error(`Failed to download ${url}:`, err)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -232,8 +228,16 @@ function getServiceDomain(collection: string): string | null {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Common service domains to always download favicons for
|
||||||
|
const COMMON_SERVICE_DOMAINS = [
|
||||||
|
'bsky.app',
|
||||||
|
'atproto.com',
|
||||||
|
'syui.ai',
|
||||||
|
'syu.is',
|
||||||
|
]
|
||||||
|
|
||||||
function getServiceDomains(collections: string[]): string[] {
|
function getServiceDomains(collections: string[]): string[] {
|
||||||
const domains = new Set<string>()
|
const domains = new Set<string>(COMMON_SERVICE_DOMAINS)
|
||||||
for (const col of collections) {
|
for (const col of collections) {
|
||||||
const domain = getServiceDomain(col)
|
const domain = getServiceDomain(col)
|
||||||
if (domain) domains.add(domain)
|
if (domain) domains.add(domain)
|
||||||
@@ -247,24 +251,25 @@ async function downloadFavicons(did: string, domains: string[]): Promise<void> {
|
|||||||
fs.mkdirSync(faviconDir, { recursive: true })
|
fs.mkdirSync(faviconDir, { recursive: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Known favicon URLs (prefer official sources over Google)
|
||||||
|
// Others will use Google's favicon API as fallback
|
||||||
const faviconUrls: Record<string, string> = {
|
const faviconUrls: Record<string, string> = {
|
||||||
'bsky.app': 'https://bsky.app/static/favicon-32x32.png',
|
'bsky.app': 'https://bsky.app/static/favicon-32x32.png',
|
||||||
'syui.ai': 'https://syui.ai/favicon.png',
|
'syui.ai': 'https://syui.ai/favicon.png',
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const domain of domains) {
|
for (const domain of domains) {
|
||||||
const url = faviconUrls[domain]
|
|
||||||
if (!url) continue
|
|
||||||
|
|
||||||
const filepath = path.join(faviconDir, `${domain}.png`)
|
const filepath = path.join(faviconDir, `${domain}.png`)
|
||||||
if (!fs.existsSync(filepath)) {
|
if (fs.existsSync(filepath)) continue
|
||||||
|
|
||||||
|
// Try known URL first, then fallback to Google's favicon API
|
||||||
|
const url = faviconUrls[domain] || `https://www.google.com/s2/favicons?domain=${domain}&sz=32`
|
||||||
const ok = await downloadFavicon(url, filepath)
|
const ok = await downloadFavicon(url, filepath)
|
||||||
if (ok) {
|
if (ok) {
|
||||||
console.log(`Downloaded: ${domain}.png`)
|
console.log(`Downloaded: ${domain}.png`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function getLocalFaviconPath(did: string, domain: string): string | null {
|
function getLocalFaviconPath(did: string, domain: string): string | null {
|
||||||
const faviconDir = getFaviconDir(did)
|
const faviconDir = getFaviconDir(did)
|
||||||
@@ -296,7 +301,8 @@ function generateHtml(title: string, content: string, config: AppConfig, assets:
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>${escapeHtml(title)} - ${escapeHtml(config.title)}</title>
|
<title>${escapeHtml(title)} - ${escapeHtml(config.title)}</title>
|
||||||
<link rel="icon" href="/favicon.png" type="image/png">
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="/pkg/icomoon/style.css">
|
||||||
<link rel="stylesheet" href="/assets/${assets.css}">
|
<link rel="stylesheet" href="/assets/${assets.css}">
|
||||||
${config.color ? `<style>:root { --btn-color: ${config.color}; }</style>` : ''}
|
${config.color ? `<style>:root { --btn-color: ${config.color}; }</style>` : ''}
|
||||||
</head>
|
</head>
|
||||||
@@ -309,16 +315,20 @@ function generateHtml(title: string, content: string, config: AppConfig, assets:
|
|||||||
</html>`
|
</html>`
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateProfileHtml(profile: Profile): string {
|
function generateProfileHtml(profile: Profile, webUrl?: string): string {
|
||||||
const avatar = profile.avatar
|
const avatar = profile.avatar
|
||||||
? `<img src="${profile.avatar}" class="profile-avatar" alt="">`
|
? `<img src="${profile.avatar}" class="profile-avatar" alt="">`
|
||||||
: ''
|
: ''
|
||||||
|
const profileLink = webUrl ? `${webUrl}/profile/${profile.did}` : null
|
||||||
|
const handleHtml = profileLink
|
||||||
|
? `<a href="${profileLink}" class="profile-handle-link" target="_blank" rel="noopener">@${escapeHtml(profile.handle)}</a>`
|
||||||
|
: `@${escapeHtml(profile.handle)}`
|
||||||
return `
|
return `
|
||||||
<div class="profile">
|
<div class="profile">
|
||||||
${avatar}
|
${avatar}
|
||||||
<div class="profile-info">
|
<div class="profile-info">
|
||||||
<div class="profile-name">${escapeHtml(profile.displayName || profile.handle)}</div>
|
<div class="profile-name">${escapeHtml(profile.displayName || profile.handle)}</div>
|
||||||
<div class="profile-handle">@${escapeHtml(profile.handle)}</div>
|
<div class="profile-handle">${handleHtml}</div>
|
||||||
${profile.description ? `<div class="profile-desc">${escapeHtml(profile.description)}</div>` : ''}
|
${profile.description ? `<div class="profile-desc">${escapeHtml(profile.description)}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -389,22 +399,66 @@ function generateTabsHtml(activeTab: 'blog' | 'browser', handle: string): string
|
|||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function generateLangSelectorHtml(): string {
|
||||||
|
return `
|
||||||
|
<div class="lang-selector" id="lang-selector">
|
||||||
|
<button type="button" class="lang-btn" id="lang-btn" title="Language">
|
||||||
|
${LANG_ICON}
|
||||||
|
</button>
|
||||||
|
<div class="lang-dropdown" id="lang-dropdown">
|
||||||
|
<div class="lang-option" data-lang="ja">
|
||||||
|
<span class="lang-name">日本語</span>
|
||||||
|
<span class="lang-check">✓</span>
|
||||||
|
</div>
|
||||||
|
<div class="lang-option selected" data-lang="en">
|
||||||
|
<span class="lang-name">English</span>
|
||||||
|
<span class="lang-check">✓</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
function generatePostListHtml(posts: BlogPost[]): string {
|
function generatePostListHtml(posts: BlogPost[]): string {
|
||||||
if (posts.length === 0) {
|
if (posts.length === 0) {
|
||||||
return '<p class="no-posts">No posts yet</p>'
|
return '<p class="no-posts">No posts yet</p>'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build translations data for titles
|
||||||
|
const titleTranslations: Record<string, { original: string; translated: string }> = {}
|
||||||
|
posts.forEach(post => {
|
||||||
|
const rkey = post.uri.split('/').pop()
|
||||||
|
if (rkey && post.translations?.en?.title) {
|
||||||
|
titleTranslations[rkey] = {
|
||||||
|
original: post.title,
|
||||||
|
translated: post.translations.en.title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasTranslations = Object.keys(titleTranslations).length > 0
|
||||||
|
const translationScript = hasTranslations
|
||||||
|
? `<script id="title-translations" type="application/json">${JSON.stringify(titleTranslations)}</script>`
|
||||||
|
: ''
|
||||||
|
|
||||||
const items = posts.map(post => {
|
const items = posts.map(post => {
|
||||||
const rkey = post.uri.split('/').pop()
|
const rkey = post.uri.split('/').pop()
|
||||||
|
// Default to English title if available
|
||||||
|
const displayTitle = post.translations?.en?.title || post.title
|
||||||
return `
|
return `
|
||||||
<li class="post-item">
|
<li class="post-item">
|
||||||
<a href="/post/${rkey}/" class="post-link">
|
<a href="/post/${rkey}/" class="post-link">
|
||||||
<span class="post-title">${escapeHtml(post.title)}</span>
|
<span class="post-title" data-rkey="${rkey}">${escapeHtml(displayTitle)}</span>
|
||||||
<span class="post-date">${formatDate(post.createdAt)}</span>
|
<span class="post-date">${formatDate(post.createdAt)}</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
`
|
`
|
||||||
}).join('')
|
}).join('')
|
||||||
return `<ul class="post-list">${items}</ul>`
|
return `
|
||||||
|
<div class="content-header">${generateLangSelectorHtml()}</div>
|
||||||
|
${translationScript}
|
||||||
|
<ul class="post-list">${items}</ul>
|
||||||
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map network to app URL for discussion links
|
// Map network to app URL for discussion links
|
||||||
@@ -418,14 +472,20 @@ function getAppUrl(network: string): string {
|
|||||||
function generatePostDetailHtml(post: BlogPost, handle: string, collection: string, network: string, siteUrl?: string): string {
|
function generatePostDetailHtml(post: BlogPost, handle: string, collection: string, network: string, siteUrl?: string): string {
|
||||||
const rkey = post.uri.split('/').pop() || ''
|
const rkey = post.uri.split('/').pop() || ''
|
||||||
const jsonUrl = `/at/${handle}/${collection}/${rkey}/`
|
const jsonUrl = `/at/${handle}/${collection}/${rkey}/`
|
||||||
const content = marked.parse(post.content) as string
|
const originalContent = marked.parse(post.content) as string
|
||||||
|
|
||||||
|
// Check for English translation
|
||||||
|
const hasTranslation = post.translations?.en?.content
|
||||||
|
const translatedContent = hasTranslation ? marked.parse(post.translations!.en.content) as string : ''
|
||||||
|
|
||||||
|
// Default to English if translation exists
|
||||||
|
const displayContent = hasTranslation ? translatedContent : originalContent
|
||||||
|
|
||||||
// Use siteUrl from config, or construct from handle
|
// Use siteUrl from config, or construct from handle
|
||||||
const baseSiteUrl = siteUrl || `https://${handle}`
|
const baseSiteUrl = siteUrl || `https://${handle}`
|
||||||
const postUrl = `${baseSiteUrl}/post/${rkey}/`
|
const postUrl = `${baseSiteUrl}/post/${rkey}/`
|
||||||
const appUrl = getAppUrl(network)
|
const appUrl = getAppUrl(network)
|
||||||
// Convert to search-friendly format (domain/post/rkey_prefix without https://)
|
// Convert to search-friendly format (domain/post/rkey_prefix without https://)
|
||||||
// Keep total length around 20 chars to avoid URL truncation in posts
|
|
||||||
const MAX_SEARCH_LENGTH = 20
|
|
||||||
const urlObj = new URL(postUrl)
|
const urlObj = new URL(postUrl)
|
||||||
const pathParts = urlObj.pathname.split('/').filter(Boolean)
|
const pathParts = urlObj.pathname.split('/').filter(Boolean)
|
||||||
const basePath = urlObj.host + '/' + (pathParts[0] || '') + '/'
|
const basePath = urlObj.host + '/' + (pathParts[0] || '') + '/'
|
||||||
@@ -434,16 +494,32 @@ function generatePostDetailHtml(post: BlogPost, handle: string, collection: stri
|
|||||||
const searchQuery = basePath + rkeyPrefix
|
const searchQuery = basePath + rkeyPrefix
|
||||||
const searchUrl = `${appUrl}/search?q=${encodeURIComponent(searchQuery)}`
|
const searchUrl = `${appUrl}/search?q=${encodeURIComponent(searchQuery)}`
|
||||||
|
|
||||||
|
// Store translation data in script tag for JS switching
|
||||||
|
const hasTranslatedTitle = !!post.translations?.en?.title
|
||||||
|
const translationScript = hasTranslation
|
||||||
|
? `<script id="translation-data" type="application/json">${JSON.stringify({
|
||||||
|
original: originalContent,
|
||||||
|
translated: translatedContent,
|
||||||
|
originalTitle: post.title,
|
||||||
|
translatedTitle: post.translations?.en?.title || post.title
|
||||||
|
})}</script>`
|
||||||
|
: ''
|
||||||
|
|
||||||
|
// Default to English title if available
|
||||||
|
const displayTitle = post.translations?.en?.title || post.title
|
||||||
|
|
||||||
return `
|
return `
|
||||||
|
<div class="content-header">${generateLangSelectorHtml()}</div>
|
||||||
|
${translationScript}
|
||||||
<article class="post-detail">
|
<article class="post-detail">
|
||||||
<header class="post-header">
|
<header class="post-header">
|
||||||
<h1 class="post-title">${escapeHtml(post.title)}</h1>
|
<h1 class="post-title" id="post-detail-title">${escapeHtml(displayTitle)}</h1>
|
||||||
<div class="post-meta">
|
<div class="post-meta">
|
||||||
<time class="post-date">${formatDate(post.createdAt)}</time>
|
<time class="post-date">${formatDate(post.createdAt)}</time>
|
||||||
<a href="${jsonUrl}" class="json-btn">json</a>
|
<a href="${jsonUrl}" class="json-btn">json</a>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="post-content">${content}</div>
|
<div class="post-content">${displayContent}</div>
|
||||||
</article>
|
</article>
|
||||||
<div class="discussion-section">
|
<div class="discussion-section">
|
||||||
<a href="${searchUrl}" target="_blank" rel="noopener" class="discuss-link">
|
<a href="${searchUrl}" target="_blank" rel="noopener" class="discuss-link">
|
||||||
@@ -457,46 +533,90 @@ function generatePostDetailHtml(post: BlogPost, handle: string, collection: stri
|
|||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateFooterHtml(handle: string): string {
|
interface FooterLink {
|
||||||
|
name: string
|
||||||
|
url: string
|
||||||
|
icon?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadLinks(): FooterLink[] {
|
||||||
|
const linksPath = path.join(process.cwd(), 'public/links.json')
|
||||||
|
if (!fs.existsSync(linksPath)) return []
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(fs.readFileSync(linksPath, 'utf-8'))
|
||||||
|
return data.links || []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateFooterLinksHtml(links: FooterLink[]): string {
|
||||||
|
if (links.length === 0) return ''
|
||||||
|
|
||||||
|
const items = links.map(link => {
|
||||||
|
let iconHtml = ''
|
||||||
|
if (link.icon && BUILTIN_ICONS[link.icon]) {
|
||||||
|
iconHtml = BUILTIN_ICONS[link.icon]
|
||||||
|
} else {
|
||||||
|
// Fallback to favicon
|
||||||
|
const domain = new URL(link.url).hostname
|
||||||
|
iconHtml = `<img src="https://www.google.com/s2/favicons?domain=${domain}&sz=32" alt="" class="footer-link-favicon">`
|
||||||
|
}
|
||||||
|
return `
|
||||||
|
<a href="${escapeHtml(link.url)}" class="footer-link-item" title="${escapeHtml(link.name)}" target="_blank" rel="noopener">
|
||||||
|
${iconHtml}
|
||||||
|
</a>
|
||||||
|
`
|
||||||
|
}).join('')
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="footer-links">
|
||||||
|
${items}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateFooterHtml(handle: string, links: FooterLink[]): string {
|
||||||
const username = handle.split('.')[0] || handle
|
const username = handle.split('.')[0] || handle
|
||||||
return `
|
return `
|
||||||
|
${generateFooterLinksHtml(links)}
|
||||||
<footer class="site-footer">
|
<footer class="site-footer">
|
||||||
<p>© ${username}</p>
|
<p>© ${username}</p>
|
||||||
</footer>
|
</footer>
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateIndexPageContent(profile: Profile, posts: BlogPost[], config: AppConfig, collections: string[]): string {
|
function generateIndexPageContent(profile: Profile, posts: BlogPost[], config: AppConfig, collections: string[], links: FooterLink[], webUrl?: string): string {
|
||||||
return `
|
return `
|
||||||
<header id="header">${generateHeaderHtml(config.handle)}</header>
|
<header id="header">${generateHeaderHtml(config.handle)}</header>
|
||||||
<main>
|
<main>
|
||||||
<section id="profile">
|
<section id="profile">
|
||||||
${generateTabsHtml('blog', config.handle)}
|
${generateTabsHtml('blog', config.handle)}
|
||||||
${generateProfileHtml(profile)}
|
${generateProfileHtml(profile, webUrl)}
|
||||||
${generateServicesHtml(profile.did, config.handle, collections)}
|
${generateServicesHtml(profile.did, config.handle, collections)}
|
||||||
</section>
|
</section>
|
||||||
<section id="content">
|
<section id="content">
|
||||||
${generatePostListHtml(posts)}
|
${generatePostListHtml(posts)}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
${generateFooterHtml(config.handle)}
|
${generateFooterHtml(config.handle, links)}
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
function generatePostPageContent(profile: Profile, post: BlogPost, config: AppConfig, collections: string[]): string {
|
function generatePostPageContent(profile: Profile, post: BlogPost, config: AppConfig, collections: string[], links: FooterLink[], webUrl?: string): string {
|
||||||
return `
|
return `
|
||||||
<header id="header">${generateHeaderHtml(config.handle)}</header>
|
<header id="header">${generateHeaderHtml(config.handle)}</header>
|
||||||
<main>
|
<main>
|
||||||
<section id="profile">
|
<section id="profile">
|
||||||
${generateTabsHtml('blog', config.handle)}
|
${generateTabsHtml('blog', config.handle)}
|
||||||
${generateProfileHtml(profile)}
|
${generateProfileHtml(profile, webUrl)}
|
||||||
${generateServicesHtml(profile.did, config.handle, collections)}
|
${generateServicesHtml(profile.did, config.handle, collections)}
|
||||||
</section>
|
</section>
|
||||||
<section id="content">
|
<section id="content">
|
||||||
${generatePostDetailHtml(post, config.handle, config.collection, config.network, config.siteUrl)}
|
${generatePostDetailHtml(post, config.handle, config.collection, config.network, config.siteUrl)}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
${generateFooterHtml(config.handle)}
|
${generateFooterHtml(config.handle, links)}
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -588,16 +708,32 @@ async function generate() {
|
|||||||
const localPosts = localDid ? loadPostsFromFiles(localDid, config.collection) : []
|
const localPosts = localDid ? loadPostsFromFiles(localDid, config.collection) : []
|
||||||
console.log(`Found ${localPosts.length} posts from local`)
|
console.log(`Found ${localPosts.length} posts from local`)
|
||||||
|
|
||||||
// Merge: API is the source of truth
|
// Merge: API is the source of truth for content, but local has translations
|
||||||
// - If post exists in API: always use API (has latest edits)
|
// - If post exists in both: use API data but merge translations from local
|
||||||
// - If post exists in local only: keep if not deleted (for posts beyond API limit)
|
// - If post exists in API only: use API
|
||||||
|
// - If post exists in local only: keep (for posts beyond API limit)
|
||||||
|
const localPostMap = new Map<string, BlogPost>()
|
||||||
|
for (const post of localPosts) {
|
||||||
|
const rkey = post.uri.split('/').pop()
|
||||||
|
if (rkey) localPostMap.set(rkey, post)
|
||||||
|
}
|
||||||
|
|
||||||
const apiRkeys = new Set(apiPosts.map(p => p.uri.split('/').pop()))
|
const apiRkeys = new Set(apiPosts.map(p => p.uri.split('/').pop()))
|
||||||
|
|
||||||
|
// Merge API posts with local translations
|
||||||
|
const mergedApiPosts = apiPosts.map(apiPost => {
|
||||||
|
const rkey = apiPost.uri.split('/').pop()
|
||||||
|
const localPost = rkey ? localPostMap.get(rkey) : undefined
|
||||||
|
if (localPost?.translations && !apiPost.translations) {
|
||||||
|
return { ...apiPost, translations: localPost.translations }
|
||||||
|
}
|
||||||
|
return apiPost
|
||||||
|
})
|
||||||
|
|
||||||
// Local posts that don't exist in API (older posts beyond 100 limit)
|
// Local posts that don't exist in API (older posts beyond 100 limit)
|
||||||
// Note: these might be deleted posts, so we keep them cautiously
|
|
||||||
const oldLocalPosts = localPosts.filter(p => !apiRkeys.has(p.uri.split('/').pop()))
|
const oldLocalPosts = localPosts.filter(p => !apiRkeys.has(p.uri.split('/').pop()))
|
||||||
|
|
||||||
posts = [...apiPosts, ...oldLocalPosts].sort((a, b) =>
|
posts = [...mergedApiPosts, ...oldLocalPosts].sort((a, b) =>
|
||||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -615,8 +751,14 @@ async function generate() {
|
|||||||
// Load collections for services display
|
// Load collections for services display
|
||||||
const collections = loadCollections(did)
|
const collections = loadCollections(did)
|
||||||
|
|
||||||
|
// Load footer links
|
||||||
|
const links = loadLinks()
|
||||||
|
|
||||||
|
// Get web URL for profile links
|
||||||
|
const webUrl = network.web
|
||||||
|
|
||||||
// Generate index page
|
// Generate index page
|
||||||
const indexContent = generateIndexPageContent(profile, posts, config, collections)
|
const indexContent = generateIndexPageContent(profile, posts, config, collections, links, webUrl)
|
||||||
const indexHtml = generateHtml(config.title, indexContent, config, assets)
|
const indexHtml = generateHtml(config.title, indexContent, config, assets)
|
||||||
fs.writeFileSync(path.join(distDir, 'index.html'), indexHtml)
|
fs.writeFileSync(path.join(distDir, 'index.html'), indexHtml)
|
||||||
console.log('Generated: /index.html')
|
console.log('Generated: /index.html')
|
||||||
@@ -649,7 +791,7 @@ async function generate() {
|
|||||||
fs.mkdirSync(postDir, { recursive: true })
|
fs.mkdirSync(postDir, { recursive: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
const postContent = generatePostPageContent(profile, post, config, collections)
|
const postContent = generatePostPageContent(profile, post, config, collections, links, webUrl)
|
||||||
const postHtml = generateHtml(post.title, postContent, config, assets)
|
const postHtml = generateHtml(post.title, postContent, config, assets)
|
||||||
fs.writeFileSync(path.join(postDir, 'index.html'), postHtml)
|
fs.writeFileSync(path.join(postDir, 'index.html'), postHtml)
|
||||||
console.log(`Generated: /post/${rkey}/index.html`)
|
console.log(`Generated: /post/${rkey}/index.html`)
|
||||||
@@ -670,14 +812,22 @@ async function generate() {
|
|||||||
console.log('Generated: /app.html')
|
console.log('Generated: /app.html')
|
||||||
|
|
||||||
// Generate _redirects for Cloudflare Pages (SPA routes)
|
// Generate _redirects for Cloudflare Pages (SPA routes)
|
||||||
const redirects = `/app / 301
|
// Static files (index.html, post/*/index.html) are served automatically
|
||||||
/oauth/* /app.html 200
|
// Dynamic routes are rewritten to app.html
|
||||||
|
const redirects = `/oauth/* /app.html 200
|
||||||
|
/at/* /app.html 200
|
||||||
|
/new /app.html 200
|
||||||
|
/app /app.html 200
|
||||||
`
|
`
|
||||||
fs.writeFileSync(path.join(distDir, '_redirects'), redirects)
|
fs.writeFileSync(path.join(distDir, '_redirects'), redirects)
|
||||||
console.log('Generated: /_redirects')
|
console.log('Generated: /_redirects')
|
||||||
|
|
||||||
|
// Generate 404.html as SPA fallback for unmatched routes (like /@handle)
|
||||||
|
fs.writeFileSync(path.join(distDir, '404.html'), spaHtml)
|
||||||
|
console.log('Generated: /404.html')
|
||||||
|
|
||||||
// Copy static files
|
// Copy static files
|
||||||
const filesToCopy = ['favicon.png', 'favicon.svg', 'config.json', 'networks.json', 'client-metadata.json']
|
const filesToCopy = ['favicon.ico', 'favicon.png', 'favicon.svg', 'config.json', 'networks.json', 'client-metadata.json', 'links.json']
|
||||||
for (const file of filesToCopy) {
|
for (const file of filesToCopy) {
|
||||||
const src = path.join(process.cwd(), 'public', file)
|
const src = path.join(process.cwd(), 'public', file)
|
||||||
const dest = path.join(distDir, file)
|
const dest = path.join(distDir, file)
|
||||||
@@ -693,6 +843,13 @@ async function generate() {
|
|||||||
fs.cpSync(wellKnownSrc, wellKnownDest, { recursive: true })
|
fs.cpSync(wellKnownSrc, wellKnownDest, { recursive: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Copy pkg directory (icomoon fonts, etc.)
|
||||||
|
const pkgSrc = path.join(process.cwd(), 'public/pkg')
|
||||||
|
const pkgDest = path.join(distDir, 'pkg')
|
||||||
|
if (fs.existsSync(pkgSrc)) {
|
||||||
|
fs.cpSync(pkgSrc, pkgDest, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
// Copy favicons from content
|
// Copy favicons from content
|
||||||
const faviconSrc = getFaviconDir(did)
|
const faviconSrc = getFaviconDir(did)
|
||||||
const faviconDest = path.join(distDir, 'favicons')
|
const faviconDest = path.join(distDir, 'favicons')
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
import { describeRepo, listRecordsRaw, getRecordRaw, fetchLexicon, resolveHandle, getServiceInfo, resolvePds, getPlc } from '../lib/api.js'
|
import { describeRepo, listRecordsRaw, getRecordRaw, fetchLexicon, resolveHandle, getServiceInfo, resolvePds, getPlcForPds } from '../lib/api.js'
|
||||||
import { deleteRecord } from '../lib/auth.js'
|
import { deleteRecord } from '../lib/auth.js'
|
||||||
import { escapeHtml } from '../lib/utils.js'
|
import { escapeHtml } from '../lib/utils.js'
|
||||||
|
import type { Networks } from '../types.js'
|
||||||
|
|
||||||
|
// Cache networks config
|
||||||
|
let networksConfig: Networks | null = null
|
||||||
|
|
||||||
|
async function loadNetworks(): Promise<Networks> {
|
||||||
|
if (networksConfig) return networksConfig
|
||||||
|
const res = await fetch('/networks.json')
|
||||||
|
networksConfig = await res.json()
|
||||||
|
return networksConfig!
|
||||||
|
}
|
||||||
|
|
||||||
function extractRkey(uri: string): string {
|
function extractRkey(uri: string): string {
|
||||||
const parts = uri.split('/')
|
const parts = uri.split('/')
|
||||||
@@ -8,13 +19,15 @@ function extractRkey(uri: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function renderServices(did: string, handle: string): Promise<string> {
|
async function renderServices(did: string, handle: string): Promise<string> {
|
||||||
const [collections, pds] = await Promise.all([
|
const [collections, pds, networks] = await Promise.all([
|
||||||
describeRepo(did),
|
describeRepo(did),
|
||||||
resolvePds(did)
|
resolvePds(did),
|
||||||
|
loadNetworks()
|
||||||
])
|
])
|
||||||
|
|
||||||
// Server info section
|
// Server info section - use PLC based on PDS
|
||||||
const plcUrl = `${getPlc()}/${did}/log`
|
const plc = getPlcForPds(pds, networks)
|
||||||
|
const plcUrl = `${plc}/${did}/log`
|
||||||
const serverHtml = `
|
const serverHtml = `
|
||||||
<div class="server-info">
|
<div class="server-info">
|
||||||
<h3>Server</h3>
|
<h3>Server</h3>
|
||||||
@@ -36,14 +49,14 @@ async function renderServices(did: string, handle: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Group by service domain
|
// Group by service domain
|
||||||
const serviceMap = new Map<string, { name: string; favicon: string; count: number }>()
|
const serviceMap = new Map<string, { name: string; favicon: string; faviconFallback: string; count: number }>()
|
||||||
|
|
||||||
for (const col of collections) {
|
for (const col of collections) {
|
||||||
const info = getServiceInfo(col)
|
const info = getServiceInfo(col)
|
||||||
if (info) {
|
if (info) {
|
||||||
const key = info.domain
|
const key = info.domain
|
||||||
if (!serviceMap.has(key)) {
|
if (!serviceMap.has(key)) {
|
||||||
serviceMap.set(key, { name: info.name, favicon: info.favicon, count: 0 })
|
serviceMap.set(key, { name: info.name, favicon: info.favicon, faviconFallback: info.faviconFallback, count: 0 })
|
||||||
}
|
}
|
||||||
serviceMap.get(key)!.count++
|
serviceMap.get(key)!.count++
|
||||||
}
|
}
|
||||||
@@ -53,7 +66,7 @@ async function renderServices(did: string, handle: string): Promise<string> {
|
|||||||
return `
|
return `
|
||||||
<li class="service-list-item">
|
<li class="service-list-item">
|
||||||
<a href="/at/${handle}/${domain}" class="service-list-link">
|
<a href="/at/${handle}/${domain}" class="service-list-link">
|
||||||
<img src="${info.favicon}" class="service-list-favicon" alt="" onerror="this.style.display='none'">
|
<img src="${info.favicon}" class="service-list-favicon" alt="" onerror="this.src='${info.faviconFallback}'; this.onerror=null;">
|
||||||
<span class="service-list-name">${info.name}</span>
|
<span class="service-list-name">${info.name}</span>
|
||||||
<span class="service-list-count">${info.count}</span>
|
<span class="service-list-count">${info.count}</span>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ export function renderHeader(currentHandle: string, isLoggedIn: boolean, userHan
|
|||||||
</svg>
|
</svg>
|
||||||
</button>`
|
</button>`
|
||||||
|
|
||||||
|
// Use logged-in user's handle for input if available
|
||||||
|
const inputHandle = isLoggedIn && userHandle ? userHandle : currentHandle
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<form class="header-form" id="header-form">
|
<form class="header-form" id="header-form">
|
||||||
@@ -21,7 +24,7 @@ export function renderHeader(currentHandle: string, isLoggedIn: boolean, userHan
|
|||||||
class="header-input"
|
class="header-input"
|
||||||
id="header-input"
|
id="header-input"
|
||||||
placeholder="handle (e.g., syui.ai)"
|
placeholder="handle (e.g., syui.ai)"
|
||||||
value="${currentHandle}"
|
value="${inputHandle}"
|
||||||
>
|
>
|
||||||
<button type="submit" class="header-btn at-btn" title="Browse">@</button>
|
<button type="submit" class="header-btn at-btn" title="Browse">@</button>
|
||||||
${loginBtn}
|
${loginBtn}
|
||||||
@@ -86,16 +89,3 @@ export function mountHeader(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep old function for compatibility
|
|
||||||
export function mountBrowser(
|
|
||||||
container: HTMLElement,
|
|
||||||
currentHandle: string,
|
|
||||||
onSubmit: (handle: string) => void
|
|
||||||
): void {
|
|
||||||
mountHeader(container, currentHandle, false, undefined, {
|
|
||||||
onBrowse: onSubmit,
|
|
||||||
onLogin: () => {},
|
|
||||||
onLogout: () => {}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { searchPostsForUrl } from '../lib/api.js'
|
import { searchPostsForUrl } from '../lib/api.js'
|
||||||
import { escapeHtml } from '../lib/utils.js'
|
import { escapeHtml, formatDate } from '../lib/utils.js'
|
||||||
|
import { MAX_SEARCH_LENGTH, DISCUSSION_POST_LIMIT } from '../lib/constants.js'
|
||||||
|
|
||||||
// Map network to app URL
|
// Map network to app URL
|
||||||
export function getAppUrl(network: string): string {
|
export function getAppUrl(network: string): string {
|
||||||
@@ -9,15 +10,6 @@ export function getAppUrl(network: string): string {
|
|||||||
return 'https://bsky.app'
|
return 'https://bsky.app'
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(dateStr: string): string {
|
|
||||||
const date = new Date(dateStr)
|
|
||||||
return date.toLocaleDateString('ja-JP', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPostUrl(uri: string, appUrl: string): string {
|
function getPostUrl(uri: string, appUrl: string): string {
|
||||||
// at://did:plc:xxx/app.bsky.feed.post/rkey -> {appUrl}/profile/did:plc:xxx/post/rkey
|
// at://did:plc:xxx/app.bsky.feed.post/rkey -> {appUrl}/profile/did:plc:xxx/post/rkey
|
||||||
const parts = uri.replace('at://', '').split('/')
|
const parts = uri.replace('at://', '').split('/')
|
||||||
@@ -29,8 +21,6 @@ function getPostUrl(uri: string, appUrl: string): string {
|
|||||||
|
|
||||||
export function renderDiscussionLink(postUrl: string, appUrl: string = 'https://bsky.app'): string {
|
export function renderDiscussionLink(postUrl: string, appUrl: string = 'https://bsky.app'): string {
|
||||||
// Convert full URL to search-friendly format (domain/post/rkey_prefix without https://)
|
// Convert full URL to search-friendly format (domain/post/rkey_prefix without https://)
|
||||||
// Keep total length around 20 chars to avoid URL truncation in posts
|
|
||||||
const MAX_SEARCH_LENGTH = 20
|
|
||||||
let searchQuery = postUrl
|
let searchQuery = postUrl
|
||||||
try {
|
try {
|
||||||
const urlObj = new URL(postUrl)
|
const urlObj = new URL(postUrl)
|
||||||
@@ -74,7 +64,7 @@ export async function loadDiscussionPosts(container: HTMLElement, postUrl: strin
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const postsHtml = posts.slice(0, 10).map(post => {
|
const postsHtml = posts.slice(0, DISCUSSION_POST_LIMIT).map(post => {
|
||||||
const author = post.author
|
const author = post.author
|
||||||
const avatar = author.avatar || ''
|
const avatar = author.avatar || ''
|
||||||
const displayName = author.displayName || author.handle
|
const displayName = author.displayName || author.handle
|
||||||
|
|||||||
@@ -1,19 +1,10 @@
|
|||||||
import type { BlogPost } from '../types.js'
|
import type { BlogPost } from '../types.js'
|
||||||
import { putRecord } from '../lib/auth.js'
|
import { putRecord } from '../lib/auth.js'
|
||||||
import { renderMarkdown } from '../lib/markdown.js'
|
import { renderMarkdown } from '../lib/markdown.js'
|
||||||
import { escapeHtml } from '../lib/utils.js'
|
import { escapeHtml, formatDate } from '../lib/utils.js'
|
||||||
import { renderDiscussionLink, loadDiscussionPosts, getAppUrl } from './discussion.js'
|
import { renderDiscussionLink, loadDiscussionPosts, getAppUrl } from './discussion.js'
|
||||||
|
|
||||||
function formatDate(dateStr: string): string {
|
export function mountPostList(container: HTMLElement, posts: BlogPost[], userHandle?: string): void {
|
||||||
const date = new Date(dateStr)
|
|
||||||
return date.toLocaleDateString('ja-JP', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mountPostList(container: HTMLElement, posts: BlogPost[]): void {
|
|
||||||
if (posts.length === 0) {
|
if (posts.length === 0) {
|
||||||
container.innerHTML = '<p class="no-posts">No posts yet</p>'
|
container.innerHTML = '<p class="no-posts">No posts yet</p>'
|
||||||
return
|
return
|
||||||
@@ -21,9 +12,11 @@ export function mountPostList(container: HTMLElement, posts: BlogPost[]): void {
|
|||||||
|
|
||||||
const html = posts.map(post => {
|
const html = posts.map(post => {
|
||||||
const rkey = post.uri.split('/').pop()
|
const rkey = post.uri.split('/').pop()
|
||||||
|
// Use /@handle/post/rkey for user pages, /post/rkey for own blog
|
||||||
|
const postUrl = userHandle ? `/@${userHandle}/post/${rkey}` : `/post/${rkey}`
|
||||||
return `
|
return `
|
||||||
<li class="post-item">
|
<li class="post-item">
|
||||||
<a href="/post/${rkey}" class="post-link">
|
<a href="${postUrl}" class="post-link">
|
||||||
<span class="post-title">${escapeHtml(post.title)}</span>
|
<span class="post-title">${escapeHtml(post.title)}</span>
|
||||||
<span class="post-date">${formatDate(post.createdAt)}</span>
|
<span class="post-date">${formatDate(post.createdAt)}</span>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -1,18 +1,23 @@
|
|||||||
import type { Profile } from '../types.js'
|
import type { Profile } from '../types.js'
|
||||||
|
import { escapeHtml } from '../lib/utils.js'
|
||||||
|
|
||||||
export function renderProfile(profile: Profile): string {
|
export function renderProfile(profile: Profile, webUrl?: string): string {
|
||||||
|
const profileLink = webUrl ? `${webUrl}/profile/${profile.did}` : null
|
||||||
|
const handleHtml = profileLink
|
||||||
|
? `<a href="${escapeHtml(profileLink)}" class="profile-handle-link" target="_blank" rel="noopener">@${escapeHtml(profile.handle)}</a>`
|
||||||
|
: `@${escapeHtml(profile.handle)}`
|
||||||
return `
|
return `
|
||||||
<div class="profile">
|
<div class="profile">
|
||||||
${profile.avatar ? `<img src="${profile.avatar}" alt="avatar" class="profile-avatar">` : ''}
|
${profile.avatar ? `<img src="${escapeHtml(profile.avatar)}" alt="avatar" class="profile-avatar">` : ''}
|
||||||
<div class="profile-info">
|
<div class="profile-info">
|
||||||
<h1 class="profile-name">${profile.displayName || profile.handle}</h1>
|
<h1 class="profile-name">${escapeHtml(profile.displayName || profile.handle)}</h1>
|
||||||
<p class="profile-handle">@${profile.handle}</p>
|
<p class="profile-handle">${handleHtml}</p>
|
||||||
${profile.description ? `<p class="profile-desc">${profile.description}</p>` : ''}
|
${profile.description ? `<p class="profile-desc">${escapeHtml(profile.description)}</p>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mountProfile(container: HTMLElement, profile: Profile): void {
|
export function mountProfile(container: HTMLElement, profile: Profile, webUrl?: string): void {
|
||||||
container.innerHTML = renderProfile(profile)
|
container.innerHTML = renderProfile(profile, webUrl)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,14 +9,14 @@ export async function renderServices(handle: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Group by service
|
// Group by service
|
||||||
const serviceMap = new Map<string, { name: string; favicon: string; collections: string[] }>()
|
const serviceMap = new Map<string, { name: string; favicon: string; faviconFallback: string; collections: string[] }>()
|
||||||
|
|
||||||
for (const col of collections) {
|
for (const col of collections) {
|
||||||
const info = getServiceInfo(col)
|
const info = getServiceInfo(col)
|
||||||
if (info) {
|
if (info) {
|
||||||
const key = info.domain
|
const key = info.domain
|
||||||
if (!serviceMap.has(key)) {
|
if (!serviceMap.has(key)) {
|
||||||
serviceMap.set(key, { name: info.name, favicon: info.favicon, collections: [] })
|
serviceMap.set(key, { name: info.name, favicon: info.favicon, faviconFallback: info.faviconFallback, collections: [] })
|
||||||
}
|
}
|
||||||
serviceMap.get(key)!.collections.push(col)
|
serviceMap.get(key)!.collections.push(col)
|
||||||
}
|
}
|
||||||
@@ -27,7 +27,7 @@ export async function renderServices(handle: string): Promise<string> {
|
|||||||
|
|
||||||
return `
|
return `
|
||||||
<a href="${url}" class="service-item" title="${info.collections.join(', ')}">
|
<a href="${url}" class="service-item" title="${info.collections.join(', ')}">
|
||||||
<img src="${info.favicon}" class="service-favicon" alt="" onerror="this.style.display='none'">
|
<img src="${info.favicon}" class="service-favicon" alt="" onerror="this.src='${info.faviconFallback}'; this.onerror=null;">
|
||||||
<span class="service-name">${info.name}</span>
|
<span class="service-name">${info.name}</span>
|
||||||
</a>
|
</a>
|
||||||
`
|
`
|
||||||
|
|||||||
109
src/lib/api.ts
109
src/lib/api.ts
@@ -1,5 +1,6 @@
|
|||||||
import { AtpAgent } from '@atproto/api'
|
import { AtpAgent } from '@atproto/api'
|
||||||
import type { Profile, BlogPost, NetworkConfig } from '../types.js'
|
import type { Profile, BlogPost, NetworkConfig } from '../types.js'
|
||||||
|
import { FALLBACK_PLCS, FALLBACK_BSKY_ENDPOINTS, SEARCH_TIMEOUT_MS } from './constants.js'
|
||||||
|
|
||||||
const agents: Map<string, AtpAgent> = new Map()
|
const agents: Map<string, AtpAgent> = new Map()
|
||||||
|
|
||||||
@@ -13,6 +14,33 @@ export function getPlc(): string {
|
|||||||
return networkConfig?.plc || 'https://plc.directory'
|
return networkConfig?.plc || 'https://plc.directory'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get PLC URL based on PDS endpoint
|
||||||
|
export function getPlcForPds(pds: string, networks: Record<string, { plc: string; bsky: string; web?: string }>): string {
|
||||||
|
// Check if PDS matches any network
|
||||||
|
for (const [_key, config] of Object.entries(networks)) {
|
||||||
|
// Match by domain (e.g., "https://syu.is" or "https://bsky.syu.is")
|
||||||
|
try {
|
||||||
|
const pdsHost = new URL(pds).hostname
|
||||||
|
const bskyHost = new URL(config.bsky).hostname
|
||||||
|
// Check if PDS host matches network's bsky host
|
||||||
|
if (pdsHost === bskyHost || pdsHost.endsWith('.' + bskyHost)) {
|
||||||
|
return config.plc
|
||||||
|
}
|
||||||
|
// Also check web host if available
|
||||||
|
if (config.web) {
|
||||||
|
const webHost = new URL(config.web).hostname
|
||||||
|
if (pdsHost === webHost || pdsHost.endsWith('.' + webHost)) {
|
||||||
|
return config.plc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Default to plc.directory
|
||||||
|
return 'https://plc.directory'
|
||||||
|
}
|
||||||
|
|
||||||
function getBsky(): string {
|
function getBsky(): string {
|
||||||
return networkConfig?.bsky || 'https://public.api.bsky.app'
|
return networkConfig?.bsky || 'https://public.api.bsky.app'
|
||||||
}
|
}
|
||||||
@@ -24,18 +52,6 @@ export function getAgent(service: string): AtpAgent {
|
|||||||
return agents.get(service)!
|
return agents.get(service)!
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback PLC directories
|
|
||||||
const FALLBACK_PLCS = [
|
|
||||||
'https://plc.directory',
|
|
||||||
'https://plc.syu.is',
|
|
||||||
]
|
|
||||||
|
|
||||||
// Fallback endpoints for handle/profile resolution
|
|
||||||
const FALLBACK_ENDPOINTS = [
|
|
||||||
'https://public.api.bsky.app',
|
|
||||||
'https://bsky.syu.is',
|
|
||||||
]
|
|
||||||
|
|
||||||
export async function resolvePds(did: string): Promise<string> {
|
export async function resolvePds(did: string): Promise<string> {
|
||||||
// Try current PLC first, then fallbacks
|
// Try current PLC first, then fallbacks
|
||||||
const plcs = [getPlc(), ...FALLBACK_PLCS.filter(p => p !== getPlc())]
|
const plcs = [getPlc(), ...FALLBACK_PLCS.filter(p => p !== getPlc())]
|
||||||
@@ -64,7 +80,7 @@ export async function resolveHandle(handle: string): Promise<string> {
|
|||||||
return res.data.did
|
return res.data.did
|
||||||
} catch {
|
} catch {
|
||||||
// Try fallback endpoints
|
// Try fallback endpoints
|
||||||
for (const endpoint of FALLBACK_ENDPOINTS) {
|
for (const endpoint of FALLBACK_BSKY_ENDPOINTS) {
|
||||||
if (endpoint === getBsky()) continue // Skip if same as current
|
if (endpoint === getBsky()) continue // Skip if same as current
|
||||||
try {
|
try {
|
||||||
const agent = getAgent(endpoint)
|
const agent = getAgent(endpoint)
|
||||||
@@ -80,19 +96,57 @@ export async function resolveHandle(handle: string): Promise<string> {
|
|||||||
|
|
||||||
export async function getProfile(actor: string): Promise<Profile> {
|
export async function getProfile(actor: string): Promise<Profile> {
|
||||||
// Try current network first
|
// Try current network first
|
||||||
const endpoints = [getBsky(), ...FALLBACK_ENDPOINTS.filter(e => e !== getBsky())]
|
const endpoints = [getBsky(), ...FALLBACK_BSKY_ENDPOINTS.filter(e => e !== getBsky())]
|
||||||
|
|
||||||
for (const endpoint of endpoints) {
|
for (const endpoint of endpoints) {
|
||||||
try {
|
try {
|
||||||
const agent = getAgent(endpoint)
|
const agent = getAgent(endpoint)
|
||||||
const res = await agent.getProfile({ actor })
|
const res = await agent.getProfile({ actor })
|
||||||
|
|
||||||
|
let avatar = res.data.avatar
|
||||||
|
let banner = res.data.banner
|
||||||
|
|
||||||
|
// If avatar uses cdn.bsky.app but user's PDS is not bsky, fetch from their PDS
|
||||||
|
const did = res.data.did
|
||||||
|
if (avatar && avatar.includes('cdn.bsky.app')) {
|
||||||
|
try {
|
||||||
|
const pds = await resolvePds(did)
|
||||||
|
// If PDS is not bsky.social/bsky.network, reconstruct avatar URL from blob
|
||||||
|
if (pds && !pds.includes('bsky.social') && !pds.includes('bsky.network')) {
|
||||||
|
const pdsAgent = getAgent(pds)
|
||||||
|
const profileRecord = await pdsAgent.com.atproto.repo.getRecord({
|
||||||
|
repo: did,
|
||||||
|
collection: 'app.bsky.actor.profile',
|
||||||
|
rkey: 'self',
|
||||||
|
})
|
||||||
|
const avatarBlob = (profileRecord.data.value as any)?.avatar
|
||||||
|
if (avatarBlob?.ref) {
|
||||||
|
// ref can be either { $link: "..." } or a CID object with toString()
|
||||||
|
const cid = avatarBlob.ref.$link || avatarBlob.ref.toString?.()
|
||||||
|
if (cid) {
|
||||||
|
avatar = `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const bannerBlob = (profileRecord.data.value as any)?.banner
|
||||||
|
if (bannerBlob?.ref) {
|
||||||
|
const cid = bannerBlob.ref.$link || bannerBlob.ref.toString?.()
|
||||||
|
if (cid) {
|
||||||
|
banner = `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Keep original avatar if blob fetch fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
did: res.data.did,
|
did: res.data.did,
|
||||||
handle: res.data.handle,
|
handle: res.data.handle,
|
||||||
displayName: res.data.displayName,
|
displayName: res.data.displayName,
|
||||||
description: res.data.description,
|
description: res.data.description,
|
||||||
avatar: res.data.avatar,
|
avatar,
|
||||||
banner: res.data.banner,
|
banner,
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
continue
|
continue
|
||||||
@@ -243,8 +297,10 @@ const SERVICE_MAP: Record<string, { domain: string; icon?: string }> = {
|
|||||||
|
|
||||||
// Search Bluesky posts mentioning a URL
|
// Search Bluesky posts mentioning a URL
|
||||||
export async function searchPostsForUrl(url: string): Promise<any[]> {
|
export async function searchPostsForUrl(url: string): Promise<any[]> {
|
||||||
// Search ALL endpoints and merge results (different networks have different indexes)
|
// Only use current network's endpoint - don't cross-search other networks
|
||||||
const endpoints = [getBsky(), ...FALLBACK_ENDPOINTS.filter(e => e !== getBsky())]
|
// This avoids CORS issues with public.api.bsky.app when using different PDS
|
||||||
|
const currentBsky = getBsky()
|
||||||
|
const endpoints = [currentBsky]
|
||||||
|
|
||||||
// Extract search-friendly patterns from URL
|
// Extract search-friendly patterns from URL
|
||||||
// e.g., "https://syui.ai/post/abc123/" -> ["syui.ai/post/abc123", "syui.ai/post"]
|
// e.g., "https://syui.ai/post/abc123/" -> ["syui.ai/post/abc123", "syui.ai/post"]
|
||||||
@@ -270,9 +326,15 @@ export async function searchPostsForUrl(url: string): Promise<any[]> {
|
|||||||
const searchPromises = endpoints.flatMap(endpoint =>
|
const searchPromises = endpoints.flatMap(endpoint =>
|
||||||
searchQueries.map(async query => {
|
searchQueries.map(async query => {
|
||||||
try {
|
try {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), SEARCH_TIMEOUT_MS)
|
||||||
|
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`${endpoint}/xrpc/app.bsky.feed.searchPosts?q=${encodeURIComponent(query)}&limit=20`
|
`${endpoint}/xrpc/app.bsky.feed.searchPosts?q=${encodeURIComponent(query)}&limit=20`,
|
||||||
|
{ signal: controller.signal }
|
||||||
)
|
)
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
|
||||||
if (!res.ok) return []
|
if (!res.ok) return []
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
// Filter posts that actually link to the target URL
|
// Filter posts that actually link to the target URL
|
||||||
@@ -282,6 +344,7 @@ export async function searchPostsForUrl(url: string): Promise<any[]> {
|
|||||||
return embedUri === url || text.includes(url) || embedUri?.includes(url.replace(/\/$/, ''))
|
return embedUri === url || text.includes(url) || embedUri?.includes(url.replace(/\/$/, ''))
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
|
// Silently fail for CORS/network/timeout errors
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -307,14 +370,15 @@ export async function searchPostsForUrl(url: string): Promise<any[]> {
|
|||||||
return allPosts
|
return allPosts
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getServiceInfo(collection: string): { name: string; domain: string; favicon: string } | null {
|
export function getServiceInfo(collection: string): { name: string; domain: string; favicon: string; faviconFallback: string } | null {
|
||||||
// Try to find matching service prefix
|
// Try to find matching service prefix
|
||||||
for (const [prefix, info] of Object.entries(SERVICE_MAP)) {
|
for (const [prefix, info] of Object.entries(SERVICE_MAP)) {
|
||||||
if (collection.startsWith(prefix)) {
|
if (collection.startsWith(prefix)) {
|
||||||
return {
|
return {
|
||||||
name: info.domain,
|
name: info.domain,
|
||||||
domain: info.domain,
|
domain: info.domain,
|
||||||
favicon: info.icon || `https://www.google.com/s2/favicons?domain=${info.domain}&sz=32`
|
favicon: `/favicons/${info.domain}.png`,
|
||||||
|
faviconFallback: info.icon || `https://www.google.com/s2/favicons?domain=${info.domain}&sz=32`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -326,7 +390,8 @@ export function getServiceInfo(collection: string): { name: string; domain: stri
|
|||||||
return {
|
return {
|
||||||
name: domain,
|
name: domain,
|
||||||
domain: domain,
|
domain: domain,
|
||||||
favicon: `https://www.google.com/s2/favicons?domain=${domain}&sz=32`
|
favicon: `/favicons/${domain}.png`,
|
||||||
|
faviconFallback: `https://www.google.com/s2/favicons?domain=${domain}&sz=32`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -98,8 +98,12 @@ export async function restoreSession(): Promise<AuthSession | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// Silently fail for CORS/network errors - don't spam console
|
||||||
|
// Only log if it's not a network error
|
||||||
|
if (err instanceof Error && !err.message.includes('NetworkError') && !err.message.includes('CORS')) {
|
||||||
console.error('Session restore error:', err)
|
console.error('Session restore error:', err)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
19
src/lib/constants.ts
Normal file
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 { marked, Renderer } from 'marked'
|
||||||
import hljs from 'highlight.js/lib/core'
|
import hljs from 'highlight.js/lib/core'
|
||||||
|
import { escapeHtml } from './utils.js'
|
||||||
|
|
||||||
// Import only common languages
|
// Import only common languages
|
||||||
import javascript from 'highlight.js/lib/languages/javascript'
|
import javascript from 'highlight.js/lib/languages/javascript'
|
||||||
@@ -53,13 +54,6 @@ renderer.code = function({ text, lang }: { text: string; lang?: string }) {
|
|||||||
return `<pre><code class="hljs">${highlighted}</code></pre>`
|
return `<pre><code class="hljs">${highlighted}</code></pre>`
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(str: string): string {
|
|
||||||
return str
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
}
|
|
||||||
|
|
||||||
marked.setOptions({
|
marked.setOptions({
|
||||||
breaks: true,
|
breaks: true,
|
||||||
gfm: true,
|
gfm: true,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export interface Route {
|
export interface Route {
|
||||||
type: 'blog' | 'post' | 'browser-services' | 'browser-collections' | 'browser-record' | 'new'
|
type: 'blog' | 'post' | 'browser-services' | 'browser-collections' | 'browser-record' | 'new' | 'user-blog' | 'user-post'
|
||||||
handle?: string
|
handle?: string
|
||||||
collection?: string
|
collection?: string
|
||||||
rkey?: string
|
rkey?: string
|
||||||
@@ -33,6 +33,16 @@ export function parseRoute(pathname: string): Route {
|
|||||||
return { type: 'new' }
|
return { type: 'new' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// /@${handle} - User blog (any user's posts)
|
||||||
|
// /@${handle}/post/${rkey} - User post detail
|
||||||
|
if (parts[0].startsWith('@')) {
|
||||||
|
const handle = parts[0].slice(1) // Remove @ prefix
|
||||||
|
if (parts[1] === 'post' && parts[2]) {
|
||||||
|
return { type: 'user-post', handle, rkey: parts[2] }
|
||||||
|
}
|
||||||
|
return { type: 'user-blog', handle }
|
||||||
|
}
|
||||||
|
|
||||||
// /at/${handle} - Browser services
|
// /at/${handle} - Browser services
|
||||||
// /at/${handle}/${service-or-collection} - Browser collections or records
|
// /at/${handle}/${service-or-collection} - Browser collections or records
|
||||||
// /at/${handle}/${collection}/${rkey} - Browser record detail
|
// /at/${handle}/${collection}/${rkey} - Browser record detail
|
||||||
@@ -75,6 +85,10 @@ export function buildPath(route: Route): string {
|
|||||||
return '/new'
|
return '/new'
|
||||||
case 'post':
|
case 'post':
|
||||||
return `/post/${route.rkey}`
|
return `/post/${route.rkey}`
|
||||||
|
case 'user-blog':
|
||||||
|
return `/@${route.handle}`
|
||||||
|
case 'user-post':
|
||||||
|
return `/@${route.handle}/post/${route.rkey}`
|
||||||
case 'browser-services':
|
case 'browser-services':
|
||||||
return `/at/${route.handle}`
|
return `/at/${route.handle}`
|
||||||
case 'browser-collections':
|
case 'browser-collections':
|
||||||
|
|||||||
@@ -5,3 +5,12 @@ export function escapeHtml(str: string): string {
|
|||||||
.replace(/>/g, '>')
|
.replace(/>/g, '>')
|
||||||
.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 { loadDiscussionPosts } from './components/discussion.js'
|
||||||
import { parseRoute, type Route } from './lib/router.js'
|
import { parseRoute, type Route } from './lib/router.js'
|
||||||
import { escapeHtml } from './lib/utils.js'
|
import { escapeHtml } from './lib/utils.js'
|
||||||
|
import { LANG_ICON, BUILTIN_ICONS } from './lib/icons.js'
|
||||||
import type { AppConfig, Networks } from './types.js'
|
import type { AppConfig, Networks } from './types.js'
|
||||||
|
|
||||||
let authSession: AuthSession | null = null
|
let authSession: AuthSession | null = null
|
||||||
let config: AppConfig
|
let config: AppConfig
|
||||||
let networks: Networks = {}
|
let networks: Networks = {}
|
||||||
let browserNetwork: string = '' // Network for AT Browser
|
let browserNetwork: string = '' // Network for AT Browser
|
||||||
|
let currentLang: string = 'en' // Default language for translations
|
||||||
|
|
||||||
// Browser state
|
// Browser state
|
||||||
let browserMode = false
|
let browserMode = false
|
||||||
@@ -36,16 +38,96 @@ async function loadNetworks(): Promise<Networks> {
|
|||||||
return res.json()
|
return res.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface FooterLink {
|
||||||
|
name: string
|
||||||
|
url: string
|
||||||
|
icon?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
let footerLinks: FooterLink[] = []
|
||||||
|
|
||||||
|
async function loadLinks(): Promise<FooterLink[]> {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/links.json')
|
||||||
|
if (!res.ok) return []
|
||||||
|
const data = await res.json()
|
||||||
|
return data.links || []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function renderFooterLinks(links: FooterLink[]): string {
|
||||||
|
if (links.length === 0) return ''
|
||||||
|
|
||||||
|
const items = links.map(link => {
|
||||||
|
let iconHtml = ''
|
||||||
|
if (link.icon && BUILTIN_ICONS[link.icon]) {
|
||||||
|
iconHtml = BUILTIN_ICONS[link.icon]
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const domain = new URL(link.url).hostname
|
||||||
|
iconHtml = `<img src="https://www.google.com/s2/favicons?domain=${domain}&sz=32" alt="" class="footer-link-favicon">`
|
||||||
|
} catch {
|
||||||
|
iconHtml = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `
|
||||||
|
<a href="${escapeHtml(link.url)}" class="footer-link-item" title="${escapeHtml(link.name)}" target="_blank" rel="noopener">
|
||||||
|
${iconHtml}
|
||||||
|
</a>
|
||||||
|
`
|
||||||
|
}).join('')
|
||||||
|
|
||||||
|
return `<div class="footer-links">${items}</div>`
|
||||||
|
}
|
||||||
|
|
||||||
function renderFooter(handle: string): string {
|
function renderFooter(handle: string): string {
|
||||||
const parts = handle.split('.')
|
const parts = handle.split('.')
|
||||||
const username = parts[0] || handle
|
const username = parts[0] || handle
|
||||||
return `
|
return `
|
||||||
|
${renderFooterLinks(footerLinks)}
|
||||||
<footer class="site-footer">
|
<footer class="site-footer">
|
||||||
<p>© ${username}</p>
|
<p>© ${username}</p>
|
||||||
</footer>
|
</footer>
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detect network from handle domain
|
||||||
|
// e.g., syui.ai → bsky.social, syui.syui.ai → syu.is
|
||||||
|
function detectNetworkFromHandle(handle: string): string {
|
||||||
|
const parts = handle.split('.')
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
// Get domain part (last 2 parts for most cases)
|
||||||
|
const domain = parts.slice(-2).join('.')
|
||||||
|
// Check if domain matches any network key
|
||||||
|
if (networks[domain]) {
|
||||||
|
return domain
|
||||||
|
}
|
||||||
|
// Check if it's a subdomain of a known network
|
||||||
|
for (const networkKey of Object.keys(networks)) {
|
||||||
|
if (handle.endsWith(`.${networkKey}`) || handle.endsWith(networkKey)) {
|
||||||
|
return networkKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Default to bsky.social
|
||||||
|
return 'bsky.social'
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchNetwork(newNetwork: string): void {
|
||||||
|
if (newNetwork === browserNetwork) return
|
||||||
|
browserNetwork = newNetwork
|
||||||
|
localStorage.setItem('browserNetwork', newNetwork)
|
||||||
|
const networkConfig = networks[newNetwork]
|
||||||
|
if (networkConfig) {
|
||||||
|
setNetworkConfig(networkConfig)
|
||||||
|
setAuthNetworkConfig(networkConfig)
|
||||||
|
}
|
||||||
|
updatePdsSelector()
|
||||||
|
}
|
||||||
|
|
||||||
function renderPdsSelector(): string {
|
function renderPdsSelector(): string {
|
||||||
const networkKeys = Object.keys(networks)
|
const networkKeys = Object.keys(networks)
|
||||||
const options = networkKeys.map(key => {
|
const options = networkKeys.map(key => {
|
||||||
@@ -79,10 +161,104 @@ function updatePdsSelector(): void {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTabs(activeTab: 'blog' | 'browser' | 'new', isLoggedIn: boolean): string {
|
function renderLangSelector(): string {
|
||||||
|
const langs = [
|
||||||
|
{ code: 'ja', name: '日本語' },
|
||||||
|
{ code: 'en', name: 'English' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const options = langs.map(lang => {
|
||||||
|
const isSelected = lang.code === currentLang
|
||||||
|
return `<div class="lang-option ${isSelected ? 'selected' : ''}" data-lang="${lang.code}">
|
||||||
|
<span class="lang-name">${lang.name}</span>
|
||||||
|
<span class="lang-check">✓</span>
|
||||||
|
</div>`
|
||||||
|
}).join('')
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="lang-selector" id="lang-selector">
|
||||||
|
<button type="button" class="lang-btn" id="lang-btn" title="Language">
|
||||||
|
${LANG_ICON}
|
||||||
|
</button>
|
||||||
|
<div class="lang-dropdown" id="lang-dropdown">
|
||||||
|
${options}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLangSelector(): void {
|
||||||
|
const dropdown = document.getElementById('lang-dropdown')
|
||||||
|
if (!dropdown) return
|
||||||
|
|
||||||
|
const options = dropdown.querySelectorAll('.lang-option')
|
||||||
|
options.forEach(opt => {
|
||||||
|
const el = opt as HTMLElement
|
||||||
|
const lang = el.dataset.lang
|
||||||
|
const isSelected = lang === currentLang
|
||||||
|
el.classList.toggle('selected', isSelected)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTranslation(): void {
|
||||||
|
const contentEl = document.querySelector('.post-content')
|
||||||
|
const titleEl = document.getElementById('post-detail-title')
|
||||||
|
|
||||||
|
// Get translation data from script tag
|
||||||
|
const scriptEl = document.getElementById('translation-data')
|
||||||
|
if (!scriptEl) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(scriptEl.textContent || '{}')
|
||||||
|
|
||||||
|
// Apply content translation
|
||||||
|
if (contentEl) {
|
||||||
|
if (currentLang === 'en' && data.translated) {
|
||||||
|
contentEl.innerHTML = data.translated
|
||||||
|
} else if (data.original) {
|
||||||
|
contentEl.innerHTML = data.original
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply title translation
|
||||||
|
if (titleEl) {
|
||||||
|
if (currentLang === 'en' && data.translatedTitle) {
|
||||||
|
titleEl.textContent = data.translatedTitle
|
||||||
|
} else if (data.originalTitle) {
|
||||||
|
titleEl.textContent = data.originalTitle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Invalid JSON, ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTitleTranslations(): void {
|
||||||
|
// Get title translations from script tag
|
||||||
|
const scriptEl = document.getElementById('title-translations')
|
||||||
|
if (!scriptEl) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const translations = JSON.parse(scriptEl.textContent || '{}') as Record<string, { original: string; translated: string }>
|
||||||
|
|
||||||
|
// Update each post title
|
||||||
|
document.querySelectorAll('.post-title[data-rkey]').forEach(el => {
|
||||||
|
const rkey = (el as HTMLElement).dataset.rkey
|
||||||
|
if (rkey && translations[rkey]) {
|
||||||
|
const { original, translated } = translations[rkey]
|
||||||
|
el.textContent = currentLang === 'en' ? translated : original
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// Invalid JSON, ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTabs(activeTab: 'blog' | 'browser' | 'new', isLoggedIn: boolean, handle?: string): string {
|
||||||
|
const browserHandle = handle || config.handle
|
||||||
let tabs = `
|
let tabs = `
|
||||||
<a href="/" class="tab ${activeTab === 'blog' ? 'active' : ''}" id="blog-tab">Blog</a>
|
<a href="/" class="tab ${activeTab === 'blog' ? 'active' : ''}" id="blog-tab">Blog</a>
|
||||||
<button type="button" class="tab ${activeTab === 'browser' ? 'active' : ''}" id="browser-tab" data-handle="${config.handle}">Browser</button>
|
<button type="button" class="tab ${activeTab === 'browser' ? 'active' : ''}" id="browser-tab" data-handle="${browserHandle}">Browser</button>
|
||||||
`
|
`
|
||||||
|
|
||||||
if (isLoggedIn) {
|
if (isLoggedIn) {
|
||||||
@@ -101,6 +277,10 @@ function openBrowser(handle: string, service: string | null = null, collection:
|
|||||||
|
|
||||||
if (!contentEl || !tabsEl) return
|
if (!contentEl || !tabsEl) return
|
||||||
|
|
||||||
|
// Auto-detect and switch network based on handle
|
||||||
|
const detectedNetwork = detectNetworkFromHandle(handle)
|
||||||
|
switchNetwork(detectedNetwork)
|
||||||
|
|
||||||
// Save current content if not already in browser mode
|
// Save current content if not already in browser mode
|
||||||
if (!browserMode) {
|
if (!browserMode) {
|
||||||
savedContent = {
|
savedContent = {
|
||||||
@@ -415,6 +595,45 @@ function setupEventHandlers(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lang button - toggle dropdown
|
||||||
|
if (target.id === 'lang-btn' || target.closest('#lang-btn')) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
const dropdown = document.getElementById('lang-dropdown')
|
||||||
|
if (dropdown) {
|
||||||
|
dropdown.classList.toggle('show')
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lang option selection
|
||||||
|
const langOption = target.closest('.lang-option') as HTMLElement
|
||||||
|
if (langOption) {
|
||||||
|
e.preventDefault()
|
||||||
|
const selectedLang = langOption.dataset.lang
|
||||||
|
if (selectedLang && selectedLang !== currentLang) {
|
||||||
|
currentLang = selectedLang
|
||||||
|
localStorage.setItem('preferredLang', selectedLang)
|
||||||
|
updateLangSelector()
|
||||||
|
applyTranslation()
|
||||||
|
applyTitleTranslations()
|
||||||
|
}
|
||||||
|
// Close dropdown
|
||||||
|
const dropdown = document.getElementById('lang-dropdown')
|
||||||
|
if (dropdown) {
|
||||||
|
dropdown.classList.remove('show')
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close lang dropdown when clicking outside
|
||||||
|
if (!target.closest('#lang-selector')) {
|
||||||
|
const dropdown = document.getElementById('lang-dropdown')
|
||||||
|
if (dropdown) {
|
||||||
|
dropdown.classList.remove('show')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// JSON button click (on post detail page)
|
// JSON button click (on post detail page)
|
||||||
const jsonBtn = target.closest('.json-btn') as HTMLAnchorElement
|
const jsonBtn = target.closest('.json-btn') as HTMLAnchorElement
|
||||||
if (jsonBtn) {
|
if (jsonBtn) {
|
||||||
@@ -498,8 +717,10 @@ async function render(): Promise<void> {
|
|||||||
const handle = route.handle || config.handle
|
const handle = route.handle || config.handle
|
||||||
|
|
||||||
// Skip re-rendering for static blog/post pages (but still mount header for login)
|
// Skip re-rendering for static blog/post pages (but still mount header for login)
|
||||||
|
// Exception: if logged in on blog page, re-render to show user's blog
|
||||||
const isStaticRoute = route.type === 'blog' || route.type === 'post'
|
const isStaticRoute = route.type === 'blog' || route.type === 'post'
|
||||||
if (isStatic && isStaticRoute) {
|
const shouldUseStatic = isStatic && isStaticRoute && !(isLoggedIn && route.type === 'blog')
|
||||||
|
if (shouldUseStatic) {
|
||||||
// Only mount header for login functionality (pass isStatic=true to skip unnecessary re-render)
|
// Only mount header for login functionality (pass isStatic=true to skip unnecessary re-render)
|
||||||
mountHeader(headerEl, handle, isLoggedIn, authSession?.handle, {
|
mountHeader(headerEl, handle, isLoggedIn, authSession?.handle, {
|
||||||
onBrowse: (newHandle) => {
|
onBrowse: (newHandle) => {
|
||||||
@@ -552,6 +773,32 @@ async function render(): Promise<void> {
|
|||||||
// For blog top page, check for new posts from API and merge
|
// For blog top page, check for new posts from API and merge
|
||||||
if (route.type === 'blog') {
|
if (route.type === 'blog') {
|
||||||
refreshPostListFromAPI()
|
refreshPostListFromAPI()
|
||||||
|
// Add lang selector above post list
|
||||||
|
const postList = contentEl?.querySelector('.post-list')
|
||||||
|
if (postList && !document.getElementById('lang-selector')) {
|
||||||
|
const contentHeader = document.createElement('div')
|
||||||
|
contentHeader.className = 'content-header'
|
||||||
|
contentHeader.innerHTML = renderLangSelector()
|
||||||
|
postList.parentNode?.insertBefore(contentHeader, postList)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For post detail page, sync lang selector state and apply translation
|
||||||
|
if (route.type === 'post') {
|
||||||
|
// Update lang selector to match current language
|
||||||
|
updateLangSelector()
|
||||||
|
|
||||||
|
// Apply translation based on current language preference
|
||||||
|
const translationScript = document.getElementById('translation-data')
|
||||||
|
if (translationScript) {
|
||||||
|
applyTranslation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For blog index page, sync lang selector state and apply title translations
|
||||||
|
if (route.type === 'blog') {
|
||||||
|
updateLangSelector()
|
||||||
|
applyTitleTranslations()
|
||||||
}
|
}
|
||||||
|
|
||||||
return // Skip content re-rendering
|
return // Skip content re-rendering
|
||||||
@@ -598,10 +845,11 @@ async function render(): Promise<void> {
|
|||||||
case 'post':
|
case 'post':
|
||||||
try {
|
try {
|
||||||
const profile = await getProfile(config.handle)
|
const profile = await getProfile(config.handle)
|
||||||
|
const webUrl = networks[config.network]?.web
|
||||||
profileEl.innerHTML = renderTabs('blog', isLoggedIn)
|
profileEl.innerHTML = renderTabs('blog', isLoggedIn)
|
||||||
const profileContentEl = document.createElement('div')
|
const profileContentEl = document.createElement('div')
|
||||||
profileEl.appendChild(profileContentEl)
|
profileEl.appendChild(profileContentEl)
|
||||||
mountProfile(profileContentEl, profile)
|
mountProfile(profileContentEl, profile, webUrl)
|
||||||
|
|
||||||
const servicesHtml = await renderServices(config.handle)
|
const servicesHtml = await renderServices(config.handle)
|
||||||
profileContentEl.insertAdjacentHTML('beforeend', servicesHtml)
|
profileContentEl.insertAdjacentHTML('beforeend', servicesHtml)
|
||||||
@@ -619,20 +867,83 @@ async function render(): Promise<void> {
|
|||||||
}
|
}
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'blog':
|
case 'user-blog':
|
||||||
default:
|
// /@{handle} - Any user's blog
|
||||||
try {
|
try {
|
||||||
const profile = await getProfile(config.handle)
|
const userHandle = route.handle!
|
||||||
profileEl.innerHTML = renderTabs('blog', isLoggedIn)
|
// Auto-detect and switch network based on handle
|
||||||
|
const detectedNetwork = detectNetworkFromHandle(userHandle)
|
||||||
|
switchNetwork(detectedNetwork)
|
||||||
|
const profile = await getProfile(userHandle)
|
||||||
|
const webUrl = networks[browserNetwork]?.web
|
||||||
|
profileEl.innerHTML = renderTabs('blog', isLoggedIn, userHandle)
|
||||||
const profileContentEl = document.createElement('div')
|
const profileContentEl = document.createElement('div')
|
||||||
profileEl.appendChild(profileContentEl)
|
profileEl.appendChild(profileContentEl)
|
||||||
mountProfile(profileContentEl, profile)
|
mountProfile(profileContentEl, profile, webUrl)
|
||||||
|
|
||||||
const servicesHtml = await renderServices(config.handle)
|
const servicesHtml = await renderServices(userHandle)
|
||||||
profileContentEl.insertAdjacentHTML('beforeend', servicesHtml)
|
profileContentEl.insertAdjacentHTML('beforeend', servicesHtml)
|
||||||
|
|
||||||
const posts = await listRecords(profile.did, config.collection)
|
const posts = await listRecords(profile.did, config.collection)
|
||||||
mountPostList(contentEl, posts)
|
mountPostList(contentEl, posts, userHandle)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
contentEl.innerHTML = `<p class="error">Failed to load: ${err}</p>`
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'user-post':
|
||||||
|
// /@{handle}/post/{rkey} - Any user's post detail
|
||||||
|
try {
|
||||||
|
const userHandle = route.handle!
|
||||||
|
// Auto-detect and switch network based on handle
|
||||||
|
const detectedNetwork = detectNetworkFromHandle(userHandle)
|
||||||
|
switchNetwork(detectedNetwork)
|
||||||
|
const profile = await getProfile(userHandle)
|
||||||
|
const webUrl = networks[browserNetwork]?.web
|
||||||
|
profileEl.innerHTML = renderTabs('blog', isLoggedIn, userHandle)
|
||||||
|
const profileContentEl = document.createElement('div')
|
||||||
|
profileEl.appendChild(profileContentEl)
|
||||||
|
mountProfile(profileContentEl, profile, webUrl)
|
||||||
|
|
||||||
|
const servicesHtml = await renderServices(userHandle)
|
||||||
|
profileContentEl.insertAdjacentHTML('beforeend', servicesHtml)
|
||||||
|
|
||||||
|
const post = await getRecord(profile.did, config.collection, route.rkey!)
|
||||||
|
if (post) {
|
||||||
|
const canEdit = isLoggedIn && authSession?.did === profile.did
|
||||||
|
mountPostDetail(contentEl, post, userHandle, config.collection, canEdit, undefined, browserNetwork)
|
||||||
|
} else {
|
||||||
|
contentEl.innerHTML = '<p>Post not found</p>'
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
contentEl.innerHTML = `<p class="error">Failed to load: ${err}</p>`
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'blog':
|
||||||
|
default:
|
||||||
|
try {
|
||||||
|
// If logged in, show logged-in user's blog instead of site owner's
|
||||||
|
const blogHandle = isLoggedIn ? authSession!.handle : config.handle
|
||||||
|
const detectedNetwork = isLoggedIn ? detectNetworkFromHandle(blogHandle) : config.network
|
||||||
|
if (isLoggedIn) {
|
||||||
|
switchNetwork(detectedNetwork)
|
||||||
|
}
|
||||||
|
const profile = await getProfile(blogHandle)
|
||||||
|
const webUrl = networks[detectedNetwork]?.web
|
||||||
|
profileEl.innerHTML = renderTabs('blog', isLoggedIn, blogHandle)
|
||||||
|
const profileContentEl = document.createElement('div')
|
||||||
|
profileEl.appendChild(profileContentEl)
|
||||||
|
mountProfile(profileContentEl, profile, webUrl)
|
||||||
|
|
||||||
|
const servicesHtml = await renderServices(blogHandle)
|
||||||
|
profileContentEl.insertAdjacentHTML('beforeend', servicesHtml)
|
||||||
|
|
||||||
|
const posts = await listRecords(profile.did, config.collection)
|
||||||
|
// Use handle for post links if logged in user
|
||||||
|
mountPostList(contentEl, posts, isLoggedIn ? blogHandle : undefined)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
contentEl.innerHTML = `<p class="error">Failed to load: ${err}</p>`
|
contentEl.innerHTML = `<p class="error">Failed to load: ${err}</p>`
|
||||||
@@ -642,9 +953,10 @@ async function render(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function init(): Promise<void> {
|
async function init(): Promise<void> {
|
||||||
const [configData, networksData] = await Promise.all([loadConfig(), loadNetworks()])
|
const [configData, networksData, linksData] = await Promise.all([loadConfig(), loadNetworks(), loadLinks()])
|
||||||
config = configData
|
config = configData
|
||||||
networks = networksData
|
networks = networksData
|
||||||
|
footerLinks = linksData
|
||||||
|
|
||||||
// Set page title
|
// Set page title
|
||||||
document.title = config.title || 'ailog'
|
document.title = config.title || 'ailog'
|
||||||
@@ -657,6 +969,9 @@ async function init(): Promise<void> {
|
|||||||
// Initialize browser network from localStorage or default to config.network
|
// Initialize browser network from localStorage or default to config.network
|
||||||
browserNetwork = localStorage.getItem('browserNetwork') || config.network
|
browserNetwork = localStorage.getItem('browserNetwork') || config.network
|
||||||
|
|
||||||
|
// Initialize language preference from localStorage (default: en)
|
||||||
|
currentLang = localStorage.getItem('preferredLang') || 'en'
|
||||||
|
|
||||||
// Set network config based on selected browser network
|
// Set network config based on selected browser network
|
||||||
const selectedNetworkConfig = networks[browserNetwork]
|
const selectedNetworkConfig = networks[browserNetwork]
|
||||||
if (selectedNetworkConfig) {
|
if (selectedNetworkConfig) {
|
||||||
|
|||||||
@@ -95,7 +95,8 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header-btn.at-btn:hover {
|
.header-btn.at-btn:hover {
|
||||||
background: #0052a3;
|
background: var(--btn-color);
|
||||||
|
filter: brightness(0.85);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-btn.login-btn {
|
.header-btn.login-btn {
|
||||||
@@ -217,6 +218,16 @@ body {
|
|||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-handle-link {
|
||||||
|
color: #666;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-handle-link:hover {
|
||||||
|
color: var(--btn-color);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
.profile-desc {
|
.profile-desc {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #444;
|
color: #444;
|
||||||
@@ -796,9 +807,56 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Footer Links */
|
||||||
|
.footer-links {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 20px;
|
||||||
|
margin-top: 40px;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-link-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
color: #666;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-link-item:hover {
|
||||||
|
color: var(--btn-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-link-item svg {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-link-item [class^="icon-"] {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-link-favicon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.footer-link-item {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.footer-link-item:hover {
|
||||||
|
color: var(--btn-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Footer */
|
/* Footer */
|
||||||
.site-footer {
|
.site-footer {
|
||||||
margin-top: 60px;
|
margin-top: 20px;
|
||||||
padding: 20px 0;
|
padding: 20px 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -903,6 +961,125 @@ body {
|
|||||||
color: transparent;
|
color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Language Selector */
|
||||||
|
.lang-selector {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
background: #f0f0f0;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #666;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-btn:hover {
|
||||||
|
background: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-dropdown {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
right: 0;
|
||||||
|
margin-top: 4px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
min-width: 140px;
|
||||||
|
z-index: 100;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-dropdown.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-option {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-option:hover {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-option.selected {
|
||||||
|
background: linear-gradient(135deg, #f0f7ff 0%, #e8f4ff 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-name {
|
||||||
|
color: #333;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-check {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid #ccc;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 10px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-option.selected .lang-check {
|
||||||
|
background: var(--btn-color);
|
||||||
|
border-color: var(--btn-color);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-option:not(.selected) .lang-check {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content Header (above post list) */
|
||||||
|
.content-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.lang-btn {
|
||||||
|
background: #2a2a2a;
|
||||||
|
border-color: #333;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.lang-btn:hover {
|
||||||
|
background: #333;
|
||||||
|
}
|
||||||
|
.lang-dropdown {
|
||||||
|
background: #1a1a1a;
|
||||||
|
border-color: #333;
|
||||||
|
}
|
||||||
|
.lang-option:hover {
|
||||||
|
background: #2a2a2a;
|
||||||
|
}
|
||||||
|
.lang-option.selected {
|
||||||
|
background: linear-gradient(135deg, #1a2a3a 0%, #1a3040 100%);
|
||||||
|
}
|
||||||
|
.lang-name {
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* AT Browser */
|
/* AT Browser */
|
||||||
.server-info {
|
.server-info {
|
||||||
padding: 16px 0;
|
padding: 16px 0;
|
||||||
|
|||||||
@@ -13,11 +13,18 @@ export interface BlogPost {
|
|||||||
title: string
|
title: string
|
||||||
content: string
|
content: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
|
translations?: {
|
||||||
|
[lang: string]: {
|
||||||
|
content: string
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NetworkConfig {
|
export interface NetworkConfig {
|
||||||
plc: string
|
plc: string
|
||||||
bsky: string
|
bsky: string
|
||||||
|
web?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppConfig {
|
export interface AppConfig {
|
||||||
|
|||||||
Reference in New Issue
Block a user