3.0.0原版

This commit is contained in:
anian
2026-03-16 00:03:35 +08:00
commit ac569b2a57
105 changed files with 14437 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
{
"$schema": "https://raw.githubusercontent.com/lit/lit/main/packages/localize-tools/config.schema.json",
"sourceLocale": "en",
"targetLocales": ["es", "zh-CN", "zh-TW"],
"tsConfig": "./tsconfig.json",
"output": {
"mode": "runtime",
"outputDir": "./src/generated/locales",
"localeCodesModule": "./src/generated/locale-codes.ts"
},
"interchange": {
"format": "xliff",
"xliffDir": "./xliff/"
}
}
+63
View File
@@ -0,0 +1,63 @@
{
"name": "@halo-dev/comment-widget",
"version": "3.0.0",
"author": {
"name": "Halo",
"url": "https://github.com/halo-dev"
},
"maintainers": [
{
"name": "Ryan Wang",
"url": "https://github.com/ruibaby"
}
],
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./var.css": "./var.css"
},
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist",
"var.css"
],
"scripts": {
"build": "vite build",
"dev": "vite build --watch",
"locale:build": "lit-localize build",
"locale:extract": "lit-localize extract"
},
"dependencies": {
"@emoji-mart/data": "^1.2.1",
"@floating-ui/dom": "^1.7.3",
"@halo-dev/api-client": "https://pkg.pr.new/@halo-dev/api-client@7679",
"@lit/context": "^1.1.6",
"@lit/localize": "^0.12.2",
"@tiptap/core": "^3.2.0",
"@tiptap/extensions": "^3.2.0",
"@tiptap/pm": "^3.2.0",
"@tiptap/starter-kit": "^3.2.0",
"dayjs": "^1.11.13",
"emoji-mart": "^5.6.0",
"es-toolkit": "^1.39.9",
"javascript-time-ago": "^2.5.11",
"lit": "^3.3.1",
"ofetch": "^1.4.1",
"sanitize-html": "^2.17.0",
"tiptap-extension-code-block-shiki": "^0.6.0",
"ua-parser-js": "^2.0.4"
},
"devDependencies": {
"@iconify/json": "^2.2.373",
"@lit/localize-tools": "^0.8.0",
"@types/sanitize-html": "^2.16.0",
"@unocss/reset": "^66.4.2",
"lit-analyzer": "^2.0.3",
"unocss": "^66.4.2"
}
}
+542
View File
@@ -0,0 +1,542 @@
lockfileVersion: '6.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
dependencies:
'@emoji-mart/data':
specifier: ^1.1.2
version: 1.1.2
'@halo-dev/api-client':
specifier: ^2.12.0
version: 2.12.0
'@lit/context':
specifier: ^1.1.0
version: 1.1.0
dayjs:
specifier: ^1.11.10
version: 1.11.10
emoji-mart:
specifier: ^5.5.2
version: 5.5.2
javascript-time-ago:
specifier: ^2.5.9
version: 2.5.9
lit:
specifier: ^3.1.2
version: 3.1.2
devDependencies:
typescript:
specifier: ^5.3.3
version: 5.3.3
vite:
specifier: ^5.1.4
version: 5.1.4
packages:
/@emoji-mart/data@1.1.2:
resolution: {integrity: sha512-1HP8BxD2azjqWJvxIaWAMyTySeZY0Osr83ukYjltPVkNXeJvTz7yDrPLBtnrD5uqJ3tg4CcLuuBW09wahqL/fg==}
dev: false
/@esbuild/android-arm64@0.19.9:
resolution: {integrity: sha512-q4cR+6ZD0938R19MyEW3jEsMzbb/1rulLXiNAJQADD/XYp7pT+rOS5JGxvpRW8dFDEfjW4wLgC/3FXIw4zYglQ==}
engines: {node: '>=12'}
cpu: [arm64]
os: [android]
requiresBuild: true
dev: true
optional: true
/@esbuild/android-arm@0.19.9:
resolution: {integrity: sha512-jkYjjq7SdsWuNI6b5quymW0oC83NN5FdRPuCbs9HZ02mfVdAP8B8eeqLSYU3gb6OJEaY5CQabtTFbqBf26H3GA==}
engines: {node: '>=12'}
cpu: [arm]
os: [android]
requiresBuild: true
dev: true
optional: true
/@esbuild/android-x64@0.19.9:
resolution: {integrity: sha512-KOqoPntWAH6ZxDwx1D6mRntIgZh9KodzgNOy5Ebt9ghzffOk9X2c1sPwtM9P+0eXbefnDhqYfkh5PLP5ULtWFA==}
engines: {node: '>=12'}
cpu: [x64]
os: [android]
requiresBuild: true
dev: true
optional: true
/@esbuild/darwin-arm64@0.19.9:
resolution: {integrity: sha512-KBJ9S0AFyLVx2E5D8W0vExqRW01WqRtczUZ8NRu+Pi+87opZn5tL4Y0xT0mA4FtHctd0ZgwNoN639fUUGlNIWw==}
engines: {node: '>=12'}
cpu: [arm64]
os: [darwin]
requiresBuild: true
dev: true
optional: true
/@esbuild/darwin-x64@0.19.9:
resolution: {integrity: sha512-vE0VotmNTQaTdX0Q9dOHmMTao6ObjyPm58CHZr1UK7qpNleQyxlFlNCaHsHx6Uqv86VgPmR4o2wdNq3dP1qyDQ==}
engines: {node: '>=12'}
cpu: [x64]
os: [darwin]
requiresBuild: true
dev: true
optional: true
/@esbuild/freebsd-arm64@0.19.9:
resolution: {integrity: sha512-uFQyd/o1IjiEk3rUHSwUKkqZwqdvuD8GevWF065eqgYfexcVkxh+IJgwTaGZVu59XczZGcN/YMh9uF1fWD8j1g==}
engines: {node: '>=12'}
cpu: [arm64]
os: [freebsd]
requiresBuild: true
dev: true
optional: true
/@esbuild/freebsd-x64@0.19.9:
resolution: {integrity: sha512-WMLgWAtkdTbTu1AWacY7uoj/YtHthgqrqhf1OaEWnZb7PQgpt8eaA/F3LkV0E6K/Lc0cUr/uaVP/49iE4M4asA==}
engines: {node: '>=12'}
cpu: [x64]
os: [freebsd]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-arm64@0.19.9:
resolution: {integrity: sha512-PiPblfe1BjK7WDAKR1Cr9O7VVPqVNpwFcPWgfn4xu0eMemzRp442hXyzF/fSwgrufI66FpHOEJk0yYdPInsmyQ==}
engines: {node: '>=12'}
cpu: [arm64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-arm@0.19.9:
resolution: {integrity: sha512-C/ChPohUYoyUaqn1h17m/6yt6OB14hbXvT8EgM1ZWaiiTYz7nWZR0SYmMnB5BzQA4GXl3BgBO1l8MYqL/He3qw==}
engines: {node: '>=12'}
cpu: [arm]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-ia32@0.19.9:
resolution: {integrity: sha512-f37i/0zE0MjDxijkPSQw1CO/7C27Eojqb+r3BbHVxMLkj8GCa78TrBZzvPyA/FNLUMzP3eyHCVkAopkKVja+6Q==}
engines: {node: '>=12'}
cpu: [ia32]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-loong64@0.19.9:
resolution: {integrity: sha512-t6mN147pUIf3t6wUt3FeumoOTPfmv9Cc6DQlsVBpB7eCpLOqQDyWBP1ymXn1lDw4fNUSb/gBcKAmvTP49oIkaA==}
engines: {node: '>=12'}
cpu: [loong64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-mips64el@0.19.9:
resolution: {integrity: sha512-jg9fujJTNTQBuDXdmAg1eeJUL4Jds7BklOTkkH80ZgQIoCTdQrDaHYgbFZyeTq8zbY+axgptncko3v9p5hLZtw==}
engines: {node: '>=12'}
cpu: [mips64el]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-ppc64@0.19.9:
resolution: {integrity: sha512-tkV0xUX0pUUgY4ha7z5BbDS85uI7ABw3V1d0RNTii7E9lbmV8Z37Pup2tsLV46SQWzjOeyDi1Q7Wx2+QM8WaCQ==}
engines: {node: '>=12'}
cpu: [ppc64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-riscv64@0.19.9:
resolution: {integrity: sha512-DfLp8dj91cufgPZDXr9p3FoR++m3ZJ6uIXsXrIvJdOjXVREtXuQCjfMfvmc3LScAVmLjcfloyVtpn43D56JFHg==}
engines: {node: '>=12'}
cpu: [riscv64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-s390x@0.19.9:
resolution: {integrity: sha512-zHbglfEdC88KMgCWpOl/zc6dDYJvWGLiUtmPRsr1OgCViu3z5GncvNVdf+6/56O2Ca8jUU+t1BW261V6kp8qdw==}
engines: {node: '>=12'}
cpu: [s390x]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-x64@0.19.9:
resolution: {integrity: sha512-JUjpystGFFmNrEHQnIVG8hKwvA2DN5o7RqiO1CVX8EN/F/gkCjkUMgVn6hzScpwnJtl2mPR6I9XV1oW8k9O+0A==}
engines: {node: '>=12'}
cpu: [x64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/netbsd-x64@0.19.9:
resolution: {integrity: sha512-GThgZPAwOBOsheA2RUlW5UeroRfESwMq/guy8uEe3wJlAOjpOXuSevLRd70NZ37ZrpO6RHGHgEHvPg1h3S1Jug==}
engines: {node: '>=12'}
cpu: [x64]
os: [netbsd]
requiresBuild: true
dev: true
optional: true
/@esbuild/openbsd-x64@0.19.9:
resolution: {integrity: sha512-Ki6PlzppaFVbLnD8PtlVQfsYw4S9n3eQl87cqgeIw+O3sRr9IghpfSKY62mggdt1yCSZ8QWvTZ9jo9fjDSg9uw==}
engines: {node: '>=12'}
cpu: [x64]
os: [openbsd]
requiresBuild: true
dev: true
optional: true
/@esbuild/sunos-x64@0.19.9:
resolution: {integrity: sha512-MLHj7k9hWh4y1ddkBpvRj2b9NCBhfgBt3VpWbHQnXRedVun/hC7sIyTGDGTfsGuXo4ebik2+3ShjcPbhtFwWDw==}
engines: {node: '>=12'}
cpu: [x64]
os: [sunos]
requiresBuild: true
dev: true
optional: true
/@esbuild/win32-arm64@0.19.9:
resolution: {integrity: sha512-GQoa6OrQ8G08guMFgeXPH7yE/8Dt0IfOGWJSfSH4uafwdC7rWwrfE6P9N8AtPGIjUzdo2+7bN8Xo3qC578olhg==}
engines: {node: '>=12'}
cpu: [arm64]
os: [win32]
requiresBuild: true
dev: true
optional: true
/@esbuild/win32-ia32@0.19.9:
resolution: {integrity: sha512-UOozV7Ntykvr5tSOlGCrqU3NBr3d8JqPes0QWN2WOXfvkWVGRajC+Ym0/Wj88fUgecUCLDdJPDF0Nna2UK3Qtg==}
engines: {node: '>=12'}
cpu: [ia32]
os: [win32]
requiresBuild: true
dev: true
optional: true
/@esbuild/win32-x64@0.19.9:
resolution: {integrity: sha512-oxoQgglOP7RH6iasDrhY+R/3cHrfwIDvRlT4CGChflq6twk8iENeVvMJjmvBb94Ik1Z+93iGO27err7w6l54GQ==}
engines: {node: '>=12'}
cpu: [x64]
os: [win32]
requiresBuild: true
dev: true
optional: true
/@halo-dev/api-client@2.12.0:
resolution: {integrity: sha512-W/FmwtdXvq/BSwi2VMYBPzV86SmUjjn/1RO3pzyBOp8KwZM97vVVPJwYhT7IUB+W7q9HtDBUlvcFS8M0bX0RaA==}
dev: false
/@lit-labs/ssr-dom-shim@1.1.2:
resolution: {integrity: sha512-jnOD+/+dSrfTWYfSXBXlo5l5f0q1UuJo3tkbMDCYA2lKUYq79jaxqtGEvnRoh049nt1vdo1+45RinipU6FGY2g==}
dev: false
/@lit-labs/ssr-dom-shim@1.2.0:
resolution: {integrity: sha512-yWJKmpGE6lUURKAaIltoPIE/wrbY3TEkqQt+X0m+7fQNnAv0keydnYvbiJFP1PnMhizmIWRWOG5KLhYyc/xl+g==}
dev: false
/@lit/context@1.1.0:
resolution: {integrity: sha512-fCyv4dsH05wCNm3AKbB+PdYbXGJd/XT8OOwo4hVmD4COq5wOWJlQreGAMDvmHZ7osqxuu06Y4nmP6ooXpN7ErA==}
dependencies:
'@lit/reactive-element': 2.0.1
dev: false
/@lit/reactive-element@2.0.1:
resolution: {integrity: sha512-eu50SQXHRthFwWJMp0oAFg95Rvm6MTPjxSXWuvAu7It90WVFLFpNBoIno7XOXSDvVgTrtKnUV4OLJqys2Svn4g==}
dependencies:
'@lit-labs/ssr-dom-shim': 1.1.2
dev: false
/@lit/reactive-element@2.0.4:
resolution: {integrity: sha512-GFn91inaUa2oHLak8awSIigYz0cU0Payr1rcFsrkf5OJ5eSPxElyZfKh0f2p9FsTiZWXQdWGJeXZICEfXXYSXQ==}
dependencies:
'@lit-labs/ssr-dom-shim': 1.2.0
dev: false
/@rollup/rollup-android-arm-eabi@4.8.0:
resolution: {integrity: sha512-zdTObFRoNENrdPpnTNnhOljYIcOX7aI7+7wyrSpPFFIOf/nRdedE6IYsjaBE7tjukphh1tMTojgJ7p3lKY8x6Q==}
cpu: [arm]
os: [android]
requiresBuild: true
dev: true
optional: true
/@rollup/rollup-android-arm64@4.8.0:
resolution: {integrity: sha512-aiItwP48BiGpMFS9Znjo/xCNQVwTQVcRKkFKsO81m8exrGjHkCBDvm9PHay2kpa8RPnZzzKcD1iQ9KaLY4fPQQ==}
cpu: [arm64]
os: [android]
requiresBuild: true
dev: true
optional: true
/@rollup/rollup-darwin-arm64@4.8.0:
resolution: {integrity: sha512-zhNIS+L4ZYkYQUjIQUR6Zl0RXhbbA0huvNIWjmPc2SL0cB1h5Djkcy+RZ3/Bwszfb6vgwUvcVJYD6e6Zkpsi8g==}
cpu: [arm64]
os: [darwin]
requiresBuild: true
dev: true
optional: true
/@rollup/rollup-darwin-x64@4.8.0:
resolution: {integrity: sha512-A/FAHFRNQYrELrb/JHncRWzTTXB2ticiRFztP4ggIUAfa9Up1qfW8aG2w/mN9jNiZ+HB0t0u0jpJgFXG6BfRTA==}
cpu: [x64]
os: [darwin]
requiresBuild: true
dev: true
optional: true
/@rollup/rollup-linux-arm-gnueabihf@4.8.0:
resolution: {integrity: sha512-JsidBnh3p2IJJA4/2xOF2puAYqbaczB3elZDT0qHxn362EIoIkq7hrR43Xa8RisgI6/WPfvb2umbGsuvf7E37A==}
cpu: [arm]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@rollup/rollup-linux-arm64-gnu@4.8.0:
resolution: {integrity: sha512-hBNCnqw3EVCkaPB0Oqd24bv8SklETptQWcJz06kb9OtiShn9jK1VuTgi7o4zPSt6rNGWQOTDEAccbk0OqJmS+g==}
cpu: [arm64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@rollup/rollup-linux-arm64-musl@4.8.0:
resolution: {integrity: sha512-Fw9ChYfJPdltvi9ALJ9wzdCdxGw4wtq4t1qY028b2O7GwB5qLNSGtqMsAel1lfWTZvf4b6/+4HKp0GlSYg0ahA==}
cpu: [arm64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@rollup/rollup-linux-riscv64-gnu@4.8.0:
resolution: {integrity: sha512-BH5xIh7tOzS9yBi8dFrCTG8Z6iNIGWGltd3IpTSKp6+pNWWO6qy8eKoRxOtwFbMrid5NZaidLYN6rHh9aB8bEw==}
cpu: [riscv64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@rollup/rollup-linux-x64-gnu@4.8.0:
resolution: {integrity: sha512-PmvAj8k6EuWiyLbkNpd6BLv5XeYFpqWuRvRNRl80xVfpGXK/z6KYXmAgbI4ogz7uFiJxCnYcqyvZVD0dgFog7Q==}
cpu: [x64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@rollup/rollup-linux-x64-musl@4.8.0:
resolution: {integrity: sha512-mdxnlW2QUzXwY+95TuxZ+CurrhgrPAMveDWI97EQlA9bfhR8tw3Pt7SUlc/eSlCNxlWktpmT//EAA8UfCHOyXg==}
cpu: [x64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@rollup/rollup-win32-arm64-msvc@4.8.0:
resolution: {integrity: sha512-ge7saUz38aesM4MA7Cad8CHo0Fyd1+qTaqoIo+Jtk+ipBi4ATSrHWov9/S4u5pbEQmLjgUjB7BJt+MiKG2kzmA==}
cpu: [arm64]
os: [win32]
requiresBuild: true
dev: true
optional: true
/@rollup/rollup-win32-ia32-msvc@4.8.0:
resolution: {integrity: sha512-p9E3PZlzurhlsN5h9g7zIP1DnqKXJe8ZUkFwAazqSvHuWfihlIISPxG9hCHCoA+dOOspL/c7ty1eeEVFTE0UTw==}
cpu: [ia32]
os: [win32]
requiresBuild: true
dev: true
optional: true
/@rollup/rollup-win32-x64-msvc@4.8.0:
resolution: {integrity: sha512-kb4/auKXkYKqlUYTE8s40FcJIj5soOyRLHKd4ugR0dCq0G2EfcF54eYcfQiGkHzjidZ40daB4ulsFdtqNKZtBg==}
cpu: [x64]
os: [win32]
requiresBuild: true
dev: true
optional: true
/@types/trusted-types@2.0.5:
resolution: {integrity: sha512-I3pkr8j/6tmQtKV/ZzHtuaqYSQvyjGRKH4go60Rr0IDLlFxuRT5V32uvB1mecM5G1EVAUyF/4r4QZ1GHgz+mxA==}
dev: false
/dayjs@1.11.10:
resolution: {integrity: sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==}
dev: false
/emoji-mart@5.5.2:
resolution: {integrity: sha512-Sqc/nso4cjxhOwWJsp9xkVm8OF5c+mJLZJFoFfzRuKO+yWiN7K8c96xmtughYb0d/fZ8UC6cLIQ/p4BR6Pv3/A==}
dev: false
/esbuild@0.19.9:
resolution: {integrity: sha512-U9CHtKSy+EpPsEBa+/A2gMs/h3ylBC0H0KSqIg7tpztHerLi6nrrcoUJAkNCEPumx8yJ+Byic4BVwHgRbN0TBg==}
engines: {node: '>=12'}
hasBin: true
requiresBuild: true
optionalDependencies:
'@esbuild/android-arm': 0.19.9
'@esbuild/android-arm64': 0.19.9
'@esbuild/android-x64': 0.19.9
'@esbuild/darwin-arm64': 0.19.9
'@esbuild/darwin-x64': 0.19.9
'@esbuild/freebsd-arm64': 0.19.9
'@esbuild/freebsd-x64': 0.19.9
'@esbuild/linux-arm': 0.19.9
'@esbuild/linux-arm64': 0.19.9
'@esbuild/linux-ia32': 0.19.9
'@esbuild/linux-loong64': 0.19.9
'@esbuild/linux-mips64el': 0.19.9
'@esbuild/linux-ppc64': 0.19.9
'@esbuild/linux-riscv64': 0.19.9
'@esbuild/linux-s390x': 0.19.9
'@esbuild/linux-x64': 0.19.9
'@esbuild/netbsd-x64': 0.19.9
'@esbuild/openbsd-x64': 0.19.9
'@esbuild/sunos-x64': 0.19.9
'@esbuild/win32-arm64': 0.19.9
'@esbuild/win32-ia32': 0.19.9
'@esbuild/win32-x64': 0.19.9
dev: true
/fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
requiresBuild: true
dev: true
optional: true
/javascript-time-ago@2.5.9:
resolution: {integrity: sha512-pQ8mNco/9g9TqWXWWjP0EWl6i/lAQScOyEeXy5AB+f7MfLSdgyV9BJhiOD1zrIac/lrxPYOWNbyl/IW8CW5n0A==}
dependencies:
relative-time-format: 1.1.6
dev: false
/lit-element@4.0.4:
resolution: {integrity: sha512-98CvgulX6eCPs6TyAIQoJZBCQPo80rgXR+dVBs61cstJXqtI+USQZAbA4gFHh6L/mxBx9MrgPLHLsUgDUHAcCQ==}
dependencies:
'@lit-labs/ssr-dom-shim': 1.2.0
'@lit/reactive-element': 2.0.4
lit-html: 3.1.2
dev: false
/lit-html@3.1.2:
resolution: {integrity: sha512-3OBZSUrPnAHoKJ9AMjRL/m01YJxQMf+TMHanNtTHG68ubjnZxK0RFl102DPzsw4mWnHibfZIBJm3LWCZ/LmMvg==}
dependencies:
'@types/trusted-types': 2.0.5
dev: false
/lit@3.1.2:
resolution: {integrity: sha512-VZx5iAyMtX7CV4K8iTLdCkMaYZ7ipjJZ0JcSdJ0zIdGxxyurjIn7yuuSxNBD7QmjvcNJwr0JS4cAdAtsy7gZ6w==}
dependencies:
'@lit/reactive-element': 2.0.4
lit-element: 4.0.4
lit-html: 3.1.2
dev: false
/nanoid@3.3.7:
resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
dev: true
/picocolors@1.0.0:
resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==}
dev: true
/postcss@8.4.35:
resolution: {integrity: sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==}
engines: {node: ^10 || ^12 || >=14}
dependencies:
nanoid: 3.3.7
picocolors: 1.0.0
source-map-js: 1.0.2
dev: true
/relative-time-format@1.1.6:
resolution: {integrity: sha512-aCv3juQw4hT1/P/OrVltKWLlp15eW1GRcwP1XdxHrPdZE9MtgqFpegjnTjLhi2m2WI9MT/hQQtE+tjEWG1hgkQ==}
dev: false
/rollup@4.8.0:
resolution: {integrity: sha512-NpsklK2fach5CdI+PScmlE5R4Ao/FSWtF7LkoIrHDxPACY/xshNasPsbpG0VVHxUTbf74tJbVT4PrP8JsJ6ZDA==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
optionalDependencies:
'@rollup/rollup-android-arm-eabi': 4.8.0
'@rollup/rollup-android-arm64': 4.8.0
'@rollup/rollup-darwin-arm64': 4.8.0
'@rollup/rollup-darwin-x64': 4.8.0
'@rollup/rollup-linux-arm-gnueabihf': 4.8.0
'@rollup/rollup-linux-arm64-gnu': 4.8.0
'@rollup/rollup-linux-arm64-musl': 4.8.0
'@rollup/rollup-linux-riscv64-gnu': 4.8.0
'@rollup/rollup-linux-x64-gnu': 4.8.0
'@rollup/rollup-linux-x64-musl': 4.8.0
'@rollup/rollup-win32-arm64-msvc': 4.8.0
'@rollup/rollup-win32-ia32-msvc': 4.8.0
'@rollup/rollup-win32-x64-msvc': 4.8.0
fsevents: 2.3.3
dev: true
/source-map-js@1.0.2:
resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==}
engines: {node: '>=0.10.0'}
dev: true
/typescript@5.3.3:
resolution: {integrity: sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==}
engines: {node: '>=14.17'}
hasBin: true
dev: true
/vite@5.1.4:
resolution: {integrity: sha512-n+MPqzq+d9nMVTKyewqw6kSt+R3CkvF9QAKY8obiQn8g1fwTscKxyfaYnC632HtBXAQGc1Yjomphwn1dtwGAHg==}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
peerDependencies:
'@types/node': ^18.0.0 || >=20.0.0
less: '*'
lightningcss: ^1.21.0
sass: '*'
stylus: '*'
sugarss: '*'
terser: ^5.4.0
peerDependenciesMeta:
'@types/node':
optional: true
less:
optional: true
lightningcss:
optional: true
sass:
optional: true
stylus:
optional: true
sugarss:
optional: true
terser:
optional: true
dependencies:
esbuild: 0.19.9
postcss: 8.4.35
rollup: 4.8.0
optionalDependencies:
fsevents: 2.3.3
dev: true
@@ -0,0 +1,99 @@
import type { CommentVo, ReplyVo } from '@halo-dev/api-client';
import { getAvatarProvider } from './providers';
abstract class AvatarPolicy {
abstract applyCommentPolicy(
comment: CommentVo | undefined
): string | undefined;
abstract applyReplyPolicy(reply: ReplyVo | undefined): string | undefined;
}
let policyInstance: AvatarPolicy | undefined;
const emailKind = 'Email';
const emailHash = 'email-hash';
class AnonymousUserPolicy extends AvatarPolicy {
applyCommentPolicy(comment: CommentVo | undefined): string | undefined {
const avatarProvider = getAvatarProvider();
const isAnonymous = comment?.owner.kind === emailKind;
if (isAnonymous) {
return avatarProvider?.getAvatarSrc(
comment?.spec.owner.annotations?.[emailHash]
);
}
return comment?.owner.avatar;
}
applyReplyPolicy(reply: ReplyVo | undefined): string | undefined {
const avatarProvider = getAvatarProvider();
const isAnonymous = reply?.owner.kind === emailKind;
if (isAnonymous) {
return avatarProvider?.getAvatarSrc(
reply?.spec.owner.annotations?.[emailHash]
);
}
return reply?.owner.avatar;
}
}
class AllUserPolicy extends AvatarPolicy {
applyCommentPolicy(comment: CommentVo | undefined): string | undefined {
const avatarProvider = getAvatarProvider();
return avatarProvider?.getAvatarSrc(
comment?.spec.owner.annotations?.[emailHash]
);
}
applyReplyPolicy(reply: ReplyVo | undefined): string | undefined {
const avatarProvider = getAvatarProvider();
return avatarProvider?.getAvatarSrc(
reply?.spec.owner.annotations?.[emailHash]
);
}
}
class NoAvatarUserPolicy extends AvatarPolicy {
applyCommentPolicy(comment: CommentVo | undefined): string | undefined {
const avatarProvider = getAvatarProvider();
const isAnonymous = comment?.owner.kind === emailKind;
const avatar = comment?.owner.avatar;
if (isAnonymous || !avatar) {
return avatarProvider?.getAvatarSrc(
comment?.spec.owner.annotations?.[emailHash]
);
}
return avatar;
}
applyReplyPolicy(reply: ReplyVo | undefined): string | undefined {
const avatarProvider = getAvatarProvider();
const isAnonymous = reply?.owner.kind === emailKind;
const avatar = reply?.owner.avatar;
if (isAnonymous || !avatar) {
return avatarProvider?.getAvatarSrc(
reply?.spec.owner.annotations?.[emailHash]
);
}
return avatar;
}
}
enum AvatarPolicyEnum {
ANONYMOUS_USER_POLICY = 'anonymousUser',
ALL_USER_POLICY = 'allUser',
NO_AVATAR_USER_POLICY = 'noAvatarUser',
}
function setPolicyInstance(nPolicyInstance: AvatarPolicy | undefined) {
policyInstance = nPolicyInstance;
}
function getPolicyInstance(): AvatarPolicy | undefined {
return policyInstance;
}
export {
AnonymousUserPolicy,
AllUserPolicy,
NoAvatarUserPolicy,
AvatarPolicyEnum,
setPolicyInstance,
getPolicyInstance,
};
@@ -0,0 +1,23 @@
export default abstract class AvatarProvider {
private readonly _name: string;
private _url: string;
constructor(name: string, url: string) {
this._name = name;
this._url = url;
}
get url(): string {
return this._url;
}
set url(value: string) {
this._url = value;
}
get name(): string {
return this._name;
}
abstract getAvatarSrc(emailHash: string | undefined): string;
}
@@ -0,0 +1,9 @@
import AvatarProvider from './avatar-provider';
class Gravatar extends AvatarProvider {
override getAvatarSrc(emailHash: string | undefined): string {
return `${this.url}/avatar/${emailHash}`;
}
}
export default new Gravatar('Gravatar', 'https://gravatar.com');
@@ -0,0 +1,24 @@
import type AvatarProvider from './avatar-provider';
import Gravatar from './gravatar';
let avatarProvider: AvatarProvider | undefined;
enum AvatarProviderEnum {
GRAVATAR = 'gravatar',
}
export function setAvatarProvider(provider: string, mirrorUrl?: string) {
switch (provider) {
case AvatarProviderEnum.GRAVATAR:
if (mirrorUrl) {
Gravatar.url = mirrorUrl;
}
avatarProvider = Gravatar;
break;
default:
}
}
export function getAvatarProvider() {
return avatarProvider;
}
@@ -0,0 +1,134 @@
import './user-avatar';
import { msg } from '@lit/localize';
import { css, html, LitElement } from 'lit';
import { property, state } from 'lit/decorators.js';
import baseStyles from './styles/base';
import { formatDate, timeAgo } from './utils/date';
import './commenter-ua-bar';
import { consume } from '@lit/context';
import { configMapDataContext } from './context';
import type { ConfigMapData } from './types';
import './comment-content';
import { ifDefined } from 'lit/directives/if-defined.js';
import { when } from 'lit/directives/when.js';
export class BaseCommentItem extends LitElement {
@property({ type: String })
userAvatar: string | undefined;
@property({ type: String })
userDisplayName: string | undefined;
@property({ type: String })
userWebsite: string | undefined;
@property({ type: String })
creationTime: string | undefined;
@property({ type: Boolean })
approved: boolean | undefined;
@property({ type: Boolean })
breath: boolean | undefined;
@property({ type: String })
content = '';
@property({ type: String })
ua: string | undefined;
@property({ type: Boolean })
private: boolean | undefined;
@consume({ context: configMapDataContext })
@state()
configMapData: ConfigMapData | undefined;
override render() {
return html`<div class="item flex gap-3 py-4 ${this.breath ? 'animate-breath' : ''}">
<div class="item-avatar flex-none">
<user-avatar
src="${this.userAvatar || ''}"
alt="${this.userDisplayName || ''}"
href=${ifDefined(this.userWebsite)}
></user-avatar>
</div>
<div class="item-main flex-[1_1_auto] min-w-0 w-full">
<div class="item-meta flex items-center gap-2 flex-wrap">
${when(
this.userWebsite,
() => html`
<a
class="item-author font-medium text-sm text-text-1 hover:underline"
target="_blank"
href=${ifDefined(this.userWebsite)}
rel="noopener noreferrer"
>
${this.userDisplayName}
</a>
`,
() => html`
<span class="item-author font-medium text-sm text-text-1">${this.userDisplayName}</span>
`
)}
${when(
this.private && this.configMapData?.basic.showPrivateCommentBadge,
() => html`<div class="inline-flex items-center gap-1 bg-muted-3 rounded-base px-1.5 py-1">
<i class="i-ri-git-repository-private-line opacity-90 size-3" aria-hidden="true"></i>
<span class="text-xs text-text-2">${msg('Private')}</span>
</div>`
)}
${when(this.ua && this.configMapData?.basic.showCommenterDevice, () => html`<commenter-ua-bar .ua=${this.ua}></commenter-ua-bar>`)}
<time class="item-meta-info text-xs text-text-3" title=${formatDate(this.creationTime)}>
${timeAgo(this.creationTime)}
</time>
${when(!this.approved, () => html`<div class="item-meta-info text-xs text-text-3">${msg('Reviewing')}</div>`)}
</div>
<div class="item-content mt-2.5 space-y-2.5"><slot name="pre-content"></slot><comment-content .content=${this.content}></comment-content></div>
<div class="item-actions mt-2 flex items-center gap-3">
<slot name="action"></slot>
</div>
<slot name="footer"></slot>
</div>
</div>`;
}
static override styles = [
...baseStyles,
css`
.animate-breath {
animation: breath 1s ease-in-out infinite;
}
@keyframes breath {
0% {
transform: scale(1);
}
50% {
transform: scale(1.02);
}
100% {
transform: scale(1);
}
}
@unocss-placeholder;
`,
];
}
customElements.get('base-comment-item') ||
customElements.define('base-comment-item', BaseCommentItem);
declare global {
interface HTMLElementTagNameMap {
'base-comment-item': BaseCommentItem;
}
}
+364
View File
@@ -0,0 +1,364 @@
import type { User } from '@halo-dev/api-client';
import { consume } from '@lit/context';
import { debounce } from 'es-toolkit';
import { css, html, LitElement } from 'lit';
import { property, state } from 'lit/decorators.js';
import { createRef, type Ref, ref } from 'lit/directives/ref.js';
import {
allowAnonymousCommentsContext,
baseUrlContext,
configMapDataContext,
currentUserContext,
groupContext,
kindContext,
nameContext,
toastContext,
} from './context';
import './icons/icon-loading';
import { msg } from '@lit/localize';
import type { ToastManager } from './lit-toast';
import baseStyles from './styles/base';
import type { ConfigMapData } from './types';
import './comment-editor';
import { when } from 'lit/directives/when.js';
import { ofetch } from 'ofetch';
import type { CommentEditor } from './comment-editor';
import { cleanHtml } from './utils/html';
import './base-tooltip';
export class BaseForm extends LitElement {
@consume({ context: baseUrlContext })
@state()
baseUrl = '';
@consume({ context: currentUserContext, subscribe: true })
@state()
currentUser: User | undefined;
@consume({ context: configMapDataContext })
@state()
configMapData: ConfigMapData | undefined;
@consume({ context: allowAnonymousCommentsContext, subscribe: true })
@state()
allowAnonymousComments = false;
@consume({ context: groupContext })
@state()
group = '';
@consume({ context: kindContext })
@state()
kind = '';
@consume({ context: nameContext })
@state()
name = '';
@property({ type: String })
@state()
captcha = '';
@property({ type: Boolean })
submitting = false;
@consume({ context: toastContext, subscribe: true })
@state()
toastManager: ToastManager | undefined;
@property({ type: Boolean })
hidePrivateCheckbox = false;
textareaRef: Ref<HTMLTextAreaElement> = createRef<HTMLTextAreaElement>();
editorRef: Ref<CommentEditor> = createRef<CommentEditor>();
get customAccount() {
return JSON.parse(
localStorage.getItem('halo-comment-custom-account') || '{}'
);
}
get parentDomId() {
return `#comment-${[this.group?.replaceAll('.', '-'), this.kind, this.name]
.join('-')
.replaceAll(/-+/g, '-')}`;
}
get loginUrl() {
return `/login?redirect_uri=${encodeURIComponent(
window.location.pathname + this.parentDomId
)}`;
}
get showCaptcha() {
return (
this.configMapData?.security.captcha.anonymousCommentCaptcha &&
!this.currentUser &&
this.allowAnonymousComments
);
}
override updated(changedProperties: Map<string, unknown>) {
if (
changedProperties.has('configMapData') ||
changedProperties.has('currentUser') ||
changedProperties.has('allowAnonymousComments')
) {
if (this.showCaptcha) {
this.handleFetchCaptcha();
}
}
}
async handleFetchCaptcha() {
if (!this.showCaptcha) {
return;
}
try {
const data = await ofetch(
`/apis/api.commentwidget.halo.run/v1alpha1/captcha/-/generate`,
{
parseResponse: (txt) => txt,
}
);
this.captcha = data;
} catch (error) {
console.error(error);
this.toastManager?.error(msg('Failed to obtain verification code'));
}
}
handleOpenLoginPage() {
window.location.href = this.loginUrl;
}
async handleLogout() {
if (
window.confirm(
msg(
'Click OK to jump to the logout page, Please make sure the content being edited has been saved.'
)
)
) {
try {
window.location.href = `/logout?redirect_uri=${encodeURIComponent(
window.location.pathname + this.parentDomId
)}`;
} catch (error) {
console.error('Failed to logout', error);
}
}
}
renderAccountInfo() {
return html`<div class="form-account flex items-center gap-2">
${when(
this.currentUser?.spec.avatar,
() => html`<div class="form-account-avatar avatar"><img src=${this.currentUser?.spec.avatar || ''} class="size-full object-cover" /></div>
`
)}
<span class="form-account-name text-base text-text-1 font-semibold">
${this.currentUser?.spec.displayName || this.currentUser?.metadata.name}
</span>
<button
@click=${this.handleLogout}
type="button"
class="form-logout text-xs text-text-3 hover:text-text-1 px-3 transition-all py-2 rounded-base border border-muted-3 opacity-100 hover:border-muted-4 hover:opacity-70 border-solid"
tabindex="-1"
>
${msg('Logout')}
</button>
</div>`;
}
onKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
const form = this.shadowRoot?.querySelector('form');
e.preventDefault();
form?.requestSubmit();
}
}
override connectedCallback(): void {
super.connectedCallback();
this.addEventListener('keydown', this.onKeydown);
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.removeEventListener('keydown', this.onKeydown);
}
override render() {
return html`
<form class="form w-full flex flex-col gap-4" @submit="${this.onSubmit}">
<comment-editor ${ref(this.editorRef)} .placeholder=${this.configMapData?.editor?.placeholder}></comment-editor>
${when(
!this.currentUser && this.allowAnonymousComments,
() => html`
<div class="form-inputs grid grid-cols-1 md:grid-cols-4 gap-2 items-center">
<input
name="displayName"
value=${this.customAccount.displayName}
type="text"
placeholder=${msg('Nicename')}
required
class="input"
/>
<input
name="email"
value=${this.customAccount.email}
type="email"
placeholder=${msg('Email')}
required
class="input"
/>
<input
name="website"
value=${this.customAccount.website}
type="url"
placeholder=${msg('Website')}
class="input"
/>
<a tabindex="-1" href=${this.loginUrl} rel="nofollow" class="form-login-link text-text-3 hover:text-text-1 text-xs transition-all select-none">${msg('(Or login)')}</a>
</div>
`
)}
<div class="form__footer">
${this.currentUser ? this.renderAccountInfo() : ''}
${when(
!this.currentUser && !this.allowAnonymousComments,
() => html`
<button
@click=${this.handleOpenLoginPage}
class="form-login text-xs text-text-3 hover:text-text-1 px-3 transition-all py-2 rounded-base border border-muted-3 opacity-100 hover:border-muted-4 hover:opacity-70 border-solid"
type="button"
tabindex="-1"
>
${msg('Login')}
</button>
`
)}
<div class="form-actions justify-end flex gap-3 flex-wrap items-center">
${when(
!this.hidePrivateCheckbox &&
this.configMapData?.basic.enablePrivateComment,
() => html`<div class="flex items-center gap-2">
<input id="hidden" name="hidden" type="checkbox" />
<label for="hidden" class="text-xs select-none text-text-3 hover:text-text-1 transition-all">${msg('Private')}</label>
<base-tooltip content=${this.currentUser ? msg('Currently logged in. After selecting the private option, comments will only be visible to yourself and the site administrator.') : msg('You are currently anonymous. After selecting the private option, the comment will only be visible to the site administrator.')}>
<i class="i-mingcute:information-line size-3.5 text-text-3 block"></i>
</base-tooltip>
</div>`
)}
${when(
this.showCaptcha && this.captcha,
() => html`
<div class="form-captcha gap-2 flex items-center">
<img
@click=${this.handleFetchCaptcha}
src="${this.captcha}"
alt="captcha"
class="h-10 rounded-base border border-gray-100 border-solid"
/>
<input
name="captchaCode"
type="text"
placeholder=${msg('Please enter the verification code')}
class="input "
/>
</div>
`
)}
<button
.disabled=${this.submitting}
type="submit"
class="form-submit outline-none focus:shadow-input h-12 text-sm inline-flex border border-primary-1 border-solid items-center justify-center gap-2 bg-primary-1 text-white px-3 rounded-base hover:opacity-80 transition-all"
>
${when(
this.submitting,
() => html`<icon-loading></icon-loading>`,
() =>
html`<i class="i-mingcute-send-line size-5" aria-hidden="true"></i>`
)}
${msg('Submit')}
</button>
</div>
</div>
</form>
`;
}
private debouncedSubmit = debounce((data: Record<string, unknown>) => {
const content = cleanHtml(this.editorRef.value?.editor?.getHTML());
const characterCount =
this.editorRef.value?.editor?.storage.characterCount.characters();
if (!characterCount) {
this.toastManager?.warn(msg('Please enter content'));
this.editorRef.value?.setFocus();
return;
}
const event = new CustomEvent('submit', {
detail: {
...data,
content,
hidden: data.hidden === 'on',
},
});
this.dispatchEvent(event);
}, 300);
onSubmit(e: Event) {
e.preventDefault();
const form = e.target as HTMLFormElement;
const formData = new FormData(form);
const data = Object.fromEntries(formData.entries());
// store account info
localStorage.setItem(
'halo-comment-custom-account',
JSON.stringify({
displayName: data.displayName,
email: data.email,
website: data.website,
})
);
this.debouncedSubmit(data);
}
resetForm() {
const form = this.shadowRoot?.querySelector('form');
form?.reset();
this.editorRef.value?.reset();
}
setFocus() {
this.textareaRef.value?.focus();
this.editorRef.value?.setFocus();
}
static override styles = [
...baseStyles,
css`
@unocss-placeholder;
`,
];
}
customElements.get('base-form') || customElements.define('base-form', BaseForm);
declare global {
interface HTMLElementTagNameMap {
'base-form': BaseForm;
}
}
+161
View File
@@ -0,0 +1,161 @@
import {
autoUpdate,
computePosition,
flip,
offset,
shift,
} from '@floating-ui/dom';
import type { TemplateResult } from 'lit';
import { css, html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
import baseStyles from './styles/base';
/**
* A simple tooltip component based on Lit and floating-ui.
*
* @example
* ```html
* <base-tooltip content="This is a tooltip">
* <button>Hover me</button>
* </base-tooltip>
* ```
*/
export class BaseTooltip extends LitElement {
/**
* The content to display in the tooltip.
*/
@property({ type: String })
content: string | TemplateResult = '';
private tooltipEl?: HTMLElement;
private cleanupFn?: () => void;
override connectedCallback(): void {
super.connectedCallback();
this.addEventListener('mouseenter', this._showTooltip);
this.addEventListener('mouseleave', this._hideTooltip);
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.removeEventListener('mouseenter', this._showTooltip);
this.removeEventListener('mouseleave', this._hideTooltip);
// Clean up positioning if needed
if (this.cleanupFn) {
this.cleanupFn();
this.cleanupFn = undefined;
}
}
override firstUpdated(): void {
// Get tooltip element after the component is rendered
this.tooltipEl = this.shadowRoot?.querySelector('.tooltip') as HTMLElement;
}
private _showTooltip = (): void => {
if (!this.tooltipEl) return;
this.tooltipEl.classList.add('show');
this._updatePosition();
};
private _hideTooltip = (): void => {
if (!this.tooltipEl) return;
this.tooltipEl.classList.remove('show');
// Clean up positioning
if (this.cleanupFn) {
this.cleanupFn();
this.cleanupFn = undefined;
}
};
private _updatePosition(): void {
if (!this.tooltipEl) return;
const floating = this.tooltipEl;
// Clean up previous positioning if any
if (this.cleanupFn) {
this.cleanupFn();
}
// Use autoUpdate to reposition on scroll/resize
this.cleanupFn = autoUpdate(this, floating, async () => {
// Calculate the position
const middlewares = [
offset(8), // 8px distance from trigger
flip(), // flip to other side if needed
shift({ padding: 5 }), // shift to keep in viewport
];
// Calculate position
const { x, y } = await computePosition(this, floating, {
placement: 'top', // Default placement is top
middleware: middlewares,
});
// Position the tooltip
Object.assign(floating.style, {
left: `${x}px`,
top: `${y}px`,
});
});
}
override render() {
return html`
<slot></slot>
<div class="tooltip">
${this.content}
</div>
`;
}
static override styles = [
...baseStyles,
css`
:host {
display: inline-block;
}
::slotted(*) {
display: inline-block;
}
.tooltip {
position: absolute;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 5px 10px;
border-radius: 4px;
font-size: 0.875em;
max-width: 300px;
width: auto;
z-index: 10;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s;
top: 0;
left: 0;
white-space: normal;
word-wrap: break-word;
}
.tooltip.show {
opacity: 1;
}
`,
];
}
customElements.get('base-tooltip') ||
customElements.define('base-tooltip', BaseTooltip);
declare global {
interface HTMLElementTagNameMap {
'base-tooltip': BaseTooltip;
}
}
@@ -0,0 +1,83 @@
import { css, html, LitElement, type PropertyValues, unsafeCSS } from 'lit';
import { property } from 'lit/decorators.js';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import baseStyles from './styles/base';
import contentStyles from './styles/content.css?inline';
import { cleanHtml } from './utils/html';
export class CommentContent extends LitElement {
@property({ type: String })
content: string = '';
protected override firstUpdated(_changedProperties: PropertyValues) {
super.firstUpdated(_changedProperties);
const codeElements = this.shadowRoot?.querySelectorAll('pre>code');
if (!codeElements?.length) return;
Promise.all(
Array.from(codeElements).map(async (codeblock) => {
const lang =
this.extractLanguageFromCodeElement(codeblock) || 'plaintext';
const content = codeblock.textContent || '';
try {
const { codeToHtml } = await import('shiki/bundle/full');
const html = await codeToHtml(content, {
lang,
theme: 'github-dark',
});
if (codeblock.parentElement) {
codeblock.parentElement.outerHTML = html;
}
} catch (error) {
console.error('Failed to highlight code:', error);
}
})
);
}
private extractLanguageFromCodeElement(codeElement: Element): string | null {
const supportedPrefixes = ['language-', 'lang-'];
const langClass = Array.from(codeElement.classList).find((className) =>
supportedPrefixes.some((prefix) => className.startsWith(prefix))
);
if (langClass) {
const prefix = supportedPrefixes.find((p) => langClass.startsWith(p));
return prefix ? langClass.substring(prefix.length) : null;
}
return null;
}
protected override render() {
return html`
<div class="content">${unsafeHTML(cleanHtml(this.content))}</div>
`;
}
static override styles = [
...baseStyles,
unsafeCSS(contentStyles),
css`
:host {
display: block;
width: 100%;
}
@unocss-placeholder;
`,
];
}
customElements.get('comment-content') ||
customElements.define('comment-content', CommentContent);
declare global {
interface HTMLElementTagNameMap {
'comment-content': CommentContent;
}
}
@@ -0,0 +1,47 @@
import { css, html, LitElement } from 'lit';
import { repeat } from 'lit/directives/repeat.js';
export class CommentEditorSkeleton extends LitElement {
protected override render() {
return html`<div
class="border rounded-base border-solid border-muted-1"
>
<div class="animate-pulse p-4">
<div class="h-4 mt-1 mb-8.75px w-20 bg-muted-1 rounded"></div>
</div>
<div class="py-2.5 px-3 flex gap-1 m-0 items-center">
${repeat(
Array(8),
() => html`
<div
role="button"
class="size-7 flex items-center justify-center cursor-pointer"
>
<div class="size-5 animate-pulse bg-muted-1 rounded-base"></div>
</div>
`
)}
</div>
</div>`;
}
static override styles = [
css`
:host {
display: block;
width: 100%;
}
@unocss-placeholder;
`,
];
}
customElements.get('comment-editor-skeleton') ||
customElements.define('comment-editor-skeleton', CommentEditorSkeleton);
declare global {
interface HTMLElementTagNameMap {
'comment-editor-skeleton': CommentEditorSkeleton;
}
}
@@ -0,0 +1,268 @@
import { msg } from '@lit/localize';
import type { Editor } from '@tiptap/core';
import { css, html, LitElement, type PropertyValues, unsafeCSS } from 'lit';
import { state } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import './emoji-button';
import contentStyles from './styles/content.css?inline';
import './comment-editor-skeleton';
import { property } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { when } from 'lit/directives/when.js';
import baseStyles from './styles/base';
import { cleanHtml } from './utils/html';
interface ActionItem {
name?: string;
displayName?: () => string;
type: 'action' | 'separator';
icon?: string;
run?: (editor?: Editor) => void;
}
const actionItems: ActionItem[] = [
{
name: 'bold',
displayName: () => msg('Bold'),
type: 'action',
icon: 'i-mingcute-bold-line',
run: (editor?: Editor) => editor?.chain().focus().toggleBold().run(),
},
{
name: 'italic',
displayName: () => msg('Italic'),
type: 'action',
icon: 'i-mingcute-italic-line',
run: (editor?: Editor) => editor?.chain().focus().toggleItalic().run(),
},
{
name: 'underline',
displayName: () => msg('Underline'),
type: 'action',
icon: 'i-mingcute-underline-line',
run: (editor?: Editor) => editor?.chain().focus().toggleUnderline().run(),
},
{
name: 'strike',
displayName: () => msg('Strike'),
type: 'action',
icon: 'i-mingcute-strikethrough-line',
run: (editor?: Editor) => editor?.chain().focus().toggleStrike().run(),
},
{
name: 'code',
displayName: () => msg('Code'),
type: 'action',
icon: 'i-mingcute-braces-line',
run: (editor?: Editor) => editor?.chain().focus().toggleCode().run(),
},
{
type: 'separator',
},
{
name: 'blockquote',
displayName: () => msg('Blockquote'),
type: 'action',
icon: 'i-mingcute-quote-left-line',
run: (editor?: Editor) => editor?.chain().focus().toggleBlockquote().run(),
},
{
name: 'codeBlock',
displayName: () => msg('Code Block'),
type: 'action',
icon: 'i-mingcute-code-line',
run: (editor?: Editor) => editor?.chain().focus().toggleCodeBlock().run(),
},
];
export class CommentEditor extends LitElement {
@property({ type: String })
placeholder: string | undefined;
@property({ type: Boolean, attribute: 'keep-alive' })
keepAlive = false;
@state()
editor: Editor | undefined;
@state()
loading = true;
protected override firstUpdated(_changedProperties: PropertyValues): void {
super.firstUpdated(_changedProperties);
this.createEditor();
}
async createEditor() {
const { Editor } = await import('@tiptap/core');
const { Placeholder } = await import('@tiptap/extensions');
const { StarterKit } = await import('@tiptap/starter-kit');
const { CodeBlockShiki } = await import(
'tiptap-extension-code-block-shiki'
);
const { CharacterCount } = await import('@tiptap/extensions');
this.loading = false;
this.editor = new Editor({
element: this.shadowRoot?.getElementById('editor-container'),
extensions: [
StarterKit.configure({
heading: false,
link: {
openOnClick: false,
},
codeBlock: false,
}),
Placeholder.configure({
placeholder: this.placeholder || msg('Write a comment'),
}),
CodeBlockShiki.configure({
defaultTheme: 'github-dark',
}),
CharacterCount,
],
onUpdate: () => {
this.requestUpdate();
},
onSelectionUpdate: () => {
this.requestUpdate();
},
onTransaction: () => {
this.requestUpdate();
},
});
this.editor.on('update', () => {
this.dispatchEvent(
new CustomEvent('update', {
detail: {
content: cleanHtml(this.editor?.getHTML()),
characterCount: this.editor?.storage.characterCount.characters(),
},
})
);
});
}
override disconnectedCallback(): void {
if (!this.keepAlive) {
this.editor?.destroy();
this.editor = undefined;
}
super.disconnectedCallback();
}
setFocus() {
setTimeout(() => {
this.editor?.chain().focus().run();
}, 100);
}
reset() {
this.editor?.commands.setContent('');
}
onEmojiSelect(e: CustomEvent) {
this.editor?.chain().focus().insertContent(e.detail.native).run();
}
protected override render() {
return html`
${when(this.loading, () => html`<comment-editor-skeleton></comment-editor-skeleton>`)}
<div
class="border rounded-base border-solid border-muted-1 focus-within:border-primary-1 focus-within:shadow-input transition-all"
?hidden=${this.loading}
@click=${this.setFocus}
>
<div
id="editor-container"
class="p-4 content !bg-transparent"
></div>
<ul class="list-none p-2.5 flex gap-1 m-0 items-center overflow-x-auto">
${repeat(actionItems, (item) =>
this.renderActionItem(item, this.editor)
)}
${this.renderActionItem({ type: 'separator' })}
<li class="flex items-center">
<emoji-button @emoji-select=${this.onEmojiSelect}></emoji-button>
</li>
</ul>
</div>`;
}
private renderActionItem(item: ActionItem, editor?: Editor) {
if (item.type === 'separator') {
return html`<li class="flex items-center" aria-hidden="true">
<div class="w-1px bg-muted-1 rounded-full h-3"></div>
</li>`;
}
if (item.type === 'action') {
const isActive = item.name ? editor?.isActive(item.name) : false;
return html`
<li>
<div
aria-label=${ifDefined(item.displayName?.())}
title=${ifDefined(item.displayName?.())}
@click=${() => item.run?.(editor)}
role="button"
class="size-7 hover:bg-muted-3 active:bg-muted-2 ${isActive ? 'bg-muted-3 text-text-1' : 'text-text-3 hover:text-text-1'} rounded-base flex items-center justify-center cursor-pointer transition-all"
>
<i class="size-5 ${item.icon}" aria-hidden="true"></i>
</div>
</li>
`;
}
return;
}
static override styles = [
...baseStyles,
unsafeCSS(contentStyles),
css`
:host {
display: block;
width: 100%;
}
.tiptap {
outline: none;
border: none;
}
.tiptap p {
padding: 0;
margin-bottom: 10px;
}
.tiptap p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
color: var(--halo-cw-text-3-color, #475569);
}
.tiptap code br {
display: block;
}
@unocss-placeholder;
`,
];
}
customElements.get('comment-editor') ||
customElements.define('comment-editor', CommentEditor);
declare global {
interface HTMLElementTagNameMap {
'comment-editor': CommentEditor;
}
}
+183
View File
@@ -0,0 +1,183 @@
import type { Comment, CommentRequest, User } from '@halo-dev/api-client';
import { consume } from '@lit/context';
import { html, LitElement } from 'lit';
import { state } from 'lit/decorators.js';
import { createRef, type Ref, ref } from 'lit/directives/ref.js';
import './base-form';
import { msg } from '@lit/localize';
import { FetchError, type FetchResponse, ofetch } from 'ofetch';
import type { BaseForm } from './base-form';
import {
allowAnonymousCommentsContext,
baseUrlContext,
currentUserContext,
groupContext,
kindContext,
nameContext,
toastContext,
versionContext,
} from './context';
import type { ToastManager } from './lit-toast';
import type { ProblemDetail } from './types';
import {
type CaptchaRequiredResponse,
getCaptchaCodeHeader,
isRequireCaptcha,
} from './utils/captcha';
export class CommentForm extends LitElement {
@consume({ context: baseUrlContext })
@state()
baseUrl = '';
@consume({ context: currentUserContext, subscribe: true })
@state()
currentUser: User | undefined;
@consume({ context: groupContext })
@state()
group = '';
@consume({ context: kindContext })
@state()
kind = '';
@consume({ context: nameContext })
@state()
name = '';
@consume({ context: versionContext })
@state()
version = 'v1alpha1';
@consume({ context: allowAnonymousCommentsContext, subscribe: true })
@state()
allowAnonymousComments = false;
@consume({ context: toastContext, subscribe: true })
@state()
toastManager: ToastManager | undefined;
@state()
submitting = false;
@state()
captcha = '';
baseFormRef: Ref<BaseForm> = createRef<BaseForm>();
override render() {
return html` <base-form
.submitting=${this.submitting}
.captcha=${this.captcha}
${ref(this.baseFormRef)}
@submit="${this.onSubmit}"
></base-form>`;
}
async onSubmit(e: CustomEvent) {
e.preventDefault();
this.submitting = true;
const data = e.detail;
const { displayName, email, website, content, hidden } = data || {};
const commentRequest: CommentRequest = {
raw: content,
content: content,
// TODO: support user input
allowNotification: true,
hidden: hidden || false,
subjectRef: {
group: this.group,
kind: this.kind,
name: this.name,
version: this.version,
},
};
if (!this.currentUser && !this.allowAnonymousComments) {
this.toastManager?.warn(msg('Please login first'));
this.submitting = false;
return;
}
if (!this.currentUser && this.allowAnonymousComments) {
if (!displayName || !email) {
this.toastManager?.warn(
msg('Please log in or complete the information first')
);
this.submitting = false;
return;
} else {
commentRequest.owner = {
displayName: displayName,
email: email,
website: website,
};
}
}
try {
const newComment = await ofetch<Comment>(
`${this.baseUrl}/apis/api.halo.run/v1alpha1/comments`,
{
method: 'POST',
headers: {
...getCaptchaCodeHeader(data.captchaCode),
},
body: commentRequest,
}
);
this.baseFormRef.value?.handleFetchCaptcha();
if (newComment.spec.approved) {
this.toastManager?.success(msg('Comment submitted successfully'));
} else {
this.toastManager?.success(
msg('Comment submitted successfully, pending review')
);
}
window.dispatchEvent(new CustomEvent('halo:comment:created'));
this.baseFormRef.value?.resetForm();
} catch (error) {
if (error instanceof FetchError) {
if (
isRequireCaptcha(
error.response as FetchResponse<CaptchaRequiredResponse>
)
) {
const { captcha, detail } =
error.data as unknown as CaptchaRequiredResponse;
this.captcha = captcha;
this.toastManager?.warn(detail);
return;
}
const problemDetail = error.data as unknown as ProblemDetail;
this.toastManager?.error(
[problemDetail?.title, problemDetail?.detail].join(' - ') ||
msg('Comment failed, please try again later')
);
return;
}
this.toastManager?.error(msg('Comment failed, please try again later'));
} finally {
this.submitting = false;
}
}
}
customElements.get('comment-form') ||
customElements.define('comment-form', CommentForm);
declare global {
interface HTMLElementTagNameMap {
'comment-form': CommentForm;
}
}
+213
View File
@@ -0,0 +1,213 @@
import type { CommentVo } from '@halo-dev/api-client';
import { css, html, LitElement } from 'lit';
import { property, state } from 'lit/decorators.js';
import baseStyles from './styles/base';
import './comment-replies';
import './user-avatar';
import './base-comment-item';
import { consume } from '@lit/context';
import { msg } from '@lit/localize';
import { createRef, type Ref, ref } from 'lit/directives/ref.js';
import { when } from 'lit/directives/when.js';
import { ofetch } from 'ofetch';
import { getPolicyInstance } from './avatar/avatar-policy';
import type { CommentReplies } from './comment-replies';
import { LS_UPVOTED_COMMENTS_KEY } from './constant';
import { baseUrlContext, configMapDataContext } from './context';
import type { ConfigMapData } from './types';
export class CommentItem extends LitElement {
@consume({ context: baseUrlContext })
@state()
baseUrl = '';
@property({ type: Object })
comment: CommentVo | undefined;
@consume({ context: configMapDataContext })
@state()
configMapData: ConfigMapData | undefined;
@state()
showReplies = false;
@state()
showReplyForm = false;
@state()
upvoted = false;
@state()
upvoteCount = 0;
commentRepliesRef: Ref<CommentReplies> = createRef<CommentReplies>();
override connectedCallback(): void {
super.connectedCallback();
this.checkUpvotedStatus();
if (this.configMapData?.basic.withReplies) {
this.showReplies = true;
this.showReplyForm = false;
}
this.upvoteCount = this.comment?.stats.upvote || 0;
}
checkUpvotedStatus() {
const upvotedComments = JSON.parse(
localStorage.getItem(LS_UPVOTED_COMMENTS_KEY) || '[]'
);
if (upvotedComments.includes(this.comment?.metadata.name)) {
this.upvoted = true;
}
}
async handleUpvote() {
const upvotedComments = JSON.parse(
localStorage.getItem(LS_UPVOTED_COMMENTS_KEY) || '[]'
);
if (upvotedComments.includes(this.comment?.metadata.name)) {
return;
}
const voteRequest = {
name: this.comment?.metadata.name,
plural: 'comments',
group: 'content.halo.run',
};
await ofetch(`${this.baseUrl}/apis/api.halo.run/v1alpha1/trackers/upvote`, {
method: 'POST',
body: voteRequest,
});
upvotedComments.push(this.comment?.metadata.name);
localStorage.setItem(
LS_UPVOTED_COMMENTS_KEY,
JSON.stringify(upvotedComments)
);
this.upvoteCount += 1;
this.upvoted = true;
this.checkUpvotedStatus();
}
handleShowReplies() {
this.showReplies = !this.showReplies;
if (!this.configMapData?.basic.withReplies) {
this.showReplyForm = !this.showReplyForm;
}
}
onReplyCreated() {
this.commentRepliesRef.value?.fetchReplies();
this.showReplies = true;
}
handleToggleReplyForm() {
this.showReplyForm = !this.showReplyForm;
}
override render() {
return html`<base-comment-item
.userAvatar="${handleCommentAvatar(this.comment)}"
.userDisplayName="${this.comment?.owner.displayName}"
.content="${this.comment?.spec.content || ''}"
.creationTime="${this.comment?.spec.creationTime}"
.approved=${this.comment?.spec.approved}
.userWebsite=${this.comment?.spec.owner.annotations?.website}
.ua=${this.comment?.spec.userAgent}
.private=${this.comment?.spec.hidden}
>
<button slot="action" class="icon-button group -ml-2" type="button" @click="${this.handleUpvote}" aria-label=${msg('Upvote')}>
<div class="icon-button-icon">
${when(
this.upvoted,
() =>
html`<i slot="icon" class="i-mingcute-heart-fill size-4 text-red-500" aria-hidden="true"></i>`,
() =>
html`<i slot="icon" class="i-mingcute-heart-line size-4" aria-hidden="true"></i>`
)}
</div>
<span class="icon-button-text">${`${this.upvoteCount || 0}`}</span>
</button>
${
this.configMapData?.basic.withReplies &&
this.comment?.status?.visibleReplyCount === 0
? ''
: html`
<button slot="action" class="icon-button group" type="button" @click="${this.handleShowReplies}" aria-label=${msg('Show replies')}>
<div class="icon-button-icon ">
<i slot="icon" class="i-tabler:message-circle size-4" aria-hidden="true"></i>
</div>
<span class="icon-button-text">${this.comment?.status?.visibleReplyCount || 0}</span>
</button>`
}
${when(
this.configMapData?.basic.withReplies,
() => html`
<button slot="action" class="icon-button group" type="button" @click="${this.handleToggleReplyForm}" aria-label=${this.showReplyForm ? msg('Cancel reply') : msg('Add reply')}>
<div class="icon-button-icon ">
<i slot="icon" class="i-tabler:message-circle-plus size-4" aria-hidden="true"></i>
</div>
<span class="icon-button-text">${this.showReplyForm ? msg('Cancel reply') : msg('Add reply')}</span>
</button>
`
)}
<div slot="footer">
${when(
this.showReplyForm,
() => html`
<div class="mt-2">
<reply-form
@reload=${this.onReplyCreated}
.comment=${this.comment}
></reply-form>
</div>
`
)}
${when(
this.showReplies,
() => html`<comment-replies
${ref(this.commentRepliesRef)}
.comment="${this.comment}"
.showReplyForm=${this.showReplyForm}
></comment-replies>`
)}
</div>
</base-comment-item>`;
}
static override styles = [
...baseStyles,
css`
@unocss-placeholder;
`,
];
}
customElements.get('comment-item') ||
customElements.define('comment-item', CommentItem);
function handleCommentAvatar(
comment: CommentVo | undefined
): string | undefined {
const avatarPolicy = getPolicyInstance();
if (avatarPolicy === undefined) {
return comment?.owner.avatar;
}
return avatarPolicy.applyCommentPolicy(comment);
}
declare global {
interface HTMLElementTagNameMap {
'comment-item': CommentItem;
}
}
+192
View File
@@ -0,0 +1,192 @@
import { css, html, LitElement } from 'lit';
import './icons/icon-loading';
import type { CommentVoList } from '@halo-dev/api-client';
import { consume } from '@lit/context';
import { msg } from '@lit/localize';
import { state } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import {
baseUrlContext,
configMapDataContext,
groupContext,
kindContext,
nameContext,
toastContext,
versionContext,
} from './context';
import type { ToastManager } from './lit-toast';
import type { ConfigMapData } from './types';
import './comment-pagination';
import './comment-item';
import './loading-block';
import { when } from 'lit/directives/when.js';
import { ofetch } from 'ofetch';
import baseStyles from './styles/base';
export class CommentList extends LitElement {
@consume({ context: baseUrlContext })
@state()
baseUrl = '';
@consume({ context: groupContext })
@state()
group = '';
@consume({ context: kindContext })
@state()
kind = '';
@consume({ context: nameContext })
@state()
name = '';
@consume({ context: versionContext })
@state()
version = '';
@consume({ context: configMapDataContext })
@state()
configMapData: ConfigMapData | undefined;
@consume({ context: toastContext, subscribe: true })
@state()
toastManager: ToastManager | undefined;
@state()
comments: CommentVoList = {
page: 1,
size: 20,
total: 0,
items: [],
first: true,
last: false,
hasNext: false,
hasPrevious: false,
totalPages: 0,
};
@state()
loading = false;
get shouldDisplayPagination() {
if (this.loading) {
return false;
}
return this.comments.hasNext || this.comments.hasPrevious;
}
async onPageChange(e: CustomEvent) {
const data = e.detail;
this.comments.page = data.page;
await this.fetchComments({ scrollIntoView: true });
}
override connectedCallback(): void {
super.connectedCallback();
this.fetchComments();
// Handle halo:comment:created event, then reload the comment list
window.addEventListener('halo:comment:created', () => {
this.fetchComments({
page: 1,
scrollIntoView: true,
});
});
}
async fetchComments(options?: { page?: number; scrollIntoView?: boolean }) {
const { page, scrollIntoView } = options || {};
try {
if (this.comments.items.length === 0) {
this.loading = true;
}
if (page) {
this.comments.page = page;
}
const data = await ofetch<CommentVoList>(
`${this.baseUrl}/apis/api.halo.run/v1alpha1/comments`,
{
query: {
group: this.group,
kind: this.kind,
name: this.name,
page: this.comments.page,
size: this.configMapData?.basic.size || 20,
version: this.version,
withReplies: this.configMapData?.basic.withReplies || false,
replySize: this.configMapData?.basic.replySize || 10,
},
}
);
this.comments = data;
} catch (_error) {
console.error(_error);
this.toastManager?.error(
msg('Failed to load comment list, please try again later')
);
} finally {
this.loading = false;
if (scrollIntoView) {
this.scrollIntoView({
block: 'start',
inline: 'start',
behavior: 'smooth',
});
}
}
}
override render() {
return html`${
this.loading
? html` <loading-block></loading-block>`
: html`
<div class="comment-list-main mt-5">
<div class="comment-stats font-medium my-3 text-sm text-text-1">
<span>${msg(html`${this.comments.total} Comments`)}</span>
</div>
<div class="comment-list">
${repeat(
this.comments.items,
(item) => item.metadata.name,
(item) => html` <comment-item .comment=${item}></comment-item>`
)}
</div>
</div>
`
}
${when(
this.shouldDisplayPagination,
() => html`<comment-pagination
.total=${this.comments.total}
.page=${this.comments.page}
.size=${this.comments.size}
@page-change=${this.onPageChange}
></comment-pagination>
`
)}
`;
}
static override styles = [
...baseStyles,
css`
@unocss-placeholder;
`,
];
}
customElements.get('comment-list') ||
customElements.define('comment-list', CommentList);
declare global {
interface HTMLElementTagNameMap {
'comment-list': CommentList;
}
}
@@ -0,0 +1,88 @@
import { msg } from '@lit/localize';
import { css, html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
import baseStyles from './styles/base';
export class CommentPagination extends LitElement {
@property({ type: Number })
total = 0;
@property({ type: Number })
page = 1;
@property({ type: Number })
size = 10;
get totalPages() {
return Math.ceil(this.total / this.size);
}
gotoPage(page: number | string) {
if (page !== this.page) {
this.dispatchEvent(
new CustomEvent('page-change', {
detail: { page },
bubbles: true,
composed: true,
})
);
}
}
override render() {
return html`
<ul class="pagination flex items-center gap-4" role="navigation">
<li>
<button
rel="prev"
type="button"
aria-label=${msg('Previous')}
@click=${() => this.gotoPage(this.page - 1)} ?disabled=${this.page === 1}
class="pagination-button"
>
<i class="i-tabler:chevron-left size-4" aria-hidden="true"></i>
${msg('Previous')}
</button>
</li>
<li class="inline-flex items-center gap-1 text-sm text-text-3 hover:text-text-1 transition-all">
<select name="pagination-value" id="pagination-value" class="pagination-select appearance-none outline-none bg-transparent" @change=${(e: Event) => this.gotoPage((e.target as HTMLSelectElement).value)}>
${Array.from({ length: this.totalPages }, (_, i) => i + 1).map((page) => html`<option .selected=${page === this.page} value=${page}>${page} / ${this.totalPages}</option>`)}
</select>
<i class="i-tabler:chevron-down size-4" aria-hidden="true"></i>
</li>
<li>
<button
rel="next"
type="button"
aria-label=${msg('Next')}
@click=${() => this.gotoPage(this.page + 1)}
?disabled=${this.page === this.totalPages}
class="pagination-button"
>
${msg('Next')}
<i class="i-tabler:chevron-right size-4" aria-hidden="true"></i>
</button>
</li>
</ul>
`;
}
static override styles = [
...baseStyles,
css`
:host {
display: flex;
justify-content: center;
}
@unocss-placeholder;
`,
];
}
customElements.get('comment-pagination') ||
customElements.define('comment-pagination', CommentPagination);
declare global {
interface HTMLElementTagNameMap {
'comment-pagination': CommentPagination;
}
}
@@ -0,0 +1,170 @@
import type { CommentVo, ReplyVo, ReplyVoList } from '@halo-dev/api-client';
import { consume } from '@lit/context';
import { css, html, LitElement } from 'lit';
import { property, state } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import { baseUrlContext, configMapDataContext, toastContext } from './context';
import './reply-item';
import './loading-block';
import './reply-form';
import { msg } from '@lit/localize';
import { when } from 'lit/directives/when.js';
import { ofetch } from 'ofetch';
import type { ToastManager } from './lit-toast';
import baseStyles from './styles/base';
import type { ConfigMapData } from './types';
export class CommentReplies extends LitElement {
@consume({ context: baseUrlContext })
@state()
baseUrl = '';
@consume({ context: configMapDataContext })
@state()
configMapData: ConfigMapData | undefined;
@property({ type: Object })
comment: CommentVo | undefined;
@property({ type: Boolean })
showReplyForm = false;
@state()
replies: ReplyVo[] = [];
@state()
page = 1;
@state()
hasNext = false;
@state()
loading = false;
@state()
activeQuoteReply: ReplyVo | undefined = undefined;
@consume({ context: toastContext, subscribe: true })
@state()
toastManager: ToastManager | undefined;
override render() {
return html` <div class="replies-main">
${when(
this.replies.length,
() => html`<div class="replies-list mt-3">
${repeat(
this.replies,
(item) => item.metadata.name,
(item) =>
html`<reply-item
.comment=${this.comment}
.reply="${item}"
.replies=${this.replies}
.activeQuoteReply=${this.activeQuoteReply}
@set-active-quote-reply=${this.onSetActiveQuoteReply}
@reload=${this.fetchReplies}
></reply-item>`
)}
</div>`
)}
${when(this.loading, () => html` <loading-block></loading-block>`)}
${when(
this.hasNext && !this.loading,
() => html`<div class="replies-next flex justify-center my-2">
<button class="replies-next-button pagination-button" @click=${this.fetchNext}>${msg('Load more')}</button>
</div>`
)}
</div>`;
}
onSetActiveQuoteReply(event: CustomEvent) {
this.activeQuoteReply = event.detail.quoteReply;
}
async fetchReplies(options?: { append: boolean }) {
try {
this.loading = true;
// Reload replies list
if (!options?.append) {
this.page = 1;
}
const data = await ofetch<ReplyVoList>(
`${this.baseUrl}/apis/api.halo.run/v1alpha1/comments/${
this.comment?.metadata.name
}/reply`,
{
query: {
page: this.page || 1,
size: this.configMapData?.basic.replySize || 10,
},
}
);
if (options?.append) {
this.replies = this.replies.concat(data.items);
} else {
this.replies = data.items;
}
this.hasNext = data.hasNext;
this.page = data.page;
} catch (error) {
console.error(error);
this.toastManager?.error(
msg('Failed to load reply list, please try again later')
);
} finally {
this.loading = false;
}
}
async fetchNext() {
if (this.configMapData?.basic.withReplies) {
// if withReplies is true, we need to reload the replies list
await this.fetchReplies({ append: !(this.page === 1) });
this.page++;
} else {
this.page++;
await this.fetchReplies({ append: true });
}
}
override connectedCallback(): void {
super.connectedCallback();
if (this.configMapData?.basic.withReplies) {
// TODO: Fix ts error
// Needs @halo-dev/api-client@2.14.0
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
this.replies = this.comment?.replies.items;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
this.page = this.comment?.replies.page;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
this.hasNext = this.comment?.replies.hasNext;
} else {
this.fetchReplies();
}
}
static override styles = [
...baseStyles,
css`
@unocss-placeholder;
`,
];
}
customElements.get('comment-replies') ||
customElements.define('comment-replies', CommentReplies);
declare global {
interface HTMLElementTagNameMap {
'comment-replies': CommentReplies;
}
}
@@ -0,0 +1,180 @@
import type { DetailedUser, User } from '@halo-dev/api-client';
import { provide } from '@lit/context';
import { css, html, LitElement } from 'lit';
import { property, state } from 'lit/decorators.js';
import {
AllUserPolicy,
AnonymousUserPolicy,
AvatarPolicyEnum,
NoAvatarUserPolicy,
setPolicyInstance,
} from './avatar/avatar-policy';
import { setAvatarProvider } from './avatar/providers';
import './comment-form';
import './comment-item';
import './comment-pagination';
import {
allowAnonymousCommentsContext,
baseUrlContext,
configMapDataContext,
currentUserContext,
groupContext,
kindContext,
nameContext,
toastContext,
versionContext,
} from './context';
import { ToastManager } from './lit-toast';
import baseStyles from './styles/base';
import type { ConfigMapData } from './types';
import './comment-list';
import { ofetch } from 'ofetch';
import './comment-editor-skeleton';
export class CommentWidget extends LitElement {
@provide({ context: baseUrlContext })
@property({ type: String, attribute: 'base-url' })
baseUrl = '';
@provide({ context: kindContext })
@property({ type: String })
kind = '';
@provide({ context: groupContext })
@property({ type: String })
group = '';
@provide({ context: versionContext })
@property({ type: String })
version = '';
@provide({ context: nameContext })
@property({ type: String })
name = '';
@provide({ context: currentUserContext })
@state()
currentUser: User | undefined;
@provide({ context: allowAnonymousCommentsContext })
@state()
allowAnonymousComments = false;
@provide({ context: configMapDataContext })
@state()
configMapData: ConfigMapData | undefined;
@provide({ context: toastContext })
@state()
toastManager: ToastManager | undefined;
@state()
isInitialized = false;
override render() {
return html` <div class="comment-widget w-full">
${
!this.isInitialized
? html`<comment-editor-skeleton></comment-editor-skeleton>`
: html`
<comment-form></comment-form>
<comment-list></comment-list>
`
}
</div>`;
}
async fetchGlobalInfo() {
const data = await ofetch(`${this.baseUrl}/actuator/globalinfo`);
this.allowAnonymousComments = data.allowAnonymousComments;
}
async fetchConfigMapData() {
const data = await ofetch<ConfigMapData>(
`${this.baseUrl}/apis/api.commentwidget.halo.run/v1alpha1/config`
);
this.configMapData = data;
}
async fetchCurrentUser() {
const data = await ofetch<DetailedUser>(
`${this.baseUrl}/apis/api.console.halo.run/v1alpha1/users/-`
);
this.currentUser =
data.user.metadata.name === 'anonymousUser' ? undefined : data.user;
}
initAvatarProvider() {
if (!this.configMapData?.avatar.enable) {
return;
}
setAvatarProvider(
this.configMapData.avatar.provider,
this.configMapData.avatar.providerMirror
);
}
initAvatarPolicy() {
if (!this.configMapData?.avatar.enable) {
setPolicyInstance(undefined);
return;
}
switch (this.configMapData.avatar.policy) {
case AvatarPolicyEnum.ALL_USER_POLICY: {
setPolicyInstance(new AllUserPolicy());
break;
}
case AvatarPolicyEnum.NO_AVATAR_USER_POLICY: {
setPolicyInstance(new NoAvatarUserPolicy());
break;
}
default:
setPolicyInstance(new AnonymousUserPolicy());
}
}
override connectedCallback(): void {
super.connectedCallback();
this.init();
}
async init() {
this.toastManager = new ToastManager();
try {
await this.fetchGlobalInfo();
await this.fetchConfigMapData();
await this.fetchCurrentUser();
} catch (error) {
if (error instanceof Error) {
this.toastManager?.show(error.message, 'error');
}
return;
} finally {
this.isInitialized = true;
}
this.initAvatarProvider();
this.initAvatarPolicy();
}
static override styles = [
...baseStyles,
css`
:host {
width: 100%;
display: flex;
justify-content: center;
}
@unocss-placeholder;
`,
];
}
customElements.get('comment-widget') ||
customElements.define('comment-widget', CommentWidget);
declare global {
interface HTMLElementTagNameMap {
'comment-widget': CommentWidget;
}
}
@@ -0,0 +1,134 @@
import { css, html, LitElement } from 'lit';
import { property, state } from 'lit/decorators.js';
import { when } from 'lit/directives/when.js';
import type { UAParser } from 'ua-parser-js';
import baseStyles from './styles/base';
const OS_ICON_MAP = {
Windows: 'i-logos:microsoft-windows-icon',
macOS: 'i-logos:apple',
Linux: 'i-logos:linux-tux',
Android: 'i-logos:android-icon',
iOS: 'i-logos:apple',
'Chrome OS': 'i-logos:chrome',
Arch: 'i-logos:archlinux',
Manjaro: 'i-logos:manjaro',
Ubuntu: 'i-logos:ubuntu',
Fedora: 'i-logos:fedora',
HarmonyOS: 'i-simple-icons:harmonyos',
};
const BROWSER_ICON_MAP = {
Chrome: 'i-logos:chrome',
'Mobile Chrome': 'i-logos:chrome',
'Chrome WebView': 'i-logos:chrome',
Firefox: 'i-logos:firefox',
'Mobile Firefox': 'i-logos:firefox',
Safari: 'i-logos:safari',
'Mobile Safari': 'i-logos:safari',
Edge: 'i-logos:microsoft-edge',
'Edge WebView': 'i-logos:microsoft-edge',
'Edge WebView2': 'i-logos:microsoft-edge',
Opera: 'i-logos:opera',
'Huawei Browser': 'i-simple-icons:huawei text-[#D6000B]',
'MIUI Browser': 'i-simple-icons:xiaomi text-[#FF6901]',
};
export class CommenterUABar extends LitElement {
@property({ type: String })
ua: string = '';
@state()
parser: UAParser | undefined;
@state()
isInitialized = false;
getOSIcon(os?: string) {
return OS_ICON_MAP[os as keyof typeof OS_ICON_MAP];
}
getBrowserIcon(browser?: string) {
return BROWSER_ICON_MAP[browser as keyof typeof BROWSER_ICON_MAP];
}
override connectedCallback(): void {
super.connectedCallback();
this.init();
}
async init() {
const { UAParser } = await import('ua-parser-js');
this.parser = new UAParser(this.ua);
this.isInitialized = true;
}
protected override render() {
if (!this.parser) {
return this.renderSkeleton();
}
const os = this.parser.getOS();
const browser = this.parser.getBrowser();
if (!os.name && !browser.name) {
return html`
<div class="inline-flex items-center gap-1 bg-muted-3 rounded-base px-1.5 py-1">
<span class="text-xs text-text-3">${this.ua}</span>
</div>
`;
}
const osIcon = this.getOSIcon(os?.name);
const browserIcon = this.getBrowserIcon(browser?.name);
return html`
<div class="inline-flex gap-2 items-center">
${when(
os,
() => html`<div class="inline-flex items-center gap-1 bg-muted-3 rounded-base px-1.5 py-1">
${when(osIcon, () => html`<i class="${osIcon} opacity-90 size-3" aria-hidden="true"></i>`)}
<span class="text-xs text-text-2">${os.name}</span>
</div>`
)}
${when(
browser,
() => html`<div class="inline-flex items-center gap-1 bg-muted-3 rounded-base px-1.5 py-1">
${when(browserIcon, () => html`<i class="${browserIcon} opacity-90 size-3" aria-hidden="true"></i>`)}
<span class="text-xs text-text-2">${[browser.name, browser.major].filter(Boolean).join(' ')}</span>
</div>`
)}
</div>`;
}
private renderSkeleton() {
return html`
<div class="inline-flex gap-2 items-center">
<div class="inline-flex items-center gap-1 bg-muted-3 rounded-base px-1.5 py-1">
<div class="size-3 animate-pulse bg-muted-1 rounded-base"></div>
<span class="w-10 h-3 animate-pulse bg-muted-1 rounded-base"></span>
</div>
<div class="inline-flex items-center gap-1 bg-muted-3 rounded-base px-1.5 py-1">
<div class="size-3 animate-pulse bg-muted-1 rounded-base"></div>
<span class="w-10 h-3 animate-pulse bg-muted-1 rounded-base"></span>
</div>
</div>
`;
}
static override styles = [
...baseStyles,
css`
@unocss-placeholder;
`,
];
}
customElements.get('commenter-ua-bar') ||
customElements.define('commenter-ua-bar', CommenterUABar);
declare global {
interface HTMLElementTagNameMap {
'commenter-ua-bar': CommenterUABar;
}
}
@@ -0,0 +1,5 @@
/**
* Upvoted comments and replies local storage keys
*/
export const LS_UPVOTED_COMMENTS_KEY = 'halo.upvoted.comments';
export const LS_UPVOTED_REPLIES_KEY = 'halo.upvoted.replies';
@@ -0,0 +1,26 @@
import type { User } from '@halo-dev/api-client';
import { createContext } from '@lit/context';
import type { ToastManager } from '../lit-toast';
import type { ConfigMapData } from '../types';
export const baseUrlContext = createContext<string>(Symbol('baseUrl'));
export const kindContext = createContext<string>(Symbol('kind'));
export const groupContext = createContext<string>(Symbol('group'));
export const nameContext = createContext<string>(Symbol('name'));
export const versionContext = createContext<string>(Symbol('version'));
export const allowAnonymousCommentsContext = createContext<boolean>(
Symbol('allowAnonymousComments')
);
export const currentUserContext = createContext<User | undefined>(
Symbol('currentUser')
);
export const toastContext = createContext<ToastManager | undefined>(
Symbol('toastContext')
);
export const configMapDataContext = createContext<ConfigMapData | undefined>(
Symbol('configMapData')
);
+287
View File
@@ -0,0 +1,287 @@
import './icons/icon-loading';
import en from '@emoji-mart/data/i18n/en.json';
import es from '@emoji-mart/data/i18n/es.json';
import zh from '@emoji-mart/data/i18n/zh.json';
import {
autoUpdate,
computePosition,
flip,
offset,
type Placement,
shift,
} from '@floating-ui/dom';
import { msg } from '@lit/localize';
import type { Picker } from 'emoji-mart';
import { css, html, LitElement } from 'lit';
import { state } from 'lit/decorators.js';
import { createRef, type Ref, ref } from 'lit/directives/ref.js';
import { getLocale } from './locale';
import baseStyles from './styles/base';
const localeMap = {
'zh-CN': zh,
'zh-TW': zh,
en: en,
es: es,
};
const sharedEmojiPanel = {
wrapper: null as HTMLDivElement | null,
picker: null as Picker | null,
activeButton: null as EmojiButton | null,
init() {
if (this.wrapper) return this.wrapper;
const styleElement = document.createElement('style');
styleElement.textContent = `
.form__emoji-panel {
box-shadow: 0 0.5em 1em rgba(0, 0, 0, 0.1);
border-radius: 0.875em;
overflow: hidden;
display: none;
}
.form__emoji-panel.visible {
display: block;
animation: fadeInUp 0.3s both;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translate3d(0, 5%, 0);
}
to {
opacity: 1;
transform: translate3d(0, 0, 0);
}
}
em-emoji-picker {
--rgb-color: var(--halo-cw-emoji-picker-rgb-color, 34, 36, 39);
--rgb-accent: var(--halo-cw-emoji-picker-rgb-accent, 34, 102, 237);
--rgb-background: var(--halo-cw-emoji-picker-rgb-background, 255, 255, 255);
--rgb-input: var(--halo-cw-emoji-picker-rgb-input, 255, 255, 255);
--color-border: var(--halo-cw-emoji-picker-color-border, rgba(0, 0, 0, .05));
--color-border-over: var(--halo-cw-emoji-picker-color-border-over, rgba(0, 0, 0, .1));
}
`;
document.head.appendChild(styleElement);
this.wrapper = document.createElement('div');
this.wrapper.className = 'form__emoji-panel';
this.wrapper.style.position = 'absolute';
this.wrapper.style.zIndex = '9999';
document.body.appendChild(this.wrapper);
return this.wrapper;
},
show() {
if (this.wrapper) {
this.wrapper.classList.add('visible');
}
},
hide() {
if (this.wrapper) {
this.wrapper.classList.remove('visible');
}
},
};
export class EmojiButton extends LitElement {
@state()
emojiPickerVisible = false;
@state()
emojiLoading = false;
buttonRef: Ref<HTMLDivElement> = createRef<HTMLDivElement>();
cleanupAutoUpdate: (() => void) | null = null;
placement: Placement = 'bottom';
constructor() {
super();
this.emojiPickerVisible = false;
this.handleClickOutside = this.handleClickOutside.bind(this);
}
override connectedCallback() {
super.connectedCallback();
document.addEventListener('click', this.handleClickOutside, true);
sharedEmojiPanel.init();
}
override disconnectedCallback() {
document.removeEventListener('click', this.handleClickOutside, true);
this.cleanupFloating();
if (sharedEmojiPanel.activeButton === this) {
sharedEmojiPanel.activeButton = null;
sharedEmojiPanel.hide();
}
super.disconnectedCallback();
}
cleanupFloating() {
if (this.cleanupAutoUpdate) {
this.cleanupAutoUpdate();
this.cleanupAutoUpdate = null;
}
}
handleClickOutside(event: Event) {
if (
this.emojiPickerVisible &&
!event.composedPath().includes(this) &&
sharedEmojiPanel.wrapper &&
!event.composedPath().includes(sharedEmojiPanel.wrapper)
) {
this.closeEmojiPicker();
}
}
closeEmojiPicker() {
this.emojiPickerVisible = false;
this.cleanupFloating();
sharedEmojiPanel.hide();
if (sharedEmojiPanel.activeButton === this) {
sharedEmojiPanel.activeButton = null;
}
}
async handleOpenEmojiPicker(e: Event) {
e.stopPropagation();
e.preventDefault();
if (this.emojiPickerVisible) {
this.closeEmojiPicker();
return;
}
if (
sharedEmojiPanel.activeButton &&
sharedEmojiPanel.activeButton !== this
) {
sharedEmojiPanel.activeButton.closeEmojiPicker();
}
sharedEmojiPanel.activeButton = this;
if (sharedEmojiPanel.wrapper?.children.length) {
this.emojiPickerVisible = true;
sharedEmojiPanel.show();
this.setupFloating();
return;
}
this.emojiLoading = true;
const { Picker } = await import('emoji-mart');
const { default: data } = await import('@emoji-mart/data');
sharedEmojiPanel.picker = new Picker({
data,
onEmojiSelect: ({ native }: { native: string }) => {
const activeButton = sharedEmojiPanel.activeButton;
if (activeButton) {
activeButton.dispatchEvent(
new CustomEvent('emoji-select', { detail: { native } })
);
activeButton.closeEmojiPicker();
}
},
i18n: localeMap[getLocale()],
});
if (sharedEmojiPanel.wrapper) {
sharedEmojiPanel.wrapper.appendChild(
sharedEmojiPanel.picker as unknown as Node
);
sharedEmojiPanel.show();
}
this.emojiPickerVisible = true;
this.emojiLoading = false;
this.setupFloating();
}
setupFloating() {
if (!this.buttonRef.value || !sharedEmojiPanel.wrapper) return;
this.cleanupFloating();
this.cleanupAutoUpdate = autoUpdate(
this.buttonRef.value,
sharedEmojiPanel.wrapper,
() => this.updatePosition()
);
this.updatePosition();
}
async updatePosition() {
const reference = this.buttonRef.value;
const floating = sharedEmojiPanel.wrapper;
if (!reference || !floating) return;
const { x, y } = await computePosition(reference, floating, {
placement: this.placement,
middleware: [
offset({
mainAxis: 8,
crossAxis: 0,
alignmentAxis: 0,
}),
flip(),
shift({ padding: 8 }),
],
});
Object.assign(floating.style, {
left: `${x}px`,
top: `${y}px`,
});
}
override render() {
return html`<div
role="button"
class="relative size-7 flex items-center justify-center cursor-pointer"
aria-label=${msg('Select emoticon')}
${ref(this.buttonRef)}
>
${
this.emojiLoading
? html`<icon-loading></icon-loading>`
: html`<div
class="i-mdi-sticker-emoji size-5 text-text-3 hover:text-text-1"
@click=${this.handleOpenEmojiPicker}
></div>`
}
</div>`;
}
static override styles = [
...baseStyles,
css`
:host {
display: inline-flex;
}
@unocss-placeholder;
`,
];
}
customElements.get('emoji-button') ||
customElements.define('emoji-button', EmojiButton);
declare global {
interface HTMLElementTagNameMap {
'emoji-button': EmojiButton;
}
}
@@ -0,0 +1,27 @@
// Do not modify this file by hand!
// Re-generate this file by running lit-localize.
/**
* The locale code that templates in this source code are written in.
*/
export const sourceLocale = `en`;
/**
* The other locale codes that this application is localized into. Sorted
* lexicographically.
*/
export const targetLocales = [
`es`,
`zh-CN`,
`zh-TW`,
] as const;
/**
* All valid project locale codes. Sorted lexicographically.
*/
export const allLocales = [
`en`,
`es`,
`zh-CN`,
`zh-TW`,
] as const;
@@ -0,0 +1,53 @@
// Do not modify this file by hand!
// Re-generate this file by running lit-localize
import {html} from 'lit';
/* eslint-disable no-irregular-whitespace */
/* eslint-disable @typescript-eslint/no-explicit-any */
export const templates = {
'h6a83761bb66bbcc5': html`${0} Comentarios`,
's053945ad43e9d1d6': `Por favor, ingrese el código de verificación`,
's09c7e0ff5f4e18cb': `Cancelar respuesta`,
's0b7ae9543c001867': `Responder`,
's0fbf6dc6a1966408': `Siguiente`,
's1c6fefb092506753': `Error al cargar la lista de comentarios, por favor intente más tarde`,
's1e3e30a26025484c': `Error al cargar la lista de respuestas, por favor intente más tarde`,
's26e4d65f2801ac9c': `Por favor, ingrese el contenido`,
's299b10f3a58a09fd': `Haga clic en OK para ir a la página de cierre de sesión, Por favor, asegúrese de que el contenido editado haya sido guardado.`,
's35c3125c7750681e': `Cita`,
's3643189d1abbb7f4': `Código`,
's3fb33d17bad61aa9': `Comentario enviado con éxito, pendiente de revisión`,
's4c0e15f9073382e6': `Error al obtener el código de verificación`,
's5184a3f3e2f7b603': `Actualmente anónimo. Después de seleccionar la opción privada, el comentario solo será visible para el administrador del sitio.`,
's523eb9043213ff0d': `Itálica`,
's58a3c1ecd4dd06cf': `Tachado`,
's67749057edb2586b': `Cerrar sesión`,
's6cb61eeccda272d5': `Bloque de código`,
's7437373e541a8037': `Apodo`,
's7584ded3d749c75e': `Cargar más`,
's82665b2ffabc9c0a': `Sitio web`,
's838e512973be01d4': `Actualmente conectado. Después de seleccionar la opción privada, los comentarios solo serán visibles para usted y el administrador del sitio.`,
's84b033b2f7360187': `Por favor, inicie sesión o complete la información primero`,
's98a5f7789c49dd3f': `En revisión`,
's9f2ed66340f019c6': `Escribir un comentario`,
'sa10fbe3fcd6ae148': `Agregar respuesta`,
'sa82fbb7f361c6f44': `Mostrar respuestas`,
'sa8dddacbaa66f8e0': `Por favor, inicie sesión primero`,
'sb206d700d26b14ff': `Subrayado`,
'sb3d4f79d9d8b71e5': `Enviar comentario`,
'sb5a28aa35006ff08': `Comentario enviado con éxito`,
'sc8da3cc71de63832': `Iniciar sesión`,
'sd1f44f1a8bc20e67': `Correo electrónico`,
'sd5e242ab9574958a': `Error al comentar, por favor intente más tarde`,
'se7bee6e9a9b5394c': `Íntimo`,
'sea7e567ed89dc0d7': `Seleccionar emoticono`,
'sf3ff78cc329d3528': `Anterior`,
'sf77128b082955d42': `(O iniciar sesión)`,
'sf7c0eba7c822e3d6': `Negrita`,
'sf9709983d4da2c54': `Me gusta`,
};
@@ -0,0 +1,53 @@
// Do not modify this file by hand!
// Re-generate this file by running lit-localize
import {html} from 'lit';
/* eslint-disable no-irregular-whitespace */
/* eslint-disable @typescript-eslint/no-explicit-any */
export const templates = {
'h6a83761bb66bbcc5': html`${0} 条评论`,
's053945ad43e9d1d6': `请输入验证码`,
's09c7e0ff5f4e18cb': `取消回复`,
's0b7ae9543c001867': `回复`,
's0fbf6dc6a1966408': `下一页`,
's1c6fefb092506753': `加载评论列表失败,请稍后重试`,
's1e3e30a26025484c': `加载回复列表失败,请稍后重试`,
's26e4d65f2801ac9c': `请输入内容`,
's299b10f3a58a09fd': `点击确定将跳转至退出登录页面,请确保正在编辑的内容已保存。`,
's35c3125c7750681e': `引用`,
's3643189d1abbb7f4': `代码`,
's3fb33d17bad61aa9': `评论成功,请等待审核`,
's4c0e15f9073382e6': `获取验证码失败`,
's5184a3f3e2f7b603': `当前是匿名状态,选择私密选项后,评论将仅对网站管理员可见。`,
's523eb9043213ff0d': `斜体`,
's58a3c1ecd4dd06cf': `删除线`,
's67749057edb2586b': `退出登录`,
's6cb61eeccda272d5': `代码块`,
's7437373e541a8037': `昵称`,
's7584ded3d749c75e': `加载更多`,
's82665b2ffabc9c0a': `网站`,
's838e512973be01d4': `当前已登录,选择私密选项后,评论将仅对您和网站管理员可见。`,
's84b033b2f7360187': `请先登录或者完善信息`,
's98a5f7789c49dd3f': `审核中`,
's9f2ed66340f019c6': `编写评论`,
'sa10fbe3fcd6ae148': `加入回复`,
'sa82fbb7f361c6f44': `显示回复`,
'sa8dddacbaa66f8e0': `请先登录`,
'sb206d700d26b14ff': `下划线`,
'sb3d4f79d9d8b71e5': `提交评论`,
'sb5a28aa35006ff08': `评论成功`,
'sc8da3cc71de63832': `登录`,
'sd1f44f1a8bc20e67': `电子邮件`,
'sd5e242ab9574958a': `评论失败,请稍后重试`,
'se7bee6e9a9b5394c': `私密`,
'sea7e567ed89dc0d7': `选择表情`,
'sf3ff78cc329d3528': `上一页`,
'sf77128b082955d42': `(或登录账号)`,
'sf7c0eba7c822e3d6': `粗体`,
'sf9709983d4da2c54': `点赞`,
};
@@ -0,0 +1,53 @@
// Do not modify this file by hand!
// Re-generate this file by running lit-localize
import {html} from 'lit';
/* eslint-disable no-irregular-whitespace */
/* eslint-disable @typescript-eslint/no-explicit-any */
export const templates = {
'h6a83761bb66bbcc5': html`${0} 則評論`,
's053945ad43e9d1d6': `請輸入驗證碼`,
's09c7e0ff5f4e18cb': `取消回覆`,
's0b7ae9543c001867': `回覆`,
's0fbf6dc6a1966408': `下一頁`,
's1c6fefb092506753': `載入評論列表失敗,請稍後重試`,
's1e3e30a26025484c': `載入回覆列表失敗,請稍後重試`,
's26e4d65f2801ac9c': `請輸入內容`,
's299b10f3a58a09fd': `点击确定将跳转至退出登录页面,请确保正在编辑的内容已保存。`,
's35c3125c7750681e': `引用`,
's3643189d1abbb7f4': `程式碼`,
's3fb33d17bad61aa9': `評論成功,請等待審核`,
's4c0e15f9073382e6': `獲取驗證碼失敗`,
's5184a3f3e2f7b603': `目前是匿名狀態,選擇私密選項後,評論將僅對網站管理員可見。`,
's523eb9043213ff0d': `斜體`,
's58a3c1ecd4dd06cf': `刪除線`,
's67749057edb2586b': `登出`,
's6cb61eeccda272d5': `程式碼區塊`,
's7437373e541a8037': `暱稱`,
's7584ded3d749c75e': `載入更多`,
's82665b2ffabc9c0a': `網站`,
's838e512973be01d4': `目前已登入,選擇私密選項後,評論將僅對您和網站管理員可見。`,
's84b033b2f7360187': `請先登入或者完善資訊`,
's98a5f7789c49dd3f': `審核中`,
's9f2ed66340f019c6': `撰寫評論`,
'sa10fbe3fcd6ae148': `新增回覆`,
'sa82fbb7f361c6f44': `顯示回覆`,
'sa8dddacbaa66f8e0': `請先登入`,
'sb206d700d26b14ff': `底線`,
'sb3d4f79d9d8b71e5': `提交評論`,
'sb5a28aa35006ff08': `評論成功`,
'sc8da3cc71de63832': `登入`,
'sd1f44f1a8bc20e67': `電子郵件`,
'sd5e242ab9574958a': `評論失敗,請稍後重試`,
'se7bee6e9a9b5394c': `私密`,
'sea7e567ed89dc0d7': `選擇表情`,
'sf3ff78cc329d3528': `上一頁`,
'sf77128b082955d42': `(或登入帳號)`,
'sf7c0eba7c822e3d6': `粗體`,
'sf9709983d4da2c54': ``,
};
@@ -0,0 +1,41 @@
import { css, html, LitElement } from 'lit';
import baseStyles from '../styles/base';
export class IconLoading extends LitElement {
override render() {
return html`<svg class="size-5 animate-spin" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<circle
style="opacity: 0.25;"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
style="opacity: 0.75;"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
fill="currentColor"
></path>
</svg>`;
}
static override styles = [
...baseStyles,
css`
:host {
display: inline-flex;
}
@unocss-placeholder;
`,
];
}
customElements.get('icon-loading') ||
customElements.define('icon-loading', IconLoading);
declare global {
interface HTMLElementTagNameMap {
'icon-loading': IconLoading;
}
}
+32
View File
@@ -0,0 +1,32 @@
import { BaseCommentItem } from './base-comment-item';
import { BaseForm } from './base-form';
import { CommentContent } from './comment-content';
import { CommentEditor } from './comment-editor';
import { CommentItem } from './comment-item';
import { CommentList } from './comment-list';
import { CommentPagination } from './comment-pagination';
import { CommentReplies } from './comment-replies';
import { CommentWidget } from './comment-widget';
import { LitToast } from './lit-toast';
import { LoadingBlock } from './loading-block';
import { ReplyForm } from './reply-form';
import { ReplyItem } from './reply-item';
import { UserAvatar } from './user-avatar';
import './locale';
export {
CommentWidget,
BaseForm,
BaseCommentItem,
CommentItem,
CommentPagination,
CommentReplies,
LoadingBlock,
ReplyForm,
ReplyItem,
UserAvatar,
CommentList,
LitToast,
CommentEditor,
CommentContent,
};
+172
View File
@@ -0,0 +1,172 @@
import { css, html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
import './icons/icon-loading';
import { classMap } from 'lit/directives/class-map.js';
import baseStyles from './styles/base';
type ToastType = 'success' | 'error' | 'warn';
export class LitToast extends LitElement {
@property({ type: String })
message = '';
@property({ type: String })
type = 'success' as ToastType;
override render() {
return html`<div
class="toast ${classMap({
'toast--error': this.type === 'error',
'toast--success': this.type === 'success',
'toast--warn': this.type === 'warn',
})}"
>
${
this.type === 'success'
? html`<i class="i-tabler:circle-check size-4 text-white" aria-hidden="true"></i>`
: html`<i class="i-tabler:alert-circle size-4 text-white" aria-hidden="true"></i>`
} <span>${this.message}</span>
</div>`;
}
static override styles = [
...baseStyles,
css`
.toast {
border-radius: var(--halo-cw-base-rounded, 0.5em);
font-size: 0.875em;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.5em 0.625em;
justify-content: space-between;
overflow: hidden;
color: #fff;
gap: 0.5em;
box-shadow:
0 0 #0000,
0 0 #0000,
0 1px 3px 0 rgb(0 0 0 / 0.1),
0 1px 2px -1px rgb(0 0 0 / 0.1);
animation: slideInDown 0.3s ease-out forwards;
}
.toast--exit {
animation: slideOutUp 0.3s ease-in forwards;
}
.toast--error {
background-color: #d71d1d;
}
.toast--success {
background-color: #4ccba0;
}
.toast--warn {
background-color: #f5a623;
}
@keyframes slideInDown {
from {
transform: translateY(0);
opacity: 0;
}
to {
transform: translateY(100%);
opacity: 1;
}
}
@keyframes slideOutUp {
from {
transform: translateY(100%);
opacity: 1;
}
to {
transform: translateY(0);
opacity: 0;
}
}
@unocss-placeholder;
`,
];
}
export class LitToastContainer extends LitElement {
override render() {
return html`<slot></slot>`;
}
static override styles = [
...baseStyles,
css`
:host {
position: fixed;
top: 1em;
z-index: 1000;
display: flex;
width: 100%;
justify-content: center;
align-items: center;
flex-direction: column;
gap: 0.5em;
}
`,
];
}
customElements.get('lit-toast') || customElements.define('lit-toast', LitToast);
customElements.get('lit-toast-container') ||
customElements.define('lit-toast-container', LitToastContainer);
declare global {
interface HTMLElementTagNameMap {
'lit-toast': LitToast;
'lit-toast-container': LitToastContainer;
}
}
export class ToastManager {
private body: HTMLBodyElement = document.body as HTMLBodyElement;
private toastContainer: LitToastContainer;
constructor() {
const container = this.body.querySelector('lit-toast-container');
if (!container) {
this.toastContainer = new LitToastContainer();
this.body.appendChild(this.toastContainer);
} else {
this.toastContainer = container as LitToastContainer;
}
}
show(message: string, type: ToastType) {
const toast = new LitToast();
toast.message = message;
toast.type = type;
this.toastContainer.appendChild(toast);
setTimeout(() => {
toast.classList.add('toast--exit');
setTimeout(() => {
this.toastContainer?.removeChild(toast);
}, 300);
}, 3000);
}
success(message: string) {
this.show(message, 'success');
}
error(message: string) {
this.show(message, 'error');
}
warn(message: string) {
this.show(message, 'warn');
}
}
@@ -0,0 +1,23 @@
import { css, html, LitElement } from 'lit';
import './icons/icon-loading';
export class LoadingBlock extends LitElement {
override render() {
return html` <div class="loading-block flex items-center justify-center p-4">
<icon-loading></icon-loading>
</div>`;
}
static override styles = css`
@unocss-placeholder;
`;
}
customElements.get('loading-block') ||
customElements.define('loading-block', LoadingBlock);
declare global {
interface HTMLElementTagNameMap {
'loading-block': LoadingBlock;
}
}
@@ -0,0 +1,38 @@
import { configureLocalization, type LocaleModule } from '@lit/localize';
import {
allLocales,
sourceLocale,
targetLocales,
} from '../generated/locale-codes';
import * as templates_es from '../generated/locales/es';
import * as templates_zh_CN from '../generated/locales/zh-CN';
import * as templates_zh_TW from '../generated/locales/zh-TW';
const localizedTemplates = new Map([
['es', templates_es],
['zh-CN', templates_zh_CN],
['zh-TW', templates_zh_TW],
]);
const { setLocale } = configureLocalization({
sourceLocale,
targetLocales,
loadLocale: async (locale) => localizedTemplates.get(locale) as LocaleModule,
});
setLocale(getLocale());
function getLanguageFromCookie() {
const match = document.cookie.match(/(^| )language=([^;]+)/);
const matchedLanguage = allLocales.find((locale) => locale === match?.[2]);
return matchedLanguage;
}
function getLanguageFromBrowser() {
const language = allLocales.find((locale) => locale === navigator.language);
return language;
}
export function getLocale() {
return getLanguageFromCookie() || getLanguageFromBrowser() || sourceLocale;
}
+188
View File
@@ -0,0 +1,188 @@
import type {
CommentVo,
Reply,
ReplyRequest,
ReplyVo,
User,
} from '@halo-dev/api-client';
import { consume } from '@lit/context';
import { html, LitElement } from 'lit';
import { property, state } from 'lit/decorators.js';
import { createRef, type Ref, ref } from 'lit/directives/ref.js';
import './base-form';
import { msg } from '@lit/localize';
import { FetchError, type FetchResponse, ofetch } from 'ofetch';
import type { BaseForm } from './base-form';
import {
allowAnonymousCommentsContext,
baseUrlContext,
currentUserContext,
toastContext,
} from './context';
import type { ToastManager } from './lit-toast';
import type { ProblemDetail } from './types';
import {
type CaptchaRequiredResponse,
getCaptchaCodeHeader,
isRequireCaptcha,
} from './utils/captcha';
export class ReplyForm extends LitElement {
@consume({ context: baseUrlContext })
@state()
baseUrl = '';
@consume({ context: currentUserContext, subscribe: true })
@state()
currentUser: User | undefined;
@property({ type: Object })
comment: CommentVo | undefined;
@property({ type: Object })
quoteReply: ReplyVo | undefined;
@consume({ context: allowAnonymousCommentsContext, subscribe: true })
@state()
allowAnonymousComments = false;
@consume({ context: toastContext, subscribe: true })
@state()
toastManager: ToastManager | undefined;
@state()
submitting = false;
@state()
captcha = '';
baseFormRef: Ref<BaseForm> = createRef<BaseForm>();
override connectedCallback(): void {
super.connectedCallback();
setTimeout(() => {
this.scrollIntoView({
block: 'center',
inline: 'start',
behavior: 'smooth',
});
this.baseFormRef.value?.setFocus();
}, 0);
}
override render() {
return html` <base-form
.submitting=${this.submitting}
.captcha=${this.captcha}
.hidePrivateCheckbox=${true}
${ref(this.baseFormRef)}
@submit="${this.onSubmit}"
></base-form>`;
}
async onSubmit(e: CustomEvent) {
e.preventDefault();
this.submitting = true;
const data = e.detail;
const { displayName, email, website, content } = data || {};
const replyRequest: ReplyRequest = {
raw: content,
content: content,
// TODO: support user input
allowNotification: true,
};
if (this.quoteReply) {
replyRequest.quoteReply = this.quoteReply.metadata.name;
}
if (!this.currentUser && !this.allowAnonymousComments) {
this.toastManager?.warn(msg('Please login first'));
this.submitting = false;
return;
}
if (!this.currentUser && this.allowAnonymousComments) {
if (!displayName || !email) {
this.toastManager?.warn(
msg('Please log in or complete the information first')
);
this.submitting = false;
return;
} else {
replyRequest.owner = {
displayName: displayName,
email: email,
website: website,
};
}
}
try {
const newReply = await ofetch<Reply>(
`${this.baseUrl}/apis/api.halo.run/v1alpha1/comments/${this.comment?.metadata.name}/reply`,
{
method: 'POST',
headers: {
...getCaptchaCodeHeader(data.captchaCode),
},
body: replyRequest,
}
);
this.baseFormRef.value?.handleFetchCaptcha();
if (newReply.spec.approved) {
this.toastManager?.success(msg('Comment submitted successfully'));
} else {
this.toastManager?.success(
msg('Comment submitted successfully, pending review')
);
}
this.dispatchEvent(new CustomEvent('reload'));
window.dispatchEvent(new CustomEvent('halo:comment-reply:created'));
this.baseFormRef.value?.resetForm();
} catch (error) {
if (error instanceof FetchError) {
if (
isRequireCaptcha(
error.response as FetchResponse<CaptchaRequiredResponse>
)
) {
const { captcha, detail } =
error.data as unknown as CaptchaRequiredResponse;
this.captcha = captcha;
this.toastManager?.warn(detail);
return;
}
const problemDetail = error.data as unknown as ProblemDetail;
this.toastManager?.error(
[problemDetail?.title, problemDetail?.detail].join(' - ') ||
msg('Comment failed, please try again later')
);
return;
}
this.toastManager?.error(msg('Comment failed, please try again later'));
} finally {
this.submitting = false;
}
}
}
customElements.get('reply-form') ||
customElements.define('reply-form', ReplyForm);
declare global {
interface HTMLElementTagNameMap {
'reply-form': ReplyForm;
}
}
+195
View File
@@ -0,0 +1,195 @@
import type { CommentVo, ReplyVo } from '@halo-dev/api-client';
import { css, html, LitElement } from 'lit';
import { property, state } from 'lit/decorators.js';
import baseStyles from './styles/base';
import './user-avatar';
import './base-comment-item';
import './reply-form';
import { consume } from '@lit/context';
import { msg } from '@lit/localize';
import { when } from 'lit/directives/when.js';
import { ofetch } from 'ofetch';
import { getPolicyInstance } from './avatar/avatar-policy';
import { LS_UPVOTED_REPLIES_KEY } from './constant';
import { baseUrlContext } from './context';
export class ReplyItem extends LitElement {
@consume({ context: baseUrlContext })
@state()
baseUrl = '';
@property({ type: Object })
comment: CommentVo | undefined;
@property({ type: Object })
reply: ReplyVo | undefined;
@property({ type: Object })
activeQuoteReply: ReplyVo | undefined = undefined;
@property({ type: Array })
replies: ReplyVo[] = [];
@state()
showReplyForm = false;
@state()
upvoted = false;
@state()
upvoteCount = 0;
get quoteReply() {
const { quoteReply: quoteReplyName } = this.reply?.spec || {};
if (!quoteReplyName) {
return undefined;
}
return this.replies.find((reply) => reply.metadata.name === quoteReplyName);
}
get isQuoteReplyHovered() {
return this.activeQuoteReply?.metadata.name === this.reply?.metadata.name;
}
handleSetActiveQuoteReply(quoteReply?: ReplyVo) {
this.dispatchEvent(
new CustomEvent('set-active-quote-reply', {
detail: { quoteReply },
bubbles: true,
composed: true,
})
);
}
override connectedCallback(): void {
super.connectedCallback();
this.checkUpvotedStatus();
this.upvoteCount = this.reply?.stats.upvote || 0;
}
checkUpvotedStatus() {
const upvotedReplies = JSON.parse(
localStorage.getItem(LS_UPVOTED_REPLIES_KEY) || '[]'
);
if (upvotedReplies.includes(this.reply?.metadata.name)) {
this.upvoted = true;
}
}
handleToggleReplyForm() {
this.showReplyForm = !this.showReplyForm;
}
async handleUpvote() {
const upvotedReplies = JSON.parse(
localStorage.getItem(LS_UPVOTED_REPLIES_KEY) || '[]'
);
if (upvotedReplies.includes(this.reply?.metadata.name)) {
return;
}
const voteRequest = {
name: this.reply?.metadata.name,
plural: 'replies',
group: 'content.halo.run',
};
await ofetch(`${this.baseUrl}/apis/api.halo.run/v1alpha1/trackers/upvote`, {
method: 'POST',
body: voteRequest,
});
upvotedReplies.push(this.reply?.metadata.name);
localStorage.setItem(
LS_UPVOTED_REPLIES_KEY,
JSON.stringify(upvotedReplies)
);
this.upvoteCount += 1;
this.upvoted = true;
this.checkUpvotedStatus();
}
override render() {
return html`
<base-comment-item
.userAvatar="${handleReplyAvatar(this.reply)}"
.userDisplayName="${this.reply?.owner.displayName}"
.content="${this.reply?.spec.content || ''}"
.creationTime="${this.reply?.metadata.creationTimestamp ?? undefined}"
.approved=${this.reply?.spec.approved}
.breath=${this.isQuoteReplyHovered}
.userWebsite=${this.reply?.spec.owner.annotations?.website}
.ua=${this.reply?.spec.userAgent}
.private=${this.comment?.spec.hidden || this.reply?.spec.hidden}
>
<button slot="action" class="icon-button group -ml-2" type="button" @click="${this.handleUpvote}" aria-label=${msg('Upvote')}>
<div class="icon-button-icon ">
${when(
this.upvoted,
() =>
html`<i slot="icon" class="i-mingcute-heart-fill size-4 text-red-500" aria-hidden="true"></i>`,
() =>
html`<i slot="icon" class="i-mingcute-heart-line size-4" aria-hidden="true"></i>`
)}
</div>
<span class="icon-button-text">${`${this.upvoteCount || 0}`}</span>
</button>
<button slot="action" class="icon-button group" type="button" @click="${this.handleToggleReplyForm}" aria-label=${this.showReplyForm ? msg('Cancel reply') : msg('Reply')}>
<div class="icon-button-icon ">
<i slot="icon" class="i-tabler:message-circle-plus size-4" aria-hidden="true"></i>
</div>
<span class="icon-button-text">${this.showReplyForm ? msg('Cancel reply') : msg('Reply')}</span>
</button>
${when(
this.showReplyForm,
() => html`<div class="reply-form mt-2" slot="footer">
<reply-form
.comment=${this.comment}
.quoteReply=${this.reply}
@reload=${() => this.dispatchEvent(new CustomEvent('reload'))}
></reply-form>
</div>`
)}
${when(
this.quoteReply,
() => html`<span
slot="pre-content"
@mouseenter=${() => this.handleSetActiveQuoteReply(this.quoteReply)}
@mouseleave=${() => this.handleSetActiveQuoteReply()}
class="quote-badge cursor-pointer inline-flex items-center gap-1 px-2 py-1.5 rounded-base bg-muted-3 text-text-2 hover:-translate-y-0.5 hover:text-text-1 hover:bg-muted-2 transition-all text-sm font-medium"
><i class="i-ic:round-reply" aria-hidden="true"></i><span>${this.quoteReply?.owner.displayName}</span>
</span>
<br slot="pre-content" />`
)}
</base-comment-item>
`;
}
static override styles = [
...baseStyles,
css`
@unocss-placeholder;
`,
];
}
customElements.get('reply-item') ||
customElements.define('reply-item', ReplyItem);
function handleReplyAvatar(reply: ReplyVo | undefined): string | undefined {
const avatarPolicy = getPolicyInstance();
if (avatarPolicy === undefined) {
return reply?.owner.avatar;
}
return avatarPolicy.applyReplyPolicy(reply);
}
declare global {
interface HTMLElementTagNameMap {
'reply-item': ReplyItem;
}
}
@@ -0,0 +1,30 @@
import resetStyles from '@unocss/reset/tailwind.css?inline';
import { css, unsafeCSS } from 'lit';
const baseStyles = [
unsafeCSS(resetStyles),
css`
:host {
font-size: var(--halo-cw-base-font-size, 1rem);
font-family: var(
--halo-cw-base-font-family,
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
Segoe UI,
Roboto,
Helvetica Neue,
Arial,
Noto Sans,
sans-serif,
'Apple Color Emoji',
'Segoe UI Emoji',
Segoe UI Symbol,
'Noto Color Emoji'
);
}
`,
];
export default baseStyles;
@@ -0,0 +1,302 @@
.content {
word-wrap: break-word;
white-space: pre-wrap;
@apply text-text-1 text-sm leading-relaxed;
}
.content details,
.content figcaption,
.content figure {
display: block;
}
.content summary {
display: list-item;
}
.content a {
background-color: transparent;
text-decoration: none;
@apply text-primary-1;
}
.content b,
.content strong {
font-weight: 600;
}
.content mark {
background-color: #fff8c5;
@apply text-text-1;
}
.content sub,
.content sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
.content sub {
bottom: -0.25em;
}
.content sup {
top: -0.5em;
}
.content img {
border-style: none;
max-width: 100%;
box-sizing: content-box;
}
.content code,
.content pre {
font-family: monospace;
font-size: 1em;
}
.content figure {
margin: 1em 2.5em;
}
.content hr {
box-sizing: content-box;
overflow: hidden;
background: transparent;
height: 0.25em;
padding: 0;
margin: 1.4em 0;
border: 0;
@apply bg-muted-1;
}
.content a:hover {
text-decoration: underline;
}
.content hr::before {
display: table;
content: "";
}
.content hr::after {
display: table;
clear: both;
content: "";
}
.content details summary {
cursor: pointer;
}
.content a:focus {
outline: 2px solid;
outline-offset: -2px;
box-shadow: none;
@apply outline-primary-1;
}
.content a:focus:not(:focus-visible) {
outline: solid 1px transparent;
}
.content a:focus-visible {
outline: 2px solid;
outline-offset: -2px;
box-shadow: none;
@apply outline-primary-1;
}
.content a:not([class]):focus,
.content a:not([class]):focus-visible {
outline-offset: 0;
}
.content p {
margin-top: 0;
margin-bottom: 10px;
}
.content blockquote {
margin: 0;
padding: 0 1em;
border-left: 0.25em;
border-style: solid;
@apply text-text-1 border-muted-1;
}
.content ul,
.content ol {
margin-top: 0;
margin-bottom: 0;
padding-left: 2em;
}
.content ol ol,
.content ul ol {
list-style-type: lower-roman;
}
.content ul ul ol,
.content ul ol ol,
.content ol ul ol,
.content ol ol ol {
list-style-type: lower-alpha;
}
.content ul {
list-style-type: disc;
}
.content ol {
list-style-type: decimal;
}
.content code {
font-family:
ui-monospace,
SFMono-Regular,
SF Mono,
Menlo,
Consolas,
"Liberation Mono",
monospace;
font-size: 12px;
}
.content pre {
margin-top: 0;
margin-bottom: 0;
font-family:
ui-monospace,
SFMono-Regular,
SF Mono,
Menlo,
Consolas,
"Liberation Mono",
monospace;
font-size: 12px;
word-wrap: normal;
}
.content::before {
display: table;
content: "";
}
.content::after {
display: table;
clear: both;
content: "";
}
.content > *:first-child {
margin-top: 0 !important;
}
.content > *:last-child {
margin-bottom: 0 !important;
}
.content a:not([href]) {
color: inherit;
text-decoration: none;
}
.content p,
.content blockquote,
.content ul,
.content ol,
.content dl,
.content pre,
.content details {
margin-top: 0;
margin-bottom: 1em;
}
.content blockquote > :first-child {
margin-top: 0;
}
.content blockquote > :last-child {
margin-bottom: 0;
}
.content ul ul,
.content ul ol,
.content ol ol,
.content ol ul {
margin-top: 0;
margin-bottom: 0;
}
.content li > p {
margin-top: 1em;
}
.content li + li {
margin-top: 0.25em;
}
.content code {
padding: 0.2em 0.4em;
margin: 0;
font-size: 85%;
white-space: break-spaces;
border-radius: 6px;
@apply bg-muted-3;
}
.content code br {
display: none;
}
.content del code {
text-decoration: inherit;
}
.content pre code {
font-size: 100%;
}
.content pre > code {
padding: 0;
margin: 0;
word-break: normal;
white-space: pre;
background: transparent;
border: 0;
color: #fff;
}
.content pre {
padding: 1em;
overflow: auto;
font-size: 85%;
border-radius: 6px;
}
.content pre code {
display: inline;
max-width: auto;
padding: 0;
margin: 0;
overflow: visible;
line-height: inherit;
word-wrap: normal;
background-color: transparent;
border: 0;
}
.content summary:focus:not(:focus-visible),
.content a:focus:not(:focus-visible) {
outline: none;
box-shadow: none;
}
.content > *:first-child > .heading-element:first-child {
margin-top: 0 !important;
}
@@ -0,0 +1,46 @@
export interface ConfigMapData {
basic: BasicConfig;
security: SecurityConfig;
avatar: AvatarConfig;
editor?: EditorConfig;
}
interface BasicConfig {
withReplies: boolean;
size: number;
withReplySize: number;
replySize: number;
showCommenterDevice?: boolean;
enablePrivateComment?: boolean;
showPrivateCommentBadge?: boolean;
}
interface SecurityConfig {
captcha: {
anonymousCommentCaptcha: boolean;
type: 'ALPHANUMERIC' | 'ARITHMETIC';
ignoreCase: boolean;
captchaLength: number;
};
}
interface AvatarConfig {
provider: 'gravatar';
enable: boolean;
providerMirror: string;
policy: 'anonymousUser' | 'allUser' | 'noAvatarUser';
}
interface EditorConfig {
placeholder?: string;
}
export interface ProblemDetail {
type: string;
title: string;
status: number;
detail: string;
instance: string;
requestId: string;
timestamp: string;
}
+111
View File
@@ -0,0 +1,111 @@
import './icons/icon-loading';
import { css, html, LitElement } from 'lit';
import { property, state } from 'lit/decorators.js';
import baseStyles from './styles/base';
export class UserAvatar extends LitElement {
@property({ type: String })
src: string | undefined;
@property({ type: String })
alt: string | undefined;
@property({ type: String })
href: string | undefined;
@state()
error = false;
@state()
loading = false;
override async connectedCallback() {
super.connectedCallback();
await this.loadImage();
}
async loadImage() {
if (!this.src) {
return Promise.resolve();
}
this.loading = true;
try {
await new Promise((resolve) => {
const img = new Image();
img.src = this.src || '';
img.onload = () => {
this.error = false;
resolve(null);
};
img.onerror = () => {
this.error = true;
resolve(null);
};
});
} catch (_e) {
this.error = true;
} finally {
this.loading = false;
}
}
getPlaceholderText() {
if (!this.alt) {
return undefined;
}
const words = this.alt.split(' ');
if (words.length === 1) {
return words[0].charAt(0).toUpperCase();
}
if (words.length > 1) {
return (
words[0].charAt(0).toUpperCase() + words[1].charAt(0).toUpperCase()
);
}
return undefined;
}
override render() {
if (this.href) {
return html`<a class="avatar" href="${this.href}" target="_blank" rel="noopener noreferrer">
${this.renderAvatarContent()}
</a>`;
}
return html`<div class="avatar">
${this.renderAvatarContent()}
</div>`;
}
renderAvatarContent() {
if (!this.src) {
return html`<span class="avatar-placeholder text-sm font-medium text-text-1 select-none">${this.getPlaceholderText()}</span>`;
}
if (this.loading) {
return html`<icon-loading></icon-loading>`;
}
if (this.error) {
return html`<i class="i-tabler:alert-circle size-5" aria-hidden="true"></i>`;
}
return html`<img class="avatar-image size-full object-cover" src="${this.src}" alt="${this.alt || ''}" loading="lazy" />`;
}
static override styles = [
...baseStyles,
css`
@unocss-placeholder;
`,
];
}
customElements.get('user-avatar') ||
customElements.define('user-avatar', UserAvatar);
declare global {
interface HTMLElementTagNameMap {
'user-avatar': UserAvatar;
}
}
@@ -0,0 +1,24 @@
import type { FetchResponse } from 'ofetch';
export const getCaptchaCodeHeader = (code: string): Record<string, string> => {
if (!code || code.trim().length === 0) {
return {};
}
return {
'X-Captcha-Code': code,
};
};
export interface CaptchaRequiredResponse {
type: string;
title: string;
status: number;
detail: string;
captcha: string;
}
export const isRequireCaptcha = (
response: FetchResponse<CaptchaRequiredResponse>
) => {
return response.status === 403 && response.headers.get('X-Require-Captcha');
};
+41
View File
@@ -0,0 +1,41 @@
import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn';
import 'dayjs/locale/zh-tw';
import 'dayjs/locale/en';
import 'dayjs/locale/es';
import relativeTime from 'dayjs/plugin/relativeTime';
import timezone from 'dayjs/plugin/timezone';
import { getLocale } from '../locale';
const localeMap = {
'zh-CN': 'zh-cn',
'zh-TW': 'zh-tw',
en: 'en',
es: 'es',
};
dayjs.extend(timezone);
dayjs.extend(relativeTime);
dayjs.locale(localeMap[getLocale()]);
export function formatDate(date: string | Date | undefined | null): string {
if (!date) {
return '';
}
return dayjs(date).format('YYYY-MM-DD HH:mm');
}
export function timeAgo(date: string | Date | undefined | null): string {
if (!date) {
return '';
}
const currentDate = new Date();
if (currentDate.getFullYear() - new Date(date).getFullYear() > 0) {
return formatDate(new Date(date));
}
return dayjs().to(dayjs(date));
}
+13
View File
@@ -0,0 +1,13 @@
import sanitizeHtml from 'sanitize-html';
export function cleanHtml(content?: string) {
if (!content) {
return '';
}
return sanitizeHtml(content, {
allowedAttributes: {
...sanitizeHtml.defaults.allowedAttributes,
code: ['class'],
},
});
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+33
View File
@@ -0,0 +1,33 @@
{
"compilerOptions": {
"target": "es2021",
"module": "es2020",
"lib": ["es2021", "DOM", "DOM.Iterable"],
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"inlineSources": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noImplicitAny": true,
"noImplicitThis": true,
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"experimentalDecorators": true,
"forceConsistentCasingInFileNames": true,
"noImplicitOverride": true,
"skipLibCheck": true,
"plugins": [
{
"name": "ts-lit-plugin",
"strict": true
}
]
},
"include": ["src/**/*.ts"]
}
+71
View File
@@ -0,0 +1,71 @@
import {
defineConfig,
definePreset,
presetIcons,
presetWind3,
transformerDirectives,
} from 'unocss';
const remRE = /(-?[.\d]+)rem/g;
const presetRemToEm = definePreset(() => {
return {
name: '@unocss/preset-rem-to-em',
postprocess: (util) => {
util.entries.forEach((i) => {
const value = i[1];
if (typeof value === 'string' && remRE.test(value))
i[1] = value.replace(remRE, (_, p1) => `${p1}em`);
});
},
};
});
export default defineConfig({
presets: [presetWind3(), presetIcons(), presetRemToEm()],
theme: {
colors: {
primary: {
1: 'var(--halo-cw-primary-1-color, #4CCBA0)',
2: 'var(--halo-cw-primary-2-color, #6EE7B7)',
3: 'var(--halo-cw-primary-3-color, #99F6E4)',
},
text: {
1: 'var(--halo-cw-text-1-color, #0f172a)',
2: 'var(--halo-cw-text-2-color, #1e293b)',
3: 'var(--halo-cw-text-3-color, #475569)',
},
muted: {
1: 'var(--halo-cw-muted-1-color, #cbd5e1)',
2: 'var(--halo-cw-muted-2-color, #e2e8f0)',
3: 'var(--halo-cw-muted-3-color, #f1f5f9)',
},
},
borderRadius: {
base: 'var(--halo-cw-base-rounded, 0.5em)',
avatar: 'var(--halo-cw-avatar-rounded, 9999px)',
},
},
rules: [
[
'shadow-input',
{
'box-shadow': '0 0 0 3px var(--halo-cw-primary-3-color, #99F6E4)',
},
],
],
shortcuts: {
input:
'px-3 py-0 text-sm rounded-base bg-transparent border h-12 border-muted-1 border-solid outline-none transition-all focus:border-primary-1 focus:shadow-input placeholder:text-text-3',
'icon-button': 'inline-flex items-center gap-[0.1em] cursor-pointer',
'icon-button-icon':
'inline-flex items-center justify-center rounded-full transition-all duration-150 p-2 text-text-3 group-hover:bg-muted-3 group-hover:text-text-1',
'icon-button-text':
'select-none text-xs text-text-3 group-hover:text-text-1',
'pagination-button':
'inline-flex h-10 items-center text-sm gap-1 hover:bg-muted-3 rounded-base px-3 transition-all text-text-1 opacity-80 disabled:!opacity-70 disabled:cursor-not-allowed hover:opacity-100 font-medium justify-center',
avatar:
'rounded-avatar size-[var(--halo-cw-avatar-size,2.25em)] overflow-hidden inline-flex items-center justify-center bg-muted-2',
},
transformers: [transformerDirectives()],
});
+50
View File
@@ -0,0 +1,50 @@
@media (prefers-color-scheme: dark) {
.color-scheme-auto,
[data-color-scheme="auto"] {
color-scheme: dark;
--halo-cw-primary-1-color: #059669;
--halo-cw-primary-2-color: #047857;
--halo-cw-primary-3-color: #065f46;
--halo-cw-text-1-color: #f9fafb;
--halo-cw-text-2-color: #e5e7eb;
--halo-cw-text-3-color: #9ca3af;
--halo-cw-muted-1-color: #4b5563;
--halo-cw-muted-2-color: #374151;
--halo-cw-muted-3-color: #1f2937;
--halo-cw-emoji-picker-rgb-color: 222, 222, 221;
--halo-cw-emoji-picker-rgb-accent: 58, 130, 247;
--halo-cw-emoji-picker-rgb-background: 21, 22, 23;
--halo-cw-emoji-picker-rgb-input: 0, 0, 0;
--halo-cw-emoji-picker-color-border: rgba(255, 255, 255, 0.1);
--halo-cw-emoji-picker-color-border-over: rgba(255, 255, 255, 0.2);
}
}
.color-scheme-dark,
.dark,
[data-color-scheme="dark"] {
color-scheme: dark;
--halo-cw-primary-1-color: #059669;
--halo-cw-primary-2-color: #047857;
--halo-cw-primary-3-color: #065f46;
--halo-cw-text-1-color: #f9fafb;
--halo-cw-text-2-color: #e2e8f0;
--halo-cw-text-3-color: #94a3b8;
--halo-cw-muted-1-color: #64748b;
--halo-cw-muted-2-color: #475569;
--halo-cw-muted-3-color: #334155;
--halo-cw-emoji-picker-rgb-color: 222, 222, 221;
--halo-cw-emoji-picker-rgb-accent: 58, 130, 247;
--halo-cw-emoji-picker-rgb-background: 21, 22, 23;
--halo-cw-emoji-picker-rgb-input: 0, 0, 0;
--halo-cw-emoji-picker-color-border: rgba(255, 255, 255, 0.1);
--halo-cw-emoji-picker-color-border-over: rgba(255, 255, 255, 0.2);
}
+24
View File
@@ -0,0 +1,24 @@
import UnoCSS from 'unocss/vite';
import { defineConfig, type Plugin } from 'vite';
import dts from 'vite-plugin-dts';
export default defineConfig({
experimental: {
enableNativePlugin: true,
},
plugins: [
dts() as Plugin,
UnoCSS({
mode: 'shadow-dom',
configFile: './uno.config.ts',
}),
],
build: {
lib: {
entry: ['src/index.ts'],
formats: ['es'],
fileName: 'index',
},
minify: false,
},
});
+167
View File
@@ -0,0 +1,167 @@
<?xml version="1.0" encoding="UTF-8"?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file target-language="es" source-language="en" original="lit-localize-inputs" datatype="plaintext">
<body>
<trans-unit id="s67749057edb2586b">
<source>Logout</source>
<target>Cerrar sesión</target>
</trans-unit>
<trans-unit id="s9f2ed66340f019c6">
<source>Write a comment</source>
<target>Escribir un comentario</target>
</trans-unit>
<trans-unit id="s7437373e541a8037">
<source>Nicename</source>
<target>Apodo</target>
</trans-unit>
<trans-unit id="sd1f44f1a8bc20e67">
<source>Email</source>
<target>Correo electrónico</target>
</trans-unit>
<trans-unit id="s82665b2ffabc9c0a">
<source>Website</source>
<target>Sitio web</target>
</trans-unit>
<trans-unit id="sc8da3cc71de63832">
<source>Login</source>
<target>Iniciar sesión</target>
</trans-unit>
<trans-unit id="sb3d4f79d9d8b71e5">
<source>Submit</source>
<target>Enviar comentario</target>
</trans-unit>
<trans-unit id="s09c7e0ff5f4e18cb">
<source>Cancel reply</source>
<target>Cancelar respuesta</target>
</trans-unit>
<trans-unit id="s0b7ae9543c001867">
<source>Reply</source>
<target>Responder</target>
</trans-unit>
<trans-unit id="sf3ff78cc329d3528">
<source>Previous</source>
<target>Anterior</target>
</trans-unit>
<trans-unit id="s0fbf6dc6a1966408">
<source>Next</source>
<target>Siguiente</target>
</trans-unit>
<trans-unit id="sf77128b082955d42">
<source>(Or login)</source>
<target>(O iniciar sesión)</target>
</trans-unit>
<trans-unit id="sa8dddacbaa66f8e0">
<source>Please login first</source>
<target>Por favor, inicie sesión primero</target>
</trans-unit>
<trans-unit id="s7584ded3d749c75e">
<source>Load more</source>
<target>Cargar más</target>
</trans-unit>
<trans-unit id="h6a83761bb66bbcc5">
<source><x id="0" equiv-text="${this.comments.total}"/> Comments</source>
<target><x id="0" equiv-text="${this.comments.total}"/> Comentarios</target>
</trans-unit>
<trans-unit id="s98a5f7789c49dd3f">
<source>Reviewing</source>
<target>En revisión</target>
</trans-unit>
<trans-unit id="sea7e567ed89dc0d7">
<source>Select emoticon</source>
<target>Seleccionar emoticono</target>
</trans-unit>
<trans-unit id="s4c0e15f9073382e6">
<source>Failed to obtain verification code</source>
<target>Error al obtener el código de verificación</target>
</trans-unit>
<trans-unit id="s053945ad43e9d1d6">
<source>Please enter the verification code</source>
<target>Por favor, ingrese el código de verificación</target>
</trans-unit>
<trans-unit id="s84b033b2f7360187">
<source>Please log in or complete the information first</source>
<target>Por favor, inicie sesión o complete la información primero</target>
</trans-unit>
<trans-unit id="sd5e242ab9574958a">
<source>Comment failed, please try again later</source>
<target>Error al comentar, por favor intente más tarde</target>
</trans-unit>
<trans-unit id="sb5a28aa35006ff08">
<source>Comment submitted successfully</source>
<target>Comentario enviado con éxito</target>
</trans-unit>
<trans-unit id="s3fb33d17bad61aa9">
<source>Comment submitted successfully, pending review</source>
<target>Comentario enviado con éxito, pendiente de revisión</target>
</trans-unit>
<trans-unit id="sa10fbe3fcd6ae148">
<source>Add reply</source>
<target>Agregar respuesta</target>
</trans-unit>
<trans-unit id="s1c6fefb092506753">
<source>Failed to load comment list, please try again later</source>
<target>Error al cargar la lista de comentarios, por favor intente más tarde</target>
</trans-unit>
<trans-unit id="s1e3e30a26025484c">
<source>Failed to load reply list, please try again later</source>
<target>Error al cargar la lista de respuestas, por favor intente más tarde</target>
</trans-unit>
<trans-unit id="s299b10f3a58a09fd">
<source>Click OK to jump to the logout page, Please make sure the content being edited has been saved.</source>
<target>Haga clic en OK para ir a la página de cierre de sesión, Por favor, asegúrese de que el contenido editado haya sido guardado.</target>
</trans-unit>
<trans-unit id="s26e4d65f2801ac9c">
<source>Please enter content</source>
<target>Por favor, ingrese el contenido</target>
</trans-unit>
<trans-unit id="sf7c0eba7c822e3d6">
<source>Bold</source>
<target>Negrita</target>
</trans-unit>
<trans-unit id="s523eb9043213ff0d">
<source>Italic</source>
<target>Itálica</target>
</trans-unit>
<trans-unit id="sb206d700d26b14ff">
<source>Underline</source>
<target>Subrayado</target>
</trans-unit>
<trans-unit id="s58a3c1ecd4dd06cf">
<source>Strike</source>
<target>Tachado</target>
</trans-unit>
<trans-unit id="s3643189d1abbb7f4">
<source>Code</source>
<target>Código</target>
</trans-unit>
<trans-unit id="s35c3125c7750681e">
<source>Blockquote</source>
<target>Cita</target>
</trans-unit>
<trans-unit id="s6cb61eeccda272d5">
<source>Code Block</source>
<target>Bloque de código</target>
</trans-unit>
<trans-unit id="sf9709983d4da2c54">
<source>Upvote</source>
<target>Me gusta</target>
</trans-unit>
<trans-unit id="sa82fbb7f361c6f44">
<source>Show replies</source>
<target>Mostrar respuestas</target>
</trans-unit>
<trans-unit id="se7bee6e9a9b5394c">
<source>Private</source>
<target>Íntimo</target>
</trans-unit>
<trans-unit id="s838e512973be01d4">
<source>Currently logged in. After selecting the private option, comments will only be visible to yourself and the site administrator.</source>
<target>Actualmente conectado. Después de seleccionar la opción privada, los comentarios solo serán visibles para usted y el administrador del sitio.</target>
</trans-unit>
<trans-unit id="s5184a3f3e2f7b603">
<source>You are currently anonymous. After selecting the private option, the comment will only be visible to the site administrator.</source>
<target>Actualmente anónimo. Después de seleccionar la opción privada, el comentario solo será visible para el administrador del sitio.</target>
</trans-unit>
</body>
</file>
</xliff>
+167
View File
@@ -0,0 +1,167 @@
<?xml version="1.0" encoding="UTF-8"?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file target-language="zh-CN" source-language="en" original="lit-localize-inputs" datatype="plaintext">
<body>
<trans-unit id="s67749057edb2586b">
<source>Logout</source>
<target>退出登录</target>
</trans-unit>
<trans-unit id="s9f2ed66340f019c6">
<source>Write a comment</source>
<target>编写评论</target>
</trans-unit>
<trans-unit id="s7437373e541a8037">
<source>Nicename</source>
<target>昵称</target>
</trans-unit>
<trans-unit id="sd1f44f1a8bc20e67">
<source>Email</source>
<target>电子邮件</target>
</trans-unit>
<trans-unit id="s82665b2ffabc9c0a">
<source>Website</source>
<target>网站</target>
</trans-unit>
<trans-unit id="sc8da3cc71de63832">
<source>Login</source>
<target>登录</target>
</trans-unit>
<trans-unit id="sb3d4f79d9d8b71e5">
<source>Submit</source>
<target>提交评论</target>
</trans-unit>
<trans-unit id="s09c7e0ff5f4e18cb">
<source>Cancel reply</source>
<target>取消回复</target>
</trans-unit>
<trans-unit id="s0b7ae9543c001867">
<source>Reply</source>
<target>回复</target>
</trans-unit>
<trans-unit id="sf3ff78cc329d3528">
<source>Previous</source>
<target>上一页</target>
</trans-unit>
<trans-unit id="s0fbf6dc6a1966408">
<source>Next</source>
<target>下一页</target>
</trans-unit>
<trans-unit id="sf77128b082955d42">
<source>(Or login)</source>
<target>(或登录账号)</target>
</trans-unit>
<trans-unit id="sa8dddacbaa66f8e0">
<source>Please login first</source>
<target>请先登录</target>
</trans-unit>
<trans-unit id="s7584ded3d749c75e">
<source>Load more</source>
<target>加载更多</target>
</trans-unit>
<trans-unit id="h6a83761bb66bbcc5">
<source><x id="0" equiv-text="${this.comments.total}"/> Comments</source>
<target><x id="0" equiv-text="${this.comments.total}"/> 条评论</target>
</trans-unit>
<trans-unit id="s98a5f7789c49dd3f">
<source>Reviewing</source>
<target>审核中</target>
</trans-unit>
<trans-unit id="sea7e567ed89dc0d7">
<source>Select emoticon</source>
<target>选择表情</target>
</trans-unit>
<trans-unit id="s4c0e15f9073382e6">
<source>Failed to obtain verification code</source>
<target>获取验证码失败</target>
</trans-unit>
<trans-unit id="s053945ad43e9d1d6">
<source>Please enter the verification code</source>
<target>请输入验证码</target>
</trans-unit>
<trans-unit id="s84b033b2f7360187">
<source>Please log in or complete the information first</source>
<target>请先登录或者完善信息</target>
</trans-unit>
<trans-unit id="sd5e242ab9574958a">
<source>Comment failed, please try again later</source>
<target>评论失败,请稍后重试</target>
</trans-unit>
<trans-unit id="sb5a28aa35006ff08">
<source>Comment submitted successfully</source>
<target>评论成功</target>
</trans-unit>
<trans-unit id="s3fb33d17bad61aa9">
<source>Comment submitted successfully, pending review</source>
<target>评论成功,请等待审核</target>
</trans-unit>
<trans-unit id="sa10fbe3fcd6ae148">
<source>Add reply</source>
<target>加入回复</target>
</trans-unit>
<trans-unit id="s1c6fefb092506753">
<source>Failed to load comment list, please try again later</source>
<target>加载评论列表失败,请稍后重试</target>
</trans-unit>
<trans-unit id="s1e3e30a26025484c">
<source>Failed to load reply list, please try again later</source>
<target>加载回复列表失败,请稍后重试</target>
</trans-unit>
<trans-unit id="s299b10f3a58a09fd">
<source>Click OK to jump to the logout page, Please make sure the content being edited has been saved.</source>
<target>点击确定将跳转至退出登录页面,请确保正在编辑的内容已保存。</target>
</trans-unit>
<trans-unit id="s26e4d65f2801ac9c">
<source>Please enter content</source>
<target>请输入内容</target>
</trans-unit>
<trans-unit id="sf7c0eba7c822e3d6">
<source>Bold</source>
<target>粗体</target>
</trans-unit>
<trans-unit id="s523eb9043213ff0d">
<source>Italic</source>
<target>斜体</target>
</trans-unit>
<trans-unit id="sb206d700d26b14ff">
<source>Underline</source>
<target>下划线</target>
</trans-unit>
<trans-unit id="s58a3c1ecd4dd06cf">
<source>Strike</source>
<target>删除线</target>
</trans-unit>
<trans-unit id="s3643189d1abbb7f4">
<source>Code</source>
<target>代码</target>
</trans-unit>
<trans-unit id="s35c3125c7750681e">
<source>Blockquote</source>
<target>引用</target>
</trans-unit>
<trans-unit id="s6cb61eeccda272d5">
<source>Code Block</source>
<target>代码块</target>
</trans-unit>
<trans-unit id="sf9709983d4da2c54">
<source>Upvote</source>
<target>点赞</target>
</trans-unit>
<trans-unit id="sa82fbb7f361c6f44">
<source>Show replies</source>
<target>显示回复</target>
</trans-unit>
<trans-unit id="se7bee6e9a9b5394c">
<source>Private</source>
<target>私密</target>
</trans-unit>
<trans-unit id="s838e512973be01d4">
<source>Currently logged in. After selecting the private option, comments will only be visible to yourself and the site administrator.</source>
<target>当前已登录,选择私密选项后,评论将仅对您和网站管理员可见。</target>
</trans-unit>
<trans-unit id="s5184a3f3e2f7b603">
<source>You are currently anonymous. After selecting the private option, the comment will only be visible to the site administrator.</source>
<target>当前是匿名状态,选择私密选项后,评论将仅对网站管理员可见。</target>
</trans-unit>
</body>
</file>
</xliff>
+167
View File
@@ -0,0 +1,167 @@
<?xml version="1.0" encoding="UTF-8"?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file target-language="zh-TW" source-language="en" original="lit-localize-inputs" datatype="plaintext">
<body>
<trans-unit id="s67749057edb2586b">
<source>Logout</source>
<target>登出</target>
</trans-unit>
<trans-unit id="s9f2ed66340f019c6">
<source>Write a comment</source>
<target>撰寫評論</target>
</trans-unit>
<trans-unit id="s7437373e541a8037">
<source>Nicename</source>
<target>暱稱</target>
</trans-unit>
<trans-unit id="sd1f44f1a8bc20e67">
<source>Email</source>
<target>電子郵件</target>
</trans-unit>
<trans-unit id="s82665b2ffabc9c0a">
<source>Website</source>
<target>網站</target>
</trans-unit>
<trans-unit id="sc8da3cc71de63832">
<source>Login</source>
<target>登入</target>
</trans-unit>
<trans-unit id="sb3d4f79d9d8b71e5">
<source>Submit</source>
<target>提交評論</target>
</trans-unit>
<trans-unit id="s09c7e0ff5f4e18cb">
<source>Cancel reply</source>
<target>取消回覆</target>
</trans-unit>
<trans-unit id="s0b7ae9543c001867">
<source>Reply</source>
<target>回覆</target>
</trans-unit>
<trans-unit id="sf3ff78cc329d3528">
<source>Previous</source>
<target>上一頁</target>
</trans-unit>
<trans-unit id="s0fbf6dc6a1966408">
<source>Next</source>
<target>下一頁</target>
</trans-unit>
<trans-unit id="sf77128b082955d42">
<source>(Or login)</source>
<target>(或登入帳號)</target>
</trans-unit>
<trans-unit id="sa8dddacbaa66f8e0">
<source>Please login first</source>
<target>請先登入</target>
</trans-unit>
<trans-unit id="s7584ded3d749c75e">
<source>Load more</source>
<target>載入更多</target>
</trans-unit>
<trans-unit id="h6a83761bb66bbcc5">
<source><x id="0" equiv-text="${this.comments.total}"/> Comments</source>
<target><x id="0" equiv-text="${this.comments.total}"/> 則評論</target>
</trans-unit>
<trans-unit id="s98a5f7789c49dd3f">
<source>Reviewing</source>
<target>審核中</target>
</trans-unit>
<trans-unit id="sea7e567ed89dc0d7">
<source>Select emoticon</source>
<target>選擇表情</target>
</trans-unit>
<trans-unit id="s4c0e15f9073382e6">
<source>Failed to obtain verification code</source>
<target>獲取驗證碼失敗</target>
</trans-unit>
<trans-unit id="s053945ad43e9d1d6">
<source>Please enter the verification code</source>
<target>請輸入驗證碼</target>
</trans-unit>
<trans-unit id="s84b033b2f7360187">
<source>Please log in or complete the information first</source>
<target>請先登入或者完善資訊</target>
</trans-unit>
<trans-unit id="sd5e242ab9574958a">
<source>Comment failed, please try again later</source>
<target>評論失敗,請稍後重試</target>
</trans-unit>
<trans-unit id="sb5a28aa35006ff08">
<source>Comment submitted successfully</source>
<target>評論成功</target>
</trans-unit>
<trans-unit id="s3fb33d17bad61aa9">
<source>Comment submitted successfully, pending review</source>
<target>評論成功,請等待審核</target>
</trans-unit>
<trans-unit id="sa10fbe3fcd6ae148">
<source>Add reply</source>
<target>新增回覆</target>
</trans-unit>
<trans-unit id="s1c6fefb092506753">
<source>Failed to load comment list, please try again later</source>
<target>載入評論列表失敗,請稍後重試</target>
</trans-unit>
<trans-unit id="s1e3e30a26025484c">
<source>Failed to load reply list, please try again later</source>
<target>載入回覆列表失敗,請稍後重試</target>
</trans-unit>
<trans-unit id="s299b10f3a58a09fd">
<source>Click OK to jump to the logout page, Please make sure the content being edited has been saved.</source>
<target>点击确定将跳转至退出登录页面,请确保正在编辑的内容已保存。</target>
</trans-unit>
<trans-unit id="s26e4d65f2801ac9c">
<source>Please enter content</source>
<target>請輸入內容</target>
</trans-unit>
<trans-unit id="sf7c0eba7c822e3d6">
<source>Bold</source>
<target>粗體</target>
</trans-unit>
<trans-unit id="s523eb9043213ff0d">
<source>Italic</source>
<target>斜體</target>
</trans-unit>
<trans-unit id="sb206d700d26b14ff">
<source>Underline</source>
<target>底線</target>
</trans-unit>
<trans-unit id="s58a3c1ecd4dd06cf">
<source>Strike</source>
<target>刪除線</target>
</trans-unit>
<trans-unit id="s3643189d1abbb7f4">
<source>Code</source>
<target>程式碼</target>
</trans-unit>
<trans-unit id="s35c3125c7750681e">
<source>Blockquote</source>
<target>引用</target>
</trans-unit>
<trans-unit id="s6cb61eeccda272d5">
<source>Code Block</source>
<target>程式碼區塊</target>
</trans-unit>
<trans-unit id="sf9709983d4da2c54">
<source>Upvote</source>
<target>讚</target>
</trans-unit>
<trans-unit id="sa82fbb7f361c6f44">
<source>Show replies</source>
<target>顯示回覆</target>
</trans-unit>
<trans-unit id="se7bee6e9a9b5394c">
<source>Private</source>
<target>私密</target>
</trans-unit>
<trans-unit id="s838e512973be01d4">
<source>Currently logged in. After selecting the private option, comments will only be visible to yourself and the site administrator.</source>
<target>目前已登入,選擇私密選項後,評論將僅對您和網站管理員可見。</target>
</trans-unit>
<trans-unit id="s5184a3f3e2f7b603">
<source>You are currently anonymous. After selecting the private option, the comment will only be visible to the site administrator.</source>
<target>目前是匿名狀態,選擇私密選項後,評論將僅對網站管理員可見。</target>
</trans-unit>
</body>
</file>
</xliff>
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+64
View File
@@ -0,0 +1,64 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link href="/favicon.ico" rel="icon" />
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
<title>@halo-dev/comment-widget - example</title>
<script src="/src/main.ts" type="module"></script>
<style>
#toggle-theme {
margin-bottom: 1rem;
}
:root {
--halo-cw-base-rounded: 0.5em;
--halo-cw-avatar-rounded: 1px;
--halo-cw-avatar-size: 2em;
--halo-cw-base-font-family: 'Inter', sans-serif;
}
</style>
<script>
document.addEventListener("DOMContentLoaded", () => {
const button = document.getElementById("toggle-theme");
button.addEventListener("click", () => {
document.documentElement.classList.toggle("dark");
// save the theme to local storage
localStorage.setItem(
"theme",
document.documentElement.classList.contains("dark")
? "dark"
: "light"
);
});
// get the theme from local storage
const theme = localStorage.getItem("theme");
if (theme) {
document.documentElement.classList.add(theme);
}
});
window.addEventListener("halo:comment:created", () => {
console.log("halo:comment:created");
});
window.addEventListener("halo:comment-reply:created", () => {
console.log("halo:comment-reply:created");
});
</script>
</head>
<body>
<button id="toggle-theme">Dark / Light</button>
<comment-widget
baseUrl="http://127.0.0.1:8090"
group="content.halo.run"
kind="Post"
version="v1alpha1"
name="5152aea5-c2e8-4717-8bba-2263d46e19d5"
with-replies="false"
></comment-widget>
</body>
</html>
+8
View File
@@ -0,0 +1,8 @@
{
"scripts": {
"dev": "vite"
},
"dependencies": {
"@halo-dev/comment-widget": "workspace:*"
}
}
+2
View File
@@ -0,0 +1,2 @@
import '@halo-dev/comment-widget';
import '@halo-dev/comment-widget/var.css';
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.json",
"include": ["src"]
}
+28
View File
@@ -0,0 +1,28 @@
import { fileURLToPath, URL } from 'node:url';
import { defineConfig } from 'vite';
export default defineConfig({
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
port: 4000,
proxy: {
'/actuator': {
target: 'http://localhost:8090',
changeOrigin: true,
},
'/apis': {
target: 'http://localhost:8090',
changeOrigin: true,
},
'/upload': {
target: 'http://localhost:8090',
changeOrigin: true,
},
},
},
});
+2
View File
@@ -0,0 +1,2 @@
/// <reference types="@rsbuild/core/types" />
/// <reference types="unplugin-icons/types/vue" />
+23
View File
@@ -0,0 +1,23 @@
{
"type": "module",
"scripts": {
"build": "rsbuild build",
"dev": "rsbuild build --watch --env-mode=development",
"type-check": "vue-tsc --build"
},
"dependencies": {
"@halo-dev/comment-widget": "workspace:*",
"@halo-dev/components": "^2.21.0",
"@halo-dev/console-shared": "link:/Users/ryanwang/Workspace/github/ruibaby/halo-next/ui/packages/shared",
"vue": "^3.5.18"
},
"devDependencies": {
"@halo-dev/ui-plugin-bundler-kit": "^2.21.2",
"@rsbuild/core": "^1.4.15",
"@rsbuild/plugin-vue": "^1.1.1",
"@types/node": "^20.19.10",
"@vue/tsconfig": "^0.7.0",
"typescript": "~5.8.3",
"vue-tsc": "^2.2.12"
}
}
+36
View File
@@ -0,0 +1,36 @@
import { rsbuildConfig } from '@halo-dev/ui-plugin-bundler-kit';
import { pluginVue } from '@rsbuild/plugin-vue';
const MANIFEST_PATH = '../../src/main/resources/plugin.yaml';
const OUT_DIR_PROD = '../../src/main/resources/console';
const OUT_DIR_DEV = '../../build/resources/main/console';
export default rsbuildConfig({
manifestPath: MANIFEST_PATH,
rsbuild: ({ envMode }) => {
const isProduction = envMode === 'production';
const outDir = isProduction ? OUT_DIR_PROD : OUT_DIR_DEV;
return {
resolve: {
alias: {
'@': './src',
},
},
plugins: [
pluginVue({
vueLoaderOptions: {
compilerOptions: {
isCustomElement: (tag) => tag.startsWith('comment-'),
},
},
}),
],
output: {
distPath: {
root: outDir,
},
},
};
},
});
+10
View File
@@ -0,0 +1,10 @@
<script lang="ts" setup>
import '@halo-dev/comment-widget';
defineProps<{
content: string;
}>();
</script>
<template>
<comment-content :content="content"></comment-content>
</template>
+33
View File
@@ -0,0 +1,33 @@
<script lang="ts" setup>
import '@halo-dev/comment-widget';
import type { CommentEditor } from '@halo-dev/comment-widget';
import { onMounted, ref } from 'vue';
const props = defineProps<{
autoFocus?: boolean;
}>();
const emit =
defineEmits<
(e: 'update', content: { content: string; characterCount: number }) => void
>();
const editorRef = ref<InstanceType<typeof CommentEditor>>();
onMounted(() => {
if (props.autoFocus) {
editorRef.value?.setFocus();
}
editorRef.value?.addEventListener('update', (e) => {
const detail = (e as CustomEvent).detail;
emit('update', {
content: detail.content,
characterCount: detail.characterCount ?? 0,
});
});
});
</script>
<template>
<comment-editor ref="editorRef" keep-alive></comment-editor>
</template>
+29
View File
@@ -0,0 +1,29 @@
import { VLoading } from '@halo-dev/components';
import { definePlugin } from '@halo-dev/console-shared';
import { defineAsyncComponent, markRaw } from 'vue';
export default definePlugin({
components: {},
routes: [],
extensionPoints: {
'comment:editor:replace': () => {
return {
component: markRaw(
defineAsyncComponent({
loader: () => import('./components/Editor.vue'),
loadingComponent: VLoading,
})
),
};
},
'comment:list-item:content:replace': () => {
return {
component: markRaw(
defineAsyncComponent({
loader: () => import('./components/Content.vue'),
})
),
};
},
},
});
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"paths": {
"@/*": ["./src/*"]
}
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"scripts": {
"build": "tsc && rslib build",
"dev": "rslib build --watch"
},
"dependencies": {
"@halo-dev/comment-widget": "workspace:*"
},
"devDependencies": {
"@rslib/core": "^0.12.1"
}
}
+32
View File
@@ -0,0 +1,32 @@
import { fileURLToPath } from 'node:url';
import { defineConfig } from '@rslib/core';
const PLUGIN_NAME = 'PluginCommentWidget';
export default defineConfig({
lib: [
{
format: 'esm',
autoExternal: false,
},
],
output: {
target: 'web',
minify: true,
cleanDistPath: true,
filename: {
js: (pathData) => {
if (pathData.chunk.name === 'index') {
return 'comment-widget.js';
}
return '[name].[contenthash:8].js';
},
},
publicPath: `/plugins/${PLUGIN_NAME}/assets/static/`,
distPath: {
root: fileURLToPath(
new URL('../../src/main/resources/static', import.meta.url)
),
},
},
});
+1
View File
@@ -0,0 +1 @@
/// <reference types="@rslib/core/types" />
+41
View File
@@ -0,0 +1,41 @@
import { CommentWidget } from '@halo-dev/comment-widget';
import '@halo-dev/comment-widget/var.css';
export { CommentWidget };
interface Props {
group: string;
kind: string;
name: string;
}
export function init(el: string, props: Props) {
const parent = document.querySelector(el) as HTMLElement;
if (!parent) {
console.error('Element not found', el);
}
const commentWidget = document.createElement(
'comment-widget'
) as CommentWidget;
commentWidget.kind = props.kind;
commentWidget.group = props.group;
commentWidget.version = 'v1alpha1';
commentWidget.name = props.name;
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && parent.childElementCount === 0) {
parent.appendChild(commentWidget);
parent.animate([{ opacity: 0 }, { opacity: 1 }], {
duration: 300,
fill: 'forwards',
});
}
});
});
observer.observe(parent as Element);
}
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.json",
"include": ["src"]
}