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",
"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
View File

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

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,
}: 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>
)
}

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() {
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>;
}

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);
useEffect(() => {
if (!isSortByLoaded) return; // wait for stored state loaded
if (!isSortByLoaded) return;
setLoading(true);
listPosts(
{

View File

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

View File

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

View File

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

View File

@ -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({

View File

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

View File

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

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);
}
async function handleCopy(e: React.MouseEvent<HTMLButtonElement>) {
async function handleCopy() {
try {
const ok = await copyToClipboard(codeContent);
if (ok) toast.success(t("copy_success"));

View File

@ -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) => (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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