mirror of
https://github.com/snowykami/neo-blog.git
synced 2025-09-26 11:06:23 +00:00
feat: add new color themes and styles for rose, violet, and yellow
- Introduced new CSS files for rose, violet, and yellow themes with custom color variables. - Implemented dark mode styles for each theme. - Created a color data structure to manage theme colors in the console settings. feat: implement image cropper component - Added an image cropper component for user profile picture editing. - Integrated the image cropper into the user profile page. feat: enhance console sidebar with user permissions - Defined sidebar items with permission checks for admin and editor roles. - Updated user center navigation to reflect user permissions. feat: add user profile and security settings - Developed user profile page with avatar upload and editing functionality. - Implemented user security settings for password and email verification. feat: create reusable dialog and OTP input components - Built a dialog component for modal interactions. - Developed an OTP input component for email verification. fix: improve file handling utilities - Added utility functions for file URI generation. - Implemented permission checks for user roles in the common utilities.
This commit is contained in:
@ -14,9 +14,26 @@ const axiosClient = axios.create({
|
||||
timeout: 10000,
|
||||
})
|
||||
|
||||
function isBrowserFormData(v: any) {
|
||||
return typeof FormData !== 'undefined' && v instanceof FormData
|
||||
}
|
||||
// node form-data (form-data package) heuristic
|
||||
function isNodeFormData(v: any) {
|
||||
return v && typeof v.getHeaders === 'function' && typeof v.pipe === 'function'
|
||||
}
|
||||
|
||||
axiosClient.interceptors.request.use((config) => {
|
||||
if (config.data && typeof config.data === 'object') {
|
||||
// 如果是 FormData(浏览器)或 node form-data,跳过对象转换
|
||||
if (config.data && typeof config.data === 'object' && !isBrowserFormData(config.data) && !isNodeFormData(config.data)) {
|
||||
config.data = camelToSnakeObj(config.data)
|
||||
} else if (isBrowserFormData(config.data)) {
|
||||
// 只处理键
|
||||
const formData = config.data as FormData
|
||||
const newFormData = new FormData()
|
||||
for (const [key, value] of formData.entries()) {
|
||||
newFormData.append(camelToSnakeObj(key), value)
|
||||
}
|
||||
config.data = newFormData
|
||||
}
|
||||
if (config.params && typeof config.params === 'object') {
|
||||
config.params = camelToSnakeObj(config.params)
|
||||
|
25
web/src/api/file.ts
Normal file
25
web/src/api/file.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { BaseResponse } from '@/models/resp'
|
||||
import axiosClient from './client'
|
||||
|
||||
export async function uploadFile({ file, name, group }: { file: File, name?: string, group?: string }): Promise<BaseResponse<{
|
||||
hash: string,
|
||||
id: number,
|
||||
}>> {
|
||||
if (typeof window === 'undefined') {
|
||||
throw new Error('uploadFile can only be used in the browser')
|
||||
}
|
||||
if (!file) {
|
||||
throw new Error('No file provided')
|
||||
}
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('name', name || file.name)
|
||||
formData.append('group', group || '')
|
||||
const res = await axiosClient.post<BaseResponse<{
|
||||
hash: string,
|
||||
id: number,
|
||||
}>>('/file/f', formData, {
|
||||
withCredentials: true,
|
||||
})
|
||||
return res.data
|
||||
}
|
@ -1,8 +1,8 @@
|
||||
import type { OidcConfig } from '@/models/oidc-config'
|
||||
import type { BaseResponse } from '@/models/resp'
|
||||
import type { RegisterRequest, User } from '@/models/user'
|
||||
import axiosClient from './client'
|
||||
import { CaptchaProvider } from '@/models/captcha'
|
||||
import axiosClient from './client'
|
||||
|
||||
export async function userLogin(
|
||||
{
|
||||
@ -83,4 +83,9 @@ export async function getCaptchaConfig(): Promise<BaseResponse<{
|
||||
url?: string
|
||||
}>>('/user/captcha')
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function updateUser(data: Partial<User>): Promise<BaseResponse<User>> {
|
||||
const res = await axiosClient.put<BaseResponse<User>>(`/user/u/${data.id}`, data)
|
||||
return res.data
|
||||
}
|
@ -7,8 +7,10 @@ import {
|
||||
} from "@/components/ui/sidebar"
|
||||
|
||||
import { useToLogin } from "@/hooks/use-route"
|
||||
import { useEffect } from "react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useAuth } from "@/contexts/auth-context"
|
||||
import { sidebarData, SidebarItem } from "@/components/console/data"
|
||||
import { usePathname } from "next/navigation"
|
||||
|
||||
export default function ConsoleLayout({
|
||||
children,
|
||||
@ -16,7 +18,21 @@ export default function ConsoleLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const { user } = useAuth();
|
||||
const [title, setTitle] = useState("Title");
|
||||
const toLogin = useToLogin();
|
||||
const pathname = usePathname() ?? "/"
|
||||
|
||||
const sideBarItems: SidebarItem[] = sidebarData.navMain.concat(sidebarData.navUserCenter);
|
||||
|
||||
useEffect(() => {
|
||||
const currentItem = sideBarItems.find(item => item.url === pathname);
|
||||
if (currentItem) {
|
||||
setTitle(currentItem.title);
|
||||
document.title = `${currentItem.title} - 控制台`;
|
||||
} else {
|
||||
setTitle("Title");
|
||||
}
|
||||
}, [pathname])
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
@ -35,8 +51,10 @@ export default function ConsoleLayout({
|
||||
>
|
||||
<AppSidebar variant="inset" />
|
||||
<SidebarInset>
|
||||
<SiteHeader />
|
||||
{children}
|
||||
<SiteHeader title={title} />
|
||||
<div className="p-5 md:p-8">
|
||||
{children}
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
|
5
web/src/app/console/setting/page.tsx
Normal file
5
web/src/app/console/setting/page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import SettingPage from "@/components/console/setting";
|
||||
|
||||
export default function Page() {
|
||||
return <SettingPage />;
|
||||
}
|
3
web/src/app/console/user-preference/page.tsx
Normal file
3
web/src/app/console/user-preference/page.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export default function Page() {
|
||||
return <div>个性化设置</div>
|
||||
}
|
5
web/src/app/console/user-profile/page.tsx
Normal file
5
web/src/app/console/user-profile/page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { UserProfilePage } from "@/components/console/user-profile";
|
||||
|
||||
export default function Page() {
|
||||
return <UserProfilePage />;
|
||||
}
|
5
web/src/app/console/user-security/page.tsx
Normal file
5
web/src/app/console/user-security/page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { UserSecurityPage } from "@/components/console/user-security";
|
||||
|
||||
export default function Page() {
|
||||
return <UserSecurityPage />;
|
||||
}
|
@ -1,5 +1,6 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "./styles/violet.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@ -43,75 +44,6 @@
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.65rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.141 0.005 285.823);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||
--primary: oklch(0.623 0.214 259.815);
|
||||
--primary-foreground: oklch(0.97 0.014 254.604);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.967 0.001 286.375);
|
||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||
--accent: oklch(0.967 0.001 286.375);
|
||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--input: oklch(0.92 0.004 286.32);
|
||||
--ring: oklch(0.623 0.214 259.815);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||
--sidebar-primary: oklch(0.623 0.214 259.815);
|
||||
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
|
||||
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||
--sidebar-ring: oklch(0.623 0.214 259.815);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.141 0.005 285.823);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.21 0.006 285.885);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.546 0.245 262.881);
|
||||
--primary-foreground: oklch(0.379 0.146 265.522);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.274 0.006 286.033);
|
||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||
--accent: oklch(0.274 0.006 286.033);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.488 0.243 264.376);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.21 0.006 285.885);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.546 0.245 262.881);
|
||||
--sidebar-primary-foreground: oklch(0.379 0.146 265.522);
|
||||
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.488 0.243 264.376);
|
||||
}
|
||||
|
||||
:root {
|
||||
--animation-duration: 0.6s;
|
||||
}
|
||||
|
68
web/src/app/styles/blue.css
Normal file
68
web/src/app/styles/blue.css
Normal file
@ -0,0 +1,68 @@
|
||||
:root {
|
||||
--radius: 0.65rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.141 0.005 285.823);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||
--primary: oklch(0.623 0.214 259.815);
|
||||
--primary-foreground: oklch(0.97 0.014 254.604);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.967 0.001 286.375);
|
||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||
--accent: oklch(0.967 0.001 286.375);
|
||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--input: oklch(0.92 0.004 286.32);
|
||||
--ring: oklch(0.623 0.214 259.815);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||
--sidebar-primary: oklch(0.623 0.214 259.815);
|
||||
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
|
||||
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||
--sidebar-ring: oklch(0.623 0.214 259.815);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.141 0.005 285.823);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.21 0.006 285.885);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.546 0.245 262.881);
|
||||
--primary-foreground: oklch(0.379 0.146 265.522);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.274 0.006 286.033);
|
||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||
--accent: oklch(0.274 0.006 286.033);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.488 0.243 264.376);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.21 0.006 285.885);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.546 0.245 262.881);
|
||||
--sidebar-primary-foreground: oklch(0.379 0.146 265.522);
|
||||
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.488 0.243 264.376);
|
||||
}
|
68
web/src/app/styles/green.css
Normal file
68
web/src/app/styles/green.css
Normal file
@ -0,0 +1,68 @@
|
||||
:root {
|
||||
--radius: 0.65rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.141 0.005 285.823);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||
--primary: oklch(0.723 0.219 149.579);
|
||||
--primary-foreground: oklch(0.982 0.018 155.826);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.967 0.001 286.375);
|
||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||
--accent: oklch(0.967 0.001 286.375);
|
||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--input: oklch(0.92 0.004 286.32);
|
||||
--ring: oklch(0.723 0.219 149.579);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||
--sidebar-primary: oklch(0.723 0.219 149.579);
|
||||
--sidebar-primary-foreground: oklch(0.982 0.018 155.826);
|
||||
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||
--sidebar-ring: oklch(0.723 0.219 149.579);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.141 0.005 285.823);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.21 0.006 285.885);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.696 0.17 162.48);
|
||||
--primary-foreground: oklch(0.393 0.095 152.535);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.274 0.006 286.033);
|
||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||
--accent: oklch(0.274 0.006 286.033);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.527 0.154 150.069);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.21 0.006 285.885);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.696 0.17 162.48);
|
||||
--sidebar-primary-foreground: oklch(0.393 0.095 152.535);
|
||||
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.527 0.154 150.069);
|
||||
}
|
68
web/src/app/styles/orange.css
Normal file
68
web/src/app/styles/orange.css
Normal file
@ -0,0 +1,68 @@
|
||||
:root {
|
||||
--radius: 0.65rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.141 0.005 285.823);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||
--primary: oklch(0.705 0.213 47.604);
|
||||
--primary-foreground: oklch(0.98 0.016 73.684);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.967 0.001 286.375);
|
||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||
--accent: oklch(0.967 0.001 286.375);
|
||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--input: oklch(0.92 0.004 286.32);
|
||||
--ring: oklch(0.705 0.213 47.604);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||
--sidebar-primary: oklch(0.705 0.213 47.604);
|
||||
--sidebar-primary-foreground: oklch(0.98 0.016 73.684);
|
||||
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||
--sidebar-ring: oklch(0.705 0.213 47.604);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.141 0.005 285.823);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.21 0.006 285.885);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.646 0.222 41.116);
|
||||
--primary-foreground: oklch(0.98 0.016 73.684);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.274 0.006 286.033);
|
||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||
--accent: oklch(0.274 0.006 286.033);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.646 0.222 41.116);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.21 0.006 285.885);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.646 0.222 41.116);
|
||||
--sidebar-primary-foreground: oklch(0.98 0.016 73.684);
|
||||
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.646 0.222 41.116);
|
||||
}
|
68
web/src/app/styles/red.css
Normal file
68
web/src/app/styles/red.css
Normal file
@ -0,0 +1,68 @@
|
||||
:root {
|
||||
--radius: 0.65rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.141 0.005 285.823);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||
--primary: oklch(0.637 0.237 25.331);
|
||||
--primary-foreground: oklch(0.971 0.013 17.38);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.967 0.001 286.375);
|
||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||
--accent: oklch(0.967 0.001 286.375);
|
||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--input: oklch(0.92 0.004 286.32);
|
||||
--ring: oklch(0.637 0.237 25.331);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||
--sidebar-primary: oklch(0.637 0.237 25.331);
|
||||
--sidebar-primary-foreground: oklch(0.971 0.013 17.38);
|
||||
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||
--sidebar-ring: oklch(0.637 0.237 25.331);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.141 0.005 285.823);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.21 0.006 285.885);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.637 0.237 25.331);
|
||||
--primary-foreground: oklch(0.971 0.013 17.38);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.274 0.006 286.033);
|
||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||
--accent: oklch(0.274 0.006 286.033);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.637 0.237 25.331);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.21 0.006 285.885);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.637 0.237 25.331);
|
||||
--sidebar-primary-foreground: oklch(0.971 0.013 17.38);
|
||||
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.637 0.237 25.331);
|
||||
}
|
68
web/src/app/styles/rose.css
Normal file
68
web/src/app/styles/rose.css
Normal file
@ -0,0 +1,68 @@
|
||||
:root {
|
||||
--radius: 0.65rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.141 0.005 285.823);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||
--primary: oklch(0.645 0.246 16.439);
|
||||
--primary-foreground: oklch(0.969 0.015 12.422);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.967 0.001 286.375);
|
||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||
--accent: oklch(0.967 0.001 286.375);
|
||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--input: oklch(0.92 0.004 286.32);
|
||||
--ring: oklch(0.645 0.246 16.439);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||
--sidebar-primary: oklch(0.645 0.246 16.439);
|
||||
--sidebar-primary-foreground: oklch(0.969 0.015 12.422);
|
||||
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||
--sidebar-ring: oklch(0.645 0.246 16.439);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.141 0.005 285.823);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.21 0.006 285.885);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.645 0.246 16.439);
|
||||
--primary-foreground: oklch(0.969 0.015 12.422);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.274 0.006 286.033);
|
||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||
--accent: oklch(0.274 0.006 286.033);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.645 0.246 16.439);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.21 0.006 285.885);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.645 0.246 16.439);
|
||||
--sidebar-primary-foreground: oklch(0.969 0.015 12.422);
|
||||
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.645 0.246 16.439);
|
||||
}
|
68
web/src/app/styles/violet.css
Normal file
68
web/src/app/styles/violet.css
Normal file
@ -0,0 +1,68 @@
|
||||
:root {
|
||||
--radius: 0.65rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.141 0.005 285.823);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||
--primary: oklch(0.606 0.25 292.717);
|
||||
--primary-foreground: oklch(0.969 0.016 293.756);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.967 0.001 286.375);
|
||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||
--accent: oklch(0.967 0.001 286.375);
|
||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--input: oklch(0.92 0.004 286.32);
|
||||
--ring: oklch(0.606 0.25 292.717);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||
--sidebar-primary: oklch(0.606 0.25 292.717);
|
||||
--sidebar-primary-foreground: oklch(0.969 0.016 293.756);
|
||||
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||
--sidebar-ring: oklch(0.606 0.25 292.717);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.141 0.005 285.823);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.21 0.006 285.885);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.541 0.281 293.009);
|
||||
--primary-foreground: oklch(0.969 0.016 293.756);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.274 0.006 286.033);
|
||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||
--accent: oklch(0.274 0.006 286.033);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.541 0.281 293.009);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.21 0.006 285.885);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.541 0.281 293.009);
|
||||
--sidebar-primary-foreground: oklch(0.969 0.016 293.756);
|
||||
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.541 0.281 293.009);
|
||||
}
|
68
web/src/app/styles/yellow.css
Normal file
68
web/src/app/styles/yellow.css
Normal file
@ -0,0 +1,68 @@
|
||||
:root {
|
||||
--radius: 0.65rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.141 0.005 285.823);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||
--primary: oklch(0.795 0.184 86.047);
|
||||
--primary-foreground: oklch(0.421 0.095 57.708);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.967 0.001 286.375);
|
||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||
--accent: oklch(0.967 0.001 286.375);
|
||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--input: oklch(0.92 0.004 286.32);
|
||||
--ring: oklch(0.795 0.184 86.047);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||
--sidebar-primary: oklch(0.795 0.184 86.047);
|
||||
--sidebar-primary-foreground: oklch(0.421 0.095 57.708);
|
||||
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||
--sidebar-ring: oklch(0.795 0.184 86.047);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.141 0.005 285.823);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.21 0.006 285.885);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.795 0.184 86.047);
|
||||
--primary-foreground: oklch(0.421 0.095 57.708);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.274 0.006 286.033);
|
||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||
--accent: oklch(0.274 0.006 286.033);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.554 0.135 66.442);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.21 0.006 285.885);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.795 0.184 86.047);
|
||||
--sidebar-primary-foreground: oklch(0.421 0.095 57.708);
|
||||
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.554 0.135 66.442);
|
||||
}
|
@ -9,6 +9,7 @@ import { cn } from '@/lib/utils'
|
||||
import { getPostHref } from '@/utils/common/post'
|
||||
import { motion } from 'motion/react'
|
||||
import { deceleration } from '@/motion/curve'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
|
||||
export function BlogCard({ post, className }: {
|
||||
@ -158,37 +159,58 @@ export function BlogCard({ post, className }: {
|
||||
// 骨架屏加载组件 - 使用 shadcn Card 结构
|
||||
export function BlogCardSkeleton() {
|
||||
return (
|
||||
<Card className="overflow-hidden h-full flex flex-col">
|
||||
{/* 封面图片骨架 */}
|
||||
<div className="aspect-[16/9] bg-muted animate-pulse" />
|
||||
<Card className="group overflow-hidden hover:shadow-xl transition-all duration-300 h-full flex flex-col cursor-default pt-0 pb-4">
|
||||
{/* 封面骨架 */}
|
||||
<div className="relative aspect-[16/9] overflow-hidden">
|
||||
<Skeleton className="absolute inset-0" />
|
||||
|
||||
{/* Header 骨架 */}
|
||||
<CardHeader className="pb-3">
|
||||
<div className="h-6 bg-muted rounded animate-pulse mb-2" />
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 bg-muted rounded animate-pulse" />
|
||||
<div className="h-4 bg-muted rounded w-3/4 animate-pulse" />
|
||||
<div className="h-4 bg-muted rounded w-1/2 animate-pulse" />
|
||||
{/* 覆盖层(模拟暗色遮罩) */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/40 via-transparent to-transparent" />
|
||||
|
||||
{/* 私有标识骨架 */}
|
||||
<div className="absolute top-2 left-2">
|
||||
<Skeleton className="h-6 w-14 rounded-md" />
|
||||
</div>
|
||||
|
||||
{/* 统计信息骨架 */}
|
||||
<div className="absolute bottom-2 left-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-3 w-6 rounded" />
|
||||
<Skeleton className="h-3 w-6 rounded" />
|
||||
<Skeleton className="h-3 w-6 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 热度骨架 */}
|
||||
<div className="absolute bottom-2 right-2">
|
||||
<Skeleton className="h-6 w-12 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 标题骨架 */}
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle>
|
||||
<Skeleton className="h-5 w-3/4 rounded" />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
{/* Content 骨架 */}
|
||||
<CardContent className="flex-1 pb-3">
|
||||
<div className="flex gap-2 mb-4">
|
||||
<div className="h-6 w-16 bg-muted rounded animate-pulse" />
|
||||
<div className="h-6 w-20 bg-muted rounded animate-pulse" />
|
||||
<div className="h-6 w-14 bg-muted rounded animate-pulse" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="h-4 bg-muted rounded animate-pulse" />
|
||||
<div className="h-4 bg-muted rounded animate-pulse" />
|
||||
</div>
|
||||
{/* 内容骨架 */}
|
||||
<CardContent className="flex-1">
|
||||
<CardDescription>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 rounded" />
|
||||
<Skeleton className="h-4 w-5/6 rounded" />
|
||||
<Skeleton className="h-4 w-2/3 rounded" />
|
||||
</div>
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
|
||||
{/* Footer 骨架 */}
|
||||
<CardFooter className="pt-3 border-t">
|
||||
<div className="h-4 w-24 bg-muted rounded animate-pulse" />
|
||||
<div className="h-4 w-20 bg-muted rounded animate-pulse ml-auto" />
|
||||
{/* 底部骨架 */}
|
||||
<CardFooter className="pb-0 border-t border-border/50 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-4 w-24 rounded" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-20 rounded" />
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
|
@ -59,7 +59,7 @@ export function SidebarHotPosts({ posts, sortType }: { posts: Post[], sortType:
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<TrendingUp className="w-5 h-5 text-orange-500" />
|
||||
{sortType === 'latest' ? '热门文章' : '最新文章'}
|
||||
{sortType === 'latest' ? '最新文章' : '热门文章'}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
|
@ -8,7 +8,7 @@ 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 { getGravatarFromUser, getGravatarUrl } from "@/utils/common/gravatar";
|
||||
import { getFirstCharFromUser } from "@/utils/common/username";
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
|
||||
@ -68,7 +68,7 @@ export function CommentInput(
|
||||
<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 && <Avatar className="h-full w-full rounded-full">
|
||||
<AvatarImage src={getGravatarUrl({ email: user.email, size: 120 })} alt={user.nickname} />
|
||||
<AvatarImage src={getGravatarFromUser({ user, size: 120 })} alt={user.nickname} />
|
||||
<AvatarFallback className="rounded-full">{getFirstCharFromUser(user)}</AvatarFallback>
|
||||
</Avatar>}
|
||||
{!user && <CircleUser className="w-full h-full fade-in" />}
|
||||
|
@ -13,7 +13,7 @@ import { createComment, deleteComment, getComment, listComments, updateComment }
|
||||
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 { getGravatarFromUser, getGravatarUrl } from "@/utils/common/gravatar";
|
||||
import { getFirstCharFromUser } from "@/utils/common/username";
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
|
||||
@ -35,7 +35,7 @@ export function CommentItem(
|
||||
onReplySubmitted: ({ commentContent, isPrivate }: { commentContent: string, isPrivate: boolean }) => void,
|
||||
}
|
||||
) {
|
||||
const {user} = useAuth();
|
||||
const { user } = useAuth();
|
||||
const locale = useLocale();
|
||||
const t = useTranslations("Comment");
|
||||
const commonT = useTranslations("Common");
|
||||
@ -160,9 +160,9 @@ export function CommentItem(
|
||||
<div className="flex">
|
||||
<div onClick={() => clickToUserProfile(commentState.user.username)} className="cursor-pointer fade-in w-12 h-12">
|
||||
<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>
|
||||
<AvatarImage src={getGravatarFromUser({ user: commentState.user, 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">
|
||||
@ -234,7 +234,7 @@ export function CommentItem(
|
||||
>
|
||||
<Heart className="w-3 h-3" /> <div>{likeCount}</div>
|
||||
</button>
|
||||
|
||||
|
||||
{/* 编辑和删除按钮 仅自己的评论可见 */}
|
||||
{user?.id === commentState.user.id && (
|
||||
<>
|
||||
@ -271,7 +271,7 @@ export function CommentItem(
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{/* 这俩输入框一次只能显示一个 */}
|
||||
|
28
web/src/components/common/image-cropper.tsx
Normal file
28
web/src/components/common/image-cropper.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
export function ImageCropper({ image, onCropped, onCancel }: { image: File, onCropped: (blob: Blob) => void, onCancel: () => void }) {
|
||||
return (
|
||||
<Dialog>
|
||||
<form>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">Edit</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
|
||||
</DialogContent>
|
||||
</form>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
@ -17,37 +17,10 @@ import {
|
||||
} from "@/components/ui/sidebar"
|
||||
import config from "@/config"
|
||||
import Link from "next/link"
|
||||
import { Folder, Gauge, MessageCircle, Newspaper, Users } from "lucide-react"
|
||||
import { NavUserCenter } from "./nav-ucenter"
|
||||
import { sidebarData } from "./data"
|
||||
|
||||
|
||||
const data = {
|
||||
navMain: [
|
||||
{
|
||||
title: "大石坝",
|
||||
url: "/console",
|
||||
icon: Gauge,
|
||||
},
|
||||
{
|
||||
title: "文章管理",
|
||||
url: "/console/post",
|
||||
icon: Newspaper,
|
||||
},
|
||||
{
|
||||
title: "评论管理",
|
||||
url: "/console/comment",
|
||||
icon: MessageCircle,
|
||||
},
|
||||
{
|
||||
title: "文件管理",
|
||||
url: "/console/file",
|
||||
icon: Folder,
|
||||
},
|
||||
{
|
||||
title: "用户管理",
|
||||
url: "/console/user",
|
||||
icon: Users,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
return (
|
||||
@ -68,7 +41,8 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<NavMain items={data.navMain} />
|
||||
<NavMain items={sidebarData.navMain} />
|
||||
<NavUserCenter items={sidebarData.navUserCenter} />
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<NavUser />
|
||||
|
71
web/src/components/console/data.ts
Normal file
71
web/src/components/console/data.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import type { User } from "@/models/user";
|
||||
import { isAdmin, isEditor } from "@/utils/common/permission";
|
||||
import { Folder, Gauge, MessageCircle, Newspaper, Palette, Settings, ShieldCheck, UserPen, Users } from "lucide-react";
|
||||
|
||||
export interface SidebarItem {
|
||||
title: string;
|
||||
url: string;
|
||||
icon: React.ComponentType<any>;
|
||||
permission: ({ user }: { user: User }) => boolean;
|
||||
}
|
||||
|
||||
export const sidebarData: { navMain: SidebarItem[]; navUserCenter: SidebarItem[] } = {
|
||||
navMain: [
|
||||
{
|
||||
title: "大石坝",
|
||||
url: "/console",
|
||||
icon: Gauge,
|
||||
permission: isAdmin
|
||||
},
|
||||
{
|
||||
title: "文章管理",
|
||||
url: "/console/post",
|
||||
icon: Newspaper,
|
||||
permission: isEditor
|
||||
},
|
||||
{
|
||||
title: "评论管理",
|
||||
url: "/console/comment",
|
||||
icon: MessageCircle,
|
||||
permission: isEditor
|
||||
},
|
||||
{
|
||||
title: "文件管理",
|
||||
url: "/console/file",
|
||||
icon: Folder,
|
||||
permission: () => true
|
||||
},
|
||||
{
|
||||
title: "用户管理",
|
||||
url: "/console/user",
|
||||
icon: Users,
|
||||
permission: isAdmin
|
||||
},
|
||||
{
|
||||
title: "全局设置",
|
||||
url: "/console/setting",
|
||||
icon: Settings,
|
||||
permission: isAdmin
|
||||
},
|
||||
],
|
||||
navUserCenter: [
|
||||
{
|
||||
title: "个人资料",
|
||||
url: "/console/user-profile",
|
||||
icon: UserPen,
|
||||
permission: () => true
|
||||
},
|
||||
{
|
||||
title: "安全设置",
|
||||
url: "/console/user-security",
|
||||
icon: ShieldCheck,
|
||||
permission: () => true
|
||||
},
|
||||
{
|
||||
title: "个性化",
|
||||
url: "/console/user-preference",
|
||||
icon: Palette,
|
||||
permission: () => true
|
||||
}
|
||||
]
|
||||
}
|
@ -1,92 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
IconDots,
|
||||
IconFolder,
|
||||
IconShare3,
|
||||
IconTrash,
|
||||
type Icon,
|
||||
} from "@tabler/icons-react"
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar"
|
||||
|
||||
export function NavDocuments({
|
||||
items,
|
||||
}: {
|
||||
items: {
|
||||
name: string
|
||||
url: string
|
||||
icon: Icon
|
||||
}[]
|
||||
}) {
|
||||
const { isMobile } = useSidebar()
|
||||
|
||||
return (
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
||||
<SidebarGroupLabel>Documents</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
<SidebarMenuItem key={item.name}>
|
||||
<SidebarMenuButton asChild>
|
||||
<a href={item.url}>
|
||||
<item.icon />
|
||||
<span>{item.name}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuAction
|
||||
showOnHover
|
||||
className="data-[state=open]:bg-accent rounded-sm"
|
||||
>
|
||||
<IconDots />
|
||||
<span className="sr-only">More</span>
|
||||
</SidebarMenuAction>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-24 rounded-lg"
|
||||
side={isMobile ? "bottom" : "right"}
|
||||
align={isMobile ? "end" : "start"}
|
||||
>
|
||||
<DropdownMenuItem>
|
||||
<IconFolder />
|
||||
<span>Open</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<IconShare3 />
|
||||
<span>Share</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive">
|
||||
<IconTrash />
|
||||
<span>Delete</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton className="text-sidebar-foreground/70">
|
||||
<IconDots className="text-sidebar-foreground/70" />
|
||||
<span>More</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
)
|
||||
}
|
@ -3,6 +3,7 @@
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
@ -11,6 +12,8 @@ import Link from "next/link"
|
||||
import type { LucideProps } from "lucide-react";
|
||||
import { ComponentType, SVGProps } from "react"
|
||||
import { usePathname } from "next/navigation";
|
||||
import { User } from "@/models/user";
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
|
||||
export function NavMain({
|
||||
items,
|
||||
@ -19,19 +22,23 @@ export function NavMain({
|
||||
title: string
|
||||
url: string
|
||||
icon?: ComponentType<SVGProps<SVGSVGElement> & LucideProps>;
|
||||
permission: ({ user }: { user: User }) => boolean
|
||||
}[]
|
||||
}) {
|
||||
const { user } = useAuth();
|
||||
const pathname = usePathname() ?? "/"
|
||||
console.log("pathname", pathname)
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent className="flex flex-col gap-2">
|
||||
<SidebarGroupLabel>General</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
item.permission({ user }) && <SidebarMenuItem key={item.title}>
|
||||
<Link href={item.url}>
|
||||
<SidebarMenuButton tooltip={item.title} isActive={pathname===item.url}>
|
||||
<SidebarMenuButton tooltip={item.title} isActive={pathname === item.url}>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
</SidebarMenuButton>
|
||||
|
67
web/src/components/console/nav-ucenter.tsx
Normal file
67
web/src/components/console/nav-ucenter.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
IconDots,
|
||||
IconFolder,
|
||||
IconShare3,
|
||||
IconTrash,
|
||||
type Icon,
|
||||
} from "@tabler/icons-react"
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar"
|
||||
import { ComponentType, SVGProps } from "react"
|
||||
import { LucideProps } from "lucide-react"
|
||||
import { User } from "@/models/user"
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { useAuth } from "@/contexts/auth-context"
|
||||
|
||||
export function NavUserCenter({
|
||||
items,
|
||||
}: {
|
||||
items: {
|
||||
title: string
|
||||
url: string
|
||||
icon?: ComponentType<SVGProps<SVGSVGElement> & LucideProps>;
|
||||
permission: ({ user }: { user: User }) => boolean
|
||||
}[]
|
||||
}) {
|
||||
const { isMobile } = useSidebar()
|
||||
const { user } = useAuth();
|
||||
const pathname = usePathname() ?? "/"
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
||||
<SidebarGroupLabel>Personal</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
item.permission({ user }) && <SidebarMenuItem key={item.title}>
|
||||
<Link href={item.url}>
|
||||
<SidebarMenuButton tooltip={item.title} isActive={pathname === item.url}>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
</SidebarMenuButton>
|
||||
</Link>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
)
|
||||
}
|
@ -28,14 +28,24 @@ import {
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar"
|
||||
import { User } from "@/models/user"
|
||||
|
||||
import { getGravatarFromUser } from "@/utils/common/gravatar"
|
||||
import { getFallbackAvatarFromUsername } from "@/utils/common/username"
|
||||
import { useAuth } from "@/contexts/auth-context"
|
||||
import { userLogout } from "@/api/user"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export function NavUser({}: {}) {
|
||||
export function NavUser({ }: {}) {
|
||||
const { isMobile } = useSidebar()
|
||||
const {user} = useAuth();
|
||||
const { user } = useAuth();
|
||||
|
||||
const handleLogout = () => {
|
||||
userLogout().then(() => {
|
||||
toast.success("Logged out successfully");
|
||||
window.location.reload();
|
||||
})
|
||||
}
|
||||
|
||||
if (!user) return null
|
||||
return (
|
||||
<SidebarMenu>
|
||||
@ -95,7 +105,7 @@ export function NavUser({}: {}) {
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleLogout}>
|
||||
<IconLogout />
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
|
23
web/src/components/console/setting/colors.ts
Normal file
23
web/src/components/console/setting/colors.ts
Normal file
@ -0,0 +1,23 @@
|
||||
export const colorData = {
|
||||
"red": {
|
||||
"primary": "oklch(0.637 0.237 25.331)",
|
||||
},
|
||||
"rose": {
|
||||
"primary": "oklch(0.645 0.246 16.439)",
|
||||
},
|
||||
"orange": {
|
||||
"primary": "oklch(0.705 0.213 47.604)",
|
||||
},
|
||||
"green": {
|
||||
"primary": "oklch(0.723 0.219 149.579)",
|
||||
},
|
||||
"blue": {
|
||||
"primary": "oklch(0.623 0.214 259.815)",
|
||||
},
|
||||
"yellow": {
|
||||
"primary": "oklch(0.795 0.184 86.047)",
|
||||
},
|
||||
"violet": {
|
||||
"primary": "oklch(0.606 0.25 292.717)",
|
||||
},
|
||||
}
|
18
web/src/components/console/setting/index.tsx
Normal file
18
web/src/components/console/setting/index.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
export default function SettingPage() {
|
||||
return <div>
|
||||
<h2 className="text-2xl font-bold">
|
||||
全局设置
|
||||
</h2>
|
||||
<div className="grid w-full max-w-sm items-center gap-3 mt-4">
|
||||
<Label htmlFor="themeColor">配色方案</Label>
|
||||
<Input type="color" id="themeColor" />
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function ColorPick() {
|
||||
|
||||
}
|
@ -2,7 +2,7 @@ import { Button } from "@/components/ui/button"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { SidebarTrigger } from "@/components/ui/sidebar"
|
||||
|
||||
export function SiteHeader() {
|
||||
export function SiteHeader({ title = "Title" }: { title?: string }) {
|
||||
return (
|
||||
<header className="flex h-(--header-height) shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)">
|
||||
<div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
|
||||
@ -11,7 +11,7 @@ export function SiteHeader() {
|
||||
orientation="vertical"
|
||||
className="mx-2 data-[orientation=vertical]:h-4"
|
||||
/>
|
||||
<h1 className="text-base font-medium">Documents</h1>
|
||||
<h1 className="text-base font-medium">{title}</h1>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button variant="ghost" asChild size="sm" className="hidden sm:flex">
|
||||
<a
|
||||
|
135
web/src/components/console/user-profile/index.tsx
Normal file
135
web/src/components/console/user-profile/index.tsx
Normal file
@ -0,0 +1,135 @@
|
||||
"use client"
|
||||
import { uploadFile } from "@/api/file";
|
||||
import { updateUser } from "@/api/user";
|
||||
import { ImageCropper } from "@/components/common/image-cropper";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
import { getFileUri } from "@/utils/client/file";
|
||||
import { getGravatarFromUser } from "@/utils/common/gravatar";
|
||||
import { getFallbackAvatarFromUsername } from "@/utils/common/username";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface UploadConstraints {
|
||||
allowedTypes: string[];
|
||||
maxSize: number;
|
||||
}
|
||||
|
||||
interface PictureInputChangeEvent {
|
||||
target: HTMLInputElement & { files?: FileList | null };
|
||||
}
|
||||
|
||||
export function UserProfilePage() {
|
||||
const { user } = useAuth();
|
||||
if (!user) return null
|
||||
|
||||
const [nickname, setNickname] = useState(user.nickname || '')
|
||||
const [username, setUsername] = useState(user.username || '')
|
||||
const [avatarUrl, setAvatarUrl] = useState(user.avatarUrl || '')
|
||||
const [avatarFile, setAvatarFile] = useState<File | null>(null)
|
||||
const [gender, setGender] = useState(user.gender || '')
|
||||
|
||||
useEffect(() => {
|
||||
// if (!avatarFile) return
|
||||
// uploadFile({ file: avatarFile! }).then(res => {
|
||||
// setAvatarUrl(getFileUri(res.data.id))
|
||||
// toast.success('Avatar uploaded successfully')
|
||||
// }).catch(err => {
|
||||
// console.log(err)
|
||||
// toast.error(`Error: ${err?.response?.data?.message || err.message || 'Failed to upload avatar'}`)
|
||||
// })
|
||||
}, [avatarFile])
|
||||
|
||||
const handlePictureSelected = (e: PictureInputChangeEvent): void => {
|
||||
const file: File | null = e.target.files?.[0] ?? null;
|
||||
if (!file) {
|
||||
setAvatarFile(null);
|
||||
return;
|
||||
}
|
||||
const constraints: UploadConstraints = {
|
||||
allowedTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'],
|
||||
maxSize: 5 * 1024 * 1024, // 5 MB
|
||||
};
|
||||
if (!file.type || !file.type.startsWith('image/') || !constraints.allowedTypes.includes(file.type)) {
|
||||
setAvatarFile(null);
|
||||
toast.error('只允许上传 PNG / JPEG / WEBP / GIF 格式的图片');
|
||||
return;
|
||||
}
|
||||
if (file.size > constraints.maxSize) {
|
||||
setAvatarFile(null);
|
||||
toast.error('图片大小不能超过 5MB');
|
||||
return;
|
||||
}
|
||||
setAvatarFile(file);
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (nickname.trim() === '' || username.trim() === '') {
|
||||
toast.error('Nickname and Username cannot be empty')
|
||||
return
|
||||
}
|
||||
if ((username.length < 3 || username.length > 20) || (nickname.length < 1 || nickname.length > 20)) {
|
||||
toast.error('Nickname and Username must be between 3 and 20 characters')
|
||||
return
|
||||
}
|
||||
if (username === user.username && nickname === user.nickname && avatarUrl === user.avatarUrl && gender === user.gender) {
|
||||
toast.warning('No changes made')
|
||||
return
|
||||
}
|
||||
updateUser({ nickname, username, avatarUrl, gender, id: user.id }).then(res => {
|
||||
toast.success('Profile updated successfully')
|
||||
window.location.reload()
|
||||
}).catch(err => {
|
||||
console.log(err)
|
||||
toast.error(`Error: ${err?.response.data?.message || err.message || 'Failed to update profile'}`)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">
|
||||
Public Profile
|
||||
</h1>
|
||||
<Separator className="my-2" />
|
||||
<div className="grid w-full max-w-sm items-center gap-3">
|
||||
<Label htmlFor="picture">Picture</Label>
|
||||
<Avatar className="h-40 w-40 rounded-xl border-2">
|
||||
{!avatarFile && <AvatarImage src={getGravatarFromUser({ user })} alt={user.username} />}
|
||||
{avatarFile && <AvatarImage src={URL.createObjectURL(avatarFile)} alt={user.username} />}
|
||||
<AvatarFallback>{getFallbackAvatarFromUsername(nickname || username)}</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex gap-3"><Input
|
||||
id="picture"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif,image/*"
|
||||
onChange={handlePictureSelected}
|
||||
/>
|
||||
<ImageCropper />
|
||||
</div>
|
||||
<Input
|
||||
id="picture-url"
|
||||
type="url"
|
||||
value={avatarUrl}
|
||||
onChange={(e) => setAvatarUrl(e.target.value)}
|
||||
placeholder="若要用外链图像,请直接填写,不支持裁剪"
|
||||
/>
|
||||
<Label htmlFor="nickname">Nickname</Label>
|
||||
<Input type="nickname" id="nickname" value={nickname} onChange={(e) => setNickname(e.target.value)} />
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Input type="username" id="username" value={username} onChange={(e) => setUsername(e.target.value)} />
|
||||
<Label htmlFor="gender">Gender</Label>
|
||||
<Input type="gender" id="gender" value={gender} onChange={(e) => setGender(e.target.value)} />
|
||||
<Button className="max-w-1/3" onClick={handleSubmit}>Submit</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PictureEditor({}){
|
||||
|
||||
}
|
84
web/src/components/console/user-security/index.tsx
Normal file
84
web/src/components/console/user-security/index.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
"use client"
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
} from "@/components/ui/input-otp"
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const VERIFY_CODE_COOL_DOWN = 60; // seconds
|
||||
|
||||
export function UserSecurityPage() {
|
||||
const [email, setEmail] = useState("")
|
||||
const [verifyCode, setVerifyCode] = useState("")
|
||||
const [oldPassword, setOldPassword] = useState("")
|
||||
const [newPassword, setNewPassword] = useState("")
|
||||
const handleSubmitPassword = () => {
|
||||
|
||||
}
|
||||
const handleSendVerifyCode = () => {
|
||||
console.log("send verify code to ", email)
|
||||
}
|
||||
const handleSubmitEmail = () => {
|
||||
console.log("submit email ", email, verifyCode)
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<div className="grid w-full max-w-sm items-center gap-3">
|
||||
<h1 className="text-2xl font-bold">
|
||||
密码设置
|
||||
</h1>
|
||||
<Label htmlFor="password">Old Password</Label>
|
||||
<Input id="password" type="password" value={oldPassword} onChange={(e) => setOldPassword(e.target.value)} />
|
||||
<Label htmlFor="password">New Password</Label>
|
||||
<Input id="password" type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} />
|
||||
<Button className="max-w-1/3 border-2" onClick={handleSubmitPassword}>Submit</Button>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
<div className="grid w-full max-w-sm items-center gap-3 py-4">
|
||||
<h1 className="text-2xl font-bold">
|
||||
邮箱设置
|
||||
</h1>
|
||||
<Label htmlFor="email">email</Label>
|
||||
<div className="flex gap-3">
|
||||
<Input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
<Button variant="outline" className="border-2" onClick={handleSendVerifyCode}>发送验证码</Button>
|
||||
</div>
|
||||
<Label htmlFor="verify-code">verify code</Label>
|
||||
<div className="flex gap-3">
|
||||
<InputOTPControlled onChange={(value) => setVerifyCode(value)} />
|
||||
<Button className="border-2" onClick={handleSubmitEmail}>Submit</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InputOTPControlled({ onChange }: { onChange: (value: string) => void }) {
|
||||
const [value, setValue] = useState("")
|
||||
useEffect(() => {
|
||||
onChange(value)
|
||||
}, [value, onChange])
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
value={value}
|
||||
onChange={(value) => setValue(value)}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -36,7 +36,7 @@ export function AvatarWithDropdownMenu() {
|
||||
{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" />}
|
||||
</Avatar> : <CircleUser className="h-8 w-8" />}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56" align="start">
|
||||
|
@ -21,12 +21,14 @@ import { useTranslations } from "next-intl"
|
||||
import Captcha from "../common/captcha"
|
||||
import { CaptchaProvider } from "@/models/captcha"
|
||||
import { toast } from "sonner"
|
||||
import { useAuth } from "@/contexts/auth-context"
|
||||
|
||||
export function LoginForm({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
const t = useTranslations('Login')
|
||||
const {user, setUser} = useAuth();
|
||||
const [oidcConfigs, setOidcConfigs] = useState<OidcConfig[]>([])
|
||||
const [captchaProps, setCaptchaProps] = useState<{
|
||||
provider: CaptchaProvider
|
||||
@ -41,14 +43,20 @@ export function LoginForm({
|
||||
const searchParams = useSearchParams()
|
||||
const redirectBack = searchParams.get("redirect_back") || "/"
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
router.push(redirectBack);
|
||||
}
|
||||
}, [user, router, redirectBack]);
|
||||
|
||||
useEffect(() => {
|
||||
ListOidcConfigs()
|
||||
.then((res) => {
|
||||
setOidcConfigs(res.data || []) // 确保是数组
|
||||
setOidcConfigs(res.data || [])
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(t("fetch_oidc_configs_failed") + (error?.message ? `: ${error.message}` : ""))
|
||||
setOidcConfigs([]) // 错误时设置为空数组
|
||||
setOidcConfigs([])
|
||||
})
|
||||
}, [t])
|
||||
|
||||
@ -69,6 +77,7 @@ export function LoginForm({
|
||||
userLogin({ username, password, captcha: captchaToken || "" })
|
||||
.then(res => {
|
||||
toast.success(t("login_success") + ` ${res.data.user.nickname || res.data.user.username}`);
|
||||
setUser(res.data.user);
|
||||
router.push(redirectBack)
|
||||
})
|
||||
.catch(error => {
|
||||
|
143
web/src/components/ui/dialog.tsx
Normal file
143
web/src/components/ui/dialog.tsx
Normal file
@ -0,0 +1,143 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
77
web/src/components/ui/input-otp.tsx
Normal file
77
web/src/components/ui/input-otp.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { OTPInput, OTPInputContext } from "input-otp"
|
||||
import { MinusIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function InputOTP({
|
||||
className,
|
||||
containerClassName,
|
||||
...props
|
||||
}: React.ComponentProps<typeof OTPInput> & {
|
||||
containerClassName?: string
|
||||
}) {
|
||||
return (
|
||||
<OTPInput
|
||||
data-slot="input-otp"
|
||||
containerClassName={cn(
|
||||
"flex items-center gap-2 has-disabled:opacity-50",
|
||||
containerClassName
|
||||
)}
|
||||
className={cn("disabled:cursor-not-allowed", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-otp-group"
|
||||
className={cn("flex items-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputOTPSlot({
|
||||
index,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
index: number
|
||||
}) {
|
||||
const inputOTPContext = React.useContext(OTPInputContext)
|
||||
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="input-otp-slot"
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{char}
|
||||
{hasFakeCaret && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div data-slot="input-otp-separator" role="separator" {...props}>
|
||||
<MinusIcon />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
|
@ -2,7 +2,7 @@
|
||||
import { User } from "@/models/user";
|
||||
import { Mail, User as UserIcon, Shield } from 'lucide-react';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
|
||||
import { getGravatarUrl } from "@/utils/common/gravatar";
|
||||
import { getGravatarFromUser } from "@/utils/common/gravatar";
|
||||
import { getFirstCharFromUser } from "@/utils/common/username";
|
||||
|
||||
export function UserHeader({ user }: { user: User }) {
|
||||
@ -13,7 +13,7 @@ export function UserHeader({ user }: { user: User }) {
|
||||
{/* wrapper 控制显示大小,父组件给具体 w/h */}
|
||||
<div className="w-40 h-40 md:w-48 md:h-48 relative">
|
||||
<Avatar className="h-full w-full rounded-full">
|
||||
<AvatarImage src={getGravatarUrl({ email: user.email, size: 120 })} alt={user.nickname} />
|
||||
<AvatarImage src={getGravatarFromUser({user})} alt={user.nickname} />
|
||||
<AvatarFallback className="rounded-full">{getFirstCharFromUser(user)}</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
|
@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useState, useMemo } from "react";
|
||||
import React, { createContext, useContext, useState, useMemo, useEffect } from "react";
|
||||
import type { User } from "@/models/user";
|
||||
import { userLogout } from "@/api/user";
|
||||
import { getLoginUser, userLogout } from "@/api/user";
|
||||
|
||||
type AuthContextValue = {
|
||||
user: User | null;
|
||||
@ -21,6 +21,16 @@ export function AuthProvider({
|
||||
}) {
|
||||
const [user, setUser] = useState<User | null>(initialUser);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user){
|
||||
getLoginUser().then(res => {
|
||||
setUser(res.data);
|
||||
}).catch(() => {
|
||||
setUser(null);
|
||||
});
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const logout = async () => {
|
||||
setUser(null);
|
||||
await userLogout();
|
||||
|
@ -9,6 +9,11 @@ export interface User {
|
||||
language: string;
|
||||
}
|
||||
|
||||
export enum Role {
|
||||
ADMIN = "admin",
|
||||
USER = "user",
|
||||
EDITOR = "editor",
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
username: string
|
||||
|
3
web/src/utils/client/file.ts
Normal file
3
web/src/utils/client/file.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export function getFileUri(id: number){
|
||||
return `/api/v1/file/f/${id}`
|
||||
}
|
9
web/src/utils/common/permission.ts
Normal file
9
web/src/utils/common/permission.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { Role, User } from "@/models/user";
|
||||
|
||||
export function isAdmin({ user }: { user: User}) {
|
||||
return user.role === Role.ADMIN;
|
||||
}
|
||||
|
||||
export function isEditor({ user }: { user: User}) {
|
||||
return user.role === Role.EDITOR || user.role === Role.ADMIN;
|
||||
}
|
0
web/src/utils/server/file.ts
Normal file
0
web/src/utils/server/file.ts
Normal file
Reference in New Issue
Block a user