mirror of
https://github.com/snowykami/neo-blog.git
synced 2025-09-26 11:06:23 +00:00
Refactor console layout and sidebar components; implement user authentication and loading states
All checks were successful
Push to Helm Chart Repository / build (push) Successful in 31s
All checks were successful
Push to Helm Chart Repository / build (push) Successful in 31s
- Updated `RootLayout` to include user authentication logic and loading states. - Removed redundant user authentication logic from `Page` component. - Enhanced `AppSidebar` to fetch and display logged-in user information. - Replaced `GravatarAvatar` with new `Avatar` component for user profile images. - Added new pages for comment, file, post, and user management. - Introduced utility functions for generating Gravatar URLs and fallback avatars based on usernames. - Cleaned up unused imports and components across various files.
This commit is contained in:
@ -38,7 +38,7 @@ export default function BlogHome() {
|
||||
const [sortBy, setSortBy, isSortByLoaded] = useStoredState<SortBy>(QueryKey.SortBy, DEFAULT_SORTBY);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSortByLoaded) return; // wait for stored state loaded
|
||||
if (!isSortByLoaded) return;
|
||||
setLoading(true);
|
||||
listPosts(
|
||||
{
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Heart, TrendingUp, Eye } from "lucide-react";
|
||||
import GravatarAvatar from "@/components/common/gravatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { Label } from "@/models/label";
|
||||
import type { Post } from "@/models/post";
|
||||
@ -9,6 +8,9 @@ import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { getPostHref } from "@/utils/common/post";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { getGravatarUrl } from "@/utils/common/gravatar";
|
||||
import { getFallbackAvatarFromUsername } from "@/utils/common/username";
|
||||
|
||||
// 侧边栏父组件,接收卡片组件列表
|
||||
export default function Sidebar({ cards }: { cards: React.ReactNode[] }) {
|
||||
@ -34,7 +36,10 @@ export function SidebarAbout({ config }: { config: typeof configType }) {
|
||||
<CardContent>
|
||||
<div className="text-center mb-4">
|
||||
<div className="w-20 h-20 mx-auto bg-gradient-to-br from-blue-400 to-purple-500 rounded-full flex items-center justify-center text-white text-2xl font-bold overflow-hidden">
|
||||
<GravatarAvatar email={config.owner.gravatarEmail} className="w-full h-full object-cover" size={200} />
|
||||
<Avatar className="h-full w-full rounded-full">
|
||||
<AvatarImage src={getGravatarUrl({email: config.owner.gravatarEmail, size: 256})} alt={config.owner.name} />
|
||||
<AvatarFallback className="rounded-full">{getFallbackAvatarFromUsername(config.owner.name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
<h3 className="font-semibold text-lg">{config.owner.name}</h3>
|
||||
<p className="text-sm text-slate-600">{config.owner.motto}</p>
|
||||
@ -66,23 +71,23 @@ export function SidebarHotPosts({ posts, sortType }: { posts: Post[], sortType:
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-medium text-sm line-clamp-2 mb-1">
|
||||
{post.title}
|
||||
</h4>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<Eye className="w-3 h-3" />
|
||||
{post.viewCount}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Heart className="w-3 h-3" />
|
||||
{post.likeCount}
|
||||
</span>
|
||||
{post.title}
|
||||
</h4>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<Eye className="w-3 h-3" />
|
||||
{post.viewCount}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Heart className="w-3 h-3" />
|
||||
{post.likeCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</Link>
|
||||
|
||||
))}
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
@ -3,11 +3,13 @@ import { User } from "@/models/user";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import GravatarAvatar from "@/components/common/gravatar";
|
||||
import { CircleUser } from "lucide-react";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { getGravatarUrl } from "@/utils/common/gravatar";
|
||||
import { getFirstCharFromUser } from "@/utils/common/username";
|
||||
|
||||
|
||||
export function CommentInput(
|
||||
@ -65,7 +67,10 @@ export function CommentInput(
|
||||
<div className="fade-in-up">
|
||||
<div className="flex py-4 fade-in">
|
||||
<div onClick={user ? () => clickToUserProfile(user.username) : clickToLogin} className="cursor-pointer flex-shrink-0 w-10 h-10 fade-in">
|
||||
{user && <GravatarAvatar className="w-full h-full" url={user.avatarUrl} email={user.email} size={100} />}
|
||||
{user && <Avatar className="h-full w-full rounded-full">
|
||||
<AvatarImage src={getGravatarUrl({ email: user.email, size: 120 })} alt={user.nickname} />
|
||||
<AvatarFallback className="rounded-full">{getFirstCharFromUser(user)}</AvatarFallback>
|
||||
</Avatar>}
|
||||
{!user && <CircleUser className="w-full h-full fade-in" />}
|
||||
</div>
|
||||
<div className="flex-1 pl-2 fade-in-up">
|
||||
|
@ -3,7 +3,6 @@ import { User } from "@/models/user";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import GravatarAvatar, { getGravatarByUser } from "@/components/common/gravatar";
|
||||
import { Reply, Trash, Heart, Pencil, Lock } from "lucide-react";
|
||||
import { Comment } from "@/models/comment";
|
||||
import { TargetType } from "@/models/types";
|
||||
@ -13,6 +12,9 @@ import { CommentInput } from "./comment-input";
|
||||
import { createComment, deleteComment, getComment, listComments, updateComment } from "@/api/comment";
|
||||
import { OrderBy } from "@/models/common";
|
||||
import { formatDateTime } from "@/utils/common/datetime";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { getGravatarUrl } from "@/utils/common/gravatar";
|
||||
import { getFirstCharFromUser } from "@/utils/common/username";
|
||||
|
||||
|
||||
export function CommentItem(
|
||||
@ -157,7 +159,10 @@ export function CommentItem(
|
||||
<div>
|
||||
<div className="flex">
|
||||
<div onClick={() => clickToUserProfile(commentState.user.username)} className="cursor-pointer fade-in w-12 h-12">
|
||||
<GravatarAvatar className="w-full h-full" url={commentState.user.avatarUrl} email={commentState.user.email} size={100} />
|
||||
<Avatar className="h-full w-full rounded-full">
|
||||
<AvatarImage src={getGravatarUrl({email: commentState.user.email, size: 120})} alt={commentState.user.nickname} />
|
||||
<AvatarFallback className="rounded-full">{getFirstCharFromUser(commentState.user)}</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
<div className="flex-1 pl-2 fade-in-up">
|
||||
<div className="flex gap-2 md:gap-4 items-center">
|
||||
|
@ -59,7 +59,7 @@ export function CommentSection(
|
||||
}).then(response => {
|
||||
setComments(response.data.comments);
|
||||
});
|
||||
}, [])
|
||||
}, [page, targetId, targetType]);
|
||||
|
||||
const onCommentSubmitted = ({ commentContent, isPrivate, showClientInfo }: { commentContent: string, isPrivate: boolean, showClientInfo: boolean }) => {
|
||||
createComment({
|
||||
|
@ -3,7 +3,6 @@
|
||||
import { useEffect } from "react";
|
||||
import { GoogleReCaptcha, GoogleReCaptchaProvider } from "react-google-recaptcha-v3";
|
||||
import HCaptcha from "@hcaptcha/react-hcaptcha";
|
||||
import { Turnstile } from "@marsidev/react-turnstile";
|
||||
import { CaptchaProvider } from "@/models/captcha";
|
||||
import "./captcha.css";
|
||||
import { TurnstileWidget } from "./turnstile";
|
||||
|
@ -52,13 +52,13 @@ export function TurnstileWidget(props: CaptchaProps) {
|
||||
|
||||
const handleSuccess = (token: string) => {
|
||||
setStatus('success');
|
||||
props.onSuccess(token);
|
||||
return props.onSuccess && props.onSuccess(token);
|
||||
};
|
||||
|
||||
const handleError = (error: string) => {
|
||||
setStatus('error');
|
||||
setError(error);
|
||||
props.onError && props.onError(error);
|
||||
return props.onError && props.onError(error);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@ -66,11 +66,11 @@ export function TurnstileWidget(props: CaptchaProps) {
|
||||
if (status === 'loading') {
|
||||
setStatus('error');
|
||||
setError('timeout');
|
||||
props.onError && props.onError('timeout');
|
||||
return props.onError && props.onError('timeout');
|
||||
}
|
||||
}, TURNSTILE_TIMEOUT * 1000);
|
||||
return () => clearTimeout(timer);
|
||||
})
|
||||
}, [status, props]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-evenly w-full border border-gray-300 rounded-md px-4 py-2 relative">
|
||||
|
@ -1,70 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import Image from "next/image";
|
||||
import crypto from "crypto";
|
||||
|
||||
// 生成 Gravatar URL 的函数
|
||||
function getGravatarUrl(email: string, size: number = 200, defaultType: string = "identicon"): string {
|
||||
const hash = crypto.createHash('md5').update(email.toLowerCase().trim()).digest('hex');
|
||||
return `https://www.gravatar.com/avatar/${hash}?s=${size}&d=${defaultType}`;
|
||||
}
|
||||
|
||||
interface GravatarAvatarProps {
|
||||
email: string;
|
||||
size?: number;
|
||||
className?: string;
|
||||
alt?: string;
|
||||
url?: string;
|
||||
defaultType?: 'mm' | 'identicon' | 'monsterid' | 'wavatar' | 'retro' | 'robohash' | 'blank';
|
||||
}
|
||||
|
||||
const GravatarAvatar: React.FC<GravatarAvatarProps> = ({
|
||||
email,
|
||||
size = 200,
|
||||
className = "",
|
||||
alt = "avatar",
|
||||
url,
|
||||
defaultType = "identicon"
|
||||
}) => {
|
||||
// 把尺寸控制交给父组件的 wrapper(父组件通过 tailwind 的 w-.. h-.. 控制)
|
||||
const gravatarUrl = url && url.trim() !== "" ? url : getGravatarUrl(email, size , defaultType);
|
||||
|
||||
return (
|
||||
<div className={`relative overflow-hidden ${className}`}>
|
||||
<Image
|
||||
src={gravatarUrl}
|
||||
alt={alt}
|
||||
fill
|
||||
sizes="(max-width: 640px) 64px, 200px"
|
||||
className="rounded-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 用户类型定义(如果还没有的话)
|
||||
interface User {
|
||||
email?: string;
|
||||
displayName?: string;
|
||||
name?: string;
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
export function getGravatarByUser({user, className="", size=640}:{user?: User, className?: string, size?: number}): React.ReactElement {
|
||||
if (!user) {
|
||||
return <GravatarAvatar email="" className={className} />;
|
||||
}
|
||||
return (
|
||||
<GravatarAvatar
|
||||
email={user.email || ""}
|
||||
size={size}
|
||||
className={className}
|
||||
alt={user.displayName || user.name || "User Avatar"}
|
||||
url={user.avatarUrl}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default GravatarAvatar;
|
@ -1,7 +0,0 @@
|
||||
export default function ImagePlaceholder() {
|
||||
return (
|
||||
<div className="w-10 h-10 bg-gray-200 flex items-center justify-center rounded-full">
|
||||
<img src="/file.svg" alt="Image Placeholder" className="w-6 h-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
@ -46,7 +46,7 @@ export default function CodeBlock(props: React.ComponentPropsWithoutRef<"pre">)
|
||||
codeContent = extractText(child.props.children);
|
||||
}
|
||||
|
||||
async function handleCopy(e: React.MouseEvent<HTMLButtonElement>) {
|
||||
async function handleCopy() {
|
||||
try {
|
||||
const ok = await copyToClipboard(codeContent);
|
||||
if (ok) toast.success(t("copy_success"));
|
||||
|
@ -21,15 +21,10 @@ export function PaginationController({
|
||||
buttons?: number
|
||||
onPageChange?: (page: number) => void
|
||||
} & React.HTMLAttributes<HTMLDivElement>) {
|
||||
// normalize buttons
|
||||
const btns = Math.max(5, buttons ?? 7);
|
||||
const buttonsToShow = totalPages < btns ? totalPages : btns;
|
||||
// rely on shadcn buttonVariants and PaginationLink's isActive prop for styling
|
||||
|
||||
const [currentPage, setCurrentPage] = useState(() => Math.min(Math.max(1, initialPage ?? 1), Math.max(1, totalPages)));
|
||||
const [direction, setDirection] = useState(0) // 1 = forward (right->left), -1 = backward
|
||||
|
||||
// sync when initialPage or totalPages props change
|
||||
useEffect(() => {
|
||||
const p = Math.min(Math.max(1, initialPage ?? 1), Math.max(1, totalPages));
|
||||
setCurrentPage(p);
|
||||
@ -37,10 +32,9 @@ export function PaginationController({
|
||||
|
||||
const handleSetPage = useCallback((p: number) => {
|
||||
const next = Math.min(Math.max(1, Math.floor(p)), Math.max(1, totalPages));
|
||||
setDirection(next > currentPage ? 1 : next < currentPage ? -1 : 0);
|
||||
setCurrentPage(next);
|
||||
if (typeof onPageChange === 'function') onPageChange(next);
|
||||
}, [onPageChange, totalPages, currentPage]);
|
||||
}, [onPageChange, totalPages]);
|
||||
|
||||
// helper to render page link
|
||||
const renderPage = (pageNum: number) => (
|
||||
|
@ -1,29 +1,16 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useEffect, useState } from "react"
|
||||
import {
|
||||
IconCamera,
|
||||
IconChartBar,
|
||||
IconDashboard,
|
||||
IconDatabase,
|
||||
IconFileAi,
|
||||
IconFileDescription,
|
||||
IconFileWord,
|
||||
IconFolder,
|
||||
IconHelp,
|
||||
IconInnerShadowTop,
|
||||
IconListDetails,
|
||||
IconReport,
|
||||
IconSearch,
|
||||
IconSettings,
|
||||
IconUsers,
|
||||
} from "@tabler/icons-react"
|
||||
|
||||
import { NavDocuments } from "@/components/console/nav-documents"
|
||||
import { NavMain } from "@/components/console/nav-main"
|
||||
import { NavSecondary } from "@/components/console/nav-secondary"
|
||||
import { NavUser } from "@/components/console/nav-user"
|
||||
import { metadata } from '../../app/layout';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@ -34,125 +21,53 @@ import {
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar"
|
||||
import config from "@/config"
|
||||
import Link from "next/link"
|
||||
import { getLoginUser } from "@/api/user"
|
||||
import { User } from "@/models/user"
|
||||
|
||||
const data = {
|
||||
user: {
|
||||
name: "shadcn",
|
||||
email: "m@example.com",
|
||||
avatar: "/avatars/shadcn.jpg",
|
||||
},
|
||||
navMain: [
|
||||
{
|
||||
title: "Dashboard",
|
||||
url: "#",
|
||||
title: "大石坝",
|
||||
url: "/console",
|
||||
icon: IconDashboard,
|
||||
},
|
||||
{
|
||||
title: "Lifecycle",
|
||||
url: "#",
|
||||
title: "文章管理",
|
||||
url: "/console/post",
|
||||
icon: IconListDetails,
|
||||
},
|
||||
{
|
||||
title: "Analytics",
|
||||
url: "#",
|
||||
title: "评论管理",
|
||||
url: "/console/comment",
|
||||
icon: IconChartBar,
|
||||
},
|
||||
{
|
||||
title: "Projects",
|
||||
url: "#",
|
||||
title: "文件管理",
|
||||
url: "/console/file",
|
||||
icon: IconFolder,
|
||||
},
|
||||
{
|
||||
title: "Team",
|
||||
url: "#",
|
||||
title: "用户管理",
|
||||
url: "/console/user",
|
||||
icon: IconUsers,
|
||||
},
|
||||
],
|
||||
navClouds: [
|
||||
{
|
||||
title: "Capture",
|
||||
icon: IconCamera,
|
||||
isActive: true,
|
||||
url: "#",
|
||||
items: [
|
||||
{
|
||||
title: "Active Proposals",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Archived",
|
||||
url: "#",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Proposal",
|
||||
icon: IconFileDescription,
|
||||
url: "#",
|
||||
items: [
|
||||
{
|
||||
title: "Active Proposals",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Archived",
|
||||
url: "#",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Prompts",
|
||||
icon: IconFileAi,
|
||||
url: "#",
|
||||
items: [
|
||||
{
|
||||
title: "Active Proposals",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Archived",
|
||||
url: "#",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
navSecondary: [
|
||||
{
|
||||
title: "Settings",
|
||||
url: "#",
|
||||
icon: IconSettings,
|
||||
},
|
||||
{
|
||||
title: "Get Help",
|
||||
url: "#",
|
||||
icon: IconHelp,
|
||||
},
|
||||
{
|
||||
title: "Search",
|
||||
url: "#",
|
||||
icon: IconSearch,
|
||||
},
|
||||
],
|
||||
documents: [
|
||||
{
|
||||
name: "Data Library",
|
||||
url: "#",
|
||||
icon: IconDatabase,
|
||||
},
|
||||
{
|
||||
name: "Reports",
|
||||
url: "#",
|
||||
icon: IconReport,
|
||||
},
|
||||
{
|
||||
name: "Word Assistant",
|
||||
url: "#",
|
||||
icon: IconFileWord,
|
||||
},
|
||||
],
|
||||
]
|
||||
}
|
||||
|
||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
const [loginUser, setLoginUser] = useState<User | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getLoginUser().then(resp => {
|
||||
setLoginUser(resp.data);
|
||||
});
|
||||
}, [])
|
||||
|
||||
if (!loginUser) {
|
||||
return null; // 或者返回一个加载指示器
|
||||
}
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="offcanvas" {...props}>
|
||||
<SidebarHeader>
|
||||
@ -162,21 +77,19 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
asChild
|
||||
className="data-[slot=sidebar-menu-button]:!p-1.5"
|
||||
>
|
||||
<a href="#">
|
||||
<Link href="/">
|
||||
<IconInnerShadowTop className="!size-5" />
|
||||
<span className="text-base font-semibold">{config.metadata.name}</span>
|
||||
</a>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<NavMain items={data.navMain} />
|
||||
<NavDocuments items={data.documents} />
|
||||
<NavSecondary items={data.navSecondary} className="mt-auto" />
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<NavUser user={data.user} />
|
||||
<NavUser user={loginUser} />
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
)
|
||||
|
@ -1,8 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { IconCirclePlusFilled, IconMail, type Icon } from "@tabler/icons-react"
|
||||
import { type Icon } from "@tabler/icons-react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
@ -10,6 +9,7 @@ import {
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar"
|
||||
import Link from "next/link"
|
||||
|
||||
export function NavMain({
|
||||
items,
|
||||
@ -26,10 +26,12 @@ export function NavMain({
|
||||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton tooltip={item.title}>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
</SidebarMenuButton>
|
||||
<Link href={item.url}>
|
||||
<SidebarMenuButton tooltip={item.title}>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
</SidebarMenuButton>
|
||||
</Link>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
|
@ -28,15 +28,14 @@ import {
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar"
|
||||
import { User } from "@/models/user"
|
||||
import { getGravatarFromUser } from "@/utils/common/gravatar"
|
||||
import { getFallbackAvatarFromUsername } from "@/utils/common/username"
|
||||
|
||||
export function NavUser({
|
||||
user,
|
||||
}: {
|
||||
user: {
|
||||
name: string
|
||||
email: string
|
||||
avatar: string
|
||||
}
|
||||
user: User
|
||||
}) {
|
||||
const { isMobile } = useSidebar()
|
||||
|
||||
@ -49,12 +48,12 @@ export function NavUser({
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<Avatar className="h-8 w-8 rounded-lg grayscale">
|
||||
<AvatarImage src={user.avatar} alt={user.name} />
|
||||
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
|
||||
<Avatar className="h-8 w-8 rounded-full">
|
||||
<AvatarImage src={getGravatarFromUser({ user })} alt={user.username} />
|
||||
<AvatarFallback className="rounded-full">{getFallbackAvatarFromUsername(user.nickname || user.username)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{user.name}</span>
|
||||
<span className="truncate font-medium">{user.nickname}({user.username})</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{user.email}
|
||||
</span>
|
||||
@ -70,12 +69,12 @@ export function NavUser({
|
||||
>
|
||||
<DropdownMenuLabel className="p-0 font-normal">
|
||||
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<Avatar className="h-8 w-8 rounded-lg">
|
||||
<AvatarImage src={user.avatar} alt={user.name} />
|
||||
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
|
||||
<Avatar className="h-8 w-8 rounded-full">
|
||||
<AvatarImage src={getGravatarFromUser({ user })} alt={user.username} />
|
||||
<AvatarFallback className="rounded-full">{getFallbackAvatarFromUsername(user.nickname || user.username)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{user.name}</span>
|
||||
<span className="truncate font-medium">{user.nickname}({user.username})</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{user.email}
|
||||
</span>
|
||||
|
@ -5,15 +5,9 @@ import {
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import GravatarAvatar from "../common/gravatar"
|
||||
import { User } from "@/models/user";
|
||||
import { useEffect, useState } from "react";
|
||||
import { getLoginUser, userLogout } from "@/api/user";
|
||||
@ -21,6 +15,9 @@ import Link from "next/link";
|
||||
import { toast } from "sonner";
|
||||
import { useToLogin } from "@/hooks/use-route";
|
||||
import { CircleUser } from "lucide-react";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { getGravatarFromUser } from "@/utils/common/gravatar";
|
||||
import { getFallbackAvatarFromUsername } from "@/utils/common/username";
|
||||
|
||||
export function AvatarWithDropdownMenu() {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
@ -44,7 +41,10 @@ export function AvatarWithDropdownMenu() {
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="rounded-full overflow-hidden">
|
||||
{user ? <GravatarAvatar className="w-8 h-8" email={user?.email || ""} url={user?.avatarUrl || ""} /> : <CircleUser className="w-9 h-9" />}
|
||||
{user ? <Avatar className="h-8 w-8 rounded-full">
|
||||
<AvatarImage src={getGravatarFromUser({ user })} alt={user.username} />
|
||||
<AvatarFallback className="rounded-full">{getFallbackAvatarFromUsername(user.nickname || user.username)}</AvatarFallback>
|
||||
</Avatar> : <CircleUser className="w-9 h-9" />}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56" align="start">
|
||||
@ -58,29 +58,6 @@ export function AvatarWithDropdownMenu() {
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>Team</DropdownMenuItem>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>Invite users</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem>Email</DropdownMenuItem>
|
||||
<DropdownMenuItem>Message</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>More...</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuItem>
|
||||
New Team
|
||||
<DropdownMenuShortcut>⌘+T</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>GitHub</DropdownMenuItem>
|
||||
<DropdownMenuItem>Support</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled>API</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={user ? handleLogout : toLogin}>
|
||||
{user ? `Logout (${user.username})` : "Login"}
|
||||
</DropdownMenuItem>
|
||||
|
@ -46,7 +46,7 @@ const navbarMenuComponents = [
|
||||
]
|
||||
|
||||
export function Navbar() {
|
||||
const { navbarAdditionalClassName, setMode, mode } = useDevice()
|
||||
const { navbarAdditionalClassName} = useDevice()
|
||||
return (
|
||||
<nav className={`grid grid-cols-[1fr_auto_1fr] items-center gap-4 h-full px-4 w-full ${navbarAdditionalClassName}`}>
|
||||
<div className="flex items-center justify-start">
|
||||
|
@ -34,7 +34,6 @@ export function LoginForm({
|
||||
url?: string
|
||||
} | null>(null)
|
||||
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
|
||||
const [captchaError, setCaptchaError] = useState<string | null>(null)
|
||||
const [isLogging, setIsLogging] = useState(false)
|
||||
const [refreshCaptchaKey, setRefreshCaptchaKey] = useState(0)
|
||||
const [{ username, password }, setCredentials] = useState({ username: '', password: '' })
|
||||
@ -51,7 +50,7 @@ export function LoginForm({
|
||||
toast.error(t("fetch_oidc_configs_failed") + (error?.message ? `: ${error.message}` : ""))
|
||||
setOidcConfigs([]) // 错误时设置为空数组
|
||||
})
|
||||
}, [])
|
||||
}, [t])
|
||||
|
||||
useEffect(() => {
|
||||
getCaptchaConfig()
|
||||
@ -62,7 +61,7 @@ export function LoginForm({
|
||||
toast.error(t("fetch_captcha_config_failed") + (error?.message ? `: ${error.message}` : ""))
|
||||
setCaptchaProps(null)
|
||||
})
|
||||
}, [refreshCaptchaKey])
|
||||
}, [refreshCaptchaKey, t])
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
setIsLogging(true)
|
||||
@ -84,8 +83,7 @@ export function LoginForm({
|
||||
}
|
||||
|
||||
const handleCaptchaError = (error: string) => {
|
||||
setCaptchaError(error);
|
||||
// 刷新验证码
|
||||
toast.error(t("captcha_error") + (error ? `: ${error}` : ""));
|
||||
setTimeout(() => {
|
||||
setRefreshCaptchaKey(k => k + 1);
|
||||
}, 1500);
|
||||
|
@ -1,7 +1,9 @@
|
||||
"use client"
|
||||
import { User } from "@/models/user";
|
||||
import GravatarAvatar from "@/components/common/gravatar";
|
||||
import { Mail, User as UserIcon, Shield } from 'lucide-react';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
|
||||
import { getGravatarUrl } from "@/utils/common/gravatar";
|
||||
import { getFirstCharFromUser } from "@/utils/common/username";
|
||||
|
||||
export function UserHeader({ user }: { user: User }) {
|
||||
return (
|
||||
@ -10,7 +12,10 @@ export function UserHeader({ user }: { user: User }) {
|
||||
<div className="md:basis-[20%] flex justify-center items-center p-4">
|
||||
{/* wrapper 控制显示大小,父组件给具体 w/h */}
|
||||
<div className="w-40 h-40 md:w-48 md:h-48 relative">
|
||||
<GravatarAvatar className="rounded-full w-full h-full" url={user.avatarUrl} email={user.email} size={200} />
|
||||
<Avatar className="h-full w-full rounded-full">
|
||||
<AvatarImage src={getGravatarUrl({ email: user.email, size: 120 })} alt={user.nickname} />
|
||||
<AvatarFallback className="rounded-full">{getFirstCharFromUser(user)}</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
Reference in New Issue
Block a user