} permissionIds - Permission IDs to check
81 | * @returns {Boolean} Whether user has all permissions
82 | */
83 | const hasAllPermissions = useCallback(
84 | (permissionIds) => {
85 | if (!nb.pubfn.isArray(permissionIds)) {
86 | return false;
87 | }
88 |
89 | // Admin has all permissions
90 | if (isAdmin || permissions.includes('*')) {
91 | return true;
92 | }
93 |
94 | return permissionIds.every((id) => permissions.includes(id));
95 | },
96 | [permissions, isAdmin]
97 | );
98 |
99 | return {
100 | permissions,
101 | isAdmin,
102 | loading,
103 | hasPermission,
104 | hasAnyPermission,
105 | hasAllPermissions,
106 | };
107 | }
108 |
109 | /**
110 | * Hook to check if user can access specific page
111 | * @param {String} pageUrl - Page URL to check
112 | * @returns {Object} Access check state
113 | */
114 | export function usePageAccess(pageUrl) {
115 | const [hasAccess, setHasAccess] = useState(false);
116 | const [loading, setLoading] = useState(true);
117 | const [error, setError] = useState(null);
118 |
119 | useEffect(() => {
120 | if (!pageUrl) {
121 | setLoading(false);
122 | return;
123 | }
124 |
125 | const checkAccess = async () => {
126 | setLoading(true);
127 | setError(null);
128 | try {
129 | const result = await checkPageAccessAction(pageUrl);
130 | if (result.success) {
131 | setHasAccess(result.hasAccess);
132 | } else {
133 | setError(result.error);
134 | setHasAccess(false);
135 | }
136 | } catch (err) {
137 | setError(err.message);
138 | setHasAccess(false);
139 | } finally {
140 | setLoading(false);
141 | }
142 | };
143 |
144 | checkAccess();
145 | }, [pageUrl]);
146 |
147 | return {
148 | hasAccess,
149 | loading,
150 | error,
151 | };
152 | }
153 |
154 | /**
155 | * Hook to get user's accessible menus
156 | * @returns {Object} Menu state
157 | */
158 | export function useUserMenus() {
159 | const [menus, setMenus] = useState([]);
160 | const [loading, setLoading] = useState(true);
161 | const [error, setError] = useState(null);
162 |
163 | useEffect(() => {
164 | const loadMenus = async () => {
165 | setLoading(true);
166 | setError(null);
167 | try {
168 | const result = await getUserAccessibleMenusAction();
169 | if (result.success) {
170 | setMenus(result.data || []);
171 | } else {
172 | setError(result.error);
173 | }
174 | } catch (err) {
175 | setError(err.message);
176 | } finally {
177 | setLoading(false);
178 | }
179 | };
180 |
181 | loadMenus();
182 | }, []);
183 |
184 | return {
185 | menus,
186 | loading,
187 | error,
188 | };
189 | }
190 |
191 |
--------------------------------------------------------------------------------
/app/(admin)/actions/system/crud-action.assets.js:
--------------------------------------------------------------------------------
1 | 'use server';
2 |
3 | import { prisma } from '@/lib/database/prisma';
4 | import { deleteFile } from '@/lib/upload/upload-service';
5 | import { wrapAction } from '@/lib/core/action-wrapper';
6 | import nb from '@/lib/function';
7 |
8 | /**
9 | * 素材管理 Actions(登录用户可用,RBAC 放宽)
10 | * - 列表/详情/更新/删除只需登录
11 | * - 非 admin 仅能访问/管理自己上传的文件
12 | */
13 | /**
14 | * 登录后即可访问的素材列表
15 | * 非 admin 只能看到自己上传的文件
16 | */
17 | export const getList = wrapAction('authGetAssetList', async (params = {}, ctx) => {
18 | const { userId, isAdmin } = ctx;
19 | if (!userId) {
20 | return { success: false, error: 'Unauthorized' };
21 | }
22 |
23 | const pageIndex = Number(params.pageIndex) || 1;
24 | const pageSize = Number(params.pageSize) || 20;
25 | const whereJson = params.whereJson || {};
26 |
27 | const where = { ...whereJson };
28 | if (!isAdmin) {
29 | where.userId = userId;
30 | }
31 |
32 | const [data, total] = await Promise.all([
33 | prisma.asset.findMany({
34 | where,
35 | orderBy: { createdAt: 'desc' },
36 | skip: (pageIndex - 1) * pageSize,
37 | take: pageSize,
38 | }),
39 | prisma.asset.count({ where }),
40 | ]);
41 |
42 | return {
43 | success: true,
44 | data,
45 | total,
46 | pageIndex,
47 | pageSize,
48 | };
49 | });
50 |
51 | /**
52 | * 获取素材详情(非 admin 只能查看自己的文件)
53 | */
54 | export const getDetail = wrapAction('authGetAssetDetail', async (params, ctx) => {
55 | const { userId, isAdmin } = ctx;
56 | const id = nb.pubfn.isString(params) ? params : params?.id;
57 |
58 | if (!id) {
59 | return { success: false, error: 'ID is required' };
60 | }
61 |
62 | const asset = await prisma.asset.findUnique({ where: { id } });
63 | if (!asset) {
64 | return { success: false, error: 'File not found' };
65 | }
66 |
67 | if (!isAdmin && asset.userId && asset.userId !== userId) {
68 | return { success: false, error: 'Permission denied' };
69 | }
70 |
71 | return { success: true, data: asset };
72 | });
73 |
74 | /**
75 | * 更新素材信息(非 admin 只能更新自己的文件)
76 | */
77 | export const update = wrapAction('authUpdateAsset', async (params, ctx) => {
78 | const { userId, isAdmin } = ctx;
79 | const { id, ...data } = params || {};
80 |
81 | if (!id) {
82 | return { success: false, error: 'ID is required for update' };
83 | }
84 |
85 | const asset = await prisma.asset.findUnique({ where: { id } });
86 | if (!asset) {
87 | return { success: false, error: 'File not found' };
88 | }
89 |
90 | if (!isAdmin && asset.userId && asset.userId !== userId) {
91 | return { success: false, error: 'Permission denied' };
92 | }
93 |
94 | const payload = {};
95 | if (data.originalName !== undefined) payload.originalName = data.originalName;
96 | if (data.remark !== undefined) payload.remark = data.remark;
97 |
98 | await prisma.asset.update({
99 | where: { id },
100 | data: payload,
101 | });
102 |
103 | return { success: true };
104 | });
105 |
106 | /**
107 | * 删除素材(同时删除 R2 文件)
108 | * 支持直接传入 id 字符串或 { id }
109 | */
110 | export const remove = wrapAction('authDeleteAsset', async (params, ctx) => {
111 | const { userId, isAdmin } = ctx;
112 | const id = nb.pubfn.isString(params) ? params : params?.id;
113 |
114 | if (!id) {
115 | return { success: false, error: 'ID is required' };
116 | }
117 |
118 | const file = await prisma.asset.findUnique({ where: { id } });
119 | if (!file) {
120 | return { success: false, error: 'File not found' };
121 | }
122 |
123 | if (!isAdmin && file.userId && file.userId !== userId) {
124 | return { success: false, error: 'Permission denied' };
125 | }
126 |
127 | // 删除 R2 文件和数据库记录(管理员可以删除任何文件)
128 | const result = await deleteFile(file.url || file.key, userId, { isAdmin });
129 |
130 | return result;
131 | });
132 |
133 | /**
134 | * 批量删除素材
135 | */
136 | export const batchDelete = wrapAction('authBatchDeleteAsset', async ({ ids }, ctx) => {
137 | const { userId, isAdmin } = ctx;
138 | if (!Array.isArray(ids) || ids.length === 0) {
139 | return { success: false, error: 'IDs are required' };
140 | }
141 |
142 | const errors = [];
143 | let successCount = 0;
144 |
145 | for (const id of ids) {
146 | try {
147 | if (!id) {
148 | errors.push('Invalid id: empty');
149 | continue;
150 | }
151 | const file = await prisma.asset.findUnique({ where: { id } });
152 | if (!file) {
153 | errors.push(`${id}: File not found`);
154 | continue;
155 | }
156 |
157 | if (!isAdmin && file.userId && file.userId !== userId) {
158 | errors.push(`${file.originalName}: Permission denied`);
159 | continue;
160 | }
161 |
162 | const result = await deleteFile(file.url || file.key, userId, { isAdmin });
163 | if (result.success) {
164 | successCount++;
165 | } else {
166 | errors.push(`${file.originalName}: ${result.error}`);
167 | }
168 | } catch (error) {
169 | errors.push(`${id}: ${error.message}`);
170 | }
171 | }
172 |
173 | return {
174 | success: errors.length === 0,
175 | deletedCount: successCount,
176 | errors: errors.length > 0 ? errors : undefined,
177 | };
178 | });
179 |
--------------------------------------------------------------------------------
/app/(admin)/actions/rbac/user-permissions.js:
--------------------------------------------------------------------------------
1 | 'use server';
2 |
3 | /**
4 | * User Permissions Server Actions
5 | *
6 | * 功能:
7 | * - 获取当前用户的菜单权限
8 | * - 获取当前用户的操作权限
9 | * - 验证用户是否有访问特定页面的权限
10 | */
11 |
12 | import { headers } from 'next/headers';
13 | import { auth } from '@/lib/auth/auth';
14 | import { prisma } from '@/lib/database/prisma';
15 | import * as sysDao from '@/app/(admin)/actions/dao/sys';
16 | import { wrapAction } from '@/lib/core/action-wrapper';
17 | import { checkBackendAccessAction } from '@/lib/auth/admin-auth';
18 | import nb from '@/lib/function';
19 |
20 | /**
21 | * Get current user's accessible menus (RBAC-aware)
22 | */
23 | export const getUserAccessibleMenusAction = wrapAction('authQueryUserAccessibleMenus', async (_, ctx) => {
24 | const backendCheck = await checkBackendAccessAction();
25 | if (!backendCheck.hasAccess) {
26 | return { success: false, error: backendCheck.error };
27 | }
28 |
29 | const session = await auth.api.getSession({
30 | headers: await headers(),
31 | });
32 |
33 | if (!session?.user) {
34 | return { success: false, error: 'Unauthorized: Please login' };
35 | }
36 |
37 | const userId = session.user.id;
38 | const userRole = session.user.role;
39 |
40 | // Admin role: get all enabled menus
41 | if (userRole === 'admin') {
42 | const allMenus = await prisma.menu.findMany({
43 | where: {
44 | enable: true,
45 | deletedAt: null,
46 | },
47 | orderBy: [{ sort: 'asc' }, { createdAt: 'asc' }],
48 | });
49 |
50 | // 使用 arrayToTree 构建树形结构
51 | const menuTree = nb.pubfn.tree.arrayToTree(allMenus, {
52 | filter: (item) => item.enable && !item.hidden,
53 | sortBy: [{ field: 'sort', order: 'asc' }],
54 | });
55 |
56 | return {
57 | success: true,
58 | data: menuTree,
59 | isAdmin: true,
60 | };
61 | }
62 |
63 | // Regular user: get menus based on RBAC roles
64 | const menuTree = await sysDao.getUserMenus(userId);
65 |
66 | return {
67 | success: true,
68 | data: menuTree,
69 | isAdmin: false,
70 | };
71 | }, { skipLog: true });
72 |
73 | /**
74 | * Get current user's permission IDs
75 | */
76 | export const getUserPermissionIdsAction = wrapAction('authQueryUserPermissionIds', async (_, ctx) => {
77 | const backendCheck = await checkBackendAccessAction();
78 | if (!backendCheck.hasAccess) {
79 | return { success: false, error: backendCheck.error };
80 | }
81 |
82 | const session = await auth.api.getSession({
83 | headers: await headers(),
84 | });
85 |
86 | if (!session?.user) {
87 | return { success: false, error: 'Unauthorized: Please login' };
88 | }
89 |
90 | const userId = session.user.id;
91 | const userRole = session.user.role;
92 |
93 | // Admin role: has all permissions
94 | if (userRole === 'admin') {
95 | return {
96 | success: true,
97 | data: ['*'],
98 | isAdmin: true,
99 | };
100 | }
101 |
102 | const permissionIds = await sysDao.getUserPermissionIds(userId);
103 |
104 | return {
105 | success: true,
106 | data: permissionIds,
107 | isAdmin: false,
108 | };
109 | }, { skipLog: true });
110 |
111 | /**
112 | * Check if current user can access a specific page URL
113 | */
114 | export const checkPageAccessAction = wrapAction('authCheckPageAccess', async (pageUrl, ctx) => {
115 | const backendCheck = await checkBackendAccessAction();
116 | if (!backendCheck.hasAccess) {
117 | return { success: false, hasAccess: false, error: backendCheck.error };
118 | }
119 |
120 | const session = await auth.api.getSession({
121 | headers: await headers(),
122 | });
123 |
124 | if (!session?.user) {
125 | return { success: false, hasAccess: false, error: 'Unauthorized: Please login' };
126 | }
127 |
128 | const userId = session.user.id;
129 | const userRole = session.user.role;
130 |
131 | // Admin role: can access all pages
132 | if (userRole === 'admin') {
133 | return {
134 | success: true,
135 | hasAccess: true,
136 | isAdmin: true,
137 | };
138 | }
139 |
140 | const menuTree = await sysDao.getUserMenus(userId);
141 | // 使用 findInTree 检查 URL 是否存在
142 | const hasAccess = !!nb.pubfn.tree.findInTree(menuTree, (item) => item.url === pageUrl);
143 |
144 | return {
145 | success: true,
146 | hasAccess,
147 | isAdmin: false,
148 | };
149 | }, { skipLog: true });
150 |
151 | /**
152 | * Get current user's roles
153 | */
154 | export const getUserRolesAction = wrapAction('authQueryUserRoles', async (_, ctx) => {
155 | const backendCheck = await checkBackendAccessAction();
156 | if (!backendCheck.hasAccess) {
157 | return { success: false, error: backendCheck.error };
158 | }
159 |
160 | const session = await auth.api.getSession({
161 | headers: await headers(),
162 | });
163 |
164 | if (!session?.user) {
165 | return { success: false, error: 'Unauthorized: Please login' };
166 | }
167 |
168 | const userId = session.user.id;
169 | const userRole = session.user.role;
170 |
171 | const roleIds = await sysDao.getUserRoleIds(userId);
172 |
173 | return {
174 | success: true,
175 | data: {
176 | betterAuthRole: userRole,
177 | rbacRoles: roleIds,
178 | },
179 | };
180 | }, { skipLog: true });
181 |
--------------------------------------------------------------------------------
/lib/upload/upload-guard.js:
--------------------------------------------------------------------------------
1 | 'use server';
2 |
3 | import { prisma } from '@/lib/database/prisma';
4 |
5 | const WINDOW_SECONDS = parseEnvInt(process.env.UPLOAD_RATE_WINDOW_SECONDS, 60);
6 | const LIMIT_PER_WINDOW = parseEnvInt(
7 | process.env.UPLOAD_RATE_LIMIT_PER_MINUTE ?? process.env.UPLOAD_RATE_LIMIT,
8 | 0
9 | );
10 | const BAN_DURATION_MINUTES = parseEnvInt(process.env.UPLOAD_RATE_BAN_DURATION, 0);
11 |
12 | const PERMANENT_BAN_UNTIL = new Date('9999-12-31T23:59:59.999Z');
13 |
14 | function parseEnvInt(value, defaultValue) {
15 | if (value === undefined || value === null || value === '') return defaultValue;
16 | const num = Number(value);
17 | return Number.isFinite(num) ? num : defaultValue;
18 | }
19 |
20 | function scopeLabel(scope) {
21 | return scope === 'ip' ? 'IP' : '用户';
22 | }
23 |
24 | function buildBlockedMessage({ scope, bannedUntil, limitReached }) {
25 | const label = scopeLabel(scope);
26 |
27 | if (bannedUntil) {
28 | if (bannedUntil.getTime() >= PERMANENT_BAN_UNTIL.getTime()) {
29 | return `Due to excessive frequency, this ${label} has been permanently banned from uploading.`;
30 | }
31 | return `Upload too frequent, this ${label} has been banned, please try again at ${bannedUntil.toLocaleString()}.`;
32 | }
33 |
34 | if (limitReached) {
35 | return `Upload too frequent, please try again later (rate limit window ${WINDOW_SECONDS} seconds).`;
36 | }
37 |
38 | return 'Upload is limited, please try again later (rate limit window ${WINDOW_SECONDS} seconds).';
39 | }
40 |
41 | async function applyGuard(type, value) {
42 | const now = new Date();
43 |
44 | let guard = await prisma.uploadGuard.findUnique({
45 | where: { type_value: { type, value } },
46 | });
47 |
48 | const windowMs = WINDOW_SECONDS * 1000;
49 | const hasGuard = !!guard;
50 | const windowStart = guard?.windowStart ? new Date(guard.windowStart) : now;
51 | const windowExpired = now.getTime() - windowStart.getTime() >= windowMs;
52 | const currentCount = windowExpired ? 0 : guard?.count || 0;
53 | const bannedUntil = guard?.bannedUntil ? new Date(guard.bannedUntil) : null;
54 |
55 | if (bannedUntil && bannedUntil.getTime() > now.getTime()) {
56 | return {
57 | blocked: true,
58 | scope: type,
59 | bannedUntil,
60 | message: buildBlockedMessage({ scope: type, bannedUntil }),
61 | };
62 | }
63 |
64 | // 未开启限流直接放行并重置窗口
65 | if (LIMIT_PER_WINDOW <= 0) {
66 | if (hasGuard) {
67 | await prisma.uploadGuard.update({
68 | where: { type_value: { type, value } },
69 | data: { windowStart: now, count: 0, bannedUntil: null },
70 | });
71 | }
72 | return { blocked: false };
73 | }
74 |
75 | const nextCount = currentCount + 1;
76 | const limitReached = currentCount >= LIMIT_PER_WINDOW;
77 |
78 | // 触发限流或封禁
79 | if (limitReached) {
80 | let nextBannedUntil = null;
81 | if (BAN_DURATION_MINUTES !== 0) {
82 | if (BAN_DURATION_MINUTES < 0) {
83 | nextBannedUntil = PERMANENT_BAN_UNTIL;
84 | } else {
85 | nextBannedUntil = new Date(now.getTime() + BAN_DURATION_MINUTES * 60 * 1000);
86 | }
87 | }
88 |
89 | await prisma.uploadGuard.upsert({
90 | where: { type_value: { type, value } },
91 | update: {
92 | windowStart: windowExpired ? now : windowStart,
93 | count: currentCount,
94 | bannedUntil: nextBannedUntil,
95 | },
96 | create: {
97 | type,
98 | value,
99 | windowStart: now,
100 | count: currentCount,
101 | bannedUntil: nextBannedUntil,
102 | },
103 | });
104 |
105 | return {
106 | blocked: true,
107 | scope: type,
108 | bannedUntil: nextBannedUntil,
109 | permanent: nextBannedUntil?.getTime() === PERMANENT_BAN_UNTIL.getTime(),
110 | message: buildBlockedMessage({
111 | scope: type,
112 | bannedUntil: nextBannedUntil,
113 | limitReached: true,
114 | }),
115 | };
116 | }
117 |
118 | // 允许上传,刷新计数
119 | await prisma.uploadGuard.upsert({
120 | where: { type_value: { type, value } },
121 | update: {
122 | windowStart: windowExpired ? now : windowStart,
123 | count: nextCount,
124 | bannedUntil: null,
125 | },
126 | create: {
127 | type,
128 | value,
129 | windowStart: now,
130 | count: 1,
131 | bannedUntil: null,
132 | },
133 | });
134 |
135 | return { blocked: false };
136 | }
137 |
138 | /**
139 | * 检查上传频率与封禁状态
140 | * @returns {Object} { allowed: boolean, message?, bannedUntil?, scope?, status? }
141 | */
142 | export async function checkUploadRateLimit({ userId, ip }) {
143 | if (!userId && !ip) {
144 | return { allowed: true };
145 | }
146 |
147 | // 未配置限流直接放行
148 | if (LIMIT_PER_WINDOW <= 0) {
149 | return { allowed: true };
150 | }
151 |
152 | const identifiers = [];
153 | if (userId) identifiers.push({ type: 'user', value: userId });
154 | if (ip) identifiers.push({ type: 'ip', value: ip });
155 |
156 | for (const item of identifiers) {
157 | const result = await applyGuard(item.type, item.value);
158 | if (result.blocked) {
159 | const status =
160 | result.bannedUntil || BAN_DURATION_MINUTES !== 0
161 | ? 403
162 | : 429;
163 | return {
164 | allowed: false,
165 | status,
166 | message: result.message,
167 | bannedUntil: result.bannedUntil,
168 | scope: result.scope,
169 | permanent: result.permanent,
170 | };
171 | }
172 | }
173 |
174 | return { allowed: true };
175 | }
176 |
--------------------------------------------------------------------------------
/app/(admin)/admin/system/login_logs/page.js:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Tag } from 'antd';
4 | import SmartCrudPage from '@/components/admin/smart-crud-page';
5 | import nb from '@/lib/function';
6 |
7 | // Server Actions
8 | import { getLoginLogListAction as getList, getLoginLogDetailAction as getDetail } from '@/app/(admin)/actions/system/admin-login-logs';
9 |
10 | export default function LoginLogsPage() {
11 | // 字段配置
12 | const fieldsConfig = [
13 | {
14 | key: 'id',
15 | title: 'ID',
16 | type: 'text',
17 | table: false,
18 | form: false,
19 | search: false,
20 | },
21 |
22 | // User
23 | {
24 | key: 'userInfo',
25 | title: 'User',
26 | type: 'custom',
27 | table: {
28 | width: 180,
29 | render: (userInfo, record) => {
30 | let user = null;
31 | if (nb.pubfn.isArray(userInfo) && userInfo.length > 0) {
32 | user = userInfo[0];
33 | } else if (userInfo && nb.pubfn.isObject(userInfo) && !nb.pubfn.isArray(userInfo)) {
34 | user = userInfo;
35 | }
36 |
37 | if (user?.name || user?.email) {
38 | return (
39 |
40 |
{user.name || 'Unknown'}
41 |
{user.email || record.userId}
42 |
43 | );
44 | }
45 |
46 | return {record.userId || 'Unknown'}
;
47 | },
48 | },
49 | detail: {
50 | show: true,
51 | render: (value, record) => {
52 | let user = null;
53 | if (nb.pubfn.isArray(value) && value.length > 0) {
54 | user = value[0];
55 | } else if (value && nb.pubfn.isObject(value)) {
56 | user = value;
57 | }
58 |
59 | if (user) {
60 | return (
61 |
62 |
63 | Name: {user.name || 'Unknown'}
64 |
65 |
66 | Email: {user.email || 'N/A'}
67 |
68 |
69 | User ID: {user.id || record.userId}
70 |
71 |
72 | );
73 | }
74 |
75 | return (
76 |
77 |
78 | User ID: {record.userId || 'Unknown'}
79 |
80 |
(User information not available)
81 |
82 | );
83 | },
84 | },
85 | form: false,
86 | search: false,
87 | },
88 |
89 | // IP Address
90 | {
91 | key: 'ipAddress',
92 | title: 'IP Address',
93 | type: 'text',
94 | table: {
95 | width: 140,
96 | },
97 | form: false,
98 | search: {
99 | mode: 'like',
100 | placeholder: 'Search IP',
101 | },
102 | },
103 |
104 | // User Agent
105 | {
106 | key: 'userAgent',
107 | title: 'User Agent',
108 | type: 'text',
109 | table: {
110 | width: 240,
111 | ellipsis: true,
112 | },
113 | form: false,
114 | search: false,
115 | detail: {
116 | render: (value) => (
117 |
126 | {value || 'N/A'}
127 |
128 | ),
129 | },
130 | },
131 |
132 | // Session Token
133 | // {
134 | // key: 'token',
135 | // title: 'Session Token',
136 | // type: 'text',
137 | // table: {
138 | // width: 260,
139 | // ellipsis: true,
140 | // copyable: true,
141 | // },
142 | // form: false,
143 | // search: false,
144 | // detail: {
145 | // render: (value) => (
146 | //
155 | // {value || 'N/A'}
156 | //
157 | // ),
158 | // },
159 | // },
160 |
161 | // Status
162 | {
163 | key: 'status',
164 | title: 'Status',
165 | type: 'custom',
166 | table: {
167 | width: 120,
168 | render: (_, record) => {
169 | const now = Date.now();
170 | const expiresAt = record.expiresAt ? new Date(record.expiresAt).getTime() : null;
171 | const isExpired = expiresAt ? expiresAt <= now : false;
172 |
173 | return {isExpired ? 'Expired' : 'Active'};
174 | },
175 | },
176 | form: false,
177 | search: false,
178 | },
179 |
180 | // Login Time
181 | {
182 | key: 'createdAt',
183 | title: 'Login Time',
184 | type: 'datetime',
185 | table: {
186 | width: 180,
187 | sorter: true,
188 | },
189 | form: false,
190 | search: {
191 | type: 'dateRange',
192 | },
193 | },
194 |
195 | // Expires At
196 | {
197 | key: 'expiresAt',
198 | title: 'Expires At',
199 | type: 'datetime',
200 | table: {
201 | width: 180,
202 | },
203 | form: false,
204 | search: false,
205 | },
206 | ];
207 |
208 | return (
209 |
223 | );
224 | }
225 |
--------------------------------------------------------------------------------
/lib/core/crud-helper.js:
--------------------------------------------------------------------------------
1 | /**
2 | * CRUD Helper - 快速创建 CRUD Actions
3 | *
4 | * 基于 wrapAction + 命名约定,自动生成带权限的 CRUD 操作
5 | *
6 | * ## 使用方式
7 | *
8 | * ```javascript
9 | * import { createCrudActions } from '@/lib/core/crud-helper';
10 | *
11 | * const crud = createCrudActions({
12 | * modelName: 'user', // Prisma 模型名(小写单数)
13 | * // ... 其他 BaseDAO 配置
14 | * });
15 | *
16 | * // 导出(自动带 sys 前缀,需要后台权限)
17 | * export const sysGetUserList = crud.getList;
18 | * export const sysCreateUser = crud.create;
19 | * export const sysUpdateUser = crud.update;
20 | * export const sysDeleteUser = crud.delete;
21 | * ```
22 | */
23 |
24 | import { BaseDAO } from '@/app/(admin)/actions/dao/base';
25 | import { wrapAction } from './action-wrapper';
26 | import nb from '@/lib/function';
27 |
28 | /**
29 | * 将字符串转换为 PascalCase(单数形式)
30 | */
31 | function pascalCase(str) {
32 | let result = str
33 | .replace(/[_-](.)/g, (_, char) => char.toUpperCase())
34 | .replace(/^(.)/, (_, char) => char.toUpperCase());
35 |
36 | // 复数 -> 单数
37 | if (result.endsWith('s') && !result.endsWith('ss')) {
38 | if (result.endsWith('ies')) {
39 | result = result.slice(0, -3) + 'y';
40 | } else if (result.endsWith('ses') || result.endsWith('xes') || result.endsWith('zes')) {
41 | result = result.slice(0, -2);
42 | } else {
43 | result = result.slice(0, -1);
44 | }
45 | }
46 |
47 | return result;
48 | }
49 |
50 | /**
51 | * 创建 CRUD Actions
52 | *
53 | * 生成的 action 名称格式:sys{Action}{Resource}
54 | * 例如:sysGetUserList, sysCreateUser, sysUpdateUser, sysDeleteUser
55 | *
56 | * @param {Object} config - BaseDAO 配置
57 | * @param {Object} options - 可选配置
58 | * @param {String} options.prefix - 权限前缀(默认 'sys')
59 | * @param {Boolean} options.enableBatch - 启用批量操作(默认 true)
60 | * @returns {Object} CRUD Actions
61 | */
62 | export function createCrudActions(config, options = {}) {
63 | const {
64 | prefix = 'sys',
65 | enableBatch = true,
66 | } = options;
67 |
68 | const dao = new BaseDAO(config);
69 | const resource = pascalCase(config.modelName || 'resource');
70 |
71 | // 获取配置中的默认 foreignDB
72 | const defaultForeignDB = config.query?.foreignDB || [];
73 |
74 | return {
75 | // 获取列表
76 | getList: wrapAction(`${prefix}Get${resource}List`, async (params, ctx) => {
77 | return await dao.getList(params);
78 | }, { skipLog: false }),
79 |
80 | // 获取详情(自动使用配置中的 foreignDB 进行连表查询)
81 | getDetail: wrapAction(`${prefix}Get${resource}Detail`, async (params, ctx) => {
82 | // 支持直接传 id 字符串或 { id } 对象
83 | const id = nb.pubfn.isString(params) ? params : params?.id;
84 | if (!id) {
85 | return { success: false, error: 'ID is required' };
86 | }
87 | return await dao.getDetail(id, {
88 | foreignDB: defaultForeignDB,
89 | });
90 | }, { skipLog: false }),
91 |
92 | // 创建
93 | create: wrapAction(`${prefix}Create${resource}`, async (data, ctx) => {
94 | return await dao.create({ ...data, userId: ctx.userId });
95 | }),
96 |
97 | // 更新
98 | update: wrapAction(`${prefix}Update${resource}`, async (params, ctx) => {
99 | const { id, ...data } = params || {};
100 | if (!id) {
101 | return { success: false, error: 'ID is required for update' };
102 | }
103 | return await dao.update(id, data);
104 | }),
105 |
106 | // 删除
107 | delete: wrapAction(`${prefix}Delete${resource}`, async (params, ctx) => {
108 | // 支持直接传 id 字符串或 { id } 对象
109 | const id = nb.pubfn.isString(params) ? params : params?.id;
110 | if (!id) {
111 | return { success: false, error: 'ID is required for delete' };
112 | }
113 | return await dao.delete(id);
114 | }),
115 |
116 | // 批量操作
117 | ...(enableBatch && {
118 | batchUpdate: wrapAction(`${prefix}BatchUpdate${resource}`, async (params, ctx) => {
119 | const { ids, data } = params;
120 | return await dao.batchUpdate(ids, data);
121 | }),
122 |
123 | batchDelete: wrapAction(`${prefix}BatchDelete${resource}`, async (params, ctx) => {
124 | const { ids } = params;
125 | return await dao.batchDelete(ids);
126 | }),
127 | }),
128 |
129 | // DAO 实例
130 | _dao: dao,
131 | };
132 | }
133 |
134 | /**
135 | * 创建只读 Actions(仅查询)
136 | *
137 | * 自动支持连表查询:
138 | * - 如果 config.query.foreignDB 配置了副表,getList 和 getDetail 都会自动连表
139 | * - getDetail 会使用相同的 foreignDB 配置
140 | */
141 | export function createReadOnlyActions(config, options = {}) {
142 | const { prefix = 'sys' } = options;
143 | const dao = new BaseDAO(config);
144 | const resource = pascalCase(config.modelName || 'resource');
145 |
146 | // 获取配置中的默认 foreignDB
147 | const defaultForeignDB = config.query?.foreignDB || [];
148 |
149 | return {
150 | getList: wrapAction(`${prefix}Get${resource}List`, async (params) => {
151 | return await dao.getList(params);
152 | }, { skipLog: true }),
153 |
154 | getDetail: wrapAction(`${prefix}Get${resource}Detail`, async (params) => {
155 | // 支持直接传 id 字符串或 { id } 对象
156 | const id = nb.pubfn.isString(params) ? params : params?.id;
157 | if (!id) {
158 | return { success: false, error: 'ID is required' };
159 | }
160 | // 自动使用配置中的 foreignDB 进行连表查询
161 | return await dao.getDetail(id, {
162 | foreignDB: defaultForeignDB,
163 | });
164 | }, { skipLog: true }),
165 |
166 | _dao: dao,
167 | };
168 | }
169 |
170 | /**
171 | * 扩展 CRUD Actions
172 | */
173 | export function extendCrudActions(crudActions, extensions) {
174 | return { ...crudActions, ...extensions };
175 | }
176 |
177 | // 向后兼容
178 | export const createSimpleCrudActions = (config) => createCrudActions(config, { enableBatch: false });
179 | export const createCrudActionsWithHooks = createCrudActions; // hooks 应该在 config 中配置
180 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # NextJS Base
2 |
3 |
4 |
5 | **A production-ready full-stack admin platform built with Next.js**
6 |
7 | [](LICENSE)
8 | [](https://nodejs.org)
9 | [](https://nextjs.org)
10 |
11 | [English](README.md) · [中文](README.zh-CN.md) · [Documentation](https://nextjsbase.com/docs)· [Website](https://nextjsbase.com)
12 | · [Demo](https://admin-demo.nextjsbase.com)
13 |
14 |
15 |
16 | ---
17 |
18 | ## ✨ Features
19 |
20 | - 🔐 **Authentication** - Email/password + OAuth (Google, GitHub) via Better Auth
21 | - 👥 **RBAC System** - Role-based access control with permissions and menus
22 | - 📊 **Admin Dashboard** - Configuration-driven CRUD with SmartCrudPage
23 | - 📝 **Action Logging** - Comprehensive audit trail for all operations
24 | - 📁 **Asset Management** - File upload and manage with Cloudflare R2 support
25 | - 🌐 **i18n Ready** - Multi-language support via next-intl
26 | - 🎨 **Modern UI** - Ant Design + ProComponents
27 |
28 | ## 🛠️ Tech Stack
29 |
30 | | Category | Technology |
31 | |:---|:---|
32 | | Framework | Next.js 16 (App Router) |
33 | | Database | PostgreSQL + Prisma |
34 | | Authentication | Better Auth |
35 | | UI Components | Ant Design, ProComponents |
36 | | Styling | Tailwind CSS |
37 | | Language | JavaScript (ES6+) |
38 |
39 | ## 🚀 Quick Start
40 |
41 | ### Prerequisites
42 |
43 | - Node.js 20.9+
44 | - PostgreSQL 16+
45 | - bun (recommended) / pnpm / npm / yarn
46 |
47 | ### Installation
48 |
49 | ```bash
50 | # Clone the repository
51 | git clone https://github.com/huglemon/nextjs-base.git
52 | cd nextjs-base
53 |
54 | # Install dependencies
55 | bun install
56 |
57 | # Configure environment
58 | cp .env.example .env.local
59 |
60 | # Initialize database and create admin
61 | bun run init
62 |
63 | # Start development server
64 | bun run dev
65 | ```
66 |
67 | Open [http://localhost:3000](http://localhost:3000) to see the application.
68 |
69 | ### Environment Variables
70 |
71 | ```env
72 | # Database (Required)
73 | DATABASE_URL="postgresql://user:password@localhost:5432/nextjs_base"
74 |
75 | # Better Auth (Required)
76 | BETTER_AUTH_SECRET="your-secret-key-at-least-32-characters"
77 | BETTER_AUTH_URL="http://localhost:3000"
78 |
79 | # OAuth (Optional)
80 | GOOGLE_CLIENT_ID=""
81 | GOOGLE_CLIENT_SECRET=""
82 | GITHUB_CLIENT_ID=""
83 | GITHUB_CLIENT_SECRET=""
84 |
85 | # Cloudflare R2 (Optional - for file uploads)
86 | R2_ACCOUNT_ID=""
87 | R2_ACCESS_KEY_ID=""
88 | R2_SECRET_ACCESS_KEY=""
89 | R2_BUCKET_NAME=""
90 | R2_PUBLIC_URL=""
91 | ```
92 |
93 | ## 📁 Project Structure
94 |
95 | ```
96 | nextjs-base/
97 | ├── app/
98 | │ ├── (admin)/ # Admin panel
99 | │ │ ├── admin/ # Admin pages
100 | │ │ └── actions/ # Server Actions
101 | │ ├── (client)/ # Frontend with i18n
102 | │ │ └── [locale]/ # Language routes
103 | │ └── api/ # API routes
104 | ├── components/
105 | │ ├── admin/ # Admin components (SmartCrudPage, SmartForm)
106 | │ └── ui/ # Base UI components
107 | ├── lib/
108 | │ ├── auth/ # Authentication
109 | │ ├── core/ # Core utilities (wrapAction, createCrudActions)
110 | │ └── database/ # Prisma client
111 | ├── prisma/
112 | │ └── schema.prisma # Database schema
113 | └── docs/ # Documentation
114 | ```
115 |
116 | ## 📖 Documentation
117 |
118 | - [Getting Started](https://nextjsbase.com/en/docs/getting-started)
119 | - [Architecture Overview](https://nextjsbase.com/en/docs/architecture/OVERVIEW)
120 | - [SmartCrudPage Guide](https://nextjsbase.com/en/docs/admin/guides/SMART_CRUD)
121 | - [RBAC Configuration](https://nextjsbase.com/en/docs/admin/rbac/CONFIGURATION)
122 | - [API Reference](https://nextjsbase.com/en/docs/api)
123 |
124 | ## 🤝 Contributing
125 |
126 | We welcome contributions! Please see our [Contributing Guide](https://nextjsbase.com/en/docs/contributing) for details.
127 |
128 | ### Quick Links
129 |
130 | - [How to Submit a PR](https://nextjsbase.com/en/docs/contributing/PULL_REQUEST)
131 | - [How to Report an Issue](https://nextjsbase.com/en/docs/contributing/ISSUE)
132 |
133 | ### Development Workflow
134 |
135 | 1. Fork the repository
136 | 2. Create a branch from `develop`: `git checkout -b feature/your-feature`
137 | 3. Make your changes
138 | 4. Submit a Pull Request to `develop`
139 |
140 | ## 📄 License
141 |
142 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
143 |
144 | ## 💬 Community
145 |
146 | Join our community for discussions, questions, and updates!
147 |
148 |
149 |
150 | [](https://discord.com/channels/1449297468654227583/)
151 |
152 | **[Join our Discord Server](https://discord.com/channels/1449297468654227583/)**
153 |
154 |
155 |
156 |
157 |

158 |
159 | *Scan to add me on WeChat, then I'll invite you to the group*
160 |
161 |
162 | ## 🙏 Acknowledgments
163 |
164 | - [Next.js](https://nextjs.org) - The React Framework
165 | - [Prisma](https://prisma.io) - Next-generation ORM
166 | - [Better Auth](https://better-auth.com) - Authentication library
167 | - [Ant Design](https://ant.design) - UI component library
168 |
169 | ---
170 |
171 |
172 |
173 | **[⬆ Back to Top](#nextjs-base)**
174 |
175 | Built with ❤️ by [huglemon](https://github.com/huglemon)
176 |
177 |
178 |
--------------------------------------------------------------------------------
/scripts/setup-admin.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | /**
3 | * 超级管理员创建脚本
4 | *
5 | * 使用 Better Auth 的密码哈希方式创建管理员账户
6 | *
7 | * 运行方式:
8 | * 交互式:bun run db:admin
9 | * 非交互式:ADMIN_EMAIL=xxx ADMIN_PASSWORD=xxx bun run db:admin
10 | */
11 |
12 | import { createInterface } from 'readline';
13 | import { v4 as uuidv4 } from 'uuid';
14 | import { PrismaClient } from '../lib/generated/prisma/client.js';
15 | import { PrismaPg } from '@prisma/adapter-pg';
16 |
17 | // 创建 Prisma 客户端
18 | function createPrisma() {
19 | const connectionString = process.env.DATABASE_URL;
20 | if (!connectionString) {
21 | console.error('❌ DATABASE_URL environment variable is not set');
22 | process.exit(1);
23 | }
24 | const adapter = new PrismaPg({ connectionString });
25 | return new PrismaClient({ adapter });
26 | }
27 |
28 | // 读取用户输入
29 | function prompt(question) {
30 | const rl = createInterface({
31 | input: process.stdin,
32 | output: process.stdout,
33 | });
34 |
35 | return new Promise((resolve) => {
36 | rl.question(question, (answer) => {
37 | rl.close();
38 | resolve(answer.trim());
39 | });
40 | });
41 | }
42 |
43 | // 验证邮箱格式
44 | function isValidEmail(email) {
45 | const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
46 | return emailRegex.test(email);
47 | }
48 |
49 | // 验证密码强度
50 | function isValidPassword(password) {
51 | return password && password.length >= 8;
52 | }
53 |
54 | async function setupAdmin() {
55 | console.log('\n🔐 Super Admin Setup\n');
56 | console.log('This script will create a super admin account with role="admin".');
57 | console.log('The admin has full access to the backend without needing RBAC roles.\n');
58 |
59 | try {
60 | // 动态导入 hashPassword
61 | const { hashPassword } = await import('better-auth/crypto');
62 |
63 | // 创建 Prisma 客户端
64 | const prisma = createPrisma();
65 |
66 | try {
67 | // 检查数据库连接
68 | await prisma.$queryRaw`SELECT 1`;
69 | console.log('✓ Database connection successful\n');
70 |
71 | // 检查是否已存在 admin 用户
72 | const existingAdmin = await prisma.user.findFirst({
73 | where: { role: 'admin' },
74 | });
75 |
76 | if (existingAdmin) {
77 | console.log(`⚠️ An admin user already exists: ${existingAdmin.email}`);
78 | const proceed = await prompt('Do you want to create another admin? (yes/no): ');
79 | if (proceed.toLowerCase() !== 'yes' && proceed.toLowerCase() !== 'y') {
80 | console.log('Cancelled.');
81 | return;
82 | }
83 | }
84 |
85 | // 获取管理员信息
86 | let email = process.env.ADMIN_EMAIL;
87 | let password = process.env.ADMIN_PASSWORD;
88 | let name = process.env.ADMIN_NAME || 'Administrator';
89 |
90 | // 交互式输入
91 | if (!email) {
92 | email = await prompt('Admin Email: ');
93 | }
94 | if (!isValidEmail(email)) {
95 | console.error('❌ Invalid email format');
96 | process.exit(1);
97 | }
98 |
99 | // 检查邮箱是否已存在
100 | const existingUser = await prisma.user.findUnique({
101 | where: { email: email.toLowerCase() },
102 | });
103 | if (existingUser) {
104 | console.error(`❌ User with email "${email}" already exists`);
105 | process.exit(1);
106 | }
107 |
108 | if (!password) {
109 | password = await prompt('Admin Password (min 8 chars): ');
110 | }
111 | if (!isValidPassword(password)) {
112 | console.error('❌ Password must be at least 8 characters');
113 | process.exit(1);
114 | }
115 |
116 | if (!process.env.ADMIN_NAME) {
117 | const inputName = await prompt('Admin Name (default: Administrator): ');
118 | if (inputName) name = inputName;
119 | }
120 |
121 | console.log('\n📝 Creating admin user...');
122 |
123 | // 使用 Better Auth 的 hashPassword 函数
124 | const hashedPassword = await hashPassword(password);
125 |
126 | // 生成 UUID
127 | const userId = uuidv4();
128 |
129 | // 创建用户
130 | const user = await prisma.user.create({
131 | data: {
132 | id: userId,
133 | email: email.toLowerCase(),
134 | name,
135 | emailVerified: true,
136 | role: 'admin', // Better Auth admin 角色
137 | roles: [], // 不需要 RBAC 角色
138 | isBackendAllowed: true, // 允许访问后台
139 | banned: false,
140 | credits: 0,
141 | totalCreditsEarned: 0,
142 | totalCreditsUsed: 0,
143 | },
144 | });
145 | console.log(' ✓ User created');
146 |
147 | // 创建账户记录(用于密码登录)
148 | // 注意:accountId 使用 email,与 Better Auth 的 credential provider 一致
149 | await prisma.account.create({
150 | data: {
151 | id: uuidv4(),
152 | userId: user.id,
153 | accountId: email.toLowerCase(),
154 | providerId: 'credential',
155 | password: hashedPassword,
156 | },
157 | });
158 | console.log(' ✓ Account credential created');
159 |
160 | console.log('\n✅ Super admin created successfully!\n');
161 | console.log('┌─────────────────────────────────────────┐');
162 | console.log('│ Admin Account Info │');
163 | console.log('├─────────────────────────────────────────┤');
164 | console.log(`│ Email: ${email.padEnd(28)}│`);
165 | console.log(`│ Password: ${'*'.repeat(Math.min(password.length, 20)).padEnd(28)}│`);
166 | console.log(`│ Name: ${name.padEnd(28)}│`);
167 | console.log(`│ Role: ${'admin'.padEnd(28)}│`);
168 | console.log('└─────────────────────────────────────────┘');
169 | console.log('\n⚠️ Please save these credentials securely!');
170 | console.log('⚠️ Change the password after first login.\n');
171 |
172 | } finally {
173 | await prisma.$disconnect();
174 | }
175 |
176 | } catch (error) {
177 | console.error('❌ Setup failed:', error.message);
178 | process.exit(1);
179 | }
180 | }
181 |
182 | // 运行
183 | setupAdmin()
184 | .then(() => process.exit(0))
185 | .catch(() => process.exit(1));
186 |
--------------------------------------------------------------------------------
/app/globals.css:
--------------------------------------------------------------------------------
1 | @import "tailwindcss";
2 | @import "tw-animate-css";
3 |
4 | @custom-variant dark (&:is(.dark *));
5 |
6 | @theme inline {
7 | --color-background: var(--background);
8 | --color-foreground: var(--foreground);
9 | --font-sans: var(--font-poppins);
10 | --font-mono: var(--font-poppins);
11 | --color-sidebar-ring: var(--sidebar-ring);
12 | --color-sidebar-border: var(--sidebar-border);
13 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
14 | --color-sidebar-accent: var(--sidebar-accent);
15 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
16 | --color-sidebar-primary: var(--sidebar-primary);
17 | --color-sidebar-foreground: var(--sidebar-foreground);
18 | --color-sidebar: var(--sidebar);
19 | --color-chart-5: var(--chart-5);
20 | --color-chart-4: var(--chart-4);
21 | --color-chart-3: var(--chart-3);
22 | --color-chart-2: var(--chart-2);
23 | --color-chart-1: var(--chart-1);
24 | --color-ring: var(--ring);
25 | --color-input: var(--input);
26 | --color-border: var(--border);
27 | --color-destructive: var(--destructive);
28 | --color-accent-foreground: var(--accent-foreground);
29 | --color-accent: var(--accent);
30 | --color-muted-foreground: var(--muted-foreground);
31 | --color-muted: var(--muted);
32 | --color-secondary-foreground: var(--secondary-foreground);
33 | --color-secondary: var(--secondary);
34 | --color-primary-foreground: var(--primary-foreground);
35 | --color-primary: var(--primary);
36 | --color-popover-foreground: var(--popover-foreground);
37 | --color-popover: var(--popover);
38 | --color-card-foreground: var(--card-foreground);
39 | --color-card: var(--card);
40 | --radius-sm: calc(var(--radius) - 4px);
41 | --radius-md: calc(var(--radius) - 2px);
42 | --radius-lg: var(--radius);
43 | --radius-xl: calc(var(--radius) + 4px);
44 | /* Marquee animation */
45 | --animate-marquee: marquee 30s linear infinite;
46 | }
47 |
48 | :root {
49 | --radius: 0.625rem;
50 | --background: oklch(1 0 0);
51 | --foreground: oklch(0.145 0 0);
52 | --card: oklch(1 0 0);
53 | --card-foreground: oklch(0.145 0 0);
54 | --popover: oklch(1 0 0);
55 | --popover-foreground: oklch(0.145 0 0);
56 | --primary: oklch(0.205 0 0);
57 | --primary-foreground: oklch(0.985 0 0);
58 | --secondary: oklch(0.97 0 0);
59 | --secondary-foreground: oklch(0.205 0 0);
60 | --muted: oklch(0.97 0 0);
61 | --muted-foreground: oklch(0.556 0 0);
62 | --accent: oklch(0.97 0 0);
63 | --accent-foreground: oklch(0.205 0 0);
64 | --destructive: oklch(0.577 0.245 27.325);
65 | --border: oklch(0.922 0 0);
66 | --input: oklch(0.922 0 0);
67 | --ring: oklch(0.708 0 0);
68 | --chart-1: oklch(0.646 0.222 41.116);
69 | --chart-2: oklch(0.6 0.118 184.704);
70 | --chart-3: oklch(0.398 0.07 227.392);
71 | --chart-4: oklch(0.828 0.189 84.429);
72 | --chart-5: oklch(0.769 0.188 70.08);
73 | --sidebar: oklch(0.985 0 0);
74 | --sidebar-foreground: oklch(0.145 0 0);
75 | --sidebar-primary: oklch(0.205 0 0);
76 | --sidebar-primary-foreground: oklch(0.985 0 0);
77 | --sidebar-accent: oklch(0.97 0 0);
78 | --sidebar-accent-foreground: oklch(0.205 0 0);
79 | --sidebar-border: oklch(0.922 0 0);
80 | --sidebar-ring: oklch(0.708 0 0);
81 | }
82 |
83 | .dark {
84 | --background: oklch(0.145 0 0);
85 | --foreground: oklch(0.985 0 0);
86 | --card: oklch(0.205 0 0);
87 | --card-foreground: oklch(0.985 0 0);
88 | --popover: oklch(0.205 0 0);
89 | --popover-foreground: oklch(0.985 0 0);
90 | --primary: oklch(0.922 0 0);
91 | --primary-foreground: oklch(0.205 0 0);
92 | --secondary: oklch(0.269 0 0);
93 | --secondary-foreground: oklch(0.985 0 0);
94 | --muted: oklch(0.269 0 0);
95 | --muted-foreground: oklch(0.708 0 0);
96 | --accent: oklch(0.269 0 0);
97 | --accent-foreground: oklch(0.985 0 0);
98 | --destructive: oklch(0.704 0.191 22.216);
99 | --border: oklch(1 0 0 / 10%);
100 | --input: oklch(1 0 0 / 15%);
101 | --ring: oklch(0.556 0 0);
102 | --chart-1: oklch(0.488 0.243 264.376);
103 | --chart-2: oklch(0.696 0.17 162.48);
104 | --chart-3: oklch(0.769 0.188 70.08);
105 | --chart-4: oklch(0.627 0.265 303.9);
106 | --chart-5: oklch(0.645 0.246 16.439);
107 | --sidebar: oklch(0.205 0 0);
108 | --sidebar-foreground: oklch(0.985 0 0);
109 | --sidebar-primary: oklch(0.488 0.243 264.376);
110 | --sidebar-primary-foreground: oklch(0.985 0 0);
111 | --sidebar-accent: oklch(0.269 0 0);
112 | --sidebar-accent-foreground: oklch(0.985 0 0);
113 | --sidebar-border: oklch(1 0 0 / 10%);
114 | --sidebar-ring: oklch(0.556 0 0);
115 | }
116 |
117 | @layer base {
118 | * {
119 | @apply border-border outline-ring/50;
120 | }
121 | body {
122 | @apply bg-background text-foreground font-sans;
123 | }
124 | html {
125 | font-family: var(--font-poppins), -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
126 | }
127 | }
128 |
129 | /* Marquee Animation for Tech Stack */
130 | @keyframes marquee {
131 | 0% {
132 | transform: translateX(0);
133 | }
134 | 100% {
135 | transform: translateX(-50%);
136 | }
137 | }
138 |
139 | .animate-marquee {
140 | animation: marquee 30s linear infinite;
141 | }
142 |
143 | .animate-marquee:hover {
144 | animation-play-state: paused;
145 | }
146 |
147 | /* Border Beam Animation */
148 | @keyframes border-beam {
149 | 0% {
150 | offset-distance: 0%;
151 | }
152 | 100% {
153 | offset-distance: 100%;
154 | }
155 | }
156 |
157 | .animate-border-beam {
158 | animation: border-beam calc(var(--duration)*1s) infinite linear;
159 | }
160 |
161 | ::view-transition-old(root), ::view-transition-new(root) {
162 | animation: none;
163 | mix-blend-mode: normal;
164 | }
--------------------------------------------------------------------------------