mirror of
https://github.com/snowykami/neo-blog.git
synced 2025-09-25 18:46:23 +00:00
Refactor console layout and sidebar components; implement user authentication and loading states
All checks were successful
Push to Helm Chart Repository / build (push) Successful in 31s
All checks were successful
Push to Helm Chart Repository / build (push) Successful in 31s
- Updated `RootLayout` to include user authentication logic and loading states. - Removed redundant user authentication logic from `Page` component. - Enhanced `AppSidebar` to fetch and display logged-in user information. - Replaced `GravatarAvatar` with new `Avatar` component for user profile images. - Added new pages for comment, file, post, and user management. - Introduced utility functions for generating Gravatar URLs and fallback avatars based on usernames. - Cleaned up unused imports and components across various files.
This commit is contained in:
@ -38,6 +38,7 @@
|
||||
"field-conv": "^1.0.9",
|
||||
"highlight.js": "^11.11.1",
|
||||
"lucide-react": "^0.525.0",
|
||||
"md5": "^2.3.0",
|
||||
"motion": "^12.23.12",
|
||||
"next": "15.4.1",
|
||||
"next-intl": "^4.3.4",
|
||||
@ -56,6 +57,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/md5": "^2.3.5",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
|
35
web/pnpm-lock.yaml
generated
35
web/pnpm-lock.yaml
generated
@ -95,6 +95,9 @@ importers:
|
||||
lucide-react:
|
||||
specifier: ^0.525.0
|
||||
version: 0.525.0(react@19.1.0)
|
||||
md5:
|
||||
specifier: ^2.3.0
|
||||
version: 2.3.0
|
||||
motion:
|
||||
specifier: ^12.23.12
|
||||
version: 12.23.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
@ -144,6 +147,9 @@ importers:
|
||||
'@tailwindcss/postcss':
|
||||
specifier: ^4
|
||||
version: 4.1.11
|
||||
'@types/md5':
|
||||
specifier: ^2.3.5
|
||||
version: 2.3.5
|
||||
'@types/node':
|
||||
specifier: ^20
|
||||
version: 20.19.8
|
||||
@ -1216,6 +1222,9 @@ packages:
|
||||
'@types/json5@0.0.29':
|
||||
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
|
||||
|
||||
'@types/md5@2.3.5':
|
||||
resolution: {integrity: sha512-/i42wjYNgE6wf0j2bcTX6kuowmdL/6PE4IVitMpm2eYKBUuYCprdcWVK+xEF0gcV6ufMCRhtxmReGfc6hIK7Jw==}
|
||||
|
||||
'@types/mdast@4.0.4':
|
||||
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
|
||||
|
||||
@ -1545,6 +1554,9 @@ packages:
|
||||
character-reference-invalid@2.0.1:
|
||||
resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==}
|
||||
|
||||
charenc@0.0.2:
|
||||
resolution: {integrity: sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==}
|
||||
|
||||
chownr@3.0.0:
|
||||
resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
|
||||
engines: {node: '>=18'}
|
||||
@ -1590,6 +1602,9 @@ packages:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
crypt@0.0.2:
|
||||
resolution: {integrity: sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==}
|
||||
|
||||
csstype@3.1.3:
|
||||
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
|
||||
|
||||
@ -2158,6 +2173,9 @@ packages:
|
||||
resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
is-buffer@1.1.6:
|
||||
resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==}
|
||||
|
||||
is-bun-module@2.0.0:
|
||||
resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==}
|
||||
|
||||
@ -2407,6 +2425,9 @@ packages:
|
||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
md5@2.3.0:
|
||||
resolution: {integrity: sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==}
|
||||
|
||||
mdast-util-from-markdown@2.0.2:
|
||||
resolution: {integrity: sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==}
|
||||
|
||||
@ -4269,6 +4290,8 @@ snapshots:
|
||||
|
||||
'@types/json5@0.0.29': {}
|
||||
|
||||
'@types/md5@2.3.5': {}
|
||||
|
||||
'@types/mdast@4.0.4':
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
@ -4620,6 +4643,8 @@ snapshots:
|
||||
|
||||
character-reference-invalid@2.0.1: {}
|
||||
|
||||
charenc@0.0.2: {}
|
||||
|
||||
chownr@3.0.0: {}
|
||||
|
||||
class-variance-authority@0.7.1:
|
||||
@ -4664,6 +4689,8 @@ snapshots:
|
||||
shebang-command: 2.0.0
|
||||
which: 2.0.2
|
||||
|
||||
crypt@0.0.2: {}
|
||||
|
||||
csstype@3.1.3: {}
|
||||
|
||||
d3-array@3.2.4:
|
||||
@ -5429,6 +5456,8 @@ snapshots:
|
||||
call-bound: 1.0.4
|
||||
has-tostringtag: 1.0.2
|
||||
|
||||
is-buffer@1.1.6: {}
|
||||
|
||||
is-bun-module@2.0.0:
|
||||
dependencies:
|
||||
semver: 7.7.2
|
||||
@ -5653,6 +5682,12 @@ snapshots:
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
|
||||
md5@2.3.0:
|
||||
dependencies:
|
||||
charenc: 0.0.2
|
||||
crypt: 0.0.2
|
||||
is-buffer: 1.1.6
|
||||
|
||||
mdast-util-from-markdown@2.0.2:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
|
3
web/src/app/console/comment/page.tsx
Normal file
3
web/src/app/console/comment/page.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export default function Page() {
|
||||
return <div>评论管理</div>;
|
||||
}
|
@ -1,614 +0,0 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"header": "Cover page",
|
||||
"type": "Cover page",
|
||||
"status": "In Process",
|
||||
"target": "18",
|
||||
"limit": "5",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"header": "Table of contents",
|
||||
"type": "Table of contents",
|
||||
"status": "Done",
|
||||
"target": "29",
|
||||
"limit": "24",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"header": "Executive summary",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "10",
|
||||
"limit": "13",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"header": "Technical approach",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "27",
|
||||
"limit": "23",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"header": "Design",
|
||||
"type": "Narrative",
|
||||
"status": "In Process",
|
||||
"target": "2",
|
||||
"limit": "16",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"header": "Capabilities",
|
||||
"type": "Narrative",
|
||||
"status": "In Process",
|
||||
"target": "20",
|
||||
"limit": "8",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"header": "Integration with existing systems",
|
||||
"type": "Narrative",
|
||||
"status": "In Process",
|
||||
"target": "19",
|
||||
"limit": "21",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"header": "Innovation and Advantages",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "25",
|
||||
"limit": "26",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"header": "Overview of EMR's Innovative Solutions",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "7",
|
||||
"limit": "23",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"header": "Advanced Algorithms and Machine Learning",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "30",
|
||||
"limit": "28",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"header": "Adaptive Communication Protocols",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "9",
|
||||
"limit": "31",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"header": "Advantages Over Current Technologies",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "12",
|
||||
"limit": "0",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"header": "Past Performance",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "22",
|
||||
"limit": "33",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"header": "Customer Feedback and Satisfaction Levels",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "15",
|
||||
"limit": "34",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"header": "Implementation Challenges and Solutions",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "3",
|
||||
"limit": "35",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"header": "Security Measures and Data Protection Policies",
|
||||
"type": "Narrative",
|
||||
"status": "In Process",
|
||||
"target": "6",
|
||||
"limit": "36",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"header": "Scalability and Future Proofing",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "4",
|
||||
"limit": "37",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"header": "Cost-Benefit Analysis",
|
||||
"type": "Plain language",
|
||||
"status": "Done",
|
||||
"target": "14",
|
||||
"limit": "38",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"header": "User Training and Onboarding Experience",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "17",
|
||||
"limit": "39",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"header": "Future Development Roadmap",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "11",
|
||||
"limit": "40",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 21,
|
||||
"header": "System Architecture Overview",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "24",
|
||||
"limit": "18",
|
||||
"reviewer": "Maya Johnson"
|
||||
},
|
||||
{
|
||||
"id": 22,
|
||||
"header": "Risk Management Plan",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "15",
|
||||
"limit": "22",
|
||||
"reviewer": "Carlos Rodriguez"
|
||||
},
|
||||
{
|
||||
"id": 23,
|
||||
"header": "Compliance Documentation",
|
||||
"type": "Legal",
|
||||
"status": "In Process",
|
||||
"target": "31",
|
||||
"limit": "27",
|
||||
"reviewer": "Sarah Chen"
|
||||
},
|
||||
{
|
||||
"id": 24,
|
||||
"header": "API Documentation",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "8",
|
||||
"limit": "12",
|
||||
"reviewer": "Raj Patel"
|
||||
},
|
||||
{
|
||||
"id": 25,
|
||||
"header": "User Interface Mockups",
|
||||
"type": "Visual",
|
||||
"status": "In Process",
|
||||
"target": "19",
|
||||
"limit": "25",
|
||||
"reviewer": "Leila Ahmadi"
|
||||
},
|
||||
{
|
||||
"id": 26,
|
||||
"header": "Database Schema",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "22",
|
||||
"limit": "20",
|
||||
"reviewer": "Thomas Wilson"
|
||||
},
|
||||
{
|
||||
"id": 27,
|
||||
"header": "Testing Methodology",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "17",
|
||||
"limit": "14",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 28,
|
||||
"header": "Deployment Strategy",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "26",
|
||||
"limit": "30",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 29,
|
||||
"header": "Budget Breakdown",
|
||||
"type": "Financial",
|
||||
"status": "In Process",
|
||||
"target": "13",
|
||||
"limit": "16",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 30,
|
||||
"header": "Market Analysis",
|
||||
"type": "Research",
|
||||
"status": "Done",
|
||||
"target": "29",
|
||||
"limit": "32",
|
||||
"reviewer": "Sophia Martinez"
|
||||
},
|
||||
{
|
||||
"id": 31,
|
||||
"header": "Competitor Comparison",
|
||||
"type": "Research",
|
||||
"status": "In Process",
|
||||
"target": "21",
|
||||
"limit": "19",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 32,
|
||||
"header": "Maintenance Plan",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "16",
|
||||
"limit": "23",
|
||||
"reviewer": "Alex Thompson"
|
||||
},
|
||||
{
|
||||
"id": 33,
|
||||
"header": "User Personas",
|
||||
"type": "Research",
|
||||
"status": "In Process",
|
||||
"target": "27",
|
||||
"limit": "24",
|
||||
"reviewer": "Nina Patel"
|
||||
},
|
||||
{
|
||||
"id": 34,
|
||||
"header": "Accessibility Compliance",
|
||||
"type": "Legal",
|
||||
"status": "Done",
|
||||
"target": "18",
|
||||
"limit": "21",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 35,
|
||||
"header": "Performance Metrics",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "23",
|
||||
"limit": "26",
|
||||
"reviewer": "David Kim"
|
||||
},
|
||||
{
|
||||
"id": 36,
|
||||
"header": "Disaster Recovery Plan",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "14",
|
||||
"limit": "17",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 37,
|
||||
"header": "Third-party Integrations",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "25",
|
||||
"limit": "28",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 38,
|
||||
"header": "User Feedback Summary",
|
||||
"type": "Research",
|
||||
"status": "Done",
|
||||
"target": "20",
|
||||
"limit": "15",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 39,
|
||||
"header": "Localization Strategy",
|
||||
"type": "Narrative",
|
||||
"status": "In Process",
|
||||
"target": "12",
|
||||
"limit": "19",
|
||||
"reviewer": "Maria Garcia"
|
||||
},
|
||||
{
|
||||
"id": 40,
|
||||
"header": "Mobile Compatibility",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "28",
|
||||
"limit": "31",
|
||||
"reviewer": "James Wilson"
|
||||
},
|
||||
{
|
||||
"id": 41,
|
||||
"header": "Data Migration Plan",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "19",
|
||||
"limit": "22",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 42,
|
||||
"header": "Quality Assurance Protocols",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "30",
|
||||
"limit": "33",
|
||||
"reviewer": "Priya Singh"
|
||||
},
|
||||
{
|
||||
"id": 43,
|
||||
"header": "Stakeholder Analysis",
|
||||
"type": "Research",
|
||||
"status": "In Process",
|
||||
"target": "11",
|
||||
"limit": "14",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 44,
|
||||
"header": "Environmental Impact Assessment",
|
||||
"type": "Research",
|
||||
"status": "Done",
|
||||
"target": "24",
|
||||
"limit": "27",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 45,
|
||||
"header": "Intellectual Property Rights",
|
||||
"type": "Legal",
|
||||
"status": "In Process",
|
||||
"target": "17",
|
||||
"limit": "20",
|
||||
"reviewer": "Sarah Johnson"
|
||||
},
|
||||
{
|
||||
"id": 46,
|
||||
"header": "Customer Support Framework",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "22",
|
||||
"limit": "25",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 47,
|
||||
"header": "Version Control Strategy",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "15",
|
||||
"limit": "18",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 48,
|
||||
"header": "Continuous Integration Pipeline",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "26",
|
||||
"limit": "29",
|
||||
"reviewer": "Michael Chen"
|
||||
},
|
||||
{
|
||||
"id": 49,
|
||||
"header": "Regulatory Compliance",
|
||||
"type": "Legal",
|
||||
"status": "In Process",
|
||||
"target": "13",
|
||||
"limit": "16",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 50,
|
||||
"header": "User Authentication System",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "28",
|
||||
"limit": "31",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 51,
|
||||
"header": "Data Analytics Framework",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "21",
|
||||
"limit": "24",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 52,
|
||||
"header": "Cloud Infrastructure",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "16",
|
||||
"limit": "19",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 53,
|
||||
"header": "Network Security Measures",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "29",
|
||||
"limit": "32",
|
||||
"reviewer": "Lisa Wong"
|
||||
},
|
||||
{
|
||||
"id": 54,
|
||||
"header": "Project Timeline",
|
||||
"type": "Planning",
|
||||
"status": "Done",
|
||||
"target": "14",
|
||||
"limit": "17",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 55,
|
||||
"header": "Resource Allocation",
|
||||
"type": "Planning",
|
||||
"status": "In Process",
|
||||
"target": "27",
|
||||
"limit": "30",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 56,
|
||||
"header": "Team Structure and Roles",
|
||||
"type": "Planning",
|
||||
"status": "Done",
|
||||
"target": "20",
|
||||
"limit": "23",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 57,
|
||||
"header": "Communication Protocols",
|
||||
"type": "Planning",
|
||||
"status": "In Process",
|
||||
"target": "15",
|
||||
"limit": "18",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 58,
|
||||
"header": "Success Metrics",
|
||||
"type": "Planning",
|
||||
"status": "Done",
|
||||
"target": "30",
|
||||
"limit": "33",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 59,
|
||||
"header": "Internationalization Support",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "23",
|
||||
"limit": "26",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 60,
|
||||
"header": "Backup and Recovery Procedures",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "18",
|
||||
"limit": "21",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 61,
|
||||
"header": "Monitoring and Alerting System",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "25",
|
||||
"limit": "28",
|
||||
"reviewer": "Daniel Park"
|
||||
},
|
||||
{
|
||||
"id": 62,
|
||||
"header": "Code Review Guidelines",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "12",
|
||||
"limit": "15",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 63,
|
||||
"header": "Documentation Standards",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "27",
|
||||
"limit": "30",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 64,
|
||||
"header": "Release Management Process",
|
||||
"type": "Planning",
|
||||
"status": "Done",
|
||||
"target": "22",
|
||||
"limit": "25",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 65,
|
||||
"header": "Feature Prioritization Matrix",
|
||||
"type": "Planning",
|
||||
"status": "In Process",
|
||||
"target": "19",
|
||||
"limit": "22",
|
||||
"reviewer": "Emma Davis"
|
||||
},
|
||||
{
|
||||
"id": 66,
|
||||
"header": "Technical Debt Assessment",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "24",
|
||||
"limit": "27",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 67,
|
||||
"header": "Capacity Planning",
|
||||
"type": "Planning",
|
||||
"status": "In Process",
|
||||
"target": "21",
|
||||
"limit": "24",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 68,
|
||||
"header": "Service Level Agreements",
|
||||
"type": "Legal",
|
||||
"status": "Done",
|
||||
"target": "26",
|
||||
"limit": "29",
|
||||
"reviewer": "Assign reviewer"
|
||||
}
|
||||
]
|
3
web/src/app/console/file/page.tsx
Normal file
3
web/src/app/console/file/page.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export default function Page() {
|
||||
return <div>文件管理</div>;
|
||||
}
|
@ -1,11 +1,50 @@
|
||||
export default function RootLayout({
|
||||
"use client"
|
||||
import { AppSidebar } from "@/components/console/app-sidebar"
|
||||
import { SiteHeader } from "@/components/console/site-header"
|
||||
import {
|
||||
SidebarInset,
|
||||
SidebarProvider,
|
||||
} from "@/components/ui/sidebar"
|
||||
|
||||
import { useToLogin } from "@/hooks/use-route"
|
||||
import { useEffect, useState } from "react"
|
||||
import { User } from "@/models/user"
|
||||
import { getLoginUser } from "@/api/user"
|
||||
|
||||
export default function ConsoleLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const toLogin = useToLogin();
|
||||
|
||||
useEffect(() => {
|
||||
getLoginUser().then(res => {
|
||||
setUser(res.data);
|
||||
}).catch(() => {
|
||||
setUser(null);
|
||||
toLogin();
|
||||
});
|
||||
}, [toLogin]);
|
||||
if (user === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
</>
|
||||
<SidebarProvider
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": "calc(var(--spacing) * 72)",
|
||||
"--header-height": "calc(var(--spacing) * 12)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<AppSidebar variant="inset" />
|
||||
<SidebarInset>
|
||||
<SiteHeader />
|
||||
{children}
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
|
@ -1,45 +1,3 @@
|
||||
"use client"
|
||||
import { AppSidebar } from "@/components/console/app-sidebar"
|
||||
import { SiteHeader } from "@/components/console/site-header"
|
||||
import {
|
||||
SidebarInset,
|
||||
SidebarProvider,
|
||||
} from "@/components/ui/sidebar"
|
||||
|
||||
import { useToLogin } from "@/hooks/use-route"
|
||||
import { useEffect, useState } from "react"
|
||||
import { User } from "@/models/user"
|
||||
import { getLoginUser } from "@/api/user"
|
||||
|
||||
export default function Page() {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const toLogin = useToLogin();
|
||||
|
||||
useEffect(() => {
|
||||
getLoginUser().then(res => {
|
||||
setUser(res.data);
|
||||
}).catch(() => {
|
||||
setUser(null);
|
||||
toLogin();
|
||||
});
|
||||
}, []);
|
||||
if (user === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarProvider
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": "calc(var(--spacing) * 72)",
|
||||
"--header-height": "calc(var(--spacing) * 12)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<AppSidebar variant="inset" />
|
||||
<SidebarInset>
|
||||
<SiteHeader />
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
return <div>Console</div>;
|
||||
}
|
3
web/src/app/console/post/page.tsx
Normal file
3
web/src/app/console/post/page.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export default function Page() {
|
||||
return <div>文章管理</div>;
|
||||
}
|
3
web/src/app/console/user/page.tsx
Normal file
3
web/src/app/console/user/page.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export default function Page() {
|
||||
return <div>用户管理</div>;
|
||||
}
|
@ -38,7 +38,7 @@ export default function BlogHome() {
|
||||
const [sortBy, setSortBy, isSortByLoaded] = useStoredState<SortBy>(QueryKey.SortBy, DEFAULT_SORTBY);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSortByLoaded) return; // wait for stored state loaded
|
||||
if (!isSortByLoaded) return;
|
||||
setLoading(true);
|
||||
listPosts(
|
||||
{
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Heart, TrendingUp, Eye } from "lucide-react";
|
||||
import GravatarAvatar from "@/components/common/gravatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { Label } from "@/models/label";
|
||||
import type { Post } from "@/models/post";
|
||||
@ -9,6 +8,9 @@ import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { getPostHref } from "@/utils/common/post";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { getGravatarUrl } from "@/utils/common/gravatar";
|
||||
import { getFallbackAvatarFromUsername } from "@/utils/common/username";
|
||||
|
||||
// 侧边栏父组件,接收卡片组件列表
|
||||
export default function Sidebar({ cards }: { cards: React.ReactNode[] }) {
|
||||
@ -34,7 +36,10 @@ export function SidebarAbout({ config }: { config: typeof configType }) {
|
||||
<CardContent>
|
||||
<div className="text-center mb-4">
|
||||
<div className="w-20 h-20 mx-auto bg-gradient-to-br from-blue-400 to-purple-500 rounded-full flex items-center justify-center text-white text-2xl font-bold overflow-hidden">
|
||||
<GravatarAvatar email={config.owner.gravatarEmail} className="w-full h-full object-cover" size={200} />
|
||||
<Avatar className="h-full w-full rounded-full">
|
||||
<AvatarImage src={getGravatarUrl({email: config.owner.gravatarEmail, size: 256})} alt={config.owner.name} />
|
||||
<AvatarFallback className="rounded-full">{getFallbackAvatarFromUsername(config.owner.name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
<h3 className="font-semibold text-lg">{config.owner.name}</h3>
|
||||
<p className="text-sm text-slate-600">{config.owner.motto}</p>
|
||||
@ -66,23 +71,23 @@ export function SidebarHotPosts({ posts, sortType }: { posts: Post[], sortType:
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-medium text-sm line-clamp-2 mb-1">
|
||||
{post.title}
|
||||
</h4>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<Eye className="w-3 h-3" />
|
||||
{post.viewCount}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Heart className="w-3 h-3" />
|
||||
{post.likeCount}
|
||||
</span>
|
||||
{post.title}
|
||||
</h4>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<Eye className="w-3 h-3" />
|
||||
{post.viewCount}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Heart className="w-3 h-3" />
|
||||
{post.likeCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</Link>
|
||||
|
||||
))}
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
@ -3,11 +3,13 @@ import { User } from "@/models/user";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import GravatarAvatar from "@/components/common/gravatar";
|
||||
import { CircleUser } from "lucide-react";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { getGravatarUrl } from "@/utils/common/gravatar";
|
||||
import { getFirstCharFromUser } from "@/utils/common/username";
|
||||
|
||||
|
||||
export function CommentInput(
|
||||
@ -65,7 +67,10 @@ export function CommentInput(
|
||||
<div className="fade-in-up">
|
||||
<div className="flex py-4 fade-in">
|
||||
<div onClick={user ? () => clickToUserProfile(user.username) : clickToLogin} className="cursor-pointer flex-shrink-0 w-10 h-10 fade-in">
|
||||
{user && <GravatarAvatar className="w-full h-full" url={user.avatarUrl} email={user.email} size={100} />}
|
||||
{user && <Avatar className="h-full w-full rounded-full">
|
||||
<AvatarImage src={getGravatarUrl({ email: user.email, size: 120 })} alt={user.nickname} />
|
||||
<AvatarFallback className="rounded-full">{getFirstCharFromUser(user)}</AvatarFallback>
|
||||
</Avatar>}
|
||||
{!user && <CircleUser className="w-full h-full fade-in" />}
|
||||
</div>
|
||||
<div className="flex-1 pl-2 fade-in-up">
|
||||
|
@ -3,7 +3,6 @@ import { User } from "@/models/user";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import GravatarAvatar, { getGravatarByUser } from "@/components/common/gravatar";
|
||||
import { Reply, Trash, Heart, Pencil, Lock } from "lucide-react";
|
||||
import { Comment } from "@/models/comment";
|
||||
import { TargetType } from "@/models/types";
|
||||
@ -13,6 +12,9 @@ import { CommentInput } from "./comment-input";
|
||||
import { createComment, deleteComment, getComment, listComments, updateComment } from "@/api/comment";
|
||||
import { OrderBy } from "@/models/common";
|
||||
import { formatDateTime } from "@/utils/common/datetime";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { getGravatarUrl } from "@/utils/common/gravatar";
|
||||
import { getFirstCharFromUser } from "@/utils/common/username";
|
||||
|
||||
|
||||
export function CommentItem(
|
||||
@ -157,7 +159,10 @@ export function CommentItem(
|
||||
<div>
|
||||
<div className="flex">
|
||||
<div onClick={() => clickToUserProfile(commentState.user.username)} className="cursor-pointer fade-in w-12 h-12">
|
||||
<GravatarAvatar className="w-full h-full" url={commentState.user.avatarUrl} email={commentState.user.email} size={100} />
|
||||
<Avatar className="h-full w-full rounded-full">
|
||||
<AvatarImage src={getGravatarUrl({email: commentState.user.email, size: 120})} alt={commentState.user.nickname} />
|
||||
<AvatarFallback className="rounded-full">{getFirstCharFromUser(commentState.user)}</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
<div className="flex-1 pl-2 fade-in-up">
|
||||
<div className="flex gap-2 md:gap-4 items-center">
|
||||
|
@ -59,7 +59,7 @@ export function CommentSection(
|
||||
}).then(response => {
|
||||
setComments(response.data.comments);
|
||||
});
|
||||
}, [])
|
||||
}, [page, targetId, targetType]);
|
||||
|
||||
const onCommentSubmitted = ({ commentContent, isPrivate, showClientInfo }: { commentContent: string, isPrivate: boolean, showClientInfo: boolean }) => {
|
||||
createComment({
|
||||
|
@ -3,7 +3,6 @@
|
||||
import { useEffect } from "react";
|
||||
import { GoogleReCaptcha, GoogleReCaptchaProvider } from "react-google-recaptcha-v3";
|
||||
import HCaptcha from "@hcaptcha/react-hcaptcha";
|
||||
import { Turnstile } from "@marsidev/react-turnstile";
|
||||
import { CaptchaProvider } from "@/models/captcha";
|
||||
import "./captcha.css";
|
||||
import { TurnstileWidget } from "./turnstile";
|
||||
|
@ -52,13 +52,13 @@ export function TurnstileWidget(props: CaptchaProps) {
|
||||
|
||||
const handleSuccess = (token: string) => {
|
||||
setStatus('success');
|
||||
props.onSuccess(token);
|
||||
return props.onSuccess && props.onSuccess(token);
|
||||
};
|
||||
|
||||
const handleError = (error: string) => {
|
||||
setStatus('error');
|
||||
setError(error);
|
||||
props.onError && props.onError(error);
|
||||
return props.onError && props.onError(error);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@ -66,11 +66,11 @@ export function TurnstileWidget(props: CaptchaProps) {
|
||||
if (status === 'loading') {
|
||||
setStatus('error');
|
||||
setError('timeout');
|
||||
props.onError && props.onError('timeout');
|
||||
return props.onError && props.onError('timeout');
|
||||
}
|
||||
}, TURNSTILE_TIMEOUT * 1000);
|
||||
return () => clearTimeout(timer);
|
||||
})
|
||||
}, [status, props]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-evenly w-full border border-gray-300 rounded-md px-4 py-2 relative">
|
||||
|
@ -1,70 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import Image from "next/image";
|
||||
import crypto from "crypto";
|
||||
|
||||
// 生成 Gravatar URL 的函数
|
||||
function getGravatarUrl(email: string, size: number = 200, defaultType: string = "identicon"): string {
|
||||
const hash = crypto.createHash('md5').update(email.toLowerCase().trim()).digest('hex');
|
||||
return `https://www.gravatar.com/avatar/${hash}?s=${size}&d=${defaultType}`;
|
||||
}
|
||||
|
||||
interface GravatarAvatarProps {
|
||||
email: string;
|
||||
size?: number;
|
||||
className?: string;
|
||||
alt?: string;
|
||||
url?: string;
|
||||
defaultType?: 'mm' | 'identicon' | 'monsterid' | 'wavatar' | 'retro' | 'robohash' | 'blank';
|
||||
}
|
||||
|
||||
const GravatarAvatar: React.FC<GravatarAvatarProps> = ({
|
||||
email,
|
||||
size = 200,
|
||||
className = "",
|
||||
alt = "avatar",
|
||||
url,
|
||||
defaultType = "identicon"
|
||||
}) => {
|
||||
// 把尺寸控制交给父组件的 wrapper(父组件通过 tailwind 的 w-.. h-.. 控制)
|
||||
const gravatarUrl = url && url.trim() !== "" ? url : getGravatarUrl(email, size , defaultType);
|
||||
|
||||
return (
|
||||
<div className={`relative overflow-hidden ${className}`}>
|
||||
<Image
|
||||
src={gravatarUrl}
|
||||
alt={alt}
|
||||
fill
|
||||
sizes="(max-width: 640px) 64px, 200px"
|
||||
className="rounded-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 用户类型定义(如果还没有的话)
|
||||
interface User {
|
||||
email?: string;
|
||||
displayName?: string;
|
||||
name?: string;
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
export function getGravatarByUser({user, className="", size=640}:{user?: User, className?: string, size?: number}): React.ReactElement {
|
||||
if (!user) {
|
||||
return <GravatarAvatar email="" className={className} />;
|
||||
}
|
||||
return (
|
||||
<GravatarAvatar
|
||||
email={user.email || ""}
|
||||
size={size}
|
||||
className={className}
|
||||
alt={user.displayName || user.name || "User Avatar"}
|
||||
url={user.avatarUrl}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default GravatarAvatar;
|
@ -1,7 +0,0 @@
|
||||
export default function ImagePlaceholder() {
|
||||
return (
|
||||
<div className="w-10 h-10 bg-gray-200 flex items-center justify-center rounded-full">
|
||||
<img src="/file.svg" alt="Image Placeholder" className="w-6 h-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
@ -46,7 +46,7 @@ export default function CodeBlock(props: React.ComponentPropsWithoutRef<"pre">)
|
||||
codeContent = extractText(child.props.children);
|
||||
}
|
||||
|
||||
async function handleCopy(e: React.MouseEvent<HTMLButtonElement>) {
|
||||
async function handleCopy() {
|
||||
try {
|
||||
const ok = await copyToClipboard(codeContent);
|
||||
if (ok) toast.success(t("copy_success"));
|
||||
|
@ -21,15 +21,10 @@ export function PaginationController({
|
||||
buttons?: number
|
||||
onPageChange?: (page: number) => void
|
||||
} & React.HTMLAttributes<HTMLDivElement>) {
|
||||
// normalize buttons
|
||||
const btns = Math.max(5, buttons ?? 7);
|
||||
const buttonsToShow = totalPages < btns ? totalPages : btns;
|
||||
// rely on shadcn buttonVariants and PaginationLink's isActive prop for styling
|
||||
|
||||
const [currentPage, setCurrentPage] = useState(() => Math.min(Math.max(1, initialPage ?? 1), Math.max(1, totalPages)));
|
||||
const [direction, setDirection] = useState(0) // 1 = forward (right->left), -1 = backward
|
||||
|
||||
// sync when initialPage or totalPages props change
|
||||
useEffect(() => {
|
||||
const p = Math.min(Math.max(1, initialPage ?? 1), Math.max(1, totalPages));
|
||||
setCurrentPage(p);
|
||||
@ -37,10 +32,9 @@ export function PaginationController({
|
||||
|
||||
const handleSetPage = useCallback((p: number) => {
|
||||
const next = Math.min(Math.max(1, Math.floor(p)), Math.max(1, totalPages));
|
||||
setDirection(next > currentPage ? 1 : next < currentPage ? -1 : 0);
|
||||
setCurrentPage(next);
|
||||
if (typeof onPageChange === 'function') onPageChange(next);
|
||||
}, [onPageChange, totalPages, currentPage]);
|
||||
}, [onPageChange, totalPages]);
|
||||
|
||||
// helper to render page link
|
||||
const renderPage = (pageNum: number) => (
|
||||
|
@ -1,29 +1,16 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useEffect, useState } from "react"
|
||||
import {
|
||||
IconCamera,
|
||||
IconChartBar,
|
||||
IconDashboard,
|
||||
IconDatabase,
|
||||
IconFileAi,
|
||||
IconFileDescription,
|
||||
IconFileWord,
|
||||
IconFolder,
|
||||
IconHelp,
|
||||
IconInnerShadowTop,
|
||||
IconListDetails,
|
||||
IconReport,
|
||||
IconSearch,
|
||||
IconSettings,
|
||||
IconUsers,
|
||||
} from "@tabler/icons-react"
|
||||
|
||||
import { NavDocuments } from "@/components/console/nav-documents"
|
||||
import { NavMain } from "@/components/console/nav-main"
|
||||
import { NavSecondary } from "@/components/console/nav-secondary"
|
||||
import { NavUser } from "@/components/console/nav-user"
|
||||
import { metadata } from '../../app/layout';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@ -34,125 +21,53 @@ import {
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar"
|
||||
import config from "@/config"
|
||||
import Link from "next/link"
|
||||
import { getLoginUser } from "@/api/user"
|
||||
import { User } from "@/models/user"
|
||||
|
||||
const data = {
|
||||
user: {
|
||||
name: "shadcn",
|
||||
email: "m@example.com",
|
||||
avatar: "/avatars/shadcn.jpg",
|
||||
},
|
||||
navMain: [
|
||||
{
|
||||
title: "Dashboard",
|
||||
url: "#",
|
||||
title: "大石坝",
|
||||
url: "/console",
|
||||
icon: IconDashboard,
|
||||
},
|
||||
{
|
||||
title: "Lifecycle",
|
||||
url: "#",
|
||||
title: "文章管理",
|
||||
url: "/console/post",
|
||||
icon: IconListDetails,
|
||||
},
|
||||
{
|
||||
title: "Analytics",
|
||||
url: "#",
|
||||
title: "评论管理",
|
||||
url: "/console/comment",
|
||||
icon: IconChartBar,
|
||||
},
|
||||
{
|
||||
title: "Projects",
|
||||
url: "#",
|
||||
title: "文件管理",
|
||||
url: "/console/file",
|
||||
icon: IconFolder,
|
||||
},
|
||||
{
|
||||
title: "Team",
|
||||
url: "#",
|
||||
title: "用户管理",
|
||||
url: "/console/user",
|
||||
icon: IconUsers,
|
||||
},
|
||||
],
|
||||
navClouds: [
|
||||
{
|
||||
title: "Capture",
|
||||
icon: IconCamera,
|
||||
isActive: true,
|
||||
url: "#",
|
||||
items: [
|
||||
{
|
||||
title: "Active Proposals",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Archived",
|
||||
url: "#",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Proposal",
|
||||
icon: IconFileDescription,
|
||||
url: "#",
|
||||
items: [
|
||||
{
|
||||
title: "Active Proposals",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Archived",
|
||||
url: "#",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Prompts",
|
||||
icon: IconFileAi,
|
||||
url: "#",
|
||||
items: [
|
||||
{
|
||||
title: "Active Proposals",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Archived",
|
||||
url: "#",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
navSecondary: [
|
||||
{
|
||||
title: "Settings",
|
||||
url: "#",
|
||||
icon: IconSettings,
|
||||
},
|
||||
{
|
||||
title: "Get Help",
|
||||
url: "#",
|
||||
icon: IconHelp,
|
||||
},
|
||||
{
|
||||
title: "Search",
|
||||
url: "#",
|
||||
icon: IconSearch,
|
||||
},
|
||||
],
|
||||
documents: [
|
||||
{
|
||||
name: "Data Library",
|
||||
url: "#",
|
||||
icon: IconDatabase,
|
||||
},
|
||||
{
|
||||
name: "Reports",
|
||||
url: "#",
|
||||
icon: IconReport,
|
||||
},
|
||||
{
|
||||
name: "Word Assistant",
|
||||
url: "#",
|
||||
icon: IconFileWord,
|
||||
},
|
||||
],
|
||||
]
|
||||
}
|
||||
|
||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
const [loginUser, setLoginUser] = useState<User | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getLoginUser().then(resp => {
|
||||
setLoginUser(resp.data);
|
||||
});
|
||||
}, [])
|
||||
|
||||
if (!loginUser) {
|
||||
return null; // 或者返回一个加载指示器
|
||||
}
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="offcanvas" {...props}>
|
||||
<SidebarHeader>
|
||||
@ -162,21 +77,19 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
asChild
|
||||
className="data-[slot=sidebar-menu-button]:!p-1.5"
|
||||
>
|
||||
<a href="#">
|
||||
<Link href="/">
|
||||
<IconInnerShadowTop className="!size-5" />
|
||||
<span className="text-base font-semibold">{config.metadata.name}</span>
|
||||
</a>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<NavMain items={data.navMain} />
|
||||
<NavDocuments items={data.documents} />
|
||||
<NavSecondary items={data.navSecondary} className="mt-auto" />
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<NavUser user={data.user} />
|
||||
<NavUser user={loginUser} />
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
)
|
||||
|
@ -1,8 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { IconCirclePlusFilled, IconMail, type Icon } from "@tabler/icons-react"
|
||||
import { type Icon } from "@tabler/icons-react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
@ -10,6 +9,7 @@ import {
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar"
|
||||
import Link from "next/link"
|
||||
|
||||
export function NavMain({
|
||||
items,
|
||||
@ -26,10 +26,12 @@ export function NavMain({
|
||||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton tooltip={item.title}>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
</SidebarMenuButton>
|
||||
<Link href={item.url}>
|
||||
<SidebarMenuButton tooltip={item.title}>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
</SidebarMenuButton>
|
||||
</Link>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
|
@ -28,15 +28,14 @@ import {
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar"
|
||||
import { User } from "@/models/user"
|
||||
import { getGravatarFromUser } from "@/utils/common/gravatar"
|
||||
import { getFallbackAvatarFromUsername } from "@/utils/common/username"
|
||||
|
||||
export function NavUser({
|
||||
user,
|
||||
}: {
|
||||
user: {
|
||||
name: string
|
||||
email: string
|
||||
avatar: string
|
||||
}
|
||||
user: User
|
||||
}) {
|
||||
const { isMobile } = useSidebar()
|
||||
|
||||
@ -49,12 +48,12 @@ export function NavUser({
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<Avatar className="h-8 w-8 rounded-lg grayscale">
|
||||
<AvatarImage src={user.avatar} alt={user.name} />
|
||||
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
|
||||
<Avatar className="h-8 w-8 rounded-full">
|
||||
<AvatarImage src={getGravatarFromUser({ user })} alt={user.username} />
|
||||
<AvatarFallback className="rounded-full">{getFallbackAvatarFromUsername(user.nickname || user.username)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{user.name}</span>
|
||||
<span className="truncate font-medium">{user.nickname}({user.username})</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{user.email}
|
||||
</span>
|
||||
@ -70,12 +69,12 @@ export function NavUser({
|
||||
>
|
||||
<DropdownMenuLabel className="p-0 font-normal">
|
||||
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<Avatar className="h-8 w-8 rounded-lg">
|
||||
<AvatarImage src={user.avatar} alt={user.name} />
|
||||
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
|
||||
<Avatar className="h-8 w-8 rounded-full">
|
||||
<AvatarImage src={getGravatarFromUser({ user })} alt={user.username} />
|
||||
<AvatarFallback className="rounded-full">{getFallbackAvatarFromUsername(user.nickname || user.username)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{user.name}</span>
|
||||
<span className="truncate font-medium">{user.nickname}({user.username})</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{user.email}
|
||||
</span>
|
||||
|
@ -5,15 +5,9 @@ import {
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import GravatarAvatar from "../common/gravatar"
|
||||
import { User } from "@/models/user";
|
||||
import { useEffect, useState } from "react";
|
||||
import { getLoginUser, userLogout } from "@/api/user";
|
||||
@ -21,6 +15,9 @@ import Link from "next/link";
|
||||
import { toast } from "sonner";
|
||||
import { useToLogin } from "@/hooks/use-route";
|
||||
import { CircleUser } from "lucide-react";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { getGravatarFromUser } from "@/utils/common/gravatar";
|
||||
import { getFallbackAvatarFromUsername } from "@/utils/common/username";
|
||||
|
||||
export function AvatarWithDropdownMenu() {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
@ -44,7 +41,10 @@ export function AvatarWithDropdownMenu() {
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="rounded-full overflow-hidden">
|
||||
{user ? <GravatarAvatar className="w-8 h-8" email={user?.email || ""} url={user?.avatarUrl || ""} /> : <CircleUser className="w-9 h-9" />}
|
||||
{user ? <Avatar className="h-8 w-8 rounded-full">
|
||||
<AvatarImage src={getGravatarFromUser({ user })} alt={user.username} />
|
||||
<AvatarFallback className="rounded-full">{getFallbackAvatarFromUsername(user.nickname || user.username)}</AvatarFallback>
|
||||
</Avatar> : <CircleUser className="w-9 h-9" />}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56" align="start">
|
||||
@ -58,29 +58,6 @@ export function AvatarWithDropdownMenu() {
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>Team</DropdownMenuItem>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>Invite users</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem>Email</DropdownMenuItem>
|
||||
<DropdownMenuItem>Message</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>More...</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuItem>
|
||||
New Team
|
||||
<DropdownMenuShortcut>⌘+T</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>GitHub</DropdownMenuItem>
|
||||
<DropdownMenuItem>Support</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled>API</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={user ? handleLogout : toLogin}>
|
||||
{user ? `Logout (${user.username})` : "Login"}
|
||||
</DropdownMenuItem>
|
||||
|
@ -46,7 +46,7 @@ const navbarMenuComponents = [
|
||||
]
|
||||
|
||||
export function Navbar() {
|
||||
const { navbarAdditionalClassName, setMode, mode } = useDevice()
|
||||
const { navbarAdditionalClassName} = useDevice()
|
||||
return (
|
||||
<nav className={`grid grid-cols-[1fr_auto_1fr] items-center gap-4 h-full px-4 w-full ${navbarAdditionalClassName}`}>
|
||||
<div className="flex items-center justify-start">
|
||||
|
@ -34,7 +34,6 @@ export function LoginForm({
|
||||
url?: string
|
||||
} | null>(null)
|
||||
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
|
||||
const [captchaError, setCaptchaError] = useState<string | null>(null)
|
||||
const [isLogging, setIsLogging] = useState(false)
|
||||
const [refreshCaptchaKey, setRefreshCaptchaKey] = useState(0)
|
||||
const [{ username, password }, setCredentials] = useState({ username: '', password: '' })
|
||||
@ -51,7 +50,7 @@ export function LoginForm({
|
||||
toast.error(t("fetch_oidc_configs_failed") + (error?.message ? `: ${error.message}` : ""))
|
||||
setOidcConfigs([]) // 错误时设置为空数组
|
||||
})
|
||||
}, [])
|
||||
}, [t])
|
||||
|
||||
useEffect(() => {
|
||||
getCaptchaConfig()
|
||||
@ -62,7 +61,7 @@ export function LoginForm({
|
||||
toast.error(t("fetch_captcha_config_failed") + (error?.message ? `: ${error.message}` : ""))
|
||||
setCaptchaProps(null)
|
||||
})
|
||||
}, [refreshCaptchaKey])
|
||||
}, [refreshCaptchaKey, t])
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
setIsLogging(true)
|
||||
@ -84,8 +83,7 @@ export function LoginForm({
|
||||
}
|
||||
|
||||
const handleCaptchaError = (error: string) => {
|
||||
setCaptchaError(error);
|
||||
// 刷新验证码
|
||||
toast.error(t("captcha_error") + (error ? `: ${error}` : ""));
|
||||
setTimeout(() => {
|
||||
setRefreshCaptchaKey(k => k + 1);
|
||||
}, 1500);
|
||||
|
@ -1,7 +1,9 @@
|
||||
"use client"
|
||||
import { User } from "@/models/user";
|
||||
import GravatarAvatar from "@/components/common/gravatar";
|
||||
import { Mail, User as UserIcon, Shield } from 'lucide-react';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
|
||||
import { getGravatarUrl } from "@/utils/common/gravatar";
|
||||
import { getFirstCharFromUser } from "@/utils/common/username";
|
||||
|
||||
export function UserHeader({ user }: { user: User }) {
|
||||
return (
|
||||
@ -10,7 +12,10 @@ export function UserHeader({ user }: { user: User }) {
|
||||
<div className="md:basis-[20%] flex justify-center items-center p-4">
|
||||
{/* wrapper 控制显示大小,父组件给具体 w/h */}
|
||||
<div className="w-40 h-40 md:w-48 md:h-48 relative">
|
||||
<GravatarAvatar className="rounded-full w-full h-full" url={user.avatarUrl} email={user.email} size={200} />
|
||||
<Avatar className="h-full w-full rounded-full">
|
||||
<AvatarImage src={getGravatarUrl({ email: user.email, size: 120 })} alt={user.nickname} />
|
||||
<AvatarFallback className="rounded-full">{getFirstCharFromUser(user)}</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -88,7 +88,7 @@ export const DeviceProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
||||
|
||||
// 监听系统主题变动
|
||||
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handleChange = (e: MediaQueryListEvent) => {
|
||||
const handleChange = () => {
|
||||
if (!localStorage.getItem("theme") || localStorage.getItem("theme") === "system") {
|
||||
applyTheme("system");
|
||||
}
|
||||
|
@ -9,7 +9,7 @@ export default getRequestConfig(async () => {
|
||||
locales.map(async (locale) => {
|
||||
try {
|
||||
return (await import(`@/locales/${locale}.json`)).default;
|
||||
} catch (err) {
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
})
|
||||
|
@ -12,7 +12,7 @@ export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
} catch {
|
||||
}
|
||||
if (typeof document === 'undefined') return false;
|
||||
|
||||
@ -42,7 +42,7 @@ export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
if (originalRange) selection.addRange(originalRange);
|
||||
}
|
||||
return Boolean(successful);
|
||||
} catch (err) {
|
||||
} catch {
|
||||
document.body.removeChild(textarea);
|
||||
if (selection) {
|
||||
selection.removeAllRanges();
|
||||
|
21
web/src/utils/common/gravatar.ts
Normal file
21
web/src/utils/common/gravatar.ts
Normal file
@ -0,0 +1,21 @@
|
||||
|
||||
import md5 from "md5";
|
||||
import type { User } from '@/models/user';
|
||||
|
||||
// 32 小图标、评论列表
|
||||
// 64 移动端评论头像
|
||||
// 80 默认显示尺寸(Gravatar 默认)
|
||||
// 96 WordPress 默认头像尺寸
|
||||
// 128 侧边栏作者头像
|
||||
// 256 用户资料页
|
||||
// 512 最大支持尺寸(上传原图也限于此)
|
||||
|
||||
export function getGravatarUrl({ email, size, proxy }: { email: string, size?: number, proxy?: string }): string {
|
||||
const hash = md5(email.trim().toLowerCase());
|
||||
console.log(`https://${proxy ? proxy : "www.gravatar.com"}/avatar/${hash}?s=${size}&d=identicon`)
|
||||
return `https://${proxy ? proxy : "www.gravatar.com"}/avatar/${hash}?s=${size}&d=identicon`;
|
||||
}
|
||||
|
||||
export function getGravatarFromUser({ user, size = 120 }: { user: User, size?: number }): string {
|
||||
return user.avatarUrl || getGravatarUrl({ email: user.email, size: size });
|
||||
}
|
22
web/src/utils/common/username.ts
Normal file
22
web/src/utils/common/username.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { User } from "@/models/user";
|
||||
|
||||
export function getFallbackAvatarFromUsername(username: string): string {
|
||||
if (!username) {
|
||||
return "N";
|
||||
}
|
||||
const firstChar = username.charAt(0);
|
||||
if (/[a-zA-Z]/.test(firstChar)) {
|
||||
return firstChar.toUpperCase();
|
||||
}
|
||||
return firstChar;
|
||||
}
|
||||
|
||||
export function getFirstCharFromUser(user: User): string {
|
||||
if (user.nickname) {
|
||||
return getFallbackAvatarFromUsername(user.nickname);
|
||||
}
|
||||
if (user.username) {
|
||||
return getFallbackAvatarFromUsername(user.username);
|
||||
}
|
||||
return "N";
|
||||
}
|
Reference in New Issue
Block a user