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

- 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:
2025-09-18 21:45:18 +08:00
parent e5896d05b1
commit 2fa462ae60
32 changed files with 253 additions and 953 deletions

View File

@ -38,6 +38,7 @@
"field-conv": "^1.0.9", "field-conv": "^1.0.9",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"lucide-react": "^0.525.0", "lucide-react": "^0.525.0",
"md5": "^2.3.0",
"motion": "^12.23.12", "motion": "^12.23.12",
"next": "15.4.1", "next": "15.4.1",
"next-intl": "^4.3.4", "next-intl": "^4.3.4",
@ -56,6 +57,7 @@
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/md5": "^2.3.5",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",

35
web/pnpm-lock.yaml generated
View File

@ -95,6 +95,9 @@ importers:
lucide-react: lucide-react:
specifier: ^0.525.0 specifier: ^0.525.0
version: 0.525.0(react@19.1.0) version: 0.525.0(react@19.1.0)
md5:
specifier: ^2.3.0
version: 2.3.0
motion: motion:
specifier: ^12.23.12 specifier: ^12.23.12
version: 12.23.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0) version: 12.23.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@ -144,6 +147,9 @@ importers:
'@tailwindcss/postcss': '@tailwindcss/postcss':
specifier: ^4 specifier: ^4
version: 4.1.11 version: 4.1.11
'@types/md5':
specifier: ^2.3.5
version: 2.3.5
'@types/node': '@types/node':
specifier: ^20 specifier: ^20
version: 20.19.8 version: 20.19.8
@ -1216,6 +1222,9 @@ packages:
'@types/json5@0.0.29': '@types/json5@0.0.29':
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
'@types/md5@2.3.5':
resolution: {integrity: sha512-/i42wjYNgE6wf0j2bcTX6kuowmdL/6PE4IVitMpm2eYKBUuYCprdcWVK+xEF0gcV6ufMCRhtxmReGfc6hIK7Jw==}
'@types/mdast@4.0.4': '@types/mdast@4.0.4':
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
@ -1545,6 +1554,9 @@ packages:
character-reference-invalid@2.0.1: character-reference-invalid@2.0.1:
resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} 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: chownr@3.0.0:
resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -1590,6 +1602,9 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
crypt@0.0.2:
resolution: {integrity: sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==}
csstype@3.1.3: csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
@ -2158,6 +2173,9 @@ packages:
resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
is-buffer@1.1.6:
resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==}
is-bun-module@2.0.0: is-bun-module@2.0.0:
resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==} resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==}
@ -2407,6 +2425,9 @@ packages:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
md5@2.3.0:
resolution: {integrity: sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==}
mdast-util-from-markdown@2.0.2: mdast-util-from-markdown@2.0.2:
resolution: {integrity: sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==} resolution: {integrity: sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==}
@ -4269,6 +4290,8 @@ snapshots:
'@types/json5@0.0.29': {} '@types/json5@0.0.29': {}
'@types/md5@2.3.5': {}
'@types/mdast@4.0.4': '@types/mdast@4.0.4':
dependencies: dependencies:
'@types/unist': 3.0.3 '@types/unist': 3.0.3
@ -4620,6 +4643,8 @@ snapshots:
character-reference-invalid@2.0.1: {} character-reference-invalid@2.0.1: {}
charenc@0.0.2: {}
chownr@3.0.0: {} chownr@3.0.0: {}
class-variance-authority@0.7.1: class-variance-authority@0.7.1:
@ -4664,6 +4689,8 @@ snapshots:
shebang-command: 2.0.0 shebang-command: 2.0.0
which: 2.0.2 which: 2.0.2
crypt@0.0.2: {}
csstype@3.1.3: {} csstype@3.1.3: {}
d3-array@3.2.4: d3-array@3.2.4:
@ -5429,6 +5456,8 @@ snapshots:
call-bound: 1.0.4 call-bound: 1.0.4
has-tostringtag: 1.0.2 has-tostringtag: 1.0.2
is-buffer@1.1.6: {}
is-bun-module@2.0.0: is-bun-module@2.0.0:
dependencies: dependencies:
semver: 7.7.2 semver: 7.7.2
@ -5653,6 +5682,12 @@ snapshots:
math-intrinsics@1.1.0: {} 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: mdast-util-from-markdown@2.0.2:
dependencies: dependencies:
'@types/mdast': 4.0.4 '@types/mdast': 4.0.4

View File

@ -0,0 +1,3 @@
export default function Page() {
return <div></div>;
}

View File

@ -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"
}
]

View File

@ -0,0 +1,3 @@
export default function Page() {
return <div></div>;
}

View File

@ -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, children,
}: Readonly<{ }: 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 ( return (
<> <SidebarProvider
{children} style={
</> {
"--sidebar-width": "calc(var(--spacing) * 72)",
"--header-height": "calc(var(--spacing) * 12)",
} as React.CSSProperties
}
>
<AppSidebar variant="inset" />
<SidebarInset>
<SiteHeader />
{children}
</SidebarInset>
</SidebarProvider>
) )
} }

View File

@ -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() { export default function Page() {
const [user, setUser] = useState<User | null>(null); return <div>Console</div>;
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>
)
} }

View File

@ -0,0 +1,3 @@
export default function Page() {
return <div></div>;
}

View File

@ -0,0 +1,3 @@
export default function Page() {
return <div></div>;
}

View File

@ -38,7 +38,7 @@ export default function BlogHome() {
const [sortBy, setSortBy, isSortByLoaded] = useStoredState<SortBy>(QueryKey.SortBy, DEFAULT_SORTBY); const [sortBy, setSortBy, isSortByLoaded] = useStoredState<SortBy>(QueryKey.SortBy, DEFAULT_SORTBY);
useEffect(() => { useEffect(() => {
if (!isSortByLoaded) return; // wait for stored state loaded if (!isSortByLoaded) return;
setLoading(true); setLoading(true);
listPosts( listPosts(
{ {

View File

@ -1,6 +1,5 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Heart, TrendingUp, Eye } from "lucide-react"; import { Heart, TrendingUp, Eye } from "lucide-react";
import GravatarAvatar from "@/components/common/gravatar";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import type { Label } from "@/models/label"; import type { Label } from "@/models/label";
import type { Post } from "@/models/post"; import type { Post } from "@/models/post";
@ -9,6 +8,9 @@ import { useEffect, useState } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import Link from "next/link"; import Link from "next/link";
import { getPostHref } from "@/utils/common/post"; 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[] }) { export default function Sidebar({ cards }: { cards: React.ReactNode[] }) {
@ -34,7 +36,10 @@ export function SidebarAbout({ config }: { config: typeof configType }) {
<CardContent> <CardContent>
<div className="text-center mb-4"> <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"> <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> </div>
<h3 className="font-semibold text-lg">{config.owner.name}</h3> <h3 className="font-semibold text-lg">{config.owner.name}</h3>
<p className="text-sm text-slate-600">{config.owner.motto}</p> <p className="text-sm text-slate-600">{config.owner.motto}</p>
@ -66,23 +71,23 @@ export function SidebarHotPosts({ posts, sortType }: { posts: Post[], sortType:
</span> </span>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h4 className="font-medium text-sm line-clamp-2 mb-1"> <h4 className="font-medium text-sm line-clamp-2 mb-1">
{post.title} {post.title}
</h4> </h4>
<div className="flex items-center gap-2 text-xs text-slate-500"> <div className="flex items-center gap-2 text-xs text-slate-500">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Eye className="w-3 h-3" /> <Eye className="w-3 h-3" />
{post.viewCount} {post.viewCount}
</span> </span>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Heart className="w-3 h-3" /> <Heart className="w-3 h-3" />
{post.likeCount} {post.likeCount}
</span> </span>
</div>
</div> </div>
</div> </div>
</div> </Link>
</Link>
))} ))}
</CardContent> </CardContent>
</Card> </Card>
); );

View File

@ -3,11 +3,13 @@ import { User } from "@/models/user";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import GravatarAvatar from "@/components/common/gravatar";
import { CircleUser } from "lucide-react"; import { CircleUser } from "lucide-react";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox" import { Checkbox } from "@/components/ui/checkbox"
import { Label } from "@/components/ui/label"; 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( export function CommentInput(
@ -65,7 +67,10 @@ export function CommentInput(
<div className="fade-in-up"> <div className="fade-in-up">
<div className="flex py-4 fade-in"> <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"> <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" />} {!user && <CircleUser className="w-full h-full fade-in" />}
</div> </div>
<div className="flex-1 pl-2 fade-in-up"> <div className="flex-1 pl-2 fade-in-up">

View File

@ -3,7 +3,6 @@ import { User } from "@/models/user";
import { useLocale, useTranslations } from "next-intl"; import { useLocale, useTranslations } from "next-intl";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import GravatarAvatar, { getGravatarByUser } from "@/components/common/gravatar";
import { Reply, Trash, Heart, Pencil, Lock } from "lucide-react"; import { Reply, Trash, Heart, Pencil, Lock } from "lucide-react";
import { Comment } from "@/models/comment"; import { Comment } from "@/models/comment";
import { TargetType } from "@/models/types"; import { TargetType } from "@/models/types";
@ -13,6 +12,9 @@ import { CommentInput } from "./comment-input";
import { createComment, deleteComment, getComment, listComments, updateComment } from "@/api/comment"; import { createComment, deleteComment, getComment, listComments, updateComment } from "@/api/comment";
import { OrderBy } from "@/models/common"; import { OrderBy } from "@/models/common";
import { formatDateTime } from "@/utils/common/datetime"; 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( export function CommentItem(
@ -157,7 +159,10 @@ export function CommentItem(
<div> <div>
<div className="flex"> <div className="flex">
<div onClick={() => clickToUserProfile(commentState.user.username)} className="cursor-pointer fade-in w-12 h-12"> <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>
<div className="flex-1 pl-2 fade-in-up"> <div className="flex-1 pl-2 fade-in-up">
<div className="flex gap-2 md:gap-4 items-center"> <div className="flex gap-2 md:gap-4 items-center">

View File

@ -59,7 +59,7 @@ export function CommentSection(
}).then(response => { }).then(response => {
setComments(response.data.comments); setComments(response.data.comments);
}); });
}, []) }, [page, targetId, targetType]);
const onCommentSubmitted = ({ commentContent, isPrivate, showClientInfo }: { commentContent: string, isPrivate: boolean, showClientInfo: boolean }) => { const onCommentSubmitted = ({ commentContent, isPrivate, showClientInfo }: { commentContent: string, isPrivate: boolean, showClientInfo: boolean }) => {
createComment({ createComment({

View File

@ -3,7 +3,6 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { GoogleReCaptcha, GoogleReCaptchaProvider } from "react-google-recaptcha-v3"; import { GoogleReCaptcha, GoogleReCaptchaProvider } from "react-google-recaptcha-v3";
import HCaptcha from "@hcaptcha/react-hcaptcha"; import HCaptcha from "@hcaptcha/react-hcaptcha";
import { Turnstile } from "@marsidev/react-turnstile";
import { CaptchaProvider } from "@/models/captcha"; import { CaptchaProvider } from "@/models/captcha";
import "./captcha.css"; import "./captcha.css";
import { TurnstileWidget } from "./turnstile"; import { TurnstileWidget } from "./turnstile";

View File

@ -52,13 +52,13 @@ export function TurnstileWidget(props: CaptchaProps) {
const handleSuccess = (token: string) => { const handleSuccess = (token: string) => {
setStatus('success'); setStatus('success');
props.onSuccess(token); return props.onSuccess && props.onSuccess(token);
}; };
const handleError = (error: string) => { const handleError = (error: string) => {
setStatus('error'); setStatus('error');
setError(error); setError(error);
props.onError && props.onError(error); return props.onError && props.onError(error);
}; };
useEffect(() => { useEffect(() => {
@ -66,11 +66,11 @@ export function TurnstileWidget(props: CaptchaProps) {
if (status === 'loading') { if (status === 'loading') {
setStatus('error'); setStatus('error');
setError('timeout'); setError('timeout');
props.onError && props.onError('timeout'); return props.onError && props.onError('timeout');
} }
}, TURNSTILE_TIMEOUT * 1000); }, TURNSTILE_TIMEOUT * 1000);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}) }, [status, props]);
return ( return (
<div className="flex items-center justify-evenly w-full border border-gray-300 rounded-md px-4 py-2 relative"> <div className="flex items-center justify-evenly w-full border border-gray-300 rounded-md px-4 py-2 relative">

View File

@ -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;

View File

@ -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>
);
}

View File

@ -46,7 +46,7 @@ export default function CodeBlock(props: React.ComponentPropsWithoutRef<"pre">)
codeContent = extractText(child.props.children); codeContent = extractText(child.props.children);
} }
async function handleCopy(e: React.MouseEvent<HTMLButtonElement>) { async function handleCopy() {
try { try {
const ok = await copyToClipboard(codeContent); const ok = await copyToClipboard(codeContent);
if (ok) toast.success(t("copy_success")); if (ok) toast.success(t("copy_success"));

View File

@ -21,15 +21,10 @@ export function PaginationController({
buttons?: number buttons?: number
onPageChange?: (page: number) => void onPageChange?: (page: number) => void
} & React.HTMLAttributes<HTMLDivElement>) { } & React.HTMLAttributes<HTMLDivElement>) {
// normalize buttons
const btns = Math.max(5, buttons ?? 7); const btns = Math.max(5, buttons ?? 7);
const buttonsToShow = totalPages < btns ? totalPages : btns; 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 [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(() => { useEffect(() => {
const p = Math.min(Math.max(1, initialPage ?? 1), Math.max(1, totalPages)); const p = Math.min(Math.max(1, initialPage ?? 1), Math.max(1, totalPages));
setCurrentPage(p); setCurrentPage(p);
@ -37,10 +32,9 @@ export function PaginationController({
const handleSetPage = useCallback((p: number) => { const handleSetPage = useCallback((p: number) => {
const next = Math.min(Math.max(1, Math.floor(p)), Math.max(1, totalPages)); const next = Math.min(Math.max(1, Math.floor(p)), Math.max(1, totalPages));
setDirection(next > currentPage ? 1 : next < currentPage ? -1 : 0);
setCurrentPage(next); setCurrentPage(next);
if (typeof onPageChange === 'function') onPageChange(next); if (typeof onPageChange === 'function') onPageChange(next);
}, [onPageChange, totalPages, currentPage]); }, [onPageChange, totalPages]);
// helper to render page link // helper to render page link
const renderPage = (pageNum: number) => ( const renderPage = (pageNum: number) => (

View File

@ -1,29 +1,16 @@
"use client" "use client"
import { useEffect, useState } from "react"
import * as React from "react"
import { import {
IconCamera,
IconChartBar, IconChartBar,
IconDashboard, IconDashboard,
IconDatabase,
IconFileAi,
IconFileDescription,
IconFileWord,
IconFolder, IconFolder,
IconHelp,
IconInnerShadowTop, IconInnerShadowTop,
IconListDetails, IconListDetails,
IconReport,
IconSearch,
IconSettings,
IconUsers, IconUsers,
} from "@tabler/icons-react" } from "@tabler/icons-react"
import { NavDocuments } from "@/components/console/nav-documents"
import { NavMain } from "@/components/console/nav-main" import { NavMain } from "@/components/console/nav-main"
import { NavSecondary } from "@/components/console/nav-secondary"
import { NavUser } from "@/components/console/nav-user" import { NavUser } from "@/components/console/nav-user"
import { metadata } from '../../app/layout';
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
@ -34,125 +21,53 @@ import {
SidebarMenuItem, SidebarMenuItem,
} from "@/components/ui/sidebar" } from "@/components/ui/sidebar"
import config from "@/config" import config from "@/config"
import Link from "next/link"
import { getLoginUser } from "@/api/user"
import { User } from "@/models/user"
const data = { const data = {
user: {
name: "shadcn",
email: "m@example.com",
avatar: "/avatars/shadcn.jpg",
},
navMain: [ navMain: [
{ {
title: "Dashboard", title: "大石坝",
url: "#", url: "/console",
icon: IconDashboard, icon: IconDashboard,
}, },
{ {
title: "Lifecycle", title: "文章管理",
url: "#", url: "/console/post",
icon: IconListDetails, icon: IconListDetails,
}, },
{ {
title: "Analytics", title: "评论管理",
url: "#", url: "/console/comment",
icon: IconChartBar, icon: IconChartBar,
}, },
{ {
title: "Projects", title: "文件管理",
url: "#", url: "/console/file",
icon: IconFolder, icon: IconFolder,
}, },
{ {
title: "Team", title: "用户管理",
url: "#", url: "/console/user",
icon: IconUsers, 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>) { 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 ( return (
<Sidebar collapsible="offcanvas" {...props}> <Sidebar collapsible="offcanvas" {...props}>
<SidebarHeader> <SidebarHeader>
@ -162,21 +77,19 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
asChild asChild
className="data-[slot=sidebar-menu-button]:!p-1.5" className="data-[slot=sidebar-menu-button]:!p-1.5"
> >
<a href="#"> <Link href="/">
<IconInnerShadowTop className="!size-5" /> <IconInnerShadowTop className="!size-5" />
<span className="text-base font-semibold">{config.metadata.name}</span> <span className="text-base font-semibold">{config.metadata.name}</span>
</a> </Link>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
</SidebarHeader> </SidebarHeader>
<SidebarContent> <SidebarContent>
<NavMain items={data.navMain} /> <NavMain items={data.navMain} />
<NavDocuments items={data.documents} />
<NavSecondary items={data.navSecondary} className="mt-auto" />
</SidebarContent> </SidebarContent>
<SidebarFooter> <SidebarFooter>
<NavUser user={data.user} /> <NavUser user={loginUser} />
</SidebarFooter> </SidebarFooter>
</Sidebar> </Sidebar>
) )

View File

@ -1,8 +1,7 @@
"use client" "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 { import {
SidebarGroup, SidebarGroup,
SidebarGroupContent, SidebarGroupContent,
@ -10,6 +9,7 @@ import {
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
} from "@/components/ui/sidebar" } from "@/components/ui/sidebar"
import Link from "next/link"
export function NavMain({ export function NavMain({
items, items,
@ -26,10 +26,12 @@ export function NavMain({
<SidebarMenu> <SidebarMenu>
{items.map((item) => ( {items.map((item) => (
<SidebarMenuItem key={item.title}> <SidebarMenuItem key={item.title}>
<SidebarMenuButton tooltip={item.title}> <Link href={item.url}>
{item.icon && <item.icon />} <SidebarMenuButton tooltip={item.title}>
<span>{item.title}</span> {item.icon && <item.icon />}
</SidebarMenuButton> <span>{item.title}</span>
</SidebarMenuButton>
</Link>
</SidebarMenuItem> </SidebarMenuItem>
))} ))}
</SidebarMenu> </SidebarMenu>

View File

@ -28,15 +28,14 @@ import {
SidebarMenuItem, SidebarMenuItem,
useSidebar, useSidebar,
} from "@/components/ui/sidebar" } from "@/components/ui/sidebar"
import { User } from "@/models/user"
import { getGravatarFromUser } from "@/utils/common/gravatar"
import { getFallbackAvatarFromUsername } from "@/utils/common/username"
export function NavUser({ export function NavUser({
user, user,
}: { }: {
user: { user: User
name: string
email: string
avatar: string
}
}) { }) {
const { isMobile } = useSidebar() const { isMobile } = useSidebar()
@ -49,12 +48,12 @@ export function NavUser({
size="lg" size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground" className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
> >
<Avatar className="h-8 w-8 rounded-lg grayscale"> <Avatar className="h-8 w-8 rounded-full">
<AvatarImage src={user.avatar} alt={user.name} /> <AvatarImage src={getGravatarFromUser({ user })} alt={user.username} />
<AvatarFallback className="rounded-lg">CN</AvatarFallback> <AvatarFallback className="rounded-full">{getFallbackAvatarFromUsername(user.nickname || user.username)}</AvatarFallback>
</Avatar> </Avatar>
<div className="grid flex-1 text-left text-sm leading-tight"> <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"> <span className="text-muted-foreground truncate text-xs">
{user.email} {user.email}
</span> </span>
@ -70,12 +69,12 @@ export function NavUser({
> >
<DropdownMenuLabel className="p-0 font-normal"> <DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm"> <div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 rounded-lg"> <Avatar className="h-8 w-8 rounded-full">
<AvatarImage src={user.avatar} alt={user.name} /> <AvatarImage src={getGravatarFromUser({ user })} alt={user.username} />
<AvatarFallback className="rounded-lg">CN</AvatarFallback> <AvatarFallback className="rounded-full">{getFallbackAvatarFromUsername(user.nickname || user.username)}</AvatarFallback>
</Avatar> </Avatar>
<div className="grid flex-1 text-left text-sm leading-tight"> <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"> <span className="text-muted-foreground truncate text-xs">
{user.email} {user.email}
</span> </span>

View File

@ -5,15 +5,9 @@ import {
DropdownMenuGroup, DropdownMenuGroup,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu"
import GravatarAvatar from "../common/gravatar"
import { User } from "@/models/user"; import { User } from "@/models/user";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { getLoginUser, userLogout } from "@/api/user"; import { getLoginUser, userLogout } from "@/api/user";
@ -21,6 +15,9 @@ import Link from "next/link";
import { toast } from "sonner"; import { toast } from "sonner";
import { useToLogin } from "@/hooks/use-route"; import { useToLogin } from "@/hooks/use-route";
import { CircleUser } from "lucide-react"; 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() { export function AvatarWithDropdownMenu() {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
@ -44,7 +41,10 @@ export function AvatarWithDropdownMenu() {
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button className="rounded-full overflow-hidden"> <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> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="start"> <DropdownMenuContent className="w-56" align="start">
@ -58,29 +58,6 @@ export function AvatarWithDropdownMenu() {
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuGroup> </DropdownMenuGroup>
<DropdownMenuSeparator /> <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}> <DropdownMenuItem onClick={user ? handleLogout : toLogin}>
{user ? `Logout (${user.username})` : "Login"} {user ? `Logout (${user.username})` : "Login"}
</DropdownMenuItem> </DropdownMenuItem>

View File

@ -46,7 +46,7 @@ const navbarMenuComponents = [
] ]
export function Navbar() { export function Navbar() {
const { navbarAdditionalClassName, setMode, mode } = useDevice() const { navbarAdditionalClassName} = useDevice()
return ( return (
<nav className={`grid grid-cols-[1fr_auto_1fr] items-center gap-4 h-full px-4 w-full ${navbarAdditionalClassName}`}> <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"> <div className="flex items-center justify-start">

View File

@ -34,7 +34,6 @@ export function LoginForm({
url?: string url?: string
} | null>(null) } | null>(null)
const [captchaToken, setCaptchaToken] = useState<string | null>(null) const [captchaToken, setCaptchaToken] = useState<string | null>(null)
const [captchaError, setCaptchaError] = useState<string | null>(null)
const [isLogging, setIsLogging] = useState(false) const [isLogging, setIsLogging] = useState(false)
const [refreshCaptchaKey, setRefreshCaptchaKey] = useState(0) const [refreshCaptchaKey, setRefreshCaptchaKey] = useState(0)
const [{ username, password }, setCredentials] = useState({ username: '', password: '' }) 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}` : "")) toast.error(t("fetch_oidc_configs_failed") + (error?.message ? `: ${error.message}` : ""))
setOidcConfigs([]) // 错误时设置为空数组 setOidcConfigs([]) // 错误时设置为空数组
}) })
}, []) }, [t])
useEffect(() => { useEffect(() => {
getCaptchaConfig() getCaptchaConfig()
@ -62,7 +61,7 @@ export function LoginForm({
toast.error(t("fetch_captcha_config_failed") + (error?.message ? `: ${error.message}` : "")) toast.error(t("fetch_captcha_config_failed") + (error?.message ? `: ${error.message}` : ""))
setCaptchaProps(null) setCaptchaProps(null)
}) })
}, [refreshCaptchaKey]) }, [refreshCaptchaKey, t])
const handleLogin = async (e: React.FormEvent) => { const handleLogin = async (e: React.FormEvent) => {
setIsLogging(true) setIsLogging(true)
@ -84,8 +83,7 @@ export function LoginForm({
} }
const handleCaptchaError = (error: string) => { const handleCaptchaError = (error: string) => {
setCaptchaError(error); toast.error(t("captcha_error") + (error ? `: ${error}` : ""));
// 刷新验证码
setTimeout(() => { setTimeout(() => {
setRefreshCaptchaKey(k => k + 1); setRefreshCaptchaKey(k => k + 1);
}, 1500); }, 1500);

View File

@ -1,7 +1,9 @@
"use client" "use client"
import { User } from "@/models/user"; import { User } from "@/models/user";
import GravatarAvatar from "@/components/common/gravatar";
import { Mail, User as UserIcon, Shield } from 'lucide-react'; 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 }) { export function UserHeader({ user }: { user: User }) {
return ( return (
@ -10,7 +12,10 @@ export function UserHeader({ user }: { user: User }) {
<div className="md:basis-[20%] flex justify-center items-center p-4"> <div className="md:basis-[20%] flex justify-center items-center p-4">
{/* wrapper 控制显示大小,父组件给具体 w/h */} {/* wrapper 控制显示大小,父组件给具体 w/h */}
<div className="w-40 h-40 md:w-48 md:h-48 relative"> <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>
</div> </div>

View File

@ -88,7 +88,7 @@ export const DeviceProvider: React.FC<{ children: React.ReactNode }> = ({ childr
// 监听系统主题变动 // 监听系统主题变动
const media = window.matchMedia("(prefers-color-scheme: dark)"); const media = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = (e: MediaQueryListEvent) => { const handleChange = () => {
if (!localStorage.getItem("theme") || localStorage.getItem("theme") === "system") { if (!localStorage.getItem("theme") || localStorage.getItem("theme") === "system") {
applyTheme("system"); applyTheme("system");
} }

View File

@ -9,7 +9,7 @@ export default getRequestConfig(async () => {
locales.map(async (locale) => { locales.map(async (locale) => {
try { try {
return (await import(`@/locales/${locale}.json`)).default; return (await import(`@/locales/${locale}.json`)).default;
} catch (err) { } catch {
return {}; return {};
} }
}) })

View File

@ -12,7 +12,7 @@ export async function copyToClipboard(text: string): Promise<boolean> {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
return true; return true;
} }
} catch (err) { } catch {
} }
if (typeof document === 'undefined') return false; if (typeof document === 'undefined') return false;
@ -42,7 +42,7 @@ export async function copyToClipboard(text: string): Promise<boolean> {
if (originalRange) selection.addRange(originalRange); if (originalRange) selection.addRange(originalRange);
} }
return Boolean(successful); return Boolean(successful);
} catch (err) { } catch {
document.body.removeChild(textarea); document.body.removeChild(textarea);
if (selection) { if (selection) {
selection.removeAllRanges(); selection.removeAllRanges();

View 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 });
}

View 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";
}