diff --git a/.env.example b/.env.example index cb264e27de6..db311bc258f 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,11 @@ +SQLITE_PATH=./data +JWT_TOKEN=waline-dev-jwt-token + +# PostgreSQL example POSTGRES_DATABASE= POSTGRES_USER= POSTGRES_PASSWORD= POSTGRES_HOST= -PORSTGRES_PORT= +POSTGRES_PORT= POSTGRES_PREFIX= -POSTGRES_SSL= \ No newline at end of file +POSTGRES_SSL= diff --git a/.gitignore b/.gitignore index 792ddb9c8e3..59d755a90fd 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,7 @@ dist/ # npm config file .npmrc + +# other +data/ +error.log diff --git a/docs/src/advanced/contribution.md b/docs/src/advanced/contribution.md index 9235a4fde1d..21509999f05 100644 --- a/docs/src/advanced/contribution.md +++ b/docs/src/advanced/contribution.md @@ -49,7 +49,9 @@ order: -1 ::: tip - 为了使 `@waline/server` 能在本地正常运行,你需要配置必要的本地环境变量至 `.env`。 + 当你没有配置任何存储环境变量时,`pnpm run server:dev` 会默认回退到 `./data` 下的本地 SQLite 数据库。 + + 如果你想在本地使用其他存储后端,请在 `.env` 中配置对应的环境变量。 在 `.env.example` 我们准备了示例供你参考 diff --git a/docs/src/en/advanced/contribution.md b/docs/src/en/advanced/contribution.md index d2ac8a96bf8..b8ec099cc39 100644 --- a/docs/src/en/advanced/contribution.md +++ b/docs/src/en/advanced/contribution.md @@ -49,7 +49,9 @@ If you want to contribute to waline, here is a guide. ::: tip - In order to run `@waline/server` locally, you need to configure some local environment variables to `.env`. + `pnpm run server:dev` will fall back to a local SQLite database at `./data` when no storage environment variables are set. + + If you want to use another storage backend locally, configure the required environment variables in `.env`. We provide an example for you in `.env.example`. diff --git a/packages/admin/index.html b/packages/admin/index.html index 05e15ad74a1..35eb9bfcc24 100644 --- a/packages/admin/index.html +++ b/packages/admin/index.html @@ -6,7 +6,8 @@ Waline Management System diff --git a/packages/admin/src/App.jsx b/packages/admin/src/App.jsx index e8e5ca39c65..49b8405f163 100644 --- a/packages/admin/src/App.jsx +++ b/packages/admin/src/App.jsx @@ -10,6 +10,7 @@ import Profile from './pages/profile/index.jsx'; import Register from './pages/register/index.jsx'; import User from './pages/user/index.jsx'; import { store } from './store/index.js'; +import { getUiPath } from './utils/ui.js'; const Access = (props) => { const user = useSelector((state) => state.user); @@ -18,15 +19,20 @@ const Access = (props) => { const meta = props.meta ?? {}; const basename = props.basename ?? ''; const emptyUser = !user?.objectId; + const currentPath = location.pathname.replace(basename, '') || '/'; + const redirectPath = currentPath.startsWith('/') ? currentPath : `/${currentPath}`; + const buildPath = (path) => `${basename}${path}`.replaceAll(/\/+/gu, '/'); if (emptyUser) { - return (location.href = `${basename}/ui/login?redirect=${location.pathname.replace(basename, '')}`); + location.href = `${buildPath(getUiPath('login'))}?redirect=${redirectPath}`; + return; } - const noPermission = meta.auth ? props.meta.auth !== user.type : false; + const noPermission = meta.auth ? meta.auth !== user.type : false; if (noPermission) { - return (location.href = `${basename}/ui/profile`); + location.href = buildPath(getUiPath('profile')); + return; } }, [user, props.meta, props.basename]); @@ -36,52 +42,61 @@ const Access = (props) => { export default function App() { const match = location.pathname.match(/(.*?)\/ui/u); const basePath = match ? match[1] : '/'; + const homePath = getUiPath(); + const userPath = getUiPath('user'); + const migrationPath = getUiPath('migration'); + const loginPath = getUiPath('login'); + const registerPath = getUiPath('register'); + const forgotPath = getUiPath('forgot'); + const profilePath = getUiPath('profile'); return ( - - - - - - } - /> - - - - } - /> - - - - } - /> - } /> - } /> - } /> - - - - } - /> - - +
+ + + + + + } + /> + + + + } + /> + + + + } + /> + } /> + } /> + } /> + + + + } + /> + + +
); } diff --git a/packages/admin/src/components/Header.jsx b/packages/admin/src/components/Header.jsx index df081ad5361..da74e8ddf2a 100644 --- a/packages/admin/src/components/Header.jsx +++ b/packages/admin/src/components/Header.jsx @@ -5,6 +5,8 @@ import { useDispatch, useSelector } from 'react-redux'; import { Link, useNavigate } from 'react-router'; import { LANGUAGE_OPTIONS } from '../locales/index.js'; +import { buildAvatar } from '../pages/manage-comments/utils.js'; +import { getUiPath } from '../utils/ui.js'; export default function Header() { const dispatch = useDispatch(); @@ -42,54 +44,75 @@ export default function Header() { const onLogout = (event) => { event.preventDefault(); dispatch.user.logout(); - navigate('/ui/login'); + navigate(getUiPath('login')); }; + const siteName = window.SITE_NAME || 'Waline'; + + const navItems = [ + { to: getUiPath(), label: t('comment') }, + { to: getUiPath('user'), label: t('user') }, + { to: getUiPath('migration'), label: t('migration') }, + ]; + return [ -
- {user?.type === 'administrator' ? ( - - ) : null} -
-
- -
- {user?.type ? ( - - {user.display_name} +
+
+
+ + W + + {siteName} + {t('management')} + - ) : null} +
+ + {user?.type === 'administrator' ? ( + + ) : ( +
+ {t('management')} +
+ )} - {user?.type ? ( - - ) : null} +
+
+ +
+ {user?.type ? ( + + {user.display_name + {user.display_name} + + ) : null} + + {user?.type ? ( + + ) : null} +
-
, + , latestVersion ? (
+ +`; +const githubSvg = ``; +const googleSvg = ` + + + + + +`; +const huaweiSvg = ``; +const oidcSvg = ` + + + + +`; +const qqSvg = ` + +`; +const twitterSvg = ``; +const weiboSvg = ` + + +`; + +const createIcon = (svg) => + function Icon(props) { + return React.createElement('span', { + ...props, + dangerouslySetInnerHTML: { __html: svg }, + }); + }; + +export const github = createIcon(githubSvg); +export const twitter = createIcon(twitterSvg); +export const facebook = createIcon(facebookSvg); +export const weibo = createIcon(weiboSvg); +export const qq = createIcon(qqSvg); +export const oidc = createIcon(oidcSvg); +export const google = createIcon(googleSvg); +export const huawei = createIcon(huaweiSvg); diff --git a/packages/admin/src/index.jsx b/packages/admin/src/index.jsx index 2b46895fa4a..b96387b8aeb 100644 --- a/packages/admin/src/index.jsx +++ b/packages/admin/src/index.jsx @@ -6,7 +6,7 @@ import { store } from './store/index.js'; // oxlint-disable-next-line import/no-unassigned-import import './i18n.js'; -import './style/index.scss'; +import './style/index.css'; const run = async () => { await Promise.race([ @@ -42,7 +42,9 @@ const run = async () => { const container = document.createElement('div'); + document.body.classList.add('waline-admin-body'); container.style.height = '100%'; + container.className = 'waline-admin-root'; document.body.append(container); const root = createRoot(container); diff --git a/packages/admin/src/pages/forgot/index.jsx b/packages/admin/src/pages/forgot/index.jsx index 8cc3891fd89..cdb124557dc 100644 --- a/packages/admin/src/pages/forgot/index.jsx +++ b/packages/admin/src/pages/forgot/index.jsx @@ -4,6 +4,7 @@ import { useDispatch, useSelector } from 'react-redux'; import { Link, useNavigate } from 'react-router'; import Header from '../../components/Header.jsx'; +import { getUiPath } from '../../utils/ui.js'; export default function Forgot() { const { t } = useTranslation(); @@ -16,7 +17,7 @@ export default function Forgot() { useEffect(() => { // if logged if (user?.objectId) { - navigate('/ui', { replace: true }); + navigate(getUiPath(), { replace: true }); } }, [navigate, user?.objectId]); @@ -36,7 +37,7 @@ export default function Forgot() { email, }); alert(t('find password success! please go to your mailbox to reset it!')); - navigate('/ui/login'); + navigate(getUiPath('login')); } catch { setError(t('find password error! try again later')); } finally { @@ -85,8 +86,8 @@ export default function Forgot() {

- {t('back to home')} •{' '} - {t('register.login')} + {t('back to home')} •{' '} + {t('register.login')}

diff --git a/packages/admin/src/pages/login/index.jsx b/packages/admin/src/pages/login/index.jsx index c5f35caab98..eb46ffb80f5 100644 --- a/packages/admin/src/pages/login/index.jsx +++ b/packages/admin/src/pages/login/index.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; import { Link, useNavigate } from 'react-router'; @@ -6,7 +6,9 @@ import { Link, useNavigate } from 'react-router'; import Header from '../../components/Header.jsx'; // oxlint-disable-next-line import/no-namespace import * as Icons from '../../components/icon/index.js'; +import { useCaptcha } from '../../components/useCaptcha.js'; import { get2FAToken } from '../../services/user.js'; +import { getUiPath } from '../../utils/ui.js'; export default function Login() { const { t } = useTranslation(); @@ -33,7 +35,7 @@ export default function Login() { const isAdmin = user.type === 'administrator'; - const defaultRedirect = isAdmin ? '/ui' : '/ui/profile'; + const defaultRedirect = isAdmin ? getUiPath() : getUiPath('profile'); const redirect = isAdmin && query.get('redirect') ? query.get('redirect') : defaultRedirect; navigate(redirect.replaceAll(/\/+/gu, '/')); @@ -177,7 +179,7 @@ export default function Login() { {t('remember me')} - {t('forgot password')} + {t('forgot password')}

@@ -190,8 +192,8 @@ export default function Login() {

- {t('back to home')} •{' '} - {t('register')} + {t('back to home')} •{' '} + {t('register')}

diff --git a/packages/admin/src/pages/manage-comments/index.jsx b/packages/admin/src/pages/manage-comments/index.jsx index d286781df10..362d8eaefd3 100644 --- a/packages/admin/src/pages/manage-comments/index.jsx +++ b/packages/admin/src/pages/manage-comments/index.jsx @@ -500,7 +500,7 @@ export default function ManageComments() {
{nick} { + const label = (typeof name === 'string' ? name.trim().charAt(0) : '').toUpperCase() || 'W'; + const fontSize = Math.round(size * 0.42); + const svg = ` + + + + + + + + ${label} +`; -export const buildAvatar = (email = '', avatar = '') => { - if (avatar) return avatar; - const normalizedEmail = typeof email === 'string' ? email : ''; + return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg)}`; +}; + +const isDefaultAvatar = (avatar = '') => { + if (!avatar) { + return true; + } + + return /https?:\/\/seccdn\.libravatar\.org\/avatar\//u.test(avatar); +}; + +export const buildAvatar = (name = '', email = '', avatar = '') => { + if (!isDefaultAvatar(avatar)) { + return avatar; + } - return `https://sdn.geekzu.org/avatar/${md5(normalizedEmail)}?s=40&r=G&d=`; + return buildFallbackAvatarDataUri(name); }; export const getPostUrl = (url) => (window.SITE_URL ?? '') + url; diff --git a/packages/admin/src/pages/profile/index.jsx b/packages/admin/src/pages/profile/index.jsx index cf5bdd04fd0..35da0bb08e5 100644 --- a/packages/admin/src/pages/profile/index.jsx +++ b/packages/admin/src/pages/profile/index.jsx @@ -7,6 +7,7 @@ import Header from '../../components/Header.jsx'; // oxlint-disable-next-line import/no-namespace import * as Icons from '../../components/icon/index.js'; import { updateProfile } from '../../services/user.js'; +import { buildAvatar } from '../manage-comments/utils.js'; import TwoFactorAuth from './twoFactorAuth.jsx'; export default function Profile() { @@ -110,10 +111,17 @@ export default function Profile() {

-

{user.display_name}

diff --git a/packages/admin/src/pages/register/index.jsx b/packages/admin/src/pages/register/index.jsx index 9721bc7fd80..54f2ce76946 100644 --- a/packages/admin/src/pages/register/index.jsx +++ b/packages/admin/src/pages/register/index.jsx @@ -5,6 +5,7 @@ import { Link, useNavigate } from 'react-router'; import Header from '../../components/Header.jsx'; import { useCaptcha } from '../../components/useCaptcha.js'; +import { getUiPath } from '../../utils/ui.js'; export default function Register() { const { t } = useTranslation(); @@ -20,7 +21,7 @@ export default function Register() { useEffect(() => { if (user?.objectId) { - navigate('/ui', { replace: true }); + navigate(getUiPath(), { replace: true }); } }, [navigate, user?.objectId]); @@ -64,7 +65,7 @@ export default function Register() { alert(t('register success! please go to your mailbox to verify it!')); } - navigate('/ui/login'); + navigate(getUiPath('login')); } catch (err) { setError(err.message); } finally { @@ -157,8 +158,8 @@ export default function Register() {

- {t('back to home')} •{' '} - {t('register.login')} + {t('back to home')} •{' '} + {t('register.login')}

diff --git a/packages/admin/src/pages/user/index.jsx b/packages/admin/src/pages/user/index.jsx index fe4eaaa9097..d140a1d6ba6 100644 --- a/packages/admin/src/pages/user/index.jsx +++ b/packages/admin/src/pages/user/index.jsx @@ -150,7 +150,7 @@ export default function User() {
{user.display_name} p:first-child, +.col-tb-3 > h2, +.col-tb-3 > p + p { + color: var(--wa-text); +} + +.typecho-list-operate { + margin-bottom: 18px; + padding: 16px 18px; + border: 1px solid var(--wa-border); + border-radius: var(--wa-radius-lg); + background: var(--wa-surface-strong); +} + +.typecho-list-operate form, +.typecho-list-operate .operate, +.typecho-list-operate .search { + display: flex; + align-items: center; + gap: 12px; +} + +.typecho-list-operate form { + justify-content: space-between; + flex-wrap: wrap; +} + +input[type='text'], +input[type='password'], +input[type='email'], +textarea, +select { + border: 1px solid var(--wa-border-strong); + border-radius: var(--wa-radius-sm); + background: #fff; + color: var(--wa-text); + box-shadow: inset 0 1px 0 rgb(255 255 255 / 6%); +} + +input[type='text']:focus, +input[type='password']:focus, +input[type='email']:focus, +textarea:focus, +select:focus { + border-color: rgb(25 118 255 / 45%); + outline: none; + box-shadow: + 0 0 0 4px rgb(25 118 255 / 10%), + inset 0 1px 0 rgb(255 255 255 / 8%); +} + +select { + min-height: 42px; + padding: 0 12px; +} + +.btn { + border: 1px solid var(--wa-border); + border-radius: 999px; + background: #fff; + color: var(--wa-text); + font-weight: 600; +} + +.btn:hover { + background: var(--wa-surface-soft); +} + +.primary { + border-color: transparent; + background: linear-gradient(135deg, #0f6bff, #3692ff); + color: #fff; + box-shadow: 0 10px 24px rgb(25 118 255 / 18%); +} + +.primary:hover { + background: linear-gradient(135deg, #005fde, #2488ff); +} + +.dropdown-menu { + min-width: 180px; + padding: 8px; + border: 1px solid var(--wa-border); + border-radius: 16px; + background: rgb(255 255 255 / 96%); + box-shadow: var(--wa-shadow-soft); + backdrop-filter: blur(18px); +} + +.dropdown-menu button:hover { + border-radius: 10px; + background: var(--wa-accent-soft); + color: var(--wa-accent-strong); +} + +.typecho-table-wrap, +.typecho-content-panel section, +.col-tb-3, +.typecho-login { + border: 1px solid var(--wa-border); + border-radius: 24px; + background: var(--wa-surface-strong); + box-shadow: var(--wa-shadow-soft); +} + +.typecho-table-wrap { + overflow: hidden; +} + +.typecho-list-table { + width: 100%; + border-collapse: separate; + border-spacing: 0; +} + +.typecho-list-table th { + border-bottom: 1px solid var(--wa-border); + background: #f6faff; + color: var(--wa-text-soft); + font-size: 0.82rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.typecho-list-table th, +.typecho-list-table td { + padding: 18px 16px; + border-color: var(--wa-border); + vertical-align: top; +} + +.typecho-list-table tbody tr:hover { + background: #fbfdff; +} + +.comment-content, +.comment-body, +.comment-head, +.comment-meta, +.comment-date { + color: var(--wa-text); +} + +.comment-date { + margin-bottom: 10px; + color: var(--wa-text-soft); +} + +.comment-action { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.comment-action button, +.comment-action span { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 34px; + padding: 0 14px; + margin-right: 0; + border: 1px solid var(--wa-border); + border-radius: 999px; + background: rgb(255 255 255 / 88%); + color: var(--wa-accent-strong); + font-size: 0.9rem; + font-weight: 600; + line-height: 1; +} + +.comment-action button { + cursor: pointer; + transition: + transform 0.24s cubic-bezier(0.22, 1, 0.36, 1), + background-color 0.2s ease, + border-color 0.2s ease, + color 0.2s ease, + box-shadow 0.2s ease; +} + +.comment-action button:hover { + transform: translateY(-2px); + border-color: var(--wa-border-strong); + background: var(--wa-accent-soft); + box-shadow: var(--wa-shadow-soft); +} + +.comment-action span.weak { + background: #f8fafc; + color: var(--wa-text-soft); +} + +button.operate-delete, +button.operate-button-delete { + border-color: rgb(211 83 83 / 18%); + color: var(--wa-danger); +} + +button.operate-delete:hover, +button.operate-button-delete:hover { + background: rgb(255 240 240 / 96%); + border-color: rgb(211 83 83 / 28%); + color: #bf3434; +} + +.typecho-content-panel section { + padding: 24px; +} + +.typecho-content-panel h3, +.typecho-content-panel h2, +.col-tb-3 h2 { + color: var(--wa-text); +} + +.col-tb-3 { + padding: 24px; + text-align: center; +} + +.profile-avatar, +.comment-avatar img, +.user-avatar img, +.avatar { + border: 3px solid rgb(255 255 255 / 85%); + border-radius: 50%; + box-shadow: 0 12px 28px rgb(44 86 134 / 14%); +} + +.profile-avatar { + width: 88px; + height: 88px; + object-fit: cover; +} + +.account-list { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.account-item { + margin-right: 0; +} + +.account-item > a { + display: inline-flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + border: 1px solid var(--wa-border); + border-radius: 14px; + background: #fff; + box-shadow: var(--wa-shadow-soft); +} + +.account-item > a:hover { + transform: translateY(-1px); +} + +.typecho-login-wrap { + display: flex; + align-items: center; + justify-content: center; + min-height: calc(100% - 110px); + padding: 36px 24px 48px; +} + +.typecho-login { + width: min(460px, 100%); + padding: 32px; +} + +.typecho-login form p { + margin-bottom: 16px; +} + +.social-accounts { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 14px; + margin-top: 24px; +} + +.social-accounts a { + display: inline-flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + border: 1px solid var(--wa-border); + border-radius: 14px; + background: #fff; +} + +.more-link { + margin-top: 22px; + text-align: center; + color: var(--wa-text-soft); +} + +.message.popup.notice { + left: 50%; + width: min(560px, calc(100% - 32px)); + margin-left: 0; + transform: translateX(-50%); +} + +.message.popup.notice ul { + padding: 14px 18px; + border: 1px solid rgb(201 73 73 / 16%); + border-radius: 16px; + background: rgb(255 244 244 / 96%); + box-shadow: var(--wa-shadow-soft); +} + +.typecho-pager li.current button { + background: var(--wa-accent-soft); + color: var(--wa-accent-strong); +} + +.typecho-pager button:hover { + background: var(--wa-surface-soft); +} + +@media (width <= 960px) { + .typecho-head-nav { + padding-inline: 16px; + } + + .waline-admin-header, + .main .body.container, + .typecho-login { + padding: 20px; + border-radius: 24px; + } + + .waline-admin-header { + flex-direction: column; + align-items: stretch; + } + + .waline-admin-nav, + .typecho-head-nav .operate { + width: 100%; + } + + .typecho-head-nav .operate { + justify-content: space-between; + flex-wrap: wrap; + } + + .main { + padding: 20px 16px 36px; + } + + .typecho-page-title h2 { + font-size: 1.6rem; + } + + .typecho-list-operate form, + .typecho-list-operate .operate, + .typecho-list-operate .search { + align-items: stretch; + flex-direction: column; + } +} + +.account-item { + position: relative; + display: inline-block; + margin-right: 16px; +} + +.account-item .account-unbind svg { + position: absolute; + top: -3px; + right: -3px; + + display: none; + + border: 1px solid #999; + border-radius: 50%; + + background: #fff; + + cursor: pointer; +} + +.account-item:hover .account-unbind svg { + display: block; +} + +.account-item.github path, +.account-item.twitter circle, +.account-item.facebook path, +.account-item.weibo circle, +.account-item.qq path, +.account-item.oidc path, +.account-item.huawei path { + fill: #808080; +} + +.account-item.github:hover path, +.account-item.github.bind path { + fill: #1b1f23; +} + +.account-item.twitter:hover circle, +.account-item.twitter.bind circle { + fill: #2daae1; +} + +.account-item.facebook:hover path, +.account-item.facebook.bind path { + fill: #1877f2; +} + +.account-item.weibo:hover circle, +.account-item.weibo.bind circle { + fill: #d34237; +} + +.account-item.qq:hover path, +.account-item.qq.bind path { + fill: #259be0; +} + +.account-item.oidc:hover path, +.account-item.oidc.bind path { + fill: #ff9626; +} + +.account-item.huawei:hover path, +.account-item.huawei.bind path { + fill: #f00; +} + +.user-page-account-item { + margin-right: 6px; + opacity: 0.5; +} + +.user-page-account-item.bind { + opacity: 1; +} + +.user-page-account-item svg { + width: 16px; + height: 16px; +} + +/* remain for historical reasons */ +.vemoji, +.wl-emoji { + display: inline-block; + vertical-align: middle; + width: 1.3rem; + margin: 0.25rem; +} + +.social-accounts a + a { + margin-left: 16px; +} + +.upgrade-tips { + padding: 0 10px; + border-bottom: 1px solid rgb(212 167 44 / 40%); + + background: #fff8c5; + color: #24292f; + + line-height: 36px; +} + +.typecho-pager { + display: flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: 8px; + margin-top: 24px; +} + +.typecho-pager li { + display: flex; + align-items: center; + justify-content: center; + min-width: 42px; + height: 42px; + margin: 0; +} + +.typecho-pager li span, +.typecho-pager button { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 42px; + height: 42px; + padding: 0 14px; + border: 1px solid var(--wa-border); + border-radius: 999px; + background: rgb(255 255 255 / 84%); + color: var(--wa-text); + font: inherit; + font-weight: 600; + line-height: 1; +} + +.typecho-pager li span { + color: var(--wa-text-soft); +} + +.typecho-pager button { + cursor: pointer; + transition: + transform 0.24s cubic-bezier(0.22, 1, 0.36, 1), + background-color 0.2s ease, + border-color 0.2s ease, + color 0.2s ease, + box-shadow 0.2s ease; +} + +.typecho-pager button:hover { + transform: translateY(-2px); + border-color: var(--wa-border-strong); + background: var(--wa-surface-soft); + text-decoration: none; + box-shadow: var(--wa-shadow-soft); +} + +.typecho-pager li.current button { + border-color: rgb(77 141 240 / 24%); + background: var(--wa-accent-soft); + color: var(--wa-accent-strong); + box-shadow: inset 0 0 0 1px rgb(77 141 240 / 8%); +} + +.typecho-head-nav .operate button { + display: inline-block; + + margin-left: 0; + padding: 0 20px; + border: 1px solid var(--wa-border); + border-radius: 999px; + background: rgb(255 255 255 / 84%); + color: var(--wa-text); + + font: inherit; + line-height: 40px; + + cursor: pointer; +} + +.typecho-head-nav .operate button:hover { + transform: translateY(-2px); + border-color: var(--wa-border-strong); + background: var(--wa-accent-soft); + color: var(--wa-accent-strong); + box-shadow: var(--wa-shadow-soft); +} + +.dropdown-menu button { + display: block; + + width: 100%; + padding: 5px 12px; + border: none; + + background: none; + color: #666; + + font: inherit; + text-align: left; + + cursor: pointer; +} + +.dropdown-menu button:hover { + background: #f6f6f3; + text-decoration: none; +} + +button.operate-delete, +button.operate-button-delete { + color: #b94a48; +} + +button.operate-edit { + color: #070; +} + +button.operate-reply { + color: #545c30; +} + +.typecho-list-operate button:hover { + text-decoration: none; +} + +.account-unbind { + padding: 0; + border: none; + background: none; + cursor: pointer; +} + +.profile-avatar-btn { + border: none; + + background: none; + + cursor: pointer; +} diff --git a/packages/admin/src/style/index.css b/packages/admin/src/style/index.css new file mode 100644 index 00000000000..3e12030a2dc --- /dev/null +++ b/packages/admin/src/style/index.css @@ -0,0 +1,4 @@ +@import './normalize.css'; +@import './style.css'; +@import './grid.css'; +@import './custom.css'; diff --git a/packages/admin/src/style/style.css b/packages/admin/src/style/style.css index d124f058354..82c494d49f0 100644 --- a/packages/admin/src/style/style.css +++ b/packages/admin/src/style/style.css @@ -430,15 +430,13 @@ select { display: block; float: left; padding: 0 20px; - border-right: 1px solid #383d45; height: 36px; line-height: 36px; color: #bbb; } #typecho-nav-list .parent a:hover, -#typecho-nav-list .focus .parent a, -#typecho-nav-list .root:hover .parent a { +#typecho-nav-list .focus .parent a { background: #202328; color: #fff; text-decoration: none; diff --git a/packages/admin/src/utils/ui.js b/packages/admin/src/utils/ui.js new file mode 100644 index 00000000000..e6877789f37 --- /dev/null +++ b/packages/admin/src/utils/ui.js @@ -0,0 +1,7 @@ +export const uiBase = /\/ui(?:\/|$)/u.test(location.pathname) ? '/ui' : ''; + +export const getUiPath = (path = '') => { + const normalizedPath = path ? `/${path.replace(/^\/+/u, '')}` : ''; + + return `${uiBase}${normalizedPath}` || '/'; +}; diff --git a/packages/admin/vite.config.ts b/packages/admin/vite.config.ts index cecb05f8fab..a9a06b2f0bc 100644 --- a/packages/admin/vite.config.ts +++ b/packages/admin/vite.config.ts @@ -24,10 +24,10 @@ export default defineConfig({ }, server: { + host: '127.0.0.1', port: 9010, proxy: { - '/token': 'http://localhost:9090', - '/user': 'http://localhost:9090', + '/api': 'http://127.0.0.1:9090', }, }, }); diff --git a/packages/server/development.js b/packages/server/development.js index 8c0f83b1cf4..d5abc01058a 100644 --- a/packages/server/development.js +++ b/packages/server/development.js @@ -5,6 +5,23 @@ require('dotenv').config({ quiet: true, }); +const hasStorageEnv = [ + 'LEAN_KEY', + 'MONGO_DB', + 'PG_DB', + 'POSTGRES_DATABASE', + 'SQLITE_PATH', + 'MYSQL_DB', + 'TIDB_DB', + 'GITHUB_TOKEN', + 'TCB_ENV', +].some((envName) => Boolean(process.env[envName])); + +if (!hasStorageEnv) { + process.env.SQLITE_PATH = path.join(__dirname, '../../data'); + process.env.JWT_TOKEN ||= 'waline-dev-jwt-token'; +} + const watcher = require('think-watcher'); const Application = require('thinkjs'); diff --git a/packages/server/src/controller/comment.js b/packages/server/src/controller/comment.js index 0c6b64c5f66..56c22f23ba2 100644 --- a/packages/server/src/controller/comment.js +++ b/packages/server/src/controller/comment.js @@ -32,7 +32,7 @@ const formatCmt = async ( const avatarUrl = user?.avatar || (await think.service('avatar').stringify(comment)); comment.avatar = - avatarProxy && !avatarUrl.includes(avatarProxy) + avatarProxy && !avatarUrl.startsWith('data:') && !avatarUrl.includes(avatarProxy) ? `${avatarProxy}?url=${encodeURIComponent(avatarUrl)}` : avatarUrl; diff --git a/packages/server/src/controller/token.js b/packages/server/src/controller/token.js index dac55fd1fca..a9ec6b6bd4d 100644 --- a/packages/server/src/controller/token.js +++ b/packages/server/src/controller/token.js @@ -53,7 +53,7 @@ module.exports = class extends BaseRest { })); const { avatarProxy } = think.config(); - if (avatarProxy) { + if (avatarProxy && !avatarUrl.startsWith('data:')) { avatarUrl = `${avatarProxy}?url=${encodeURIComponent(avatarUrl)}`; } diff --git a/packages/server/src/controller/user.js b/packages/server/src/controller/user.js index e62e9c89002..abbc8ff7569 100644 --- a/packages/server/src/controller/user.js +++ b/packages/server/src/controller/user.js @@ -268,7 +268,7 @@ module.exports = class UserController extends BaseRest { if (count.user_id && users[count.user_id]) { const { display_name: nick, url: link, avatar: avatarUrl, label } = users[count.user_id]; const avatar = - avatarProxy && !avatarUrl.includes(avatarProxy) + avatarProxy && !avatarUrl.startsWith('data:') && !avatarUrl.includes(avatarProxy) ? `${avatarProxy}?url=${encodeURIComponent(avatarUrl)}` : avatarUrl; @@ -292,7 +292,7 @@ module.exports = class UserController extends BaseRest { const { nick, link } = comment; const avatarUrl = await think.service('avatar').stringify(comment); const avatar = - avatarProxy && !avatarUrl.includes(avatarProxy) + avatarProxy && !avatarUrl.startsWith('data:') && !avatarUrl.includes(avatarProxy) ? `${avatarProxy}?url=${encodeURIComponent(avatarUrl)}` : avatarUrl; diff --git a/packages/server/src/logic/base.js b/packages/server/src/logic/base.js index 1427b685e24..6734694cb7b 100644 --- a/packages/server/src/logic/base.js +++ b/packages/server/src/logic/base.js @@ -70,7 +70,7 @@ module.exports = class BaseLogic extends think.Logic { })); const { avatarProxy } = think.config(); - if (avatarProxy) { + if (avatarProxy && !avatarUrl.startsWith('data:')) { avatarUrl = `${avatarProxy}?url=${encodeURIComponent(avatarUrl)}`; } diff --git a/packages/server/src/middleware/dashboard.js b/packages/server/src/middleware/dashboard.js index 8f145dcef78..4b784886d28 100644 --- a/packages/server/src/middleware/dashboard.js +++ b/packages/server/src/middleware/dashboard.js @@ -1,6 +1,35 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +const adminBundlePath = path.resolve(__dirname, '../../../admin/dist/admin.js'); + +const getUiBasePath = (requestPath = '') => + requestPath.match(/^(.*?\/ui)(?:\/.*)?$/u)?.[1] || '/ui'; + // oxlint-disable-next-line func-names module.exports = function () { return (ctx) => { + const uiBasePath = getUiBasePath(ctx.path); + const localAdminAssetPath = `${uiBasePath}/assets/admin.js`; + const localAdminBundleAvailable = fs.existsSync(adminBundlePath); + + if (ctx.path === localAdminAssetPath) { + if (!localAdminBundleAvailable) { + ctx.status = 404; + ctx.body = 'Local admin bundle not found'; + return; + } + + ctx.type = 'js'; + ctx.body = fs.createReadStream(adminBundlePath); + return; + } + + const adminScriptSrc = localAdminBundleAvailable + ? `${localAdminAssetPath}?v=${encodeURIComponent(require('../../../admin/package.json').version)}` + : process.env.WALINE_ADMIN_MODULE_ASSET_URL || '//unpkg.com/@waline/admin'; + const scriptType = localAdminBundleAvailable ? ' type="module"' : ''; + ctx.type = 'html'; ctx.body = ` @@ -18,9 +47,7 @@ module.exports = function () { window.oauthServices = ${JSON.stringify(ctx.state.oauthServices || [])}; window.serverURL = '${ctx.serverURL}/api/'; - + `; }; diff --git a/packages/server/src/service/akismet.js b/packages/server/src/service/akismet.js index b708c783d75..7d688deb3b4 100644 --- a/packages/server/src/service/akismet.js +++ b/packages/server/src/service/akismet.js @@ -2,8 +2,7 @@ const Akismet = require('akismet'); const DEFAULT_KEY = '70542d86693e'; -// oxlint-disable-next-line func-names -module.exports = async function (comment, blog) { +module.exports = function akismet(comment, blog) { let { AKISMET_KEY, SITE_URL } = process.env; if (!AKISMET_KEY) { diff --git a/packages/server/src/service/avatar.js b/packages/server/src/service/avatar.js index 508e8bbcc02..dab3e957267 100644 --- a/packages/server/src/service/avatar.js +++ b/packages/server/src/service/avatar.js @@ -10,15 +10,30 @@ const env = new nunjucks.Environment(); env.addFilter('md5', (str) => helper.md5(str)); env.addFilter('sha256', (str) => crypto.createHash('sha256').update(str).digest('hex')); -const DEFAULT_GRAVATAR_STR = `{%- set numExp = r/^[0-9]+$/g -%} -{%- set qqMailExp = r/^[0-9]+@qq.com$/ig -%} -{%- if numExp.test(nick) -%} - https://q1.qlogo.cn/g?b=qq&nk={{nick}}&s=100 -{%- elif qqMailExp.test(mail) -%} - https://q1.qlogo.cn/g?b=qq&nk={{mail|replace('@qq.com', '')}}&s=100 -{%- else -%} - https://seccdn.libravatar.org/avatar/{{mail|md5}} -{%- endif -%}`; +const escapeSvgText = (value = '') => + String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + +const buildFallbackAvatarDataUri = (nick = '', size = 80) => { + const label = (typeof nick === 'string' ? nick.trim().charAt(0) : '').toUpperCase() || 'W'; + const fontSize = Math.round(size * 0.42); + const svg = ` + + + + + + + + ${escapeSvgText(label)} +`; + + return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg)}`; +}; module.exports = class extends think.Service { async stringify(comment) { @@ -32,8 +47,10 @@ module.exports = class extends think.Service { } } - const gravatarStr = GRAVATAR_STR || DEFAULT_GRAVATAR_STR; + if (GRAVATAR_STR) { + return env.renderString(GRAVATAR_STR, comment); + } - return env.renderString(gravatarStr, comment); + return buildFallbackAvatarDataUri(comment?.nick); } }; diff --git a/packages/server/src/service/markdown/mathjax.js b/packages/server/src/service/markdown/mathjax.js index 76d4ac187a2..636b731fa69 100644 --- a/packages/server/src/service/markdown/mathjax.js +++ b/packages/server/src/service/markdown/mathjax.js @@ -1,4 +1,5 @@ const { liteAdaptor } = require('mathjax-full/js/adaptors/liteAdaptor.js'); +const { RegisterHTMLHandler } = require('mathjax-full/js/handlers/html.js'); const { AllPackages } = require('mathjax-full/js/input/tex/AllPackages.js'); const { TeX } = require('mathjax-full/js/input/tex.js'); const { mathjax } = require('mathjax-full/js/mathjax'); @@ -9,6 +10,7 @@ const { escapeHtml } = require('./utils'); const mathjaxPlugin = (md) => { const adaptor = liteAdaptor(); + RegisterHTMLHandler(adaptor); const packages = AllPackages.sort(); const tex = new TeX({ packages }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b70899c3694..6af26aedafa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,7 +14,9 @@ overrides: apidoc>esbuild-loader: ^4.4.3 patchedDependencies: - apidoc: 14f1b3501c5015248e143b5b4216cb0f70cff2b6340214529fd5a63c8d59cffb + apidoc: + hash: 14f1b3501c5015248e143b5b4216cb0f70cff2b6340214529fd5a63c8d59cffb + path: patches/apidoc.patch importers: @@ -304,7 +306,7 @@ importers: version: 4.0.5 ip2region: specifier: ^2.3.0 - version: 2.3.0(@types/node@24.0.0) + version: 2.3.0(@types/node@24.12.4) jsdom: specifier: ^19.0.0 version: 19.0.0 @@ -358,7 +360,7 @@ importers: version: 1.1.7 think-model-mysql2: specifier: ^2.0.0 - version: 2.0.0(@types/node@24.0.0) + version: 2.0.0(@types/node@24.12.4) think-model-postgresql: specifier: 1.1.7 version: 1.1.7 @@ -13502,9 +13504,9 @@ snapshots: ip-address@10.2.0: {} - ip2region@2.3.0(@types/node@24.0.0): + ip2region@2.3.0(@types/node@24.12.4): dependencies: - '@types/node': 24.0.0 + '@types/node': 24.12.4 is-alphabetical@2.0.1: {} @@ -14477,9 +14479,9 @@ snapshots: muggle-string@0.4.1: {} - mysql2@3.22.3(@types/node@24.0.0): + mysql2@3.22.3(@types/node@24.12.4): dependencies: - '@types/node': 24.0.0 + '@types/node': 24.12.4 aws-ssl-profiles: 1.1.2 denque: 2.1.0 generate-function: 2.3.1 @@ -16243,12 +16245,12 @@ snapshots: transitivePeerDependencies: - supports-color - think-model-mysql2@2.0.0(@types/node@24.0.0): + think-model-mysql2@2.0.0(@types/node@24.12.4): dependencies: think-debounce: 1.0.4 think-helper: 1.1.4 think-model-abstract: 1.6.5 - think-mysql: think-mysql2@2.0.0(@types/node@24.0.0) + think-mysql: think-mysql2@2.0.0(@types/node@24.12.4) transitivePeerDependencies: - '@types/node' - supports-color @@ -16308,10 +16310,10 @@ snapshots: - mongodb-extjson - snappy - think-mysql2@2.0.0(@types/node@24.0.0): + think-mysql2@2.0.0(@types/node@24.12.4): dependencies: debug: 2.6.9 - mysql: mysql2@3.22.3(@types/node@24.0.0) + mysql: mysql2@3.22.3(@types/node@24.12.4) think-debounce: 1.0.4 think-helper: 1.1.4 think-instance: 1.0.2