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:
2025-09-20 12:45:10 +08:00
parent f8e4a84d53
commit 709aa82337
62 changed files with 1844 additions and 487 deletions

View File

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

View File

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

View File

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

View File

@ -0,0 +1,5 @@
import SettingPage from "@/components/console/setting";
export default function Page() {
return <SettingPage />;
}

View File

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

View File

@ -0,0 +1,5 @@
import { UserProfilePage } from "@/components/console/user-profile";
export default function Page() {
return <UserProfilePage />;
}

View File

@ -0,0 +1,5 @@
import { UserSecurityPage } from "@/components/console/user-security";
export default function Page() {
return <UserSecurityPage />;
}

View File

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

@ -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>
{/* 这俩输入框一次只能显示一个 */}

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

View File

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

View 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
}
]
}

View File

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

View File

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

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

View File

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

View 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)",
},
}

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

View File

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

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

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

View File

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

View File

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

View 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,
}

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

View File

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

View File

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

View File

@ -9,6 +9,11 @@ export interface User {
language: string;
}
export enum Role {
ADMIN = "admin",
USER = "user",
EDITOR = "editor",
}
export interface RegisterRequest {
username: string

View File

@ -0,0 +1,3 @@
export function getFileUri(id: number){
return `/api/v1/file/f/${id}`
}

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

View File