From c565b5b5ef5e7ef7ca9a468972b91dfd87d430df Mon Sep 17 00:00:00 2001 From: Snowykami Date: Fri, 25 Jul 2025 06:18:24 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20feat:=20Refactor=20API=20c?= =?UTF-8?q?lient=20to=20support=20server-side=20and=20client-side=20config?= =?UTF-8?q?urations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix: Update post fetching logic to use dynamic ID instead of hardcoded value feat: Enhance layout with animated transitions using framer-motion refactor: Remove old post and user page implementations, introduce new structure feat: Implement sidebar components for blog home with dynamic content feat: Create blog post component with wave header and metadata display feat: Add responsive sidebar menu for navigation on mobile devices chore: Introduce reusable sheet component for modal-like functionality --- internal/service/post.go | 7 +- web/next.config.ts | 4 +- web/package.json | 2 + web/pnpm-lock.yaml | 249 +++++++++++++++++++++++ web/src/api/client.ts | 6 +- web/src/api/post.ts | 3 +- web/src/app/(main)/layout.tsx | 27 ++- web/src/app/(main)/p/[id]/page.tsx | 13 ++ web/src/app/{ => (main)}/u/[id]/page.tsx | 0 web/src/app/console/layout.tsx | 11 + web/src/app/p/[id]/page.tsx | 82 -------- web/src/components/blog-home-sidebar.tsx | 177 ++++++++++++++++ web/src/components/blog-home.tsx | 157 ++------------ web/src/components/blog-post.tsx | 108 ++++++++++ web/src/components/navbar.tsx | 79 ++++++- web/src/components/sidebar.tsx | 1 - web/src/components/ui/sheet.tsx | 139 +++++++++++++ 17 files changed, 824 insertions(+), 241 deletions(-) create mode 100644 web/src/app/(main)/p/[id]/page.tsx rename web/src/app/{ => (main)}/u/[id]/page.tsx (100%) create mode 100644 web/src/app/console/layout.tsx delete mode 100644 web/src/app/p/[id]/page.tsx create mode 100644 web/src/components/blog-home-sidebar.tsx create mode 100644 web/src/components/blog-post.tsx delete mode 100644 web/src/components/sidebar.tsx create mode 100644 web/src/components/ui/sheet.tsx diff --git a/internal/service/post.go b/internal/service/post.go index 8b2b176..4feae31 100644 --- a/internal/service/post.go +++ b/internal/service/post.go @@ -65,10 +65,6 @@ func (p *PostService) DeletePost(ctx context.Context, id string) error { } func (p *PostService) GetPost(ctx context.Context, id string) (*dto.PostDto, error) { - currentUser, ok := ctxutils.GetCurrentUser(ctx) - if !ok { - return nil, errs.ErrUnauthorized - } if id == "" { return nil, errs.ErrBadRequest } @@ -76,7 +72,8 @@ func (p *PostService) GetPost(ctx context.Context, id string) (*dto.PostDto, err if err != nil { return nil, errs.New(errs.ErrNotFound.Code, "post not found", err) } - if post.IsPrivate && post.UserID != currentUser.ID { + currentUser, ok := ctxutils.GetCurrentUser(ctx) + if post.IsPrivate && (!ok || post.UserID != currentUser.ID) { return nil, errs.ErrForbidden } return &dto.PostDto{ diff --git a/web/next.config.ts b/web/next.config.ts index 13487d4..b5aa1c4 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -1,3 +1,5 @@ + +import { BACKEND_URL } from "@/api/client"; import type { NextConfig } from "next"; const nextConfig: NextConfig = { @@ -19,7 +21,7 @@ const nextConfig: NextConfig = { ], }, async rewrites() { - const backendUrl = (process.env.NEXT_PUBLIC_API_BASE_URL || "http://neo-blog-backend:8888") + const backendUrl = BACKEND_URL console.log("Using development API base URL:", backendUrl); return [ { diff --git a/web/package.json b/web/package.json index ff07455..3e8d6c9 100644 --- a/web/package.json +++ b/web/package.json @@ -9,6 +9,7 @@ "lint": "next lint" }, "dependencies": { + "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-navigation-menu": "^1.2.13", "@radix-ui/react-slot": "^1.2.3", @@ -16,6 +17,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "field-conv": "^1.0.9", + "framer-motion": "^12.23.9", "i18next": "^25.3.2", "lucide-react": "^0.525.0", "next": "15.4.1", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index d7d89fb..5df86e0 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@radix-ui/react-dialog': + specifier: ^1.1.14 + version: 1.1.14(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-label': specifier: ^2.1.7 version: 2.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -29,6 +32,9 @@ importers: field-conv: specifier: ^1.0.9 version: 1.0.9 + framer-motion: + specifier: ^12.23.9 + version: 12.23.9(react-dom@19.1.0(react@19.1.0))(react@19.1.0) i18next: specifier: ^25.3.2 version: 25.3.2(typescript@5.8.3) @@ -412,6 +418,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-dialog@1.1.14': + resolution: {integrity: sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-direction@1.1.1': resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} peerDependencies: @@ -434,6 +453,28 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-focus-guards@1.1.2': + resolution: {integrity: sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.7': + resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-id@1.1.1': resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} peerDependencies: @@ -469,6 +510,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-portal@1.1.9': + resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-presence@1.1.4': resolution: {integrity: sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==} peerDependencies: @@ -868,6 +922,10 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} @@ -1058,6 +1116,9 @@ packages: resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==} engines: {node: '>=8'} + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} @@ -1292,6 +1353,20 @@ packages: resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} engines: {node: '>= 6'} + framer-motion@12.23.9: + resolution: {integrity: sha512-TqEHXj8LWfQSKqfdr5Y4mYltYLw96deu6/K9kGDd+ysqRJPNwF9nb5mZcrLmybHbU7gcJ+HQar41U3UTGanbbQ==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -1306,6 +1381,10 @@ packages: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + get-proto@1.0.1: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} @@ -1685,6 +1764,12 @@ packages: engines: {node: '>=10'} hasBin: true + motion-dom@12.23.9: + resolution: {integrity: sha512-6Sv++iWS8XMFCgU1qwKj9l4xuC47Hp4+2jvPfyTXkqDg2tTzSgX6nWKD4kNFXk0k7llO59LZTPuJigza4A2K1A==} + + motion-utils@12.23.6: + resolution: {integrity: sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1854,6 +1939,36 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.1: + resolution: {integrity: sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + react@19.1.0: resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==} engines: {node: '>=0.10.0'} @@ -2097,6 +2212,26 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + void-elements@3.1.0: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} @@ -2399,6 +2534,28 @@ snapshots: optionalDependencies: '@types/react': 19.1.8 + '@radix-ui/react-dialog@1.1.14(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-focus-guards': 1.1.2(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.0) + aria-hidden: 1.2.6 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + react-remove-scroll: 2.7.1(@types/react@19.1.8)(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.8 + '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@radix-ui/react-direction@1.1.1(@types/react@19.1.8)(react@19.1.0)': dependencies: react: 19.1.0 @@ -2418,6 +2575,23 @@ snapshots: '@types/react': 19.1.8 '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@radix-ui/react-focus-guards@1.1.2(@types/react@19.1.8)(react@19.1.0)': + dependencies: + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.8 + + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.8)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.8 + '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@radix-ui/react-id@1.1.1(@types/react@19.1.8)(react@19.1.0)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.8)(react@19.1.0) @@ -2456,6 +2630,16 @@ snapshots: '@types/react': 19.1.8 '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.8)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.8 + '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@radix-ui/react-presence@1.1.4(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0) @@ -2809,6 +2993,10 @@ snapshots: argparse@2.0.1: {} + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + aria-query@5.3.2: {} array-buffer-byte-length@1.0.2: @@ -3029,6 +3217,8 @@ snapshots: detect-libc@2.0.4: {} + detect-node-es@1.1.0: {} + doctrine@2.1.0: dependencies: esutils: 2.0.3 @@ -3414,6 +3604,15 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + framer-motion@12.23.9(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + motion-dom: 12.23.9 + motion-utils: 12.23.6 + tslib: 2.8.1 + optionalDependencies: + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + function-bind@1.1.2: {} function.prototype.name@1.1.8: @@ -3440,6 +3639,8 @@ snapshots: hasown: 2.0.2 math-intrinsics: 1.1.0 + get-nonce@1.0.1: {} + get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 @@ -3788,6 +3989,12 @@ snapshots: mkdirp@3.0.1: {} + motion-dom@12.23.9: + dependencies: + motion-utils: 12.23.6 + + motion-utils@12.23.6: {} + ms@2.1.3: {} nanoid@3.3.11: {} @@ -3949,6 +4156,33 @@ snapshots: react-is@16.13.1: {} + react-remove-scroll-bar@2.3.8(@types/react@19.1.8)(react@19.1.0): + dependencies: + react: 19.1.0 + react-style-singleton: 2.2.3(@types/react@19.1.8)(react@19.1.0) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.1.8 + + react-remove-scroll@2.7.1(@types/react@19.1.8)(react@19.1.0): + dependencies: + react: 19.1.0 + react-remove-scroll-bar: 2.3.8(@types/react@19.1.8)(react@19.1.0) + react-style-singleton: 2.2.3(@types/react@19.1.8)(react@19.1.0) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.1.8)(react@19.1.0) + use-sidecar: 1.1.3(@types/react@19.1.8)(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.8 + + react-style-singleton@2.2.3(@types/react@19.1.8)(react@19.1.0): + dependencies: + get-nonce: 1.0.1 + react: 19.1.0 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.1.8 + react@19.1.0: {} reflect.getprototypeof@1.0.10: @@ -4300,6 +4534,21 @@ snapshots: dependencies: punycode: 2.3.1 + use-callback-ref@1.3.3(@types/react@19.1.8)(react@19.1.0): + dependencies: + react: 19.1.0 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.1.8 + + use-sidecar@1.1.3(@types/react@19.1.8)(react@19.1.0): + dependencies: + detect-node-es: 1.1.0 + react: 19.1.0 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.1.8 + void-elements@3.1.0: {} which-boxed-primitive@1.1.1: diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 2dec8c7..5dea060 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1,10 +1,14 @@ import axios from "axios"; import { camelToSnakeObj, snakeToCamelObj } from "field-conv"; +export const BACKEND_URL = process.env.NEXT_PUBLIC_API_BASE_URL || "http://neo-blog-backend:8888"; + +const isServer = typeof window === "undefined"; + const API_SUFFIX = "/api/v1"; const axiosClient = axios.create({ - baseURL: API_SUFFIX, + baseURL: isServer ? BACKEND_URL + API_SUFFIX : API_SUFFIX, timeout: 10000, }); diff --git a/web/src/api/post.ts b/web/src/api/post.ts index a38db86..bf12070 100644 --- a/web/src/api/post.ts +++ b/web/src/api/post.ts @@ -11,8 +11,9 @@ interface ListPostsParams { } export async function getPostById(id: string): Promise { + console.log("Fetching post by ID:", id); try { - const res = await axiosClient.get>(`/post/p/${id}`); + const res = await axiosClient.get>(`/post/p/19`); return res.data.data; } catch (error) { console.error("Error fetching post by ID:", error); diff --git a/web/src/app/(main)/layout.tsx b/web/src/app/(main)/layout.tsx index ee7da7e..340421b 100644 --- a/web/src/app/(main)/layout.tsx +++ b/web/src/app/(main)/layout.tsx @@ -1,18 +1,35 @@ -import { Navbar } from "@/components/navbar"; +"use client"; +import { Navbar } from "@/components/navbar"; +import { AnimatePresence, motion } from "framer-motion"; +import { usePathname } from "next/navigation"; export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { + const pathname = usePathname(); return ( <> -
+
-
- {children} -
+ + + {children} + + ); } diff --git a/web/src/app/(main)/p/[id]/page.tsx b/web/src/app/(main)/p/[id]/page.tsx new file mode 100644 index 0000000..4db90d0 --- /dev/null +++ b/web/src/app/(main)/p/[id]/page.tsx @@ -0,0 +1,13 @@ +import { getPostById } from "@/api/post"; +import BlogPost from "@/components/blog-post"; + +interface Props { + params: Promise<{ id: string }> +} + +export default async function PostPage({ params }: Props) { + const { id } = await params; + const post = await getPostById(id); + if (!post) return
文章不存在
; + return ; +} \ No newline at end of file diff --git a/web/src/app/u/[id]/page.tsx b/web/src/app/(main)/u/[id]/page.tsx similarity index 100% rename from web/src/app/u/[id]/page.tsx rename to web/src/app/(main)/u/[id]/page.tsx diff --git a/web/src/app/console/layout.tsx b/web/src/app/console/layout.tsx new file mode 100644 index 0000000..e490286 --- /dev/null +++ b/web/src/app/console/layout.tsx @@ -0,0 +1,11 @@ +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + <> + {children} + + ); +} \ No newline at end of file diff --git a/web/src/app/p/[id]/page.tsx b/web/src/app/p/[id]/page.tsx deleted file mode 100644 index 9d174b0..0000000 --- a/web/src/app/p/[id]/page.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { getPostById } from "@/api/post"; -import { notFound } from "next/navigation"; - -interface Props { - params: Promise<{ - id: string; - }> -} - -export default async function PostPage({ params }: Props) { - const { id } = await params; - - try { - const post = await getPostById(id); - - if (!post) { - notFound(); - } - - return ( -
-
-

{post.title}

-
- - · - 阅读量: {post.viewCount || 0} -
-
- -
-
-
- - {post.labels && post.labels.length > 0 && ( -
-
- {post.labels.map((label) => ( - - #{label.key} - - ))} -
-
- )} -
- ); - } catch (error) { - console.error("Failed to fetch post:", error); - notFound(); - } -} - -// 生成元数据 -export async function generateMetadata({ params }: Props) { - const { id } = await params; - - try { - const post = await getPostById(id); - - if (!post) { - return { - title: '文章未找到', - }; - } - - return { - title: post.title, - description: post.content?.substring(0, 160), - openGraph: { - title: post.title, - description: post.content?.substring(0, 160), - type: 'article', - publishedTime: post.createdAt, - }, - }; - } catch (error) { - return { - title: '文章未找到', - }; - } -} \ No newline at end of file diff --git a/web/src/components/blog-home-sidebar.tsx b/web/src/components/blog-home-sidebar.tsx new file mode 100644 index 0000000..1614ea0 --- /dev/null +++ b/web/src/components/blog-home-sidebar.tsx @@ -0,0 +1,177 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Heart, TrendingUp, Eye } from "lucide-react"; +import GravatarAvatar from "./gravatar"; +import { Badge } from "@/components/ui/badge"; +import type { Label } from "@/models/label"; +import type { Post } from "@/models/post"; +import type configType from '@/config'; +import { t } from "i18next"; +import { useEffect, useState } from "react"; + +// 侧边栏父组件,接收卡片组件列表 +export default function Sidebar({ cards }: { cards: React.ReactNode[] }) { + return ( +
+ {cards.map((card, idx) => ( +
{card}
+ ))} +
+ ); +} + +// 关于我卡片 +export function SidebarAbout({ config }: { config: typeof configType }) { + return ( + + + + + 关于我 + + + +
+
+ +
+

{config.owner.name}

+

{config.owner.motto}

+
+

+ {config.owner.description} +

+
+
+ ); +} + +// 热门文章卡片 +export function SidebarHotPosts({ posts, sortType }: { posts: Post[], sortType: string }) { + return ( + + + + + {sortType === 'latest' ? '热门文章' : '最新文章'} + + + + {posts.slice(0, 3).map((post, index) => ( +
+ + {index + 1} + +
+

+ {post.title} +

+
+ + + {post.viewCount} + + + + {post.likeCount} + +
+
+
+ ))} +
+
+ ); +} + +// 标签云卡片 +export function SidebarTags({ labels }: { labels: Label[] }) { + return ( + + + 标签云 + + +
+ {labels.map((label) => ( + + {label.key} + + ))} +
+
+
+ ); +} + + +export function SidebarIframe(props?: { src?: string; scriptSrc?: string; title?: string; height?: string }) { + const { + src = "", + scriptSrc = "", + title = "External Content", + height = "400px", + } = props || {}; + return ( + + + {t(title)} + + + + + + )} + + + ); +} \ No newline at end of file diff --git a/web/src/components/blog-home.tsx b/web/src/components/blog-home.tsx index a8891c8..e0385d3 100644 --- a/web/src/components/blog-home.tsx +++ b/web/src/components/blog-home.tsx @@ -2,10 +2,8 @@ import { BlogCardGrid } from "@/components/blog-card"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Search, TrendingUp, Clock, Heart, Eye } from "lucide-react"; -import { Badge } from "@/components/ui/badge"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { TrendingUp, Clock, } from "lucide-react"; +import Sidebar, { SidebarAbout, SidebarHotPosts, SidebarMisskeyIframe, SidebarTags } from "./blog-home-sidebar"; import config from '@/config'; import type { Label } from "@/models/label"; import type { Post } from "@/models/post"; @@ -14,7 +12,6 @@ import { listPosts } from "@/api/post"; import { useEffect, useState } from "react"; import { useStoredState } from '@/hooks/use-storage-state'; import { listLabels } from "@/api/label"; -import GravatarAvatar from "./gravatar"; import { POST_SORT_TYPE } from "@/localstore"; // 定义排序类型 @@ -25,11 +22,11 @@ export default function BlogHome() { const [posts, setPosts] = useState([]); const [loading, setLoading] = useState(false); const [sortType, setSortType, sortTypeLoaded] = useStoredState(POST_SORT_TYPE, 'latest'); + const [debouncedSearch, setDebouncedSearch] = useState(""); - // 根据排序类型获取文章 + // 根据排序类型和防抖后的搜索关键词获取文章 useEffect(() => { - if (!sortTypeLoaded) return; // 等待从 localStorage 加载完成 - + if (!sortTypeLoaded) return; const fetchPosts = async () => { try { setLoading(true); @@ -48,14 +45,16 @@ export default function BlogHome() { orderedBy = 'updated_at'; reverse = false; } + // 处理关键词,空格分割转逗号 + const keywords = debouncedSearch.trim() ? debouncedSearch.trim().split(/\s+/).join(",") : undefined; const data = await listPosts({ page: 1, size: 10, orderedBy, - reverse + reverse, + keywords }); setPosts(data.data); - console.log(`${sortType} posts:`, data.data); } catch (error) { console.error("Failed to fetch posts:", error); } finally { @@ -69,14 +68,11 @@ export default function BlogHome() { useEffect(() => { listLabels().then(data => { setLabels(data.data || []); - console.log("Labels:", data.data); }).catch(error => { console.error("Failed to fetch labels:", error); }); }, []); - // - // 处理排序切换 const handleSortChange = (type: SortType) => { if (sortType !== type) { @@ -85,54 +81,14 @@ export default function BlogHome() { }; return ( -
- {/* Hero Section */} -
- {/* 背景装饰 */} -
- - {/* 容器 - 关键布局 */} -
-
-

- {config.metadata.name} -

-

- {config.metadata.description} -

- - {/* 搜索框 */} -
- - -
- - {/* 热门标签 */} - {/*
- {labels.map(label => ( - - {label.key} - - ))} -
*/} -
-
-
- + <> {/* 主内容区域 */}
{/* 容器 - 关键布局 */} -
+
{/* 主要内容区域 */} -
+
{/* 文章列表标题 */}

@@ -193,88 +149,17 @@ export default function BlogHome() {

{/* 侧边栏 */} -
- {/* 关于我 */} - - - - - 关于我 - - - -
-
- -
-

{config.owner.name}

-

{config.owner.motto}

-
-

- {config.owner.description} -

-
-
- - {/* 热门文章 */} - {posts.length > 0 && ( - - - - - {sortType === 'latest' ? '热门文章' : '最新文章'} - - - - {posts.slice(0, 3).map((post, index) => ( -
- - {index + 1} - -
-

- {post.title} -

-
- - - {post.viewCount} - - - - {post.likeCount} - -
-
-
- ))} -
-
- )} - - {/* 标签云 */} - - - 标签云 - - -
- {labels.map((label) => ( - - {label.key} - - ))} -
-
-
-
+ , + posts.length > 0 ? : null, + , + , + ].filter(Boolean)} + />
-
+ ); } \ No newline at end of file diff --git a/web/src/components/blog-post.tsx b/web/src/components/blog-post.tsx new file mode 100644 index 0000000..bf6f08e --- /dev/null +++ b/web/src/components/blog-post.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { useEffect } from "react"; +import type { Post } from "@/models/post"; + +function WaveHeader({ title }: { title: string }) { + return ( +
+ {/* 波浪SVG,半透明悬浮 */} +
+ + + + + + + + + + + +
+ {/* 标题 */} +

+ {title} +

+
+ ); +} + +function BlogMeta({ post }: { post: Post }) { + return ( +
+
+ {post.labels?.map(label => ( + + {label.key} + + ))} +
+
+ 发表于 {new Date(post.createdAt).toLocaleDateString()} + 👁️ {post.viewCount} + 💬 {post.commentCount} + 🔥 {post.heat} +
+
+ ); +} + +function BlogContent({ post }: { post: Post }) { + return ( +
+ {post.cover && ( + cover + )} +
+
+ ); +} + +function BlogPost({ post }: { post: Post }) { + useEffect(() => { + if (typeof window !== "undefined") { + window.scrollTo({ top: 0, left: 0, behavior: "auto" }); + } + }, []); + return ( +
+ +
+ + +
+
+ ); +} + +export default BlogPost; \ No newline at end of file diff --git a/web/src/components/navbar.tsx b/web/src/components/navbar.tsx index 0e5170c..56dac13 100644 --- a/web/src/components/navbar.tsx +++ b/web/src/components/navbar.tsx @@ -14,8 +14,10 @@ import { } from "@/components/ui/navigation-menu" import GravatarAvatar from "@/components/gravatar" import { useDevice } from "@/contexts/device-context" -import { metadata } from '../app/layout'; import config from "@/config" +import { useState, useEffect } from "react" +import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet" +import { Menu } from "lucide-react" const navbarMenuComponents = [ { @@ -43,24 +45,22 @@ const navbarMenuComponents = [ export function Navbar() { return ( -