From 3620f03250885fd2220ee741a99f5bebe8e342da Mon Sep 17 00:00:00 2001 From: FFFold Date: Thu, 27 Aug 2026 18:55:48 +0800 Subject: [PATCH 01/10] chore: add qrcode dependency for share poster --- package.json | 2 + pnpm-lock.yaml | 113 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) diff --git a/package.json b/package.json index b39f859..bb197cd 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "mdast-util-to-string": "^4.0.0", "overlayscrollbars": "^2.16.0", "pagefind": "^1.5.2", + "qrcode": "^1.5.4", "reading-time": "^1.5.0", "rehype-autolink-headings": "^7.1.0", "rehype-components": "^0.3.0", @@ -68,6 +69,7 @@ "@rollup/plugin-yaml": "^5.0.0", "@types/hast": "^3.0.5", "@types/mdast": "^4.0.4", + "@types/qrcode": "^1.5.6", "@types/sanitize-html": "^2.16.1" }, "packageManager": "pnpm@9.14.4" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8789fc3..eb47823 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -95,6 +95,9 @@ importers: pagefind: specifier: ^1.5.2 version: 1.5.2 + qrcode: + specifier: ^1.5.4 + version: 1.5.4 reading-time: specifier: ^1.5.0 version: 1.5.0 @@ -159,6 +162,9 @@ importers: '@types/mdast': specifier: ^4.0.4 version: 4.0.4 + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 '@types/sanitize-html': specifier: ^2.16.1 version: 2.16.1 @@ -1954,6 +1960,9 @@ packages: '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + '@types/resolve@1.17.1': resolution: {integrity: sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw==} @@ -2208,6 +2217,10 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + camelcase@6.3.0: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} @@ -2257,6 +2270,9 @@ packages: resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} engines: {node: '>=8'} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -2424,6 +2440,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} @@ -2473,6 +2493,9 @@ packages: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + direction@2.0.1: resolution: {integrity: sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA==} hasBin: true @@ -3818,6 +3841,10 @@ packages: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -4078,6 +4105,11 @@ packages: property-information@7.2.0: resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + radix3@1.1.2: resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} @@ -4224,6 +4256,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -4351,6 +4386,9 @@ packages: serialize-javascript@4.0.0: resolution: {integrity: sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==} + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -4992,6 +5030,9 @@ packages: resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} engines: {node: '>= 0.4'} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which-pm-runs@1.1.0: resolution: {integrity: sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==} engines: {node: '>=4'} @@ -5005,6 +5046,10 @@ packages: engines: {node: '>= 8'} hasBin: true + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -5027,6 +5072,9 @@ packages: xxhash-wasm@1.1.0: resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==} + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -5052,6 +5100,10 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -5060,6 +5112,10 @@ packages: resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} engines: {node: ^20.19.0 || ^22.12.0 || >=23} + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + yargs@17.7.3: resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} engines: {node: '>=12'} @@ -6964,6 +7020,10 @@ snapshots: '@types/parse-json@4.0.2': {} + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 26.2.0 + '@types/resolve@1.17.1': dependencies: '@types/node': 26.2.0 @@ -7320,6 +7380,8 @@ snapshots: callsites@3.1.0: {} + camelcase@5.3.1: {} + camelcase@6.3.0: {} caniuse-api@3.0.0: @@ -7366,6 +7428,12 @@ snapshots: ci-info@4.4.0: {} + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -7555,6 +7623,8 @@ snapshots: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + decode-named-character-reference@1.3.0: dependencies: character-entities: 2.0.2 @@ -7597,6 +7667,8 @@ snapshots: diff@8.0.4: {} + dijkstrajs@1.0.3: {} + direction@2.0.1: {} dom-serializer@1.4.1: @@ -9473,6 +9545,8 @@ snapshots: dependencies: find-up: 4.1.0 + pngjs@5.0.0: {} + possible-typed-array-names@1.1.0: {} postcss-calc@8.2.4(postcss@8.5.26): @@ -9707,6 +9781,12 @@ snapshots: property-information@7.2.0: {} + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + radix3@1.1.2: {} randombytes@2.1.0: @@ -9960,6 +10040,8 @@ snapshots: require-from-string@2.0.2: {} + require-main-filename@2.0.0: {} + resolve-from@4.0.0: {} resolve-from@5.0.0: {} @@ -10142,6 +10224,8 @@ snapshots: dependencies: randombytes: 2.1.0 + set-blocking@2.0.0: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -10861,6 +10945,8 @@ snapshots: is-weakmap: 2.0.2 is-weakset: 2.0.4 + which-module@2.0.1: {} + which-pm-runs@1.1.0: {} which-typed-array@1.1.22: @@ -10877,6 +10963,12 @@ snapshots: dependencies: isexe: 2.0.0 + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -10901,6 +10993,8 @@ snapshots: xxhash-wasm@1.1.0: {} + y18n@4.0.3: {} + y18n@5.0.8: {} yallist@3.1.1: {} @@ -10926,10 +11020,29 @@ snapshots: yaml@2.9.0: {} + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + yargs-parser@21.1.1: {} yargs-parser@22.0.0: {} + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + yargs@17.7.3: dependencies: cliui: 8.0.1 From d5cb2ae7da75fad2d5a8745ab9effd60687c4e82 Mon Sep 17 00:00:00 2001 From: FFFold Date: Thu, 27 Aug 2026 18:57:42 +0800 Subject: [PATCH 02/10] feat: add share poster i18n keys and translations --- src/i18n/i18nKey.ts | 7 +++++++ src/i18n/languages/en.ts | 7 +++++++ src/i18n/languages/es.ts | 7 +++++++ src/i18n/languages/id.ts | 7 +++++++ src/i18n/languages/ja.ts | 7 +++++++ src/i18n/languages/ko.ts | 7 +++++++ src/i18n/languages/th.ts | 7 +++++++ src/i18n/languages/tr.ts | 7 +++++++ src/i18n/languages/vi.ts | 7 +++++++ src/i18n/languages/zh_CN.ts | 7 +++++++ src/i18n/languages/zh_TW.ts | 7 +++++++ src/styles/variables.styl | 1 + 12 files changed, 78 insertions(+) diff --git a/src/i18n/i18nKey.ts b/src/i18n/i18nKey.ts index 3cdf1c3..7f2a57b 100644 --- a/src/i18n/i18nKey.ts +++ b/src/i18n/i18nKey.ts @@ -42,6 +42,13 @@ enum I18nKey { copyLink = "copyLink", copied = "copied", emptyArchive = "emptyArchive", + + shareArticle = "shareArticle", + generatingPoster = "generatingPoster", + savePoster = "savePoster", + shareOnSocial = "shareOnSocial", + shareOnSocialDescription = "shareOnSocialDescription", + scanToRead = "scanToRead", } export default I18nKey; diff --git a/src/i18n/languages/en.ts b/src/i18n/languages/en.ts index 308bdd3..8e8a212 100644 --- a/src/i18n/languages/en.ts +++ b/src/i18n/languages/en.ts @@ -45,4 +45,11 @@ export const en: Translation = { [Key.copyLink]: "Copy Link", [Key.copied]: "Copied", [Key.emptyArchive]: "No matching posts", + + [Key.shareArticle]: "Share", + [Key.generatingPoster]: "Generating poster...", + [Key.savePoster]: "Save Poster", + [Key.shareOnSocial]: "Share", + [Key.shareOnSocialDescription]: "If this article helped you, please share it with others!", + [Key.scanToRead]: "Source", }; diff --git a/src/i18n/languages/es.ts b/src/i18n/languages/es.ts index 635756f..4427e32 100644 --- a/src/i18n/languages/es.ts +++ b/src/i18n/languages/es.ts @@ -45,4 +45,11 @@ export const es: Translation = { [Key.copyLink]: "Copiar enlace", [Key.copied]: "Copiado", [Key.emptyArchive]: "No hay publicaciones que coincidan", + + [Key.shareArticle]: "Compartir", + [Key.generatingPoster]: "Generando póster...", + [Key.savePoster]: "Guardar póster", + [Key.shareOnSocial]: "Compartir", + [Key.shareOnSocialDescription]: "Si este artículo te ha ayudado, ¡compártelo con más gente!", + [Key.scanToRead]: "Fuente", }; diff --git a/src/i18n/languages/id.ts b/src/i18n/languages/id.ts index dfc3c94..4f2d0c4 100644 --- a/src/i18n/languages/id.ts +++ b/src/i18n/languages/id.ts @@ -45,4 +45,11 @@ export const id: Translation = { [Key.copyLink]: "Salin Tautan", [Key.copied]: "Tersalin", [Key.emptyArchive]: "Tidak ada postingan yang cocok", + + [Key.shareArticle]: "Bagikan", + [Key.generatingPoster]: "Membuat poster...", + [Key.savePoster]: "Simpan Poster", + [Key.shareOnSocial]: "Bagikan", + [Key.shareOnSocialDescription]: "Jika artikel ini membantu Anda, silakan bagikan kepada orang lain!", + [Key.scanToRead]: "Sumber", }; diff --git a/src/i18n/languages/ja.ts b/src/i18n/languages/ja.ts index 9fdd1ef..b19c50f 100644 --- a/src/i18n/languages/ja.ts +++ b/src/i18n/languages/ja.ts @@ -45,4 +45,11 @@ export const ja: Translation = { [Key.copyLink]: "リンクをコピー", [Key.copied]: "コピーしました", [Key.emptyArchive]: "一致する記事がありません", + + [Key.shareArticle]: "共有", + [Key.generatingPoster]: "ポスターを生成中...", + [Key.savePoster]: "ポスターを保存", + [Key.shareOnSocial]: "共有", + [Key.shareOnSocialDescription]: "この記事が役に立ったら、ぜひシェアしてください!", + [Key.scanToRead]: "出典元", }; diff --git a/src/i18n/languages/ko.ts b/src/i18n/languages/ko.ts index ba3d835..d3a2a7a 100644 --- a/src/i18n/languages/ko.ts +++ b/src/i18n/languages/ko.ts @@ -45,4 +45,11 @@ export const ko: Translation = { [Key.copyLink]: "링크 복사", [Key.copied]: "복사됨", [Key.emptyArchive]: "일치하는 게시글이 없습니다", + + [Key.shareArticle]: "공유", + [Key.generatingPoster]: "포스터 생성 중...", + [Key.savePoster]: "포스터 저장", + [Key.shareOnSocial]: "공유", + [Key.shareOnSocialDescription]: "이 글이 도움이 되었다면 다른 사람들과 공유해 주세요!", + [Key.scanToRead]: "출처", }; diff --git a/src/i18n/languages/th.ts b/src/i18n/languages/th.ts index ab4227b..6770704 100644 --- a/src/i18n/languages/th.ts +++ b/src/i18n/languages/th.ts @@ -45,4 +45,11 @@ export const th: Translation = { [Key.copyLink]: "คัดลอกลิงก์", [Key.copied]: "คัดลอกแล้ว", [Key.emptyArchive]: "ไม่พบโพสต์ที่ตรงกัน", + + [Key.shareArticle]: "แชร์", + [Key.generatingPoster]: "กำลังสร้างโปสเตอร์...", + [Key.savePoster]: "บันทึกโปสเตอร์", + [Key.shareOnSocial]: "แชร์", + [Key.shareOnSocialDescription]: "หากบทความนี้เป็นประโยชน์ต่อคุณ แบ่งปันให้คนอื่นๆ ได้เลย!", + [Key.scanToRead]: "แหล่งที่มา", }; diff --git a/src/i18n/languages/tr.ts b/src/i18n/languages/tr.ts index 89e22f5..268af5d 100644 --- a/src/i18n/languages/tr.ts +++ b/src/i18n/languages/tr.ts @@ -45,4 +45,11 @@ export const tr: Translation = { [Key.copyLink]: "Bağlantıyı Kopyala", [Key.copied]: "Kopyalandı", [Key.emptyArchive]: "Eşleşen yazı bulunamadı", + + [Key.shareArticle]: "Paylaş", + [Key.generatingPoster]: "Poster oluşturuluyor...", + [Key.savePoster]: "Posteri Kaydet", + [Key.shareOnSocial]: "Paylaş", + [Key.shareOnSocialDescription]: "Bu yazı işinize yaradıysa başkalarıyla paylaşın!", + [Key.scanToRead]: "Kaynak", }; diff --git a/src/i18n/languages/vi.ts b/src/i18n/languages/vi.ts index 7fa66f3..6ff70d2 100644 --- a/src/i18n/languages/vi.ts +++ b/src/i18n/languages/vi.ts @@ -45,4 +45,11 @@ export const vi: Translation = { [Key.copyLink]: "Sao chép liên kết", [Key.copied]: "Đã sao chép", [Key.emptyArchive]: "Không có bài viết phù hợp", + + [Key.shareArticle]: "Chia sẻ", + [Key.generatingPoster]: "Đang tạo poster...", + [Key.savePoster]: "Lưu Poster", + [Key.shareOnSocial]: "Chia sẻ", + [Key.shareOnSocialDescription]: "Nếu bài viết này hữu ích, hãy chia sẻ cho người khác!", + [Key.scanToRead]: "Nguồn", }; diff --git a/src/i18n/languages/zh_CN.ts b/src/i18n/languages/zh_CN.ts index 41f828f..f70a5c7 100644 --- a/src/i18n/languages/zh_CN.ts +++ b/src/i18n/languages/zh_CN.ts @@ -45,4 +45,11 @@ export const zh_CN: Translation = { [Key.copyLink]: "复制链接", [Key.copied]: "已复制", [Key.emptyArchive]: "暂无匹配的文章", + + [Key.shareArticle]: "分享", + [Key.generatingPoster]: "正在生成海报...", + [Key.savePoster]: "保存海报", + [Key.shareOnSocial]: "分享", + [Key.shareOnSocialDescription]: "如果这篇文章对你有帮助,欢迎分享给更多人!", + [Key.scanToRead]: "来源网站", }; diff --git a/src/i18n/languages/zh_TW.ts b/src/i18n/languages/zh_TW.ts index 2c00adc..425ff68 100644 --- a/src/i18n/languages/zh_TW.ts +++ b/src/i18n/languages/zh_TW.ts @@ -45,4 +45,11 @@ export const zh_TW: Translation = { [Key.copyLink]: "複製連結", [Key.copied]: "已複製", [Key.emptyArchive]: "暫無匹配的文章", + + [Key.shareArticle]: "分享", + [Key.generatingPoster]: "正在生成海報...", + [Key.savePoster]: "儲存海報", + [Key.shareOnSocial]: "分享", + [Key.shareOnSocialDescription]: "如果這篇文章對你有幫助,歡迎分享給更多人!", + [Key.scanToRead]: "來源網站", }; diff --git a/src/styles/variables.styl b/src/styles/variables.styl index ab9958b..f3ac918 100644 --- a/src/styles/variables.styl +++ b/src/styles/variables.styl @@ -51,6 +51,7 @@ define({ --line-color: black(0.1) white(0.1) --meta-divider: black(0.2) white(0.2) + --content-meta: black(0.7) white(0.6) --inline-code-bg: var(--btn-regular-bg) --inline-code-color: var(--btn-content) From 3b98c8436ccacf7eaf1b5928ad461e735768b24e Mon Sep 17 00:00:00 2001 From: FFFold Date: Thu, 27 Aug 2026 18:57:49 +0800 Subject: [PATCH 03/10] feat: add SharePoster canvas component and poster renderer --- src/components/misc/SharePoster.svelte | 490 +++++++++++++++++++ src/components/misc/utils/poster-renderer.ts | 246 ++++++++++ 2 files changed, 736 insertions(+) create mode 100644 src/components/misc/SharePoster.svelte create mode 100644 src/components/misc/utils/poster-renderer.ts diff --git a/src/components/misc/SharePoster.svelte b/src/components/misc/SharePoster.svelte new file mode 100644 index 0000000..694773f --- /dev/null +++ b/src/components/misc/SharePoster.svelte @@ -0,0 +1,490 @@ + + + + +{#if showModal} +
{ + if (e.key === "Enter" || e.key === " ") { + closeModal(); + } + }} + > +
{ + e.stopPropagation(); + }} + onkeydown={(e) => { + e.stopPropagation(); + }} + role="dialog" + tabindex="0" + > +
+ {#if posterImage} + Poster + {:else} +
+
+ {i18n(I18nKey.generatingPoster)} +
+ {/if} +
+ +
+ + +
+
+
+{/if} diff --git a/src/components/misc/utils/poster-renderer.ts b/src/components/misc/utils/poster-renderer.ts new file mode 100644 index 0000000..757dec9 --- /dev/null +++ b/src/components/misc/utils/poster-renderer.ts @@ -0,0 +1,246 @@ +/** + * poster-renderer.ts - Canvas rendering utilities for SharePoster component + */ + +export interface PosterConfig { + title: string; + author: string; + description: string; + pubDate: string; + coverImage: string | null; + url: string; + siteTitle: string; + avatar: string | null; + themeColor: string; +} + +export interface SizeConfig { + scale: number; + width: number; + padding: number; + contentWidth: number; +} + +/** + * Load image from URL with fallback to proxy + */ +export async function loadImage(src: string): Promise { + return new Promise((resolve) => { + const img = new Image(); + img.crossOrigin = "anonymous"; + + img.onload = () => resolve(img); + img.onerror = () => { + if (src.includes("images.weserv.nl") || src.startsWith("data:")) { + resolve(null); + return; + } + + const proxyImg = new Image(); + proxyImg.crossOrigin = "anonymous"; + proxyImg.onload = () => resolve(proxyImg); + proxyImg.onerror = () => resolve(null); + proxyImg.src = `https://images.weserv.nl/?url=${encodeURIComponent(src)}&output=png`; + }; + + img.src = src; + }); +} + +/** + * Get text lines wrapped to fit maxWidth + */ +export function getLines( + ctx: CanvasRenderingContext2D, + text: string, + maxWidth: number, +): string[] { + const lines: string[] = []; + let currentLine = ""; + + for (const char of text) { + if (ctx.measureText(currentLine + char).width < maxWidth) { + currentLine += char; + } else { + lines.push(currentLine); + currentLine = char; + } + } + + if (currentLine) { + lines.push(currentLine); + } + return lines; +} + +/** + * Draw rounded rectangle on canvas + */ +export function drawRoundedRect( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + width: number, + height: number, + radius: number, +): void { + ctx.beginPath(); + ctx.moveTo(x + radius, y); + ctx.lineTo(x + width - radius, y); + ctx.quadraticCurveTo(x + width, y, x + width, y + radius); + ctx.lineTo(x + width, y + height - radius); + ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height); + ctx.lineTo(x + radius, y + height); + ctx.quadraticCurveTo(x, y + height, x, y + height - radius); + ctx.lineTo(x, y + radius); + ctx.quadraticCurveTo(x, y, x + radius, y); + ctx.closePath(); +} + +/** + * Parse date string to object + */ +export function parseDate( + dateStr: string, +): { day: string; month: string; year: string } | null { + try { + const d = new Date(dateStr); + if (Number.isNaN(d.getTime())) { + return null; + } + + return { + day: d.getDate().toString().padStart(2, "0"), + month: (d.getMonth() + 1).toString().padStart(2, "0"), + year: d.getFullYear().toString(), + }; + } catch { + return null; + } +} + +/** + * Calculate poster dimensions + */ +export function calculateDimensions( + hasCover: boolean, + title: string, + description: string, + ctx: CanvasRenderingContext2D, + config: SizeConfig, +): { + coverHeight: number; + titleHeight: number; + descHeight: number; + canvasHeight: number; +} { + const { scale, padding, contentWidth } = config; + const FONT_FAMILY = "'Roboto', sans-serif"; + + const coverHeight = (hasCover ? 200 : 120) * scale; + const titleFontSize = 24 * scale; + const descFontSize = 14 * scale; + const titleLineHeight = 30 * scale; + + // Calculate title height + ctx.font = `700 ${titleFontSize}px ${FONT_FAMILY}`; + const titleLines = getLines(ctx, title, contentWidth); + const titleHeight = titleLines.length * titleLineHeight; + + // Calculate description height + let descHeight = 0; + if (description) { + ctx.font = `${descFontSize}px ${FONT_FAMILY}`; + const descLines = getLines(ctx, description, contentWidth - 16 * scale); + descHeight = Math.min(descLines.length, 6) * (25 * scale); + } + + const canvasHeight = + coverHeight + + padding + + titleHeight + + 16 * scale + + descHeight + + (description ? 24 * scale : 8 * scale) + + 24 * scale + + 80 * scale + // QR size + padding; + + return { coverHeight, titleHeight, descHeight, canvasHeight }; +} + +/** + * Draw decorative circles on poster + */ +export function drawDecorativeCircles( + ctx: CanvasRenderingContext2D, + canvasWidth: number, + canvasHeight: number, + themeColor: string, + scale: number, +): void { + ctx.save(); + ctx.globalAlpha = 0.1; + ctx.fillStyle = themeColor; + ctx.beginPath(); + ctx.arc(canvasWidth - 25 * scale, 25 * scale, 75 * scale, 0, Math.PI * 2); + ctx.fill(); + ctx.beginPath(); + ctx.arc(10 * scale, canvasHeight - 10 * scale, 50 * scale, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); +} + +/** + * Draw date badge on cover image + */ +export function drawDateBadge( + ctx: CanvasRenderingContext2D, + dateObj: { day: string; month: string; year: string }, + padding: number, + coverHeight: number, + scale: number, + FONT_FAMILY: string, + isDarkMode = false, +): void { + const dateBoxW = 60 * scale; + const dateBoxH = 60 * scale; + const dateBoxX = padding; + const dateBoxY = coverHeight - dateBoxH; + + const bgColor = isDarkMode + ? "rgba(255, 255, 255, 0.15)" + : "rgba(0, 0, 0, 0.3)"; + const textColor = isDarkMode ? "#e5e5e5" : "#ffffff"; + const separatorColor = isDarkMode + ? "rgba(255, 255, 255, 0.4)" + : "rgba(255, 255, 255, 0.6)"; + + // Background + ctx.fillStyle = bgColor; + drawRoundedRect(ctx, dateBoxX, dateBoxY, dateBoxW, dateBoxH, 4 * scale); + ctx.fill(); + + // Day number + ctx.fillStyle = textColor; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.font = `700 ${30 * scale}px ${FONT_FAMILY}`; + ctx.fillText(dateObj.day, dateBoxX + dateBoxW / 2, dateBoxY + 24 * scale); + + // Separator line + ctx.beginPath(); + ctx.strokeStyle = separatorColor; + ctx.lineWidth = 1 * scale; + ctx.moveTo(dateBoxX + 10 * scale, dateBoxY + 42 * scale); + ctx.lineTo(dateBoxX + dateBoxW - 10 * scale, dateBoxY + 42 * scale); + ctx.stroke(); + + // Year month + ctx.font = `${10 * scale}px ${FONT_FAMILY}`; + ctx.fillText( + `${dateObj.year} ${dateObj.month}`, + dateBoxX + dateBoxW / 2, + dateBoxY + 51 * scale, + ); +} From a7f5abe34844a5f0bcedf123754606c756c3cac6 Mon Sep 17 00:00:00 2001 From: FFFold Date: Thu, 27 Aug 2026 18:59:10 +0800 Subject: [PATCH 04/10] feat: replace SocialShare with ShareCard poster component --- src/components/misc/ShareCard.astro | 69 +++++++++++++++++++++++++++++ src/pages/posts/[...slug].astro | 48 +++++++++++++++++--- 2 files changed, 110 insertions(+), 7 deletions(-) create mode 100644 src/components/misc/ShareCard.astro diff --git a/src/components/misc/ShareCard.astro b/src/components/misc/ShareCard.astro new file mode 100644 index 0000000..69667a0 --- /dev/null +++ b/src/components/misc/ShareCard.astro @@ -0,0 +1,69 @@ +--- +import SharePoster from "./SharePoster.svelte"; +import I18nKey from "../../i18n/i18nKey"; +import { i18n } from "../../i18n/translation"; +import { Icon } from "astro-icon/components"; +import { profileConfig } from "../../config"; + +interface Props { + title: string; + description?: string; + author?: string; + pubDate: string; + coverImage?: string | null; + url: string; + siteTitle: string; + avatar?: string | null; + class?: string; +} + +const { + title, + description, + author = profileConfig.name, + pubDate, + coverImage, + url, + siteTitle, + avatar, + class: className, +} = Astro.props; + +const finalAuthor = author || profileConfig.name; +--- + +
+
+
+
+ +
+
+
+ {i18n(I18nKey.shareOnSocial)} +
+

+ {i18n(I18nKey.shareOnSocialDescription)} +

+
+
+
+ +
+
+
diff --git a/src/pages/posts/[...slug].astro b/src/pages/posts/[...slug].astro index 45033f0..cf8568a 100644 --- a/src/pages/posts/[...slug].astro +++ b/src/pages/posts/[...slug].astro @@ -1,10 +1,12 @@ --- +import path from "node:path"; import { render } from "astro:content"; +import type { ImageMetadata } from "astro"; import Giscus from "@components/Giscus.astro"; import License from "@components/misc/License.astro"; import Markdown from "@components/misc/Markdown.astro"; -import SocialShare from "@components/SocialShare.astro"; +import ShareCard from "@components/misc/ShareCard.astro"; import I18nKey from "@i18n/i18nKey"; import { i18n } from "@i18n/translation"; import MainGridLayout from "@layouts/MainGridLayout.astro"; @@ -45,6 +47,32 @@ const shareImageUrl = postImageSrc ? new URL(postImageSrc, Astro.site).href : ""; +// Resolve the configured profile avatar into a served URL for the poster canvas. +let posterAvatarUrl = profileConfig.avatar || ""; +const avatarSource = posterAvatarUrl; +const isAvatarLocal = + avatarSource !== "" && + !( + avatarSource.startsWith("/") || + avatarSource.startsWith("http") || + avatarSource.startsWith("https") || + avatarSource.startsWith("data:") + ); +if (isAvatarLocal) { + const avatarFiles = import.meta.glob( + "../../**/*.{jpg,jpeg,png,gif,webp,avif,svg}", + { import: "default" }, + ); + const normalizedAvatarPath = path + .normalize(path.join("../../", avatarSource)) + .replace(/\\/g, "/"); + const avatarFile = avatarFiles[normalizedAvatarPath] || avatarFiles[`./${normalizedAvatarPath}`]; + if (avatarFile) { + const avatarImg = await avatarFile(); + posterAvatarUrl = avatarImg.src; + } +} + // The hero spans the full content column (~50rem on desktop). Requesting a // responsive srcset keeps the decode small, and Astro emits // width/height attributes so the layout space is reserved before the image @@ -149,21 +177,27 @@ const giscusConfig = { - + - +> From 69f32bcd684faa01e64e3e0dc61cd78ee679e676 Mon Sep 17 00:00:00 2001 From: FFFold Date: Thu, 27 Aug 2026 18:59:11 +0800 Subject: [PATCH 05/10] refactor: remove legacy SocialShare component --- src/components/SocialShare.astro | 376 ------------------------------- 1 file changed, 376 deletions(-) delete mode 100644 src/components/SocialShare.astro diff --git a/src/components/SocialShare.astro b/src/components/SocialShare.astro deleted file mode 100644 index 59c9eb6..0000000 --- a/src/components/SocialShare.astro +++ /dev/null @@ -1,376 +0,0 @@ ---- -import { Icon } from "astro-icon/components"; -import I18nKey from "../i18n/i18nKey"; -import { i18n } from "../i18n/translation"; - -interface Props { - title: string; - url: string; - description?: string; - tags?: string[]; - image?: string; -} - -const { title, url, description = "", tags = [], image = "" } = Astro.props; - -const pageUrl = new URL(url, Astro.site).href; -const pageImage = image ? new URL(image, Astro.site).href : ""; - -// 分享平台配置 -const sharePlatforms = [ - { - key: "twitter", - name: "Twitter/X", - icon: "fa6-brands:x-twitter", - color: "bg-[#1DA1F2] hover:bg-[#0d8bd9]", - webUrl: `https://twitter.com/intent/tweet?text=${encodeURIComponent(title)}&url=${encodeURIComponent(pageUrl)}&hashtags=${encodeURIComponent(tags.join(","))}`, - mobileUrl: `twitter://post?message=${encodeURIComponent(`${title} ${pageUrl}`)}`, - }, - { - key: "weibo", - name: "微博", - icon: "fa6-brands:weibo", - color: "bg-[#E6162D] hover:bg-[#c11225]", - webUrl: `https://service.weibo.com/share/share.php?title=${encodeURIComponent(`${title} - ${description}`)}&url=${encodeURIComponent(pageUrl)}&pic=${pageImage ? encodeURIComponent(pageImage) : ""}`, - mobileUrl: `sinaweibo://share?title=${encodeURIComponent(title)}&content=${encodeURIComponent(`${description}\n${pageUrl}`)}`, - }, - { - key: "qq", - name: "QQ", - icon: "fa6-brands:qq", - color: "bg-[#12B7F5] hover:bg-[#0e9dce]", - webUrl: `https://connect.qq.com/widget/shareqq/index.html?url=${encodeURIComponent(pageUrl)}&title=${encodeURIComponent(title)}&desc=${encodeURIComponent(description)}&pics=${pageImage ? encodeURIComponent(pageImage) : ""}&summary=${encodeURIComponent(tags.join(","))}`, - mobileUrl: `mqq://share/to_fri?url=${encodeURIComponent(pageUrl)}&title=${encodeURIComponent(title)}&desc=${encodeURIComponent(description)}&image_url=${pageImage ? encodeURIComponent(pageImage) : ""}&share_id=1101685683`, - }, - { - key: "wechat", - name: "微信", - icon: "fa6-brands:weixin", - color: "bg-[#07C160] hover:bg-[#06a854]", - webUrl: null, // 微信没有网页版分享 - mobileUrl: "weixin://", // 只是尝试唤起微信 - isWechat: true, // 特殊处理 - }, - { - key: "qzone", - name: "QQ空间", - icon: "simple-icons:qzone", - color: "bg-[#FECE00] hover:bg-[#e6b800]", - webUrl: `https://sns.qzone.qq.com/cgi-bin/qzshare/cgi_qzshare_onekey?url=${encodeURIComponent(pageUrl)}&title=${encodeURIComponent(title)}&desc=${encodeURIComponent(description)}&pics=${pageImage ? encodeURIComponent(pageImage) : ""}&summary=${encodeURIComponent(tags.join(","))}&site=${encodeURIComponent(Astro.site?.hostname || "")}`, - mobileUrl: `mqqzone://arise/share?url=${encodeURIComponent(pageUrl)}&title=${encodeURIComponent(title)}&desc=${encodeURIComponent(description)}&image_url=${pageImage ? encodeURIComponent(pageImage) : ""}`, - }, - { - key: "copy", - name: i18n(I18nKey.copyLink), - icon: "material-symbols:link", - color: - "bg-gray-500 hover:bg-gray-600 dark:bg-gray-600 dark:hover:bg-gray-700", - isCopy: true, - }, -]; ---- - - - - - - From c258fe20699d74802ac9561589dbddeab052a520 Mon Sep 17 00:00:00 2001 From: FFFold Date: Thu, 27 Aug 2026 19:00:34 +0800 Subject: [PATCH 06/10] style: apply biome formatting to share poster changes --- src/components/misc/ShareCard.astro | 6 +++--- src/i18n/languages/en.ts | 3 ++- src/i18n/languages/es.ts | 3 ++- src/i18n/languages/id.ts | 3 ++- src/i18n/languages/ja.ts | 3 ++- src/i18n/languages/ko.ts | 3 ++- src/i18n/languages/th.ts | 3 ++- src/i18n/languages/tr.ts | 3 ++- src/i18n/languages/vi.ts | 3 ++- src/pages/posts/[...slug].astro | 10 +++++----- 10 files changed, 24 insertions(+), 16 deletions(-) diff --git a/src/components/misc/ShareCard.astro b/src/components/misc/ShareCard.astro index 69667a0..a0fe165 100644 --- a/src/components/misc/ShareCard.astro +++ b/src/components/misc/ShareCard.astro @@ -1,9 +1,9 @@ --- -import SharePoster from "./SharePoster.svelte"; -import I18nKey from "../../i18n/i18nKey"; -import { i18n } from "../../i18n/translation"; import { Icon } from "astro-icon/components"; import { profileConfig } from "../../config"; +import I18nKey from "../../i18n/i18nKey"; +import { i18n } from "../../i18n/translation"; +import SharePoster from "./SharePoster.svelte"; interface Props { title: string; diff --git a/src/i18n/languages/en.ts b/src/i18n/languages/en.ts index 8e8a212..9503e78 100644 --- a/src/i18n/languages/en.ts +++ b/src/i18n/languages/en.ts @@ -50,6 +50,7 @@ export const en: Translation = { [Key.generatingPoster]: "Generating poster...", [Key.savePoster]: "Save Poster", [Key.shareOnSocial]: "Share", - [Key.shareOnSocialDescription]: "If this article helped you, please share it with others!", + [Key.shareOnSocialDescription]: + "If this article helped you, please share it with others!", [Key.scanToRead]: "Source", }; diff --git a/src/i18n/languages/es.ts b/src/i18n/languages/es.ts index 4427e32..7b7e442 100644 --- a/src/i18n/languages/es.ts +++ b/src/i18n/languages/es.ts @@ -50,6 +50,7 @@ export const es: Translation = { [Key.generatingPoster]: "Generando póster...", [Key.savePoster]: "Guardar póster", [Key.shareOnSocial]: "Compartir", - [Key.shareOnSocialDescription]: "Si este artículo te ha ayudado, ¡compártelo con más gente!", + [Key.shareOnSocialDescription]: + "Si este artículo te ha ayudado, ¡compártelo con más gente!", [Key.scanToRead]: "Fuente", }; diff --git a/src/i18n/languages/id.ts b/src/i18n/languages/id.ts index 4f2d0c4..c9e0fb7 100644 --- a/src/i18n/languages/id.ts +++ b/src/i18n/languages/id.ts @@ -50,6 +50,7 @@ export const id: Translation = { [Key.generatingPoster]: "Membuat poster...", [Key.savePoster]: "Simpan Poster", [Key.shareOnSocial]: "Bagikan", - [Key.shareOnSocialDescription]: "Jika artikel ini membantu Anda, silakan bagikan kepada orang lain!", + [Key.shareOnSocialDescription]: + "Jika artikel ini membantu Anda, silakan bagikan kepada orang lain!", [Key.scanToRead]: "Sumber", }; diff --git a/src/i18n/languages/ja.ts b/src/i18n/languages/ja.ts index b19c50f..8cbbb91 100644 --- a/src/i18n/languages/ja.ts +++ b/src/i18n/languages/ja.ts @@ -50,6 +50,7 @@ export const ja: Translation = { [Key.generatingPoster]: "ポスターを生成中...", [Key.savePoster]: "ポスターを保存", [Key.shareOnSocial]: "共有", - [Key.shareOnSocialDescription]: "この記事が役に立ったら、ぜひシェアしてください!", + [Key.shareOnSocialDescription]: + "この記事が役に立ったら、ぜひシェアしてください!", [Key.scanToRead]: "出典元", }; diff --git a/src/i18n/languages/ko.ts b/src/i18n/languages/ko.ts index d3a2a7a..d8c78ec 100644 --- a/src/i18n/languages/ko.ts +++ b/src/i18n/languages/ko.ts @@ -50,6 +50,7 @@ export const ko: Translation = { [Key.generatingPoster]: "포스터 생성 중...", [Key.savePoster]: "포스터 저장", [Key.shareOnSocial]: "공유", - [Key.shareOnSocialDescription]: "이 글이 도움이 되었다면 다른 사람들과 공유해 주세요!", + [Key.shareOnSocialDescription]: + "이 글이 도움이 되었다면 다른 사람들과 공유해 주세요!", [Key.scanToRead]: "출처", }; diff --git a/src/i18n/languages/th.ts b/src/i18n/languages/th.ts index 6770704..e779832 100644 --- a/src/i18n/languages/th.ts +++ b/src/i18n/languages/th.ts @@ -50,6 +50,7 @@ export const th: Translation = { [Key.generatingPoster]: "กำลังสร้างโปสเตอร์...", [Key.savePoster]: "บันทึกโปสเตอร์", [Key.shareOnSocial]: "แชร์", - [Key.shareOnSocialDescription]: "หากบทความนี้เป็นประโยชน์ต่อคุณ แบ่งปันให้คนอื่นๆ ได้เลย!", + [Key.shareOnSocialDescription]: + "หากบทความนี้เป็นประโยชน์ต่อคุณ แบ่งปันให้คนอื่นๆ ได้เลย!", [Key.scanToRead]: "แหล่งที่มา", }; diff --git a/src/i18n/languages/tr.ts b/src/i18n/languages/tr.ts index 268af5d..34c9ca0 100644 --- a/src/i18n/languages/tr.ts +++ b/src/i18n/languages/tr.ts @@ -50,6 +50,7 @@ export const tr: Translation = { [Key.generatingPoster]: "Poster oluşturuluyor...", [Key.savePoster]: "Posteri Kaydet", [Key.shareOnSocial]: "Paylaş", - [Key.shareOnSocialDescription]: "Bu yazı işinize yaradıysa başkalarıyla paylaşın!", + [Key.shareOnSocialDescription]: + "Bu yazı işinize yaradıysa başkalarıyla paylaşın!", [Key.scanToRead]: "Kaynak", }; diff --git a/src/i18n/languages/vi.ts b/src/i18n/languages/vi.ts index 6ff70d2..f19ca43 100644 --- a/src/i18n/languages/vi.ts +++ b/src/i18n/languages/vi.ts @@ -50,6 +50,7 @@ export const vi: Translation = { [Key.generatingPoster]: "Đang tạo poster...", [Key.savePoster]: "Lưu Poster", [Key.shareOnSocial]: "Chia sẻ", - [Key.shareOnSocialDescription]: "Nếu bài viết này hữu ích, hãy chia sẻ cho người khác!", + [Key.shareOnSocialDescription]: + "Nếu bài viết này hữu ích, hãy chia sẻ cho người khác!", [Key.scanToRead]: "Nguồn", }; diff --git a/src/pages/posts/[...slug].astro b/src/pages/posts/[...slug].astro index cf8568a..cc5e636 100644 --- a/src/pages/posts/[...slug].astro +++ b/src/pages/posts/[...slug].astro @@ -1,8 +1,7 @@ --- -import path from "node:path"; import { render } from "astro:content"; -import type { ImageMetadata } from "astro"; +import path from "node:path"; import Giscus from "@components/Giscus.astro"; import License from "@components/misc/License.astro"; import Markdown from "@components/misc/Markdown.astro"; @@ -10,10 +9,9 @@ import ShareCard from "@components/misc/ShareCard.astro"; import I18nKey from "@i18n/i18nKey"; import { i18n } from "@i18n/translation"; import MainGridLayout from "@layouts/MainGridLayout.astro"; - import { getSortedPosts } from "@utils/content-utils"; - import { getPostUrlById } from "@utils/url-utils"; +import type { ImageMetadata } from "astro"; import { Icon } from "astro-icon/components"; @@ -66,7 +64,9 @@ if (isAvatarLocal) { const normalizedAvatarPath = path .normalize(path.join("../../", avatarSource)) .replace(/\\/g, "/"); - const avatarFile = avatarFiles[normalizedAvatarPath] || avatarFiles[`./${normalizedAvatarPath}`]; + const avatarFile = + avatarFiles[normalizedAvatarPath] || + avatarFiles[`./${normalizedAvatarPath}`]; if (avatarFile) { const avatarImg = await avatarFile(); posterAvatarUrl = avatarImg.src; From 89397d07734660dd0fd9f1b0d72f79da3af17747 Mon Sep 17 00:00:00 2001 From: FFFold Date: Thu, 27 Aug 2026 19:20:41 +0800 Subject: [PATCH 07/10] fix: address share poster review findings (date, error state, accessibility) --- src/components/misc/SharePoster.svelte | 82 ++++++++++++++++---- src/components/misc/utils/poster-renderer.ts | 9 +++ src/i18n/i18nKey.ts | 1 + src/i18n/languages/en.ts | 1 + src/i18n/languages/es.ts | 1 + src/i18n/languages/id.ts | 1 + src/i18n/languages/ja.ts | 1 + src/i18n/languages/ko.ts | 1 + src/i18n/languages/th.ts | 1 + src/i18n/languages/tr.ts | 1 + src/i18n/languages/vi.ts | 1 + src/i18n/languages/zh_CN.ts | 1 + src/i18n/languages/zh_TW.ts | 1 + 13 files changed, 87 insertions(+), 15 deletions(-) diff --git a/src/components/misc/SharePoster.svelte b/src/components/misc/SharePoster.svelte index 694773f..e147d60 100644 --- a/src/components/misc/SharePoster.svelte +++ b/src/components/misc/SharePoster.svelte @@ -48,7 +48,10 @@ const FONT_FAMILY = "'Roboto', sans-serif"; let showModal = $state(false); let posterImage = $state(null); let generating = $state(false); +let errorMessage = $state(null); let themeColor = $state("#558e88"); +let previouslyFocused: HTMLElement | null = null; +let copyTimeout: ReturnType | undefined; function isDarkMode(): boolean { return document.documentElement.classList.contains("dark"); @@ -87,21 +90,34 @@ onMount(() => { const observer = new MutationObserver(() => { posterImage = null; + errorMessage = null; }); observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"], }); - return () => observer.disconnect(); + return () => { + observer.disconnect(); + if (copyTimeout) { + clearTimeout(copyTimeout); + } + }; }); async function generatePoster() { + if (generating) { + return; + } + if (!showModal) { + previouslyFocused = document.activeElement as HTMLElement | null; + } showModal = true; if (posterImage) { return; } + errorMessage = null; generating = true; const colors = getPosterColors(); @@ -329,6 +345,7 @@ async function generatePoster() { posterImage = canvas.toDataURL("image/png"); } catch (error) { console.error("Failed to generate poster:", error); + errorMessage = i18n(I18nKey.posterFailed); } finally { generating = false; } @@ -343,8 +360,16 @@ function downloadPoster() { } } +function focusDialog(node: HTMLElement) { + node.focus(); +} + function closeModal() { showModal = false; + if (previouslyFocused) { + previouslyFocused.focus(); + previouslyFocused = null; + } } let copied = $state(false); @@ -358,7 +383,10 @@ async function copyLink() { await navigator.clipboard.writeText(url); copied = true; - setTimeout(() => { + if (copyTimeout) { + clearTimeout(copyTimeout); + } + copyTimeout = setTimeout(() => { copied = false; }, COPY_FEEDBACK_DURATION); } catch (error) { @@ -380,9 +408,10 @@ function portal(node: HTMLElement) { @@ -393,25 +422,23 @@ function portal(node: HTMLElement) { class="fixed inset-0 z-[9999] flex items-center justify-center p-4 transition-opacity" style="background-color: rgba(0, 0, 0, 0.6); backdrop-filter: blur(4px);" onclick={closeModal} - role="button" - tabindex="0" - onkeydown={(e) => { - if (e.key === "Enter" || e.key === " ") { - closeModal(); - } - }} >
{ e.stopPropagation(); }} onkeydown={(e) => { - e.stopPropagation(); + if (e.key === "Escape") { + closeModal(); + } }} role="dialog" - tabindex="0" + aria-modal="true" + aria-label={i18n(I18nKey.shareArticle)} + tabindex="-1" >
- {:else} + {:else if generating}
{i18n(I18nKey.generatingPoster)}
+ {:else if errorMessage} +
+ {errorMessage} + +
+ {:else} +
+ +
{/if}
diff --git a/src/components/misc/utils/poster-renderer.ts b/src/components/misc/utils/poster-renderer.ts index 757dec9..b3f10f4 100644 --- a/src/components/misc/utils/poster-renderer.ts +++ b/src/components/misc/utils/poster-renderer.ts @@ -103,6 +103,15 @@ export function drawRoundedRect( export function parseDate( dateStr: string, ): { day: string; month: string; year: string } | null { + const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateStr.trim()); + if (dateOnly) { + return { + day: dateOnly[3], + month: dateOnly[2], + year: dateOnly[1], + }; + } + try { const d = new Date(dateStr); if (Number.isNaN(d.getTime())) { diff --git a/src/i18n/i18nKey.ts b/src/i18n/i18nKey.ts index 7f2a57b..13e59d7 100644 --- a/src/i18n/i18nKey.ts +++ b/src/i18n/i18nKey.ts @@ -45,6 +45,7 @@ enum I18nKey { shareArticle = "shareArticle", generatingPoster = "generatingPoster", + posterFailed = "posterFailed", savePoster = "savePoster", shareOnSocial = "shareOnSocial", shareOnSocialDescription = "shareOnSocialDescription", diff --git a/src/i18n/languages/en.ts b/src/i18n/languages/en.ts index 9503e78..8b4b188 100644 --- a/src/i18n/languages/en.ts +++ b/src/i18n/languages/en.ts @@ -48,6 +48,7 @@ export const en: Translation = { [Key.shareArticle]: "Share", [Key.generatingPoster]: "Generating poster...", + [Key.posterFailed]: "Failed to generate poster. Please try again.", [Key.savePoster]: "Save Poster", [Key.shareOnSocial]: "Share", [Key.shareOnSocialDescription]: diff --git a/src/i18n/languages/es.ts b/src/i18n/languages/es.ts index 7b7e442..00663d3 100644 --- a/src/i18n/languages/es.ts +++ b/src/i18n/languages/es.ts @@ -48,6 +48,7 @@ export const es: Translation = { [Key.shareArticle]: "Compartir", [Key.generatingPoster]: "Generando póster...", + [Key.posterFailed]: "No se pudo generar el póster. Inténtalo de nuevo.", [Key.savePoster]: "Guardar póster", [Key.shareOnSocial]: "Compartir", [Key.shareOnSocialDescription]: diff --git a/src/i18n/languages/id.ts b/src/i18n/languages/id.ts index c9e0fb7..234c374 100644 --- a/src/i18n/languages/id.ts +++ b/src/i18n/languages/id.ts @@ -48,6 +48,7 @@ export const id: Translation = { [Key.shareArticle]: "Bagikan", [Key.generatingPoster]: "Membuat poster...", + [Key.posterFailed]: "Gagal membuat poster. Silakan coba lagi.", [Key.savePoster]: "Simpan Poster", [Key.shareOnSocial]: "Bagikan", [Key.shareOnSocialDescription]: diff --git a/src/i18n/languages/ja.ts b/src/i18n/languages/ja.ts index 8cbbb91..3c11746 100644 --- a/src/i18n/languages/ja.ts +++ b/src/i18n/languages/ja.ts @@ -48,6 +48,7 @@ export const ja: Translation = { [Key.shareArticle]: "共有", [Key.generatingPoster]: "ポスターを生成中...", + [Key.posterFailed]: "ポスターの生成に失敗しました。もう一度お試しください。", [Key.savePoster]: "ポスターを保存", [Key.shareOnSocial]: "共有", [Key.shareOnSocialDescription]: diff --git a/src/i18n/languages/ko.ts b/src/i18n/languages/ko.ts index d8c78ec..3d71dce 100644 --- a/src/i18n/languages/ko.ts +++ b/src/i18n/languages/ko.ts @@ -48,6 +48,7 @@ export const ko: Translation = { [Key.shareArticle]: "공유", [Key.generatingPoster]: "포스터 생성 중...", + [Key.posterFailed]: "포스터 생성에 실패했습니다. 다시 시도해 주세요.", [Key.savePoster]: "포스터 저장", [Key.shareOnSocial]: "공유", [Key.shareOnSocialDescription]: diff --git a/src/i18n/languages/th.ts b/src/i18n/languages/th.ts index e779832..2f67704 100644 --- a/src/i18n/languages/th.ts +++ b/src/i18n/languages/th.ts @@ -48,6 +48,7 @@ export const th: Translation = { [Key.shareArticle]: "แชร์", [Key.generatingPoster]: "กำลังสร้างโปสเตอร์...", + [Key.posterFailed]: "สร้างโปสเตอร์ไม่สำเร็จ กรุณาลองอีกครั้ง", [Key.savePoster]: "บันทึกโปสเตอร์", [Key.shareOnSocial]: "แชร์", [Key.shareOnSocialDescription]: diff --git a/src/i18n/languages/tr.ts b/src/i18n/languages/tr.ts index 34c9ca0..657be0e 100644 --- a/src/i18n/languages/tr.ts +++ b/src/i18n/languages/tr.ts @@ -48,6 +48,7 @@ export const tr: Translation = { [Key.shareArticle]: "Paylaş", [Key.generatingPoster]: "Poster oluşturuluyor...", + [Key.posterFailed]: "Poster oluşturulamadı. Lütfen tekrar deneyin.", [Key.savePoster]: "Posteri Kaydet", [Key.shareOnSocial]: "Paylaş", [Key.shareOnSocialDescription]: diff --git a/src/i18n/languages/vi.ts b/src/i18n/languages/vi.ts index f19ca43..b5a52a9 100644 --- a/src/i18n/languages/vi.ts +++ b/src/i18n/languages/vi.ts @@ -48,6 +48,7 @@ export const vi: Translation = { [Key.shareArticle]: "Chia sẻ", [Key.generatingPoster]: "Đang tạo poster...", + [Key.posterFailed]: "Không thể tạo poster. Vui lòng thử lại.", [Key.savePoster]: "Lưu Poster", [Key.shareOnSocial]: "Chia sẻ", [Key.shareOnSocialDescription]: diff --git a/src/i18n/languages/zh_CN.ts b/src/i18n/languages/zh_CN.ts index f70a5c7..0fbde10 100644 --- a/src/i18n/languages/zh_CN.ts +++ b/src/i18n/languages/zh_CN.ts @@ -48,6 +48,7 @@ export const zh_CN: Translation = { [Key.shareArticle]: "分享", [Key.generatingPoster]: "正在生成海报...", + [Key.posterFailed]: "生成失败,请重试", [Key.savePoster]: "保存海报", [Key.shareOnSocial]: "分享", [Key.shareOnSocialDescription]: "如果这篇文章对你有帮助,欢迎分享给更多人!", diff --git a/src/i18n/languages/zh_TW.ts b/src/i18n/languages/zh_TW.ts index 425ff68..5783a41 100644 --- a/src/i18n/languages/zh_TW.ts +++ b/src/i18n/languages/zh_TW.ts @@ -48,6 +48,7 @@ export const zh_TW: Translation = { [Key.shareArticle]: "分享", [Key.generatingPoster]: "正在生成海報...", + [Key.posterFailed]: "生成失敗,請重試", [Key.savePoster]: "儲存海報", [Key.shareOnSocial]: "分享", [Key.shareOnSocialDescription]: "如果這篇文章對你有幫助,歡迎分享給更多人!", From 31b095fac0012e54c1f23483596d4b853d48cfd1 Mon Sep 17 00:00:00 2001 From: FFFold Date: Thu, 27 Aug 2026 20:19:30 +0800 Subject: [PATCH 08/10] fix: use relative cover source for SharePoster to work in dev --- src/pages/posts/[...slug].astro | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/pages/posts/[...slug].astro b/src/pages/posts/[...slug].astro index cc5e636..caea769 100644 --- a/src/pages/posts/[...slug].astro +++ b/src/pages/posts/[...slug].astro @@ -41,9 +41,6 @@ const postImageSrc = entry.data.image ? entry.data.image.src : entry.data.image : ""; -const shareImageUrl = postImageSrc - ? new URL(postImageSrc, Astro.site).href - : ""; // Resolve the configured profile avatar into a served URL for the poster canvas. let posterAvatarUrl = profileConfig.avatar || ""; @@ -189,7 +186,7 @@ const giscusConfig = { pubDate={formatDateToYYYYMMDD(entry.data.published)} - coverImage={shareImageUrl || null} + coverImage={postImageSrc || null} url={new URL(getPostUrlById(entry.id), Astro.site).href} From cb0e97d9ab0f4634c0e4b0544256938df3ffbc78 Mon Sep 17 00:00:00 2001 From: FFFold Date: Thu, 27 Aug 2026 20:37:41 +0800 Subject: [PATCH 09/10] fix: keep poster across tab switches by only invalidating on theme change --- src/components/misc/SharePoster.svelte | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/components/misc/SharePoster.svelte b/src/components/misc/SharePoster.svelte index e147d60..84d2964 100644 --- a/src/components/misc/SharePoster.svelte +++ b/src/components/misc/SharePoster.svelte @@ -52,6 +52,7 @@ let errorMessage = $state(null); let themeColor = $state("#558e88"); let previouslyFocused: HTMLElement | null = null; let copyTimeout: ReturnType | undefined; +let lastIsDark = false; function isDarkMode(): boolean { return document.documentElement.classList.contains("dark"); @@ -88,9 +89,15 @@ onMount(() => { themeColor = computedColor; } + lastIsDark = isDarkMode(); + const observer = new MutationObserver(() => { - posterImage = null; - errorMessage = null; + const currentIsDark = isDarkMode(); + if (currentIsDark !== lastIsDark) { + lastIsDark = currentIsDark; + posterImage = null; + errorMessage = null; + } }); observer.observe(document.documentElement, { attributes: true, From db22f815bf4f764c279cbeb90ae1782ec45c8be1 Mon Sep 17 00:00:00 2001 From: FFFold Date: Thu, 27 Aug 2026 21:03:21 +0800 Subject: [PATCH 10/10] fix: address code review comments on SharePoster --- src/components/misc/SharePoster.svelte | 49 +++++++++++++++----- src/components/misc/utils/poster-renderer.ts | 4 +- src/i18n/i18nKey.ts | 1 + src/i18n/languages/en.ts | 1 + src/i18n/languages/es.ts | 1 + src/i18n/languages/id.ts | 1 + src/i18n/languages/ja.ts | 1 + src/i18n/languages/ko.ts | 1 + src/i18n/languages/th.ts | 1 + src/i18n/languages/tr.ts | 1 + src/i18n/languages/vi.ts | 1 + src/i18n/languages/zh_CN.ts | 1 + src/i18n/languages/zh_TW.ts | 1 + 13 files changed, 52 insertions(+), 12 deletions(-) diff --git a/src/components/misc/SharePoster.svelte b/src/components/misc/SharePoster.svelte index 84d2964..3008d5d 100644 --- a/src/components/misc/SharePoster.svelte +++ b/src/components/misc/SharePoster.svelte @@ -50,14 +50,29 @@ let posterImage = $state(null); let generating = $state(false); let errorMessage = $state(null); let themeColor = $state("#558e88"); +let copyFailed = $state(false); let previouslyFocused: HTMLElement | null = null; let copyTimeout: ReturnType | undefined; let lastIsDark = false; +let posterGeneration = 0; function isDarkMode(): boolean { return document.documentElement.classList.contains("dark"); } +function updateThemeColor(): void { + const temp = document.createElement("div"); + temp.style.color = "var(--primary)"; + temp.style.display = "none"; + document.body.appendChild(temp); + const computedColor = getComputedStyle(temp).color; + document.body.removeChild(temp); + + if (computedColor) { + themeColor = computedColor; + } +} + function getPosterColors() { const dark = isDarkMode(); return { @@ -78,25 +93,17 @@ function getPosterColors() { } onMount(() => { - const temp = document.createElement("div"); - temp.style.color = "var(--primary)"; - temp.style.display = "none"; - document.body.appendChild(temp); - const computedColor = getComputedStyle(temp).color; - document.body.removeChild(temp); - - if (computedColor) { - themeColor = computedColor; - } - + updateThemeColor(); lastIsDark = isDarkMode(); const observer = new MutationObserver(() => { const currentIsDark = isDarkMode(); if (currentIsDark !== lastIsDark) { lastIsDark = currentIsDark; + posterGeneration += 1; posterImage = null; errorMessage = null; + updateThemeColor(); } }); observer.observe(document.documentElement, { @@ -126,6 +133,7 @@ async function generatePoster() { errorMessage = null; generating = true; + const generation = ++posterGeneration; const colors = getPosterColors(); try { @@ -349,6 +357,9 @@ async function generatePoster() { ctx.font = `700 ${20 * SCALE}px ${FONT_FAMILY}`; ctx.fillText(siteTitle, textX, footerY + 60 * SCALE); + if (generation !== posterGeneration) { + return; + } posterImage = canvas.toDataURL("image/png"); } catch (error) { console.error("Failed to generate poster:", error); @@ -383,6 +394,7 @@ let copied = $state(false); const COPY_FEEDBACK_DURATION = 2000; async function copyLink() { + copyFailed = false; try { if (!navigator.clipboard?.writeText) { throw new Error("Clipboard API is not available"); @@ -398,6 +410,14 @@ async function copyLink() { }, COPY_FEEDBACK_DURATION); } catch (error) { console.error("Failed to copy link:", error); + copied = false; + copyFailed = true; + if (copyTimeout) { + clearTimeout(copyTimeout); + } + copyTimeout = setTimeout(() => { + copyFailed = false; + }, COPY_FEEDBACK_DURATION); } } @@ -520,6 +540,13 @@ function portal(node: HTMLElement) { height="20" /> {i18n(I18nKey.copied)} + {:else if copyFailed} + + {i18n(I18nKey.copyLinkFailed)} {:else}