-
- Halolight Fresh
-
-
- 基于 Deno Fresh 的现代化中文后台管理系统
-
+
+ {/* 导航 */}
+
+
+ {/* 主内容 */}
+
+
+ {/* 标题 */}
+
+
+ {APP_CONFIG.name}
+
+
+ {APP_CONFIG.description}
+
+
+
+ {/* 特性 */}
+
+
+
+
极致性能
+
+ 基于 Islands 架构,默认零 JavaScript,按需水合
+
+
+
+
+
现代技术栈
+
+ Deno + Fresh + Preact + Signals + Tailwind CSS
+
+
+
+
+
开箱即用
+
+ 完整的认证、权限、仪表盘、主题系统
+
+
+
+
+ {/* Islands 示例 */}
+
+
+ Islands 架构示例
+
+
+ 此计数器是一个 Island 组件,仅此部分会发送客户端 JavaScript
+
+
+
+
+ {/* 操作按钮 */}
+
+
+
+
+ {/* 页脚 */}
+
);
});
diff --git a/routes/login.tsx b/routes/login.tsx
new file mode 100644
index 0000000..14eecdd
--- /dev/null
+++ b/routes/login.tsx
@@ -0,0 +1,169 @@
+import { define } from "@/utils.ts";
+import LoginForm from "@/islands/LoginForm.tsx";
+import { APP_CONFIG } from "@/lib/config.ts";
+import { Sparkles } from "lucide-preact";
+
+export default define.page(function LoginPage() {
+ const demoEmail = Deno.env.get("DEMO_EMAIL") || "admin@halolight.h7ml.cn";
+ const demoPassword = Deno.env.get("DEMO_PASSWORD") || "123456";
+ const showDemoHint = Deno.env.get("SHOW_DEMO_HINT") !== "false";
+
+ const features = [
+ { icon: "🚀", text: "快速部署,即刻启动" },
+ { icon: "📊", text: "实时数据分析与可视化" },
+ { icon: "🔒", text: "企业级安全保障" },
+ { icon: "⚡", text: "极致性能体验" },
+ ];
+
+ return (
+
+ {/* 左侧品牌区域 - 桌面端显示 */}
+
+ {/* 渐变背景 */}
+
+ {/* 网格覆盖 */}
+
+
+ {/* 动态光晕 */}
+
+
+
+
+ {/* 浮动圆点 */}
+
+
+
+
+
+
+ {/* 内容 */}
+
+ {/* Logo */}
+
+
+
+
{APP_CONFIG.name}
+
企业级管理系统
+
+
+
+ {/* 标题 */}
+
+ 欢迎回来
+ 👋
+
+
+ 登录您的账户,开始管理您的业务数据和团队协作,体验高效的工作流程。
+
+
+ {/* 特性列表 */}
+
+ {features.map((item, index) => (
+
+
+ {item.icon}
+
+
{item.text}
+
+ ))}
+
+
+
+
+ {/* 右侧登录区域 */}
+
+
+ {/* 移动端 Logo */}
+
+
+
+ {APP_CONFIG.name}
+
+
欢迎回来,请登录您的账户
+
+
+ {/* 登录卡片 */}
+
+ {/* 渐变顶部边框 */}
+
+
+ {/* 卡片头部 */}
+
+
+ 登录账户
+
+
+ 输入您的邮箱和密码登录
+
+
+
+ {/* 表单区域 */}
+
+
+
+
+ {/* 底部 */}
+
+
+
+
+
+ );
+});
diff --git a/routes/messages.tsx b/routes/messages.tsx
new file mode 100644
index 0000000..13ad381
--- /dev/null
+++ b/routes/messages.tsx
@@ -0,0 +1,98 @@
+import { define } from "@/utils.ts";
+import AdminLayout from "@/islands/AdminLayout.tsx";
+import StoreInitializer from "@/islands/StoreInitializer.tsx";
+import { Archive, Inbox, Mail, Search, Send, Star, Trash2 } from "lucide-preact";
+
+export default define.page(function MessagesPage() {
+ return (
+ <>
+
+
+
+ {/* 页面标题和操作 */}
+
+
+
+ {/* 侧边栏 */}
+
+
+ {/* 消息列表 */}
+
+
+ {/* 搜索 */}
+
+ {/* 空状态 */}
+
+
+
+
+
+
+ >
+ );
+});
diff --git a/routes/notifications.tsx b/routes/notifications.tsx
new file mode 100644
index 0000000..aaca9ad
--- /dev/null
+++ b/routes/notifications.tsx
@@ -0,0 +1,140 @@
+import { define } from "@/utils.ts";
+import AdminLayout from "@/islands/AdminLayout.tsx";
+import StoreInitializer from "@/islands/StoreInitializer.tsx";
+import {
+ AlertTriangle,
+ Bell,
+ Check,
+ CheckCheck,
+ CheckCircle,
+ Info,
+ Settings,
+ Trash2,
+} from "lucide-preact";
+
+const notifications = [
+ {
+ id: "1",
+ type: "success" as const,
+ title: "系统更新完成",
+ message: "系统已成功更新到最新版本 v2.0.0",
+ time: "5分钟前",
+ read: false,
+ },
+ {
+ id: "2",
+ type: "info" as const,
+ title: "新用户注册",
+ message: "用户 张三 已完成注册",
+ time: "1小时前",
+ read: false,
+ },
+ {
+ id: "3",
+ type: "warning" as const,
+ title: "存储空间告警",
+ message: "存储空间已使用 85%,请及时清理",
+ time: "2小时前",
+ read: true,
+ },
+ {
+ id: "4",
+ type: "info" as const,
+ title: "会议提醒",
+ message: "今天下午3点有产品评审会议",
+ time: "3小时前",
+ read: true,
+ },
+];
+
+const typeStyles = {
+ success: {
+ icon: CheckCircle,
+ bg: "bg-green-100 dark:bg-green-900/30",
+ color: "text-green-600 dark:text-green-400",
+ },
+ info: {
+ icon: Info,
+ bg: "bg-blue-100 dark:bg-blue-900/30",
+ color: "text-blue-600 dark:text-blue-400",
+ },
+ warning: {
+ icon: AlertTriangle,
+ bg: "bg-yellow-100 dark:bg-yellow-900/30",
+ color: "text-yellow-600 dark:text-yellow-400",
+ },
+};
+
+export default define.page(function NotificationsPage() {
+ return (
+ <>
+
+
+
+ {/* 页面标题和操作 */}
+
+
+
+
+
+
+
+
+ {/* 通知列表 */}
+
+
+ {notifications.map((notification) => {
+ const style = typeStyles[notification.type];
+ const Icon = style.icon;
+ return (
+
+
+
+
+
+
+
+ {notification.title}
+
+ {!notification.read && }
+
+
+ {notification.message}
+
+
+ {notification.time}
+
+
+
+ {!notification.read && (
+
+ )}
+
+
+
+ );
+ })}
+
+
+
+
+ >
+ );
+});
diff --git a/routes/privacy.tsx b/routes/privacy.tsx
new file mode 100644
index 0000000..8536643
--- /dev/null
+++ b/routes/privacy.tsx
@@ -0,0 +1,95 @@
+import { define } from "@/utils.ts";
+import { APP_CONFIG } from "@/lib/config.ts";
+import { ArrowLeft, Sparkles } from "lucide-preact";
+
+export default define.page(function PrivacyPage() {
+ return (
+
+
+ {/* 头部 */}
+
+
+
+ 返回登录
+
+
+
+
+
+
{APP_CONFIG.name}
+
+
隐私政策
+
最后更新:2024年1月1日
+
+
+ {/* 内容 */}
+
+
+ 1. 信息收集
+
+ 我们收集您在使用服务时主动提供的信息,包括但不限于:注册账户时提供的邮箱地址、用户名等基本信息;
+ 使用服务过程中产生的操作日志、设备信息等。我们承诺仅收集提供服务所必需的最少信息。
+
+
+
+
+ 2. 信息使用
+
+ 我们使用收集的信息用于:提供、维护和改进我们的服务;向您发送服务相关的通知;
+ 进行数据分析以改善用户体验;防止欺诈和滥用行为。我们不会将您的个人信息出售给第三方。
+
+
+
+
+ 3. 信息保护
+
+ 我们采用业界标准的安全措施来保护您的个人信息,包括数据加密、访问控制、安全审计等。
+ 我们定期审查安全实践,确保您的数据得到妥善保护。
+
+
+
+
+ 4. Cookie 使用
+
+ 我们使用 Cookie 和类似技术来记住您的偏好设置、分析服务使用情况。
+ 您可以通过浏览器设置管理 Cookie,但这可能影响部分功能的正常使用。
+
+
+
+
+ 5. 信息共享
+
+ 除以下情况外,我们不会与第三方共享您的个人信息:获得您的明确同意;
+ 法律法规要求;保护我们或用户的权益;与我们的服务提供商合作(他们同样受保密义务约束)。
+
+
+
+
+ 6. 您的权利
+
+ 您有权访问、更正或删除您的个人信息;有权撤回您的同意;有权要求我们限制处理您的信息。
+ 如需行使这些权利,请通过下方联系方式与我们联系。
+
+
+
+
+ 7. 联系我们
+
+ 如果您对本隐私政策有任何疑问,请通过以下方式联系我们:
+
+ 邮箱:privacy@halolight.h7ml.cn
+
+
+
+
+ {/* 底部 */}
+
+
© {new Date().getFullYear()} {APP_CONFIG.name}. 保留所有权利。
+
+
+
+ );
+});
diff --git a/routes/register.tsx b/routes/register.tsx
new file mode 100644
index 0000000..baa1b0c
--- /dev/null
+++ b/routes/register.tsx
@@ -0,0 +1,48 @@
+import { define } from "@/utils.ts";
+import RegisterForm from "@/islands/RegisterForm.tsx";
+import { APP_CONFIG } from "@/lib/config.ts";
+
+export default define.page(function RegisterPage() {
+ return (
+
+ {/* 左侧品牌区域 */}
+
+
+
{APP_CONFIG.name}
+
{APP_CONFIG.description}
+
+
+
+
快速上手
+
注册即可体验完整的后台管理功能
+
+
+
+
功能丰富
+
用户管理、数据分析、文档管理等一应俱全
+
+
+
+ © {new Date().getFullYear()} {APP_CONFIG.name}. All rights reserved.
+
+
+
+ {/* 右侧注册区域 */}
+
+
+
+
创建账号
+
开始使用 {APP_CONFIG.name}
+
+
+
+
+
+
+
+
+ );
+});
diff --git a/routes/reset-password.tsx b/routes/reset-password.tsx
new file mode 100644
index 0000000..fbf7841
--- /dev/null
+++ b/routes/reset-password.tsx
@@ -0,0 +1,25 @@
+import { define } from "@/utils.ts";
+import ResetPasswordForm from "@/islands/ResetPasswordForm.tsx";
+import { APP_CONFIG } from "@/lib/config.ts";
+
+export default define.page(function ResetPasswordPage(props) {
+ // 从 URL 获取 token
+ const url = new URL(props.url);
+ const token = url.searchParams.get("token") || "";
+
+ return (
+
+
+
+
{APP_CONFIG.name}
+
重置密码
+
设置您的新密码
+
+
+
+
+
+
+
+ );
+});
diff --git a/routes/settings.tsx b/routes/settings.tsx
new file mode 100644
index 0000000..f6dc424
--- /dev/null
+++ b/routes/settings.tsx
@@ -0,0 +1,15 @@
+import { define } from "@/utils.ts";
+import AdminLayout from "@/islands/AdminLayout.tsx";
+import Settings from "@/islands/Settings.tsx";
+import StoreInitializer from "@/islands/StoreInitializer.tsx";
+
+export default define.page(function SettingsPage() {
+ return (
+ <>
+
+
+
+
+ >
+ );
+});
diff --git a/routes/terms.tsx b/routes/terms.tsx
new file mode 100644
index 0000000..05e03a7
--- /dev/null
+++ b/routes/terms.tsx
@@ -0,0 +1,104 @@
+import { define } from "@/utils.ts";
+import { APP_CONFIG } from "@/lib/config.ts";
+import { ArrowLeft, Sparkles } from "lucide-preact";
+
+export default define.page(function TermsPage() {
+ return (
+
+
+ {/* 头部 */}
+
+
+
+ 返回登录
+
+
+
+
+
+
{APP_CONFIG.name}
+
+
服务条款
+
最后更新:2024年1月1日
+
+
+ {/* 内容 */}
+
+
+ 1. 服务说明
+
+ {APP_CONFIG.name}{" "}
+ 是一个企业级后台管理系统平台,为用户提供数据管理、团队协作、业务分析等功能。
+ 使用本服务即表示您同意遵守本服务条款。
+
+
+
+
+ 2. 账户注册
+
+ 您需要注册账户才能使用本服务的全部功能。注册时,您应提供真实、准确的信息,
+ 并妥善保管您的账户凭证。您对账户下的所有活动负责。
+
+
+
+
+ 3. 使用规范
+
+ 您同意不会将本服务用于任何非法目的,不会上传或传播违法、有害、威胁性、辱骂性、
+ 骚扰性、诽谤性、粗俗、淫秽或其他令人反感的内容。您不得尝试未经授权访问我们的系统。
+
+
+
+
+ 4. 知识产权
+
+ 本服务及其所有内容(包括但不限于文本、图形、标识、图标、图像、音频、视频、软件)
+ 均受知识产权法保护。未经我们书面许可,您不得复制、修改、分发或以其他方式使用这些内容。
+
+
+
+
+ 5. 服务变更
+
+ 我们保留随时修改、暂停或终止服务的权利,恕不另行通知。我们可能会不时更新本服务条款,
+ 更新后的条款将在发布后立即生效。继续使用服务即表示您接受更新后的条款。
+
+
+
+
+ 6. 免责声明
+
+ 本服务按"现状"和"可用"基础提供,不提供任何明示或暗示的保证。我们不保证服务将不间断、
+ 及时、安全或无错误。对于因使用本服务而产生的任何直接或间接损失,我们不承担责任。
+
+
+
+
+ 7. 适用法律
+
+ 本服务条款受中华人民共和国法律管辖。因本条款引起的或与之相关的任何争议,
+ 双方应首先通过友好协商解决;协商不成的,任何一方均可向有管辖权的人民法院提起诉讼。
+
+
+
+
+ 8. 联系方式
+
+ 如果您对本服务条款有任何疑问,请通过以下方式联系我们:
+
+ 邮箱:support@halolight.h7ml.cn
+
+
+
+
+ {/* 底部 */}
+
+
© {new Date().getFullYear()} {APP_CONFIG.name}. 保留所有权利。
+
+
+
+ );
+});
diff --git a/routes/users.tsx b/routes/users.tsx
new file mode 100644
index 0000000..566593d
--- /dev/null
+++ b/routes/users.tsx
@@ -0,0 +1,15 @@
+import { define } from "@/utils.ts";
+import AdminLayout from "@/islands/AdminLayout.tsx";
+import UserList from "@/islands/UserList.tsx";
+import StoreInitializer from "@/islands/StoreInitializer.tsx";
+
+export default define.page(function UsersPage() {
+ return (
+ <>
+
+
+
+
+ >
+ );
+});
diff --git a/static/styles.css b/static/styles.css
index b5c61c9..2b05225 100644
--- a/static/styles.css
+++ b/static/styles.css
@@ -1,3 +1,2861 @@
-@tailwind base;
-@tailwind components;
-@tailwind utilities;
+*, ::before, ::after {
+ --tw-border-spacing-x: 0;
+ --tw-border-spacing-y: 0;
+ --tw-translate-x: 0;
+ --tw-translate-y: 0;
+ --tw-rotate: 0;
+ --tw-skew-x: 0;
+ --tw-skew-y: 0;
+ --tw-scale-x: 1;
+ --tw-scale-y: 1;
+ --tw-pan-x: ;
+ --tw-pan-y: ;
+ --tw-pinch-zoom: ;
+ --tw-scroll-snap-strictness: proximity;
+ --tw-gradient-from-position: ;
+ --tw-gradient-via-position: ;
+ --tw-gradient-to-position: ;
+ --tw-ordinal: ;
+ --tw-slashed-zero: ;
+ --tw-numeric-figure: ;
+ --tw-numeric-spacing: ;
+ --tw-numeric-fraction: ;
+ --tw-ring-inset: ;
+ --tw-ring-offset-width: 0px;
+ --tw-ring-offset-color: #fff;
+ --tw-ring-color: rgb(59 130 246 / 0.5);
+ --tw-ring-offset-shadow: 0 0 #0000;
+ --tw-ring-shadow: 0 0 #0000;
+ --tw-shadow: 0 0 #0000;
+ --tw-shadow-colored: 0 0 #0000;
+ --tw-blur: ;
+ --tw-brightness: ;
+ --tw-contrast: ;
+ --tw-grayscale: ;
+ --tw-hue-rotate: ;
+ --tw-invert: ;
+ --tw-saturate: ;
+ --tw-sepia: ;
+ --tw-drop-shadow: ;
+ --tw-backdrop-blur: ;
+ --tw-backdrop-brightness: ;
+ --tw-backdrop-contrast: ;
+ --tw-backdrop-grayscale: ;
+ --tw-backdrop-hue-rotate: ;
+ --tw-backdrop-invert: ;
+ --tw-backdrop-opacity: ;
+ --tw-backdrop-saturate: ;
+ --tw-backdrop-sepia: ;
+ --tw-contain-size: ;
+ --tw-contain-layout: ;
+ --tw-contain-paint: ;
+ --tw-contain-style: ;
+}
+
+::backdrop {
+ --tw-border-spacing-x: 0;
+ --tw-border-spacing-y: 0;
+ --tw-translate-x: 0;
+ --tw-translate-y: 0;
+ --tw-rotate: 0;
+ --tw-skew-x: 0;
+ --tw-skew-y: 0;
+ --tw-scale-x: 1;
+ --tw-scale-y: 1;
+ --tw-pan-x: ;
+ --tw-pan-y: ;
+ --tw-pinch-zoom: ;
+ --tw-scroll-snap-strictness: proximity;
+ --tw-gradient-from-position: ;
+ --tw-gradient-via-position: ;
+ --tw-gradient-to-position: ;
+ --tw-ordinal: ;
+ --tw-slashed-zero: ;
+ --tw-numeric-figure: ;
+ --tw-numeric-spacing: ;
+ --tw-numeric-fraction: ;
+ --tw-ring-inset: ;
+ --tw-ring-offset-width: 0px;
+ --tw-ring-offset-color: #fff;
+ --tw-ring-color: rgb(59 130 246 / 0.5);
+ --tw-ring-offset-shadow: 0 0 #0000;
+ --tw-ring-shadow: 0 0 #0000;
+ --tw-shadow: 0 0 #0000;
+ --tw-shadow-colored: 0 0 #0000;
+ --tw-blur: ;
+ --tw-brightness: ;
+ --tw-contrast: ;
+ --tw-grayscale: ;
+ --tw-hue-rotate: ;
+ --tw-invert: ;
+ --tw-saturate: ;
+ --tw-sepia: ;
+ --tw-drop-shadow: ;
+ --tw-backdrop-blur: ;
+ --tw-backdrop-brightness: ;
+ --tw-backdrop-contrast: ;
+ --tw-backdrop-grayscale: ;
+ --tw-backdrop-hue-rotate: ;
+ --tw-backdrop-invert: ;
+ --tw-backdrop-opacity: ;
+ --tw-backdrop-saturate: ;
+ --tw-backdrop-sepia: ;
+ --tw-contain-size: ;
+ --tw-contain-layout: ;
+ --tw-contain-paint: ;
+ --tw-contain-style: ;
+}
+
+/*
+! tailwindcss v3.4.18 | MIT License | https://tailwindcss.com
+*/
+
+/*
+1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
+2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)
+*/
+
+*,
+::before,
+::after {
+ box-sizing: border-box;
+ /* 1 */
+ border-width: 0;
+ /* 2 */
+ border-style: solid;
+ /* 2 */
+ border-color: #e5e7eb;
+ /* 2 */
+}
+
+::before,
+::after {
+ --tw-content: '';
+}
+
+/*
+1. Use a consistent sensible line-height in all browsers.
+2. Prevent adjustments of font size after orientation changes in iOS.
+3. Use a more readable tab size.
+4. Use the user's configured `sans` font-family by default.
+5. Use the user's configured `sans` font-feature-settings by default.
+6. Use the user's configured `sans` font-variation-settings by default.
+7. Disable tap highlights on iOS
+*/
+
+html,
+:host {
+ line-height: 1.5;
+ /* 1 */
+ -webkit-text-size-adjust: 100%;
+ /* 2 */
+ -moz-tab-size: 4;
+ /* 3 */
+ -o-tab-size: 4;
+ tab-size: 4;
+ /* 3 */
+ font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+ /* 4 */
+ font-feature-settings: normal;
+ /* 5 */
+ font-variation-settings: normal;
+ /* 6 */
+ -webkit-tap-highlight-color: transparent;
+ /* 7 */
+}
+
+/*
+1. Remove the margin in all browsers.
+2. Inherit line-height from `html` so users can set them as a class directly on the `html` element.
+*/
+
+body {
+ margin: 0;
+ /* 1 */
+ line-height: inherit;
+ /* 2 */
+}
+
+/*
+1. Add the correct height in Firefox.
+2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
+3. Ensure horizontal rules are visible by default.
+*/
+
+hr {
+ height: 0;
+ /* 1 */
+ color: inherit;
+ /* 2 */
+ border-top-width: 1px;
+ /* 3 */
+}
+
+/*
+Add the correct text decoration in Chrome, Edge, and Safari.
+*/
+
+abbr:where([title]) {
+ -webkit-text-decoration: underline dotted;
+ text-decoration: underline dotted;
+}
+
+/*
+Remove the default font size and weight for headings.
+*/
+
+h1,
+h2,
+h3,
+h4,
+h5,
+h6 {
+ font-size: inherit;
+ font-weight: inherit;
+}
+
+/*
+Reset links to optimize for opt-in styling instead of opt-out.
+*/
+
+a {
+ color: inherit;
+ text-decoration: inherit;
+}
+
+/*
+Add the correct font weight in Edge and Safari.
+*/
+
+b,
+strong {
+ font-weight: bolder;
+}
+
+/*
+1. Use the user's configured `mono` font-family by default.
+2. Use the user's configured `mono` font-feature-settings by default.
+3. Use the user's configured `mono` font-variation-settings by default.
+4. Correct the odd `em` font sizing in all browsers.
+*/
+
+code,
+kbd,
+samp,
+pre {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+ /* 1 */
+ font-feature-settings: normal;
+ /* 2 */
+ font-variation-settings: normal;
+ /* 3 */
+ font-size: 1em;
+ /* 4 */
+}
+
+/*
+Add the correct font size in all browsers.
+*/
+
+small {
+ font-size: 80%;
+}
+
+/*
+Prevent `sub` and `sup` elements from affecting the line height in all browsers.
+*/
+
+sub,
+sup {
+ font-size: 75%;
+ line-height: 0;
+ position: relative;
+ vertical-align: baseline;
+}
+
+sub {
+ bottom: -0.25em;
+}
+
+sup {
+ top: -0.5em;
+}
+
+/*
+1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
+2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
+3. Remove gaps between table borders by default.
+*/
+
+table {
+ text-indent: 0;
+ /* 1 */
+ border-color: inherit;
+ /* 2 */
+ border-collapse: collapse;
+ /* 3 */
+}
+
+/*
+1. Change the font styles in all browsers.
+2. Remove the margin in Firefox and Safari.
+3. Remove default padding in all browsers.
+*/
+
+button,
+input,
+optgroup,
+select,
+textarea {
+ font-family: inherit;
+ /* 1 */
+ font-feature-settings: inherit;
+ /* 1 */
+ font-variation-settings: inherit;
+ /* 1 */
+ font-size: 100%;
+ /* 1 */
+ font-weight: inherit;
+ /* 1 */
+ line-height: inherit;
+ /* 1 */
+ letter-spacing: inherit;
+ /* 1 */
+ color: inherit;
+ /* 1 */
+ margin: 0;
+ /* 2 */
+ padding: 0;
+ /* 3 */
+}
+
+/*
+Remove the inheritance of text transform in Edge and Firefox.
+*/
+
+button,
+select {
+ text-transform: none;
+}
+
+/*
+1. Correct the inability to style clickable types in iOS and Safari.
+2. Remove default button styles.
+*/
+
+button,
+input:where([type='button']),
+input:where([type='reset']),
+input:where([type='submit']) {
+ -webkit-appearance: button;
+ /* 1 */
+ background-color: transparent;
+ /* 2 */
+ background-image: none;
+ /* 2 */
+}
+
+/*
+Use the modern Firefox focus style for all focusable elements.
+*/
+
+:-moz-focusring {
+ outline: auto;
+}
+
+/*
+Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)
+*/
+
+:-moz-ui-invalid {
+ box-shadow: none;
+}
+
+/*
+Add the correct vertical alignment in Chrome and Firefox.
+*/
+
+progress {
+ vertical-align: baseline;
+}
+
+/*
+Correct the cursor style of increment and decrement buttons in Safari.
+*/
+
+::-webkit-inner-spin-button,
+::-webkit-outer-spin-button {
+ height: auto;
+}
+
+/*
+1. Correct the odd appearance in Chrome and Safari.
+2. Correct the outline style in Safari.
+*/
+
+[type='search'] {
+ -webkit-appearance: textfield;
+ /* 1 */
+ outline-offset: -2px;
+ /* 2 */
+}
+
+/*
+Remove the inner padding in Chrome and Safari on macOS.
+*/
+
+::-webkit-search-decoration {
+ -webkit-appearance: none;
+}
+
+/*
+1. Correct the inability to style clickable types in iOS and Safari.
+2. Change font properties to `inherit` in Safari.
+*/
+
+::-webkit-file-upload-button {
+ -webkit-appearance: button;
+ /* 1 */
+ font: inherit;
+ /* 2 */
+}
+
+/*
+Add the correct display in Chrome and Safari.
+*/
+
+summary {
+ display: list-item;
+}
+
+/*
+Removes the default spacing and border for appropriate elements.
+*/
+
+blockquote,
+dl,
+dd,
+h1,
+h2,
+h3,
+h4,
+h5,
+h6,
+hr,
+figure,
+p,
+pre {
+ margin: 0;
+}
+
+fieldset {
+ margin: 0;
+ padding: 0;
+}
+
+legend {
+ padding: 0;
+}
+
+ol,
+ul,
+menu {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+/*
+Reset default styling for dialogs.
+*/
+
+dialog {
+ padding: 0;
+}
+
+/*
+Prevent resizing textareas horizontally by default.
+*/
+
+textarea {
+ resize: vertical;
+}
+
+/*
+1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)
+2. Set the default placeholder color to the user's configured gray 400 color.
+*/
+
+input::-moz-placeholder, textarea::-moz-placeholder {
+ opacity: 1;
+ /* 1 */
+ color: #9ca3af;
+ /* 2 */
+}
+
+input::placeholder,
+textarea::placeholder {
+ opacity: 1;
+ /* 1 */
+ color: #9ca3af;
+ /* 2 */
+}
+
+/*
+Set the default cursor for buttons.
+*/
+
+button,
+[role="button"] {
+ cursor: pointer;
+}
+
+/*
+Make sure disabled buttons don't get the pointer cursor.
+*/
+
+:disabled {
+ cursor: default;
+}
+
+/*
+1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)
+2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)
+ This can trigger a poorly considered lint error in some tools but is included by design.
+*/
+
+img,
+svg,
+video,
+canvas,
+audio,
+iframe,
+embed,
+object {
+ display: block;
+ /* 1 */
+ vertical-align: middle;
+ /* 2 */
+}
+
+/*
+Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)
+*/
+
+img,
+video {
+ max-width: 100%;
+ height: auto;
+}
+
+/* Make elements with the HTML hidden attribute stay hidden by default */
+
+[hidden]:where(:not([hidden="until-found"])) {
+ display: none;
+}
+
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border-width: 0;
+}
+
+.fixed {
+ position: fixed;
+}
+
+.absolute {
+ position: absolute;
+}
+
+.relative {
+ position: relative;
+}
+
+.sticky {
+ position: sticky;
+}
+
+.inset-0 {
+ inset: 0px;
+}
+
+.inset-y-0 {
+ top: 0px;
+ bottom: 0px;
+}
+
+.-bottom-32 {
+ bottom: -8rem;
+}
+
+.-left-40 {
+ left: -10rem;
+}
+
+.-right-32 {
+ right: -8rem;
+}
+
+.-top-40 {
+ top: -10rem;
+}
+
+.bottom-6 {
+ bottom: 1.5rem;
+}
+
+.left-0 {
+ left: 0px;
+}
+
+.left-1\/2 {
+ left: 50%;
+}
+
+.left-1\/4 {
+ left: 25%;
+}
+
+.left-3 {
+ left: 0.75rem;
+}
+
+.left-4 {
+ left: 1rem;
+}
+
+.right-0 {
+ right: 0px;
+}
+
+.right-1 {
+ right: 0.25rem;
+}
+
+.right-3 {
+ right: 0.75rem;
+}
+
+.top-0 {
+ top: 0px;
+}
+
+.top-1 {
+ top: 0.25rem;
+}
+
+.top-1\/2 {
+ top: 50%;
+}
+
+.top-1\/3 {
+ top: 33.333333%;
+}
+
+.top-4 {
+ top: 1rem;
+}
+
+.top-\[38px\] {
+ top: 38px;
+}
+
+.top-full {
+ top: 100%;
+}
+
+.z-10 {
+ z-index: 10;
+}
+
+.z-20 {
+ z-index: 20;
+}
+
+.z-30 {
+ z-index: 30;
+}
+
+.z-40 {
+ z-index: 40;
+}
+
+.z-50 {
+ z-index: 50;
+}
+
+.mx-auto {
+ margin-left: auto;
+ margin-right: auto;
+}
+
+.my-1 {
+ margin-top: 0.25rem;
+ margin-bottom: 0.25rem;
+}
+
+.mb-1 {
+ margin-bottom: 0.25rem;
+}
+
+.mb-12 {
+ margin-bottom: 3rem;
+}
+
+.mb-2 {
+ margin-bottom: 0.5rem;
+}
+
+.mb-3 {
+ margin-bottom: 0.75rem;
+}
+
+.mb-4 {
+ margin-bottom: 1rem;
+}
+
+.mb-6 {
+ margin-bottom: 1.5rem;
+}
+
+.mb-8 {
+ margin-bottom: 2rem;
+}
+
+.ml-1 {
+ margin-left: 0.25rem;
+}
+
+.ml-2 {
+ margin-left: 0.5rem;
+}
+
+.ml-4 {
+ margin-left: 1rem;
+}
+
+.ml-auto {
+ margin-left: auto;
+}
+
+.mr-1 {
+ margin-right: 0.25rem;
+}
+
+.mt-0\.5 {
+ margin-top: 0.125rem;
+}
+
+.mt-1 {
+ margin-top: 0.25rem;
+}
+
+.mt-2 {
+ margin-top: 0.5rem;
+}
+
+.mt-3 {
+ margin-top: 0.75rem;
+}
+
+.mt-4 {
+ margin-top: 1rem;
+}
+
+.mt-6 {
+ margin-top: 1.5rem;
+}
+
+.mt-8 {
+ margin-top: 2rem;
+}
+
+.block {
+ display: block;
+}
+
+.inline-block {
+ display: inline-block;
+}
+
+.flex {
+ display: flex;
+}
+
+.inline-flex {
+ display: inline-flex;
+}
+
+.table {
+ display: table;
+}
+
+.grid {
+ display: grid;
+}
+
+.hidden {
+ display: none;
+}
+
+.h-1 {
+ height: 0.25rem;
+}
+
+.h-1\.5 {
+ height: 0.375rem;
+}
+
+.h-10 {
+ height: 2.5rem;
+}
+
+.h-11 {
+ height: 2.75rem;
+}
+
+.h-12 {
+ height: 3rem;
+}
+
+.h-14 {
+ height: 3.5rem;
+}
+
+.h-16 {
+ height: 4rem;
+}
+
+.h-2 {
+ height: 0.5rem;
+}
+
+.h-20 {
+ height: 5rem;
+}
+
+.h-3 {
+ height: 0.75rem;
+}
+
+.h-3\.5 {
+ height: 0.875rem;
+}
+
+.h-4 {
+ height: 1rem;
+}
+
+.h-5 {
+ height: 1.25rem;
+}
+
+.h-6 {
+ height: 1.5rem;
+}
+
+.h-64 {
+ height: 16rem;
+}
+
+.h-7 {
+ height: 1.75rem;
+}
+
+.h-8 {
+ height: 2rem;
+}
+
+.h-80 {
+ height: 20rem;
+}
+
+.h-96 {
+ height: 24rem;
+}
+
+.h-full {
+ height: 100%;
+}
+
+.h-px {
+ height: 1px;
+}
+
+.max-h-96 {
+ max-height: 24rem;
+}
+
+.min-h-24 {
+ min-height: 6rem;
+}
+
+.min-h-screen {
+ min-height: 100vh;
+}
+
+.w-10 {
+ width: 2.5rem;
+}
+
+.w-11 {
+ width: 2.75rem;
+}
+
+.w-12 {
+ width: 3rem;
+}
+
+.w-14 {
+ width: 3.5rem;
+}
+
+.w-16 {
+ width: 4rem;
+}
+
+.w-2 {
+ width: 0.5rem;
+}
+
+.w-20 {
+ width: 5rem;
+}
+
+.w-3 {
+ width: 0.75rem;
+}
+
+.w-3\.5 {
+ width: 0.875rem;
+}
+
+.w-4 {
+ width: 1rem;
+}
+
+.w-40 {
+ width: 10rem;
+}
+
+.w-5 {
+ width: 1.25rem;
+}
+
+.w-56 {
+ width: 14rem;
+}
+
+.w-6 {
+ width: 1.5rem;
+}
+
+.w-64 {
+ width: 16rem;
+}
+
+.w-7 {
+ width: 1.75rem;
+}
+
+.w-8 {
+ width: 2rem;
+}
+
+.w-80 {
+ width: 20rem;
+}
+
+.w-96 {
+ width: 24rem;
+}
+
+.w-full {
+ width: 100%;
+}
+
+.min-w-0 {
+ min-width: 0px;
+}
+
+.min-w-\[200px\] {
+ min-width: 200px;
+}
+
+.min-w-\[3ch\] {
+ min-width: 3ch;
+}
+
+.min-w-full {
+ min-width: 100%;
+}
+
+.max-w-2xl {
+ max-width: 42rem;
+}
+
+.max-w-4xl {
+ max-width: 56rem;
+}
+
+.max-w-md {
+ max-width: 28rem;
+}
+
+.flex-1 {
+ flex: 1 1 0%;
+}
+
+.flex-shrink-0 {
+ flex-shrink: 0;
+}
+
+.-translate-x-1\/2 {
+ --tw-translate-x: -50%;
+ transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
+}
+
+.-translate-y-1\/2 {
+ --tw-translate-y: -50%;
+ transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
+}
+
+@keyframes bounce {
+ 0%, 100% {
+ transform: translateY(-25%);
+ animation-timing-function: cubic-bezier(0.8,0,1,1);
+ }
+
+ 50% {
+ transform: none;
+ animation-timing-function: cubic-bezier(0,0,0.2,1);
+ }
+}
+
+.animate-bounce {
+ animation: bounce 1s infinite;
+}
+
+@keyframes pulse {
+ 50% {
+ opacity: .5;
+ }
+}
+
+.animate-pulse {
+ animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.animate-spin {
+ animation: spin 1s linear infinite;
+}
+
+.cursor-pointer {
+ cursor: pointer;
+}
+
+.grid-cols-2 {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.grid-cols-3 {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.grid-cols-7 {
+ grid-template-columns: repeat(7, minmax(0, 1fr));
+}
+
+.flex-row {
+ flex-direction: row;
+}
+
+.flex-col {
+ flex-direction: column;
+}
+
+.flex-wrap {
+ flex-wrap: wrap;
+}
+
+.items-start {
+ align-items: flex-start;
+}
+
+.items-end {
+ align-items: flex-end;
+}
+
+.items-center {
+ align-items: center;
+}
+
+.items-baseline {
+ align-items: baseline;
+}
+
+.justify-end {
+ justify-content: flex-end;
+}
+
+.justify-center {
+ justify-content: center;
+}
+
+.justify-between {
+ justify-content: space-between;
+}
+
+.gap-1 {
+ gap: 0.25rem;
+}
+
+.gap-1\.5 {
+ gap: 0.375rem;
+}
+
+.gap-2 {
+ gap: 0.5rem;
+}
+
+.gap-3 {
+ gap: 0.75rem;
+}
+
+.gap-4 {
+ gap: 1rem;
+}
+
+.gap-6 {
+ gap: 1.5rem;
+}
+
+.gap-8 {
+ gap: 2rem;
+}
+
+.space-y-1 > :not([hidden]) ~ :not([hidden]) {
+ --tw-space-y-reverse: 0;
+ margin-top: calc(0.25rem * calc(1 - var(--tw-space-y-reverse)));
+ margin-bottom: calc(0.25rem * var(--tw-space-y-reverse));
+}
+
+.space-y-1\.5 > :not([hidden]) ~ :not([hidden]) {
+ --tw-space-y-reverse: 0;
+ margin-top: calc(0.375rem * calc(1 - var(--tw-space-y-reverse)));
+ margin-bottom: calc(0.375rem * var(--tw-space-y-reverse));
+}
+
+.space-y-12 > :not([hidden]) ~ :not([hidden]) {
+ --tw-space-y-reverse: 0;
+ margin-top: calc(3rem * calc(1 - var(--tw-space-y-reverse)));
+ margin-bottom: calc(3rem * var(--tw-space-y-reverse));
+}
+
+.space-y-2 > :not([hidden]) ~ :not([hidden]) {
+ --tw-space-y-reverse: 0;
+ margin-top: calc(0.5rem * calc(1 - var(--tw-space-y-reverse)));
+ margin-bottom: calc(0.5rem * var(--tw-space-y-reverse));
+}
+
+.space-y-3 > :not([hidden]) ~ :not([hidden]) {
+ --tw-space-y-reverse: 0;
+ margin-top: calc(0.75rem * calc(1 - var(--tw-space-y-reverse)));
+ margin-bottom: calc(0.75rem * var(--tw-space-y-reverse));
+}
+
+.space-y-4 > :not([hidden]) ~ :not([hidden]) {
+ --tw-space-y-reverse: 0;
+ margin-top: calc(1rem * calc(1 - var(--tw-space-y-reverse)));
+ margin-bottom: calc(1rem * var(--tw-space-y-reverse));
+}
+
+.space-y-5 > :not([hidden]) ~ :not([hidden]) {
+ --tw-space-y-reverse: 0;
+ margin-top: calc(1.25rem * calc(1 - var(--tw-space-y-reverse)));
+ margin-bottom: calc(1.25rem * var(--tw-space-y-reverse));
+}
+
+.space-y-6 > :not([hidden]) ~ :not([hidden]) {
+ --tw-space-y-reverse: 0;
+ margin-top: calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));
+ margin-bottom: calc(1.5rem * var(--tw-space-y-reverse));
+}
+
+.space-y-8 > :not([hidden]) ~ :not([hidden]) {
+ --tw-space-y-reverse: 0;
+ margin-top: calc(2rem * calc(1 - var(--tw-space-y-reverse)));
+ margin-bottom: calc(2rem * var(--tw-space-y-reverse));
+}
+
+.divide-y > :not([hidden]) ~ :not([hidden]) {
+ --tw-divide-y-reverse: 0;
+ border-top-width: calc(1px * calc(1 - var(--tw-divide-y-reverse)));
+ border-bottom-width: calc(1px * var(--tw-divide-y-reverse));
+}
+
+.divide-gray-200 > :not([hidden]) ~ :not([hidden]) {
+ --tw-divide-opacity: 1;
+ border-color: rgb(229 231 235 / var(--tw-divide-opacity, 1));
+}
+
+.overflow-hidden {
+ overflow: hidden;
+}
+
+.overflow-x-auto {
+ overflow-x: auto;
+}
+
+.overflow-y-auto {
+ overflow-y: auto;
+}
+
+.scroll-smooth {
+ scroll-behavior: smooth;
+}
+
+.truncate {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.rounded {
+ border-radius: 0.25rem;
+}
+
+.rounded-2xl {
+ border-radius: 1rem;
+}
+
+.rounded-full {
+ border-radius: 9999px;
+}
+
+.rounded-lg {
+ border-radius: 0.5rem;
+}
+
+.rounded-sm {
+ border-radius: 0.125rem;
+}
+
+.rounded-xl {
+ border-radius: 0.75rem;
+}
+
+.rounded-t {
+ border-top-left-radius: 0.25rem;
+ border-top-right-radius: 0.25rem;
+}
+
+.rounded-t-2xl {
+ border-top-left-radius: 1rem;
+ border-top-right-radius: 1rem;
+}
+
+.border {
+ border-width: 1px;
+}
+
+.border-2 {
+ border-width: 2px;
+}
+
+.border-4 {
+ border-width: 4px;
+}
+
+.border-b {
+ border-bottom-width: 1px;
+}
+
+.border-r {
+ border-right-width: 1px;
+}
+
+.border-t {
+ border-top-width: 1px;
+}
+
+.border-blue-200 {
+ --tw-border-opacity: 1;
+ border-color: rgb(191 219 254 / var(--tw-border-opacity, 1));
+}
+
+.border-blue-500 {
+ --tw-border-opacity: 1;
+ border-color: rgb(59 130 246 / var(--tw-border-opacity, 1));
+}
+
+.border-blue-600 {
+ --tw-border-opacity: 1;
+ border-color: rgb(37 99 235 / var(--tw-border-opacity, 1));
+}
+
+.border-gray-200 {
+ --tw-border-opacity: 1;
+ border-color: rgb(229 231 235 / var(--tw-border-opacity, 1));
+}
+
+.border-gray-200\/50 {
+ border-color: rgb(229 231 235 / 0.5);
+}
+
+.border-gray-300 {
+ --tw-border-opacity: 1;
+ border-color: rgb(209 213 219 / var(--tw-border-opacity, 1));
+}
+
+.border-gray-500 {
+ --tw-border-opacity: 1;
+ border-color: rgb(107 114 128 / var(--tw-border-opacity, 1));
+}
+
+.border-green-200 {
+ --tw-border-opacity: 1;
+ border-color: rgb(187 247 208 / var(--tw-border-opacity, 1));
+}
+
+.border-red-200 {
+ --tw-border-opacity: 1;
+ border-color: rgb(254 202 202 / var(--tw-border-opacity, 1));
+}
+
+.border-red-500 {
+ --tw-border-opacity: 1;
+ border-color: rgb(239 68 68 / var(--tw-border-opacity, 1));
+}
+
+.border-white\/20 {
+ border-color: rgb(255 255 255 / 0.2);
+}
+
+.border-yellow-200 {
+ --tw-border-opacity: 1;
+ border-color: rgb(254 240 138 / var(--tw-border-opacity, 1));
+}
+
+.border-t-transparent {
+ border-top-color: transparent;
+}
+
+.bg-black\/50 {
+ background-color: rgb(0 0 0 / 0.5);
+}
+
+.bg-blue-100 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(219 234 254 / var(--tw-bg-opacity, 1));
+}
+
+.bg-blue-50 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(239 246 255 / var(--tw-bg-opacity, 1));
+}
+
+.bg-blue-50\/50 {
+ background-color: rgb(239 246 255 / 0.5);
+}
+
+.bg-blue-500 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(59 130 246 / var(--tw-bg-opacity, 1));
+}
+
+.bg-blue-600 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(37 99 235 / var(--tw-bg-opacity, 1));
+}
+
+.bg-emerald-100 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(209 250 229 / var(--tw-bg-opacity, 1));
+}
+
+.bg-emerald-500 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(16 185 129 / var(--tw-bg-opacity, 1));
+}
+
+.bg-gray-100 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(243 244 246 / var(--tw-bg-opacity, 1));
+}
+
+.bg-gray-200 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(229 231 235 / var(--tw-bg-opacity, 1));
+}
+
+.bg-gray-50 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(249 250 251 / var(--tw-bg-opacity, 1));
+}
+
+.bg-gray-900 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(17 24 39 / var(--tw-bg-opacity, 1));
+}
+
+.bg-green-100 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(220 252 231 / var(--tw-bg-opacity, 1));
+}
+
+.bg-green-50 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(240 253 244 / var(--tw-bg-opacity, 1));
+}
+
+.bg-green-500 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(34 197 94 / var(--tw-bg-opacity, 1));
+}
+
+.bg-orange-100 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(255 237 213 / var(--tw-bg-opacity, 1));
+}
+
+.bg-orange-500 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(249 115 22 / var(--tw-bg-opacity, 1));
+}
+
+.bg-pink-500 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(236 72 153 / var(--tw-bg-opacity, 1));
+}
+
+.bg-purple-100 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(243 232 255 / var(--tw-bg-opacity, 1));
+}
+
+.bg-purple-500 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(168 85 247 / var(--tw-bg-opacity, 1));
+}
+
+.bg-red-100 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(254 226 226 / var(--tw-bg-opacity, 1));
+}
+
+.bg-red-50 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(254 242 242 / var(--tw-bg-opacity, 1));
+}
+
+.bg-red-500 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(239 68 68 / var(--tw-bg-opacity, 1));
+}
+
+.bg-red-600 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(220 38 38 / var(--tw-bg-opacity, 1));
+}
+
+.bg-transparent {
+ background-color: transparent;
+}
+
+.bg-white {
+ --tw-bg-opacity: 1;
+ background-color: rgb(255 255 255 / var(--tw-bg-opacity, 1));
+}
+
+.bg-white\/10 {
+ background-color: rgb(255 255 255 / 0.1);
+}
+
+.bg-white\/20 {
+ background-color: rgb(255 255 255 / 0.2);
+}
+
+.bg-white\/80 {
+ background-color: rgb(255 255 255 / 0.8);
+}
+
+.bg-yellow-100 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(254 249 195 / var(--tw-bg-opacity, 1));
+}
+
+.bg-yellow-50 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(254 252 232 / var(--tw-bg-opacity, 1));
+}
+
+.bg-yellow-500 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(234 179 8 / var(--tw-bg-opacity, 1));
+}
+
+.bg-\[linear-gradient\(to_right\2c \#ffffff0a_1px\2c transparent_1px\)\2c linear-gradient\(to_bottom\2c \#ffffff0a_1px\2c transparent_1px\)\] {
+ background-image: linear-gradient(to right,#ffffff0a 1px,transparent 1px),linear-gradient(to bottom,#ffffff0a 1px,transparent 1px);
+}
+
+.bg-gradient-to-br {
+ background-image: linear-gradient(to bottom right, var(--tw-gradient-stops));
+}
+
+.bg-gradient-to-r {
+ background-image: linear-gradient(to right, var(--tw-gradient-stops));
+}
+
+.from-blue-400\/30 {
+ --tw-gradient-from: rgb(96 165 250 / 0.3) var(--tw-gradient-from-position);
+ --tw-gradient-to: rgb(96 165 250 / 0) var(--tw-gradient-to-position);
+ --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
+}
+
+.from-blue-600 {
+ --tw-gradient-from: #2563eb var(--tw-gradient-from-position);
+ --tw-gradient-to: rgb(37 99 235 / 0) var(--tw-gradient-to-position);
+ --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
+}
+
+.from-emerald-600 {
+ --tw-gradient-from: #059669 var(--tw-gradient-from-position);
+ --tw-gradient-to: rgb(5 150 105 / 0) var(--tw-gradient-to-position);
+ --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
+}
+
+.from-gray-50 {
+ --tw-gradient-from: #f9fafb var(--tw-gradient-from-position);
+ --tw-gradient-to: rgb(249 250 251 / 0) var(--tw-gradient-to-position);
+ --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
+}
+
+.from-indigo-400\/40 {
+ --tw-gradient-from: rgb(129 140 248 / 0.4) var(--tw-gradient-from-position);
+ --tw-gradient-to: rgb(129 140 248 / 0) var(--tw-gradient-to-position);
+ --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
+}
+
+.from-slate-50 {
+ --tw-gradient-from: #f8fafc var(--tw-gradient-from-position);
+ --tw-gradient-to: rgb(248 250 252 / 0) var(--tw-gradient-to-position);
+ --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
+}
+
+.from-violet-400\/30 {
+ --tw-gradient-from: rgb(167 139 250 / 0.3) var(--tw-gradient-from-position);
+ --tw-gradient-to: rgb(167 139 250 / 0) var(--tw-gradient-to-position);
+ --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
+}
+
+.from-white\/20 {
+ --tw-gradient-from: rgb(255 255 255 / 0.2) var(--tw-gradient-from-position);
+ --tw-gradient-to: rgb(255 255 255 / 0) var(--tw-gradient-to-position);
+ --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
+}
+
+.via-blue-50\/30 {
+ --tw-gradient-to: rgb(239 246 255 / 0) var(--tw-gradient-to-position);
+ --tw-gradient-stops: var(--tw-gradient-from), rgb(239 246 255 / 0.3) var(--tw-gradient-via-position), var(--tw-gradient-to);
+}
+
+.via-indigo-600 {
+ --tw-gradient-to: rgb(79 70 229 / 0) var(--tw-gradient-to-position);
+ --tw-gradient-stops: var(--tw-gradient-from), #4f46e5 var(--tw-gradient-via-position), var(--tw-gradient-to);
+}
+
+.to-cyan-400\/30 {
+ --tw-gradient-to: rgb(34 211 238 / 0.3) var(--tw-gradient-to-position);
+}
+
+.to-gray-100 {
+ --tw-gradient-to: #f3f4f6 var(--tw-gradient-to-position);
+}
+
+.to-indigo-50\/50 {
+ --tw-gradient-to: rgb(238 242 255 / 0.5) var(--tw-gradient-to-position);
+}
+
+.to-indigo-600 {
+ --tw-gradient-to: #4f46e5 var(--tw-gradient-to-position);
+}
+
+.to-pink-400\/30 {
+ --tw-gradient-to: rgb(244 114 182 / 0.3) var(--tw-gradient-to-position);
+}
+
+.to-purple-400\/40 {
+ --tw-gradient-to: rgb(192 132 252 / 0.4) var(--tw-gradient-to-position);
+}
+
+.to-teal-700 {
+ --tw-gradient-to: #0f766e var(--tw-gradient-to-position);
+}
+
+.to-transparent {
+ --tw-gradient-to: transparent var(--tw-gradient-to-position);
+}
+
+.to-violet-600 {
+ --tw-gradient-to: #7c3aed var(--tw-gradient-to-position);
+}
+
+.to-violet-700 {
+ --tw-gradient-to: #6d28d9 var(--tw-gradient-to-position);
+}
+
+.bg-\[size\:32px_32px\] {
+ background-size: 32px 32px;
+}
+
+.bg-clip-text {
+ -webkit-background-clip: text;
+ background-clip: text;
+}
+
+.object-cover {
+ -o-object-fit: cover;
+ object-fit: cover;
+}
+
+.p-1\.5 {
+ padding: 0.375rem;
+}
+
+.p-12 {
+ padding: 3rem;
+}
+
+.p-2 {
+ padding: 0.5rem;
+}
+
+.p-3 {
+ padding: 0.75rem;
+}
+
+.p-4 {
+ padding: 1rem;
+}
+
+.p-6 {
+ padding: 1.5rem;
+}
+
+.p-8 {
+ padding: 2rem;
+}
+
+.px-1\.5 {
+ padding-left: 0.375rem;
+ padding-right: 0.375rem;
+}
+
+.px-12 {
+ padding-left: 3rem;
+ padding-right: 3rem;
+}
+
+.px-2 {
+ padding-left: 0.5rem;
+ padding-right: 0.5rem;
+}
+
+.px-2\.5 {
+ padding-left: 0.625rem;
+ padding-right: 0.625rem;
+}
+
+.px-3 {
+ padding-left: 0.75rem;
+ padding-right: 0.75rem;
+}
+
+.px-4 {
+ padding-left: 1rem;
+ padding-right: 1rem;
+}
+
+.px-5 {
+ padding-left: 1.25rem;
+ padding-right: 1.25rem;
+}
+
+.px-6 {
+ padding-left: 1.5rem;
+ padding-right: 1.5rem;
+}
+
+.py-0\.5 {
+ padding-top: 0.125rem;
+ padding-bottom: 0.125rem;
+}
+
+.py-1 {
+ padding-top: 0.25rem;
+ padding-bottom: 0.25rem;
+}
+
+.py-1\.5 {
+ padding-top: 0.375rem;
+ padding-bottom: 0.375rem;
+}
+
+.py-12 {
+ padding-top: 3rem;
+ padding-bottom: 3rem;
+}
+
+.py-2 {
+ padding-top: 0.5rem;
+ padding-bottom: 0.5rem;
+}
+
+.py-2\.5 {
+ padding-top: 0.625rem;
+ padding-bottom: 0.625rem;
+}
+
+.py-20 {
+ padding-top: 5rem;
+ padding-bottom: 5rem;
+}
+
+.py-3 {
+ padding-top: 0.75rem;
+ padding-bottom: 0.75rem;
+}
+
+.py-4 {
+ padding-top: 1rem;
+ padding-bottom: 1rem;
+}
+
+.py-5 {
+ padding-top: 1.25rem;
+ padding-bottom: 1.25rem;
+}
+
+.py-6 {
+ padding-top: 1.5rem;
+ padding-bottom: 1.5rem;
+}
+
+.py-8 {
+ padding-top: 2rem;
+ padding-bottom: 2rem;
+}
+
+.pb-4 {
+ padding-bottom: 1rem;
+}
+
+.pb-6 {
+ padding-bottom: 1.5rem;
+}
+
+.pl-10 {
+ padding-left: 2.5rem;
+}
+
+.pr-10 {
+ padding-right: 2.5rem;
+}
+
+.pr-4 {
+ padding-right: 1rem;
+}
+
+.pt-4 {
+ padding-top: 1rem;
+}
+
+.pt-6 {
+ padding-top: 1.5rem;
+}
+
+.text-left {
+ text-align: left;
+}
+
+.text-center {
+ text-align: center;
+}
+
+.text-right {
+ text-align: right;
+}
+
+.text-2xl {
+ font-size: 1.5rem;
+ line-height: 2rem;
+}
+
+.text-3xl {
+ font-size: 1.875rem;
+ line-height: 2.25rem;
+}
+
+.text-5xl {
+ font-size: 3rem;
+ line-height: 1;
+}
+
+.text-base {
+ font-size: 1rem;
+ line-height: 1.5rem;
+}
+
+.text-lg {
+ font-size: 1.125rem;
+ line-height: 1.75rem;
+}
+
+.text-sm {
+ font-size: 0.875rem;
+ line-height: 1.25rem;
+}
+
+.text-xl {
+ font-size: 1.25rem;
+ line-height: 1.75rem;
+}
+
+.text-xs {
+ font-size: 0.75rem;
+ line-height: 1rem;
+}
+
+.font-bold {
+ font-weight: 700;
+}
+
+.font-medium {
+ font-weight: 500;
+}
+
+.font-semibold {
+ font-weight: 600;
+}
+
+.uppercase {
+ text-transform: uppercase;
+}
+
+.leading-relaxed {
+ line-height: 1.625;
+}
+
+.leading-tight {
+ line-height: 1.25;
+}
+
+.tracking-tight {
+ letter-spacing: -0.025em;
+}
+
+.text-blue-500 {
+ --tw-text-opacity: 1;
+ color: rgb(59 130 246 / var(--tw-text-opacity, 1));
+}
+
+.text-blue-600 {
+ --tw-text-opacity: 1;
+ color: rgb(37 99 235 / var(--tw-text-opacity, 1));
+}
+
+.text-blue-800 {
+ --tw-text-opacity: 1;
+ color: rgb(30 64 175 / var(--tw-text-opacity, 1));
+}
+
+.text-emerald-100 {
+ --tw-text-opacity: 1;
+ color: rgb(209 250 229 / var(--tw-text-opacity, 1));
+}
+
+.text-emerald-200 {
+ --tw-text-opacity: 1;
+ color: rgb(167 243 208 / var(--tw-text-opacity, 1));
+}
+
+.text-emerald-600 {
+ --tw-text-opacity: 1;
+ color: rgb(5 150 105 / var(--tw-text-opacity, 1));
+}
+
+.text-gray-400 {
+ --tw-text-opacity: 1;
+ color: rgb(156 163 175 / var(--tw-text-opacity, 1));
+}
+
+.text-gray-500 {
+ --tw-text-opacity: 1;
+ color: rgb(107 114 128 / var(--tw-text-opacity, 1));
+}
+
+.text-gray-600 {
+ --tw-text-opacity: 1;
+ color: rgb(75 85 99 / var(--tw-text-opacity, 1));
+}
+
+.text-gray-700 {
+ --tw-text-opacity: 1;
+ color: rgb(55 65 81 / var(--tw-text-opacity, 1));
+}
+
+.text-gray-800 {
+ --tw-text-opacity: 1;
+ color: rgb(31 41 55 / var(--tw-text-opacity, 1));
+}
+
+.text-gray-900 {
+ --tw-text-opacity: 1;
+ color: rgb(17 24 39 / var(--tw-text-opacity, 1));
+}
+
+.text-green-500 {
+ --tw-text-opacity: 1;
+ color: rgb(34 197 94 / var(--tw-text-opacity, 1));
+}
+
+.text-green-600 {
+ --tw-text-opacity: 1;
+ color: rgb(22 163 74 / var(--tw-text-opacity, 1));
+}
+
+.text-green-800 {
+ --tw-text-opacity: 1;
+ color: rgb(22 101 52 / var(--tw-text-opacity, 1));
+}
+
+.text-orange-600 {
+ --tw-text-opacity: 1;
+ color: rgb(234 88 12 / var(--tw-text-opacity, 1));
+}
+
+.text-purple-600 {
+ --tw-text-opacity: 1;
+ color: rgb(147 51 234 / var(--tw-text-opacity, 1));
+}
+
+.text-red-500 {
+ --tw-text-opacity: 1;
+ color: rgb(239 68 68 / var(--tw-text-opacity, 1));
+}
+
+.text-red-600 {
+ --tw-text-opacity: 1;
+ color: rgb(220 38 38 / var(--tw-text-opacity, 1));
+}
+
+.text-red-800 {
+ --tw-text-opacity: 1;
+ color: rgb(153 27 27 / var(--tw-text-opacity, 1));
+}
+
+.text-transparent {
+ color: transparent;
+}
+
+.text-white {
+ --tw-text-opacity: 1;
+ color: rgb(255 255 255 / var(--tw-text-opacity, 1));
+}
+
+.text-white\/60 {
+ color: rgb(255 255 255 / 0.6);
+}
+
+.text-white\/70 {
+ color: rgb(255 255 255 / 0.7);
+}
+
+.text-white\/90 {
+ color: rgb(255 255 255 / 0.9);
+}
+
+.text-yellow-500 {
+ --tw-text-opacity: 1;
+ color: rgb(234 179 8 / var(--tw-text-opacity, 1));
+}
+
+.text-yellow-600 {
+ --tw-text-opacity: 1;
+ color: rgb(202 138 4 / var(--tw-text-opacity, 1));
+}
+
+.text-yellow-800 {
+ --tw-text-opacity: 1;
+ color: rgb(133 77 14 / var(--tw-text-opacity, 1));
+}
+
+.antialiased {
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+.opacity-25 {
+ opacity: 0.25;
+}
+
+.opacity-75 {
+ opacity: 0.75;
+}
+
+.shadow-2xl {
+ --tw-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25);
+ --tw-shadow-colored: 0 25px 50px -12px var(--tw-shadow-color);
+ box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
+}
+
+.shadow-lg {
+ --tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
+ --tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);
+ box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
+}
+
+.shadow-sm {
+ --tw-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
+ --tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color);
+ box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
+}
+
+.shadow-xl {
+ --tw-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
+ --tw-shadow-colored: 0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color);
+ box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
+}
+
+.outline {
+ outline-style: solid;
+}
+
+.blur-3xl {
+ --tw-blur: blur(64px);
+ filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
+}
+
+.filter {
+ filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
+}
+
+.backdrop-blur-sm {
+ --tw-backdrop-blur: blur(4px);
+ -webkit-backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);
+ backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);
+}
+
+.backdrop-blur-xl {
+ --tw-backdrop-blur: blur(24px);
+ -webkit-backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);
+ backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);
+}
+
+.transition {
+ transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter;
+ transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter;
+ transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter, -webkit-backdrop-filter;
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
+ transition-duration: 150ms;
+}
+
+.transition-all {
+ transition-property: all;
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
+ transition-duration: 150ms;
+}
+
+.transition-colors {
+ transition-property: color, background-color, border-color, text-decoration-color, fill, stroke;
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
+ transition-duration: 150ms;
+}
+
+.transition-transform {
+ transition-property: transform;
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
+ transition-duration: 150ms;
+}
+
+.duration-200 {
+ transition-duration: 200ms;
+}
+
+.duration-300 {
+ transition-duration: 300ms;
+}
+
+.placeholder\:text-gray-400::-moz-placeholder {
+ --tw-text-opacity: 1;
+ color: rgb(156 163 175 / var(--tw-text-opacity, 1));
+}
+
+.placeholder\:text-gray-400::placeholder {
+ --tw-text-opacity: 1;
+ color: rgb(156 163 175 / var(--tw-text-opacity, 1));
+}
+
+.after\:absolute::after {
+ content: var(--tw-content);
+ position: absolute;
+}
+
+.after\:left-\[2px\]::after {
+ content: var(--tw-content);
+ left: 2px;
+}
+
+.after\:top-\[2px\]::after {
+ content: var(--tw-content);
+ top: 2px;
+}
+
+.after\:h-5::after {
+ content: var(--tw-content);
+ height: 1.25rem;
+}
+
+.after\:w-5::after {
+ content: var(--tw-content);
+ width: 1.25rem;
+}
+
+.after\:rounded-full::after {
+ content: var(--tw-content);
+ border-radius: 9999px;
+}
+
+.after\:border::after {
+ content: var(--tw-content);
+ border-width: 1px;
+}
+
+.after\:border-gray-300::after {
+ content: var(--tw-content);
+ --tw-border-opacity: 1;
+ border-color: rgb(209 213 219 / var(--tw-border-opacity, 1));
+}
+
+.after\:bg-white::after {
+ content: var(--tw-content);
+ --tw-bg-opacity: 1;
+ background-color: rgb(255 255 255 / var(--tw-bg-opacity, 1));
+}
+
+.after\:transition-all::after {
+ content: var(--tw-content);
+ transition-property: all;
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
+ transition-duration: 150ms;
+}
+
+.after\:content-\[\'\'\]::after {
+ --tw-content: '';
+ content: var(--tw-content);
+}
+
+.hover\:border-blue-300:hover {
+ --tw-border-opacity: 1;
+ border-color: rgb(147 197 253 / var(--tw-border-opacity, 1));
+}
+
+.hover\:bg-blue-50:hover {
+ --tw-bg-opacity: 1;
+ background-color: rgb(239 246 255 / var(--tw-bg-opacity, 1));
+}
+
+.hover\:bg-blue-600:hover {
+ --tw-bg-opacity: 1;
+ background-color: rgb(37 99 235 / var(--tw-bg-opacity, 1));
+}
+
+.hover\:bg-blue-700:hover {
+ --tw-bg-opacity: 1;
+ background-color: rgb(29 78 216 / var(--tw-bg-opacity, 1));
+}
+
+.hover\:bg-emerald-600:hover {
+ --tw-bg-opacity: 1;
+ background-color: rgb(5 150 105 / var(--tw-bg-opacity, 1));
+}
+
+.hover\:bg-gray-100:hover {
+ --tw-bg-opacity: 1;
+ background-color: rgb(243 244 246 / var(--tw-bg-opacity, 1));
+}
+
+.hover\:bg-gray-200:hover {
+ --tw-bg-opacity: 1;
+ background-color: rgb(229 231 235 / var(--tw-bg-opacity, 1));
+}
+
+.hover\:bg-gray-300:hover {
+ --tw-bg-opacity: 1;
+ background-color: rgb(209 213 219 / var(--tw-bg-opacity, 1));
+}
+
+.hover\:bg-gray-50:hover {
+ --tw-bg-opacity: 1;
+ background-color: rgb(249 250 251 / var(--tw-bg-opacity, 1));
+}
+
+.hover\:bg-red-50:hover {
+ --tw-bg-opacity: 1;
+ background-color: rgb(254 242 242 / var(--tw-bg-opacity, 1));
+}
+
+.hover\:bg-red-700:hover {
+ --tw-bg-opacity: 1;
+ background-color: rgb(185 28 28 / var(--tw-bg-opacity, 1));
+}
+
+.hover\:from-blue-700:hover {
+ --tw-gradient-from: #1d4ed8 var(--tw-gradient-from-position);
+ --tw-gradient-to: rgb(29 78 216 / 0) var(--tw-gradient-to-position);
+ --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
+}
+
+.hover\:to-indigo-700:hover {
+ --tw-gradient-to: #4338ca var(--tw-gradient-to-position);
+}
+
+.hover\:text-blue-600:hover {
+ --tw-text-opacity: 1;
+ color: rgb(37 99 235 / var(--tw-text-opacity, 1));
+}
+
+.hover\:text-blue-700:hover {
+ --tw-text-opacity: 1;
+ color: rgb(29 78 216 / var(--tw-text-opacity, 1));
+}
+
+.hover\:text-gray-600:hover {
+ --tw-text-opacity: 1;
+ color: rgb(75 85 99 / var(--tw-text-opacity, 1));
+}
+
+.hover\:text-gray-900:hover {
+ --tw-text-opacity: 1;
+ color: rgb(17 24 39 / var(--tw-text-opacity, 1));
+}
+
+.hover\:text-red-600:hover {
+ --tw-text-opacity: 1;
+ color: rgb(220 38 38 / var(--tw-text-opacity, 1));
+}
+
+.hover\:underline:hover {
+ text-decoration-line: underline;
+}
+
+.hover\:shadow-xl:hover {
+ --tw-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
+ --tw-shadow-colored: 0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color);
+ box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
+}
+
+.focus\:border-blue-500:focus {
+ --tw-border-opacity: 1;
+ border-color: rgb(59 130 246 / var(--tw-border-opacity, 1));
+}
+
+.focus\:border-red-500:focus {
+ --tw-border-opacity: 1;
+ border-color: rgb(239 68 68 / var(--tw-border-opacity, 1));
+}
+
+.focus\:outline-none:focus {
+ outline: 2px solid transparent;
+ outline-offset: 2px;
+}
+
+.focus\:ring-2:focus {
+ --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
+ --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);
+ box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
+}
+
+.focus\:ring-blue-500:focus {
+ --tw-ring-opacity: 1;
+ --tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity, 1));
+}
+
+.focus\:ring-blue-500\/20:focus {
+ --tw-ring-color: rgb(59 130 246 / 0.2);
+}
+
+.focus\:ring-blue-500\/50:focus {
+ --tw-ring-color: rgb(59 130 246 / 0.5);
+}
+
+.focus\:ring-gray-500\/50:focus {
+ --tw-ring-color: rgb(107 114 128 / 0.5);
+}
+
+.focus\:ring-red-500\/20:focus {
+ --tw-ring-color: rgb(239 68 68 / 0.2);
+}
+
+.focus\:ring-red-500\/50:focus {
+ --tw-ring-color: rgb(239 68 68 / 0.5);
+}
+
+.focus\:ring-offset-2:focus {
+ --tw-ring-offset-width: 2px;
+}
+
+.disabled\:cursor-not-allowed:disabled {
+ cursor: not-allowed;
+}
+
+.disabled\:bg-gray-50:disabled {
+ --tw-bg-opacity: 1;
+ background-color: rgb(249 250 251 / var(--tw-bg-opacity, 1));
+}
+
+.disabled\:text-gray-500:disabled {
+ --tw-text-opacity: 1;
+ color: rgb(107 114 128 / var(--tw-text-opacity, 1));
+}
+
+.disabled\:opacity-50:disabled {
+ opacity: 0.5;
+}
+
+.group:focus-within .group-focus-within\:text-blue-500 {
+ --tw-text-opacity: 1;
+ color: rgb(59 130 246 / var(--tw-text-opacity, 1));
+}
+
+.group:hover .group-hover\:scale-110 {
+ --tw-scale-x: 1.1;
+ --tw-scale-y: 1.1;
+ transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
+}
+
+.group:hover .group-hover\:text-gray-700 {
+ --tw-text-opacity: 1;
+ color: rgb(55 65 81 / var(--tw-text-opacity, 1));
+}
+
+.peer:checked ~ .peer-checked\:bg-blue-600 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(37 99 235 / var(--tw-bg-opacity, 1));
+}
+
+.peer:checked ~ .peer-checked\:after\:translate-x-full::after {
+ content: var(--tw-content);
+ --tw-translate-x: 100%;
+ transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
+}
+
+.peer:checked ~ .peer-checked\:after\:border-white::after {
+ content: var(--tw-content);
+ --tw-border-opacity: 1;
+ border-color: rgb(255 255 255 / var(--tw-border-opacity, 1));
+}
+
+.dark\:divide-gray-700:is(.dark *) > :not([hidden]) ~ :not([hidden]) {
+ --tw-divide-opacity: 1;
+ border-color: rgb(55 65 81 / var(--tw-divide-opacity, 1));
+}
+
+.dark\:border-blue-800:is(.dark *) {
+ --tw-border-opacity: 1;
+ border-color: rgb(30 64 175 / var(--tw-border-opacity, 1));
+}
+
+.dark\:border-gray-600:is(.dark *) {
+ --tw-border-opacity: 1;
+ border-color: rgb(75 85 99 / var(--tw-border-opacity, 1));
+}
+
+.dark\:border-gray-700:is(.dark *) {
+ --tw-border-opacity: 1;
+ border-color: rgb(55 65 81 / var(--tw-border-opacity, 1));
+}
+
+.dark\:border-gray-700\/50:is(.dark *) {
+ border-color: rgb(55 65 81 / 0.5);
+}
+
+.dark\:border-green-800:is(.dark *) {
+ --tw-border-opacity: 1;
+ border-color: rgb(22 101 52 / var(--tw-border-opacity, 1));
+}
+
+.dark\:border-red-800:is(.dark *) {
+ --tw-border-opacity: 1;
+ border-color: rgb(153 27 27 / var(--tw-border-opacity, 1));
+}
+
+.dark\:border-yellow-800:is(.dark *) {
+ --tw-border-opacity: 1;
+ border-color: rgb(133 77 14 / var(--tw-border-opacity, 1));
+}
+
+.dark\:bg-blue-400:is(.dark *) {
+ --tw-bg-opacity: 1;
+ background-color: rgb(96 165 250 / var(--tw-bg-opacity, 1));
+}
+
+.dark\:bg-blue-500:is(.dark *) {
+ --tw-bg-opacity: 1;
+ background-color: rgb(59 130 246 / var(--tw-bg-opacity, 1));
+}
+
+.dark\:bg-blue-900\/10:is(.dark *) {
+ background-color: rgb(30 58 138 / 0.1);
+}
+
+.dark\:bg-blue-900\/20:is(.dark *) {
+ background-color: rgb(30 58 138 / 0.2);
+}
+
+.dark\:bg-blue-900\/30:is(.dark *) {
+ background-color: rgb(30 58 138 / 0.3);
+}
+
+.dark\:bg-emerald-400:is(.dark *) {
+ --tw-bg-opacity: 1;
+ background-color: rgb(52 211 153 / var(--tw-bg-opacity, 1));
+}
+
+.dark\:bg-emerald-900\/30:is(.dark *) {
+ background-color: rgb(6 78 59 / 0.3);
+}
+
+.dark\:bg-gray-600:is(.dark *) {
+ --tw-bg-opacity: 1;
+ background-color: rgb(75 85 99 / var(--tw-bg-opacity, 1));
+}
+
+.dark\:bg-gray-700:is(.dark *) {
+ --tw-bg-opacity: 1;
+ background-color: rgb(55 65 81 / var(--tw-bg-opacity, 1));
+}
+
+.dark\:bg-gray-800:is(.dark *) {
+ --tw-bg-opacity: 1;
+ background-color: rgb(31 41 55 / var(--tw-bg-opacity, 1));
+}
+
+.dark\:bg-gray-800\/50:is(.dark *) {
+ background-color: rgb(31 41 55 / 0.5);
+}
+
+.dark\:bg-gray-800\/80:is(.dark *) {
+ background-color: rgb(31 41 55 / 0.8);
+}
+
+.dark\:bg-gray-900:is(.dark *) {
+ --tw-bg-opacity: 1;
+ background-color: rgb(17 24 39 / var(--tw-bg-opacity, 1));
+}
+
+.dark\:bg-green-900\/20:is(.dark *) {
+ background-color: rgb(20 83 45 / 0.2);
+}
+
+.dark\:bg-green-900\/30:is(.dark *) {
+ background-color: rgb(20 83 45 / 0.3);
+}
+
+.dark\:bg-orange-900\/30:is(.dark *) {
+ background-color: rgb(124 45 18 / 0.3);
+}
+
+.dark\:bg-purple-900\/30:is(.dark *) {
+ background-color: rgb(88 28 135 / 0.3);
+}
+
+.dark\:bg-red-500:is(.dark *) {
+ --tw-bg-opacity: 1;
+ background-color: rgb(239 68 68 / var(--tw-bg-opacity, 1));
+}
+
+.dark\:bg-red-900\/20:is(.dark *) {
+ background-color: rgb(127 29 29 / 0.2);
+}
+
+.dark\:bg-red-900\/30:is(.dark *) {
+ background-color: rgb(127 29 29 / 0.3);
+}
+
+.dark\:bg-yellow-900\/20:is(.dark *) {
+ background-color: rgb(113 63 18 / 0.2);
+}
+
+.dark\:bg-yellow-900\/30:is(.dark *) {
+ background-color: rgb(113 63 18 / 0.3);
+}
+
+.dark\:from-gray-900:is(.dark *) {
+ --tw-gradient-from: #111827 var(--tw-gradient-from-position);
+ --tw-gradient-to: rgb(17 24 39 / 0) var(--tw-gradient-to-position);
+ --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
+}
+
+.dark\:from-slate-900:is(.dark *) {
+ --tw-gradient-from: #0f172a var(--tw-gradient-from-position);
+ --tw-gradient-to: rgb(15 23 42 / 0) var(--tw-gradient-to-position);
+ --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
+}
+
+.dark\:via-slate-800:is(.dark *) {
+ --tw-gradient-to: rgb(30 41 59 / 0) var(--tw-gradient-to-position);
+ --tw-gradient-stops: var(--tw-gradient-from), #1e293b var(--tw-gradient-via-position), var(--tw-gradient-to);
+}
+
+.dark\:to-gray-800:is(.dark *) {
+ --tw-gradient-to: #1f2937 var(--tw-gradient-to-position);
+}
+
+.dark\:to-slate-900:is(.dark *) {
+ --tw-gradient-to: #0f172a var(--tw-gradient-to-position);
+}
+
+.dark\:text-blue-300:is(.dark *) {
+ --tw-text-opacity: 1;
+ color: rgb(147 197 253 / var(--tw-text-opacity, 1));
+}
+
+.dark\:text-blue-400:is(.dark *) {
+ --tw-text-opacity: 1;
+ color: rgb(96 165 250 / var(--tw-text-opacity, 1));
+}
+
+.dark\:text-emerald-400:is(.dark *) {
+ --tw-text-opacity: 1;
+ color: rgb(52 211 153 / var(--tw-text-opacity, 1));
+}
+
+.dark\:text-gray-100:is(.dark *) {
+ --tw-text-opacity: 1;
+ color: rgb(243 244 246 / var(--tw-text-opacity, 1));
+}
+
+.dark\:text-gray-200:is(.dark *) {
+ --tw-text-opacity: 1;
+ color: rgb(229 231 235 / var(--tw-text-opacity, 1));
+}
+
+.dark\:text-gray-300:is(.dark *) {
+ --tw-text-opacity: 1;
+ color: rgb(209 213 219 / var(--tw-text-opacity, 1));
+}
+
+.dark\:text-gray-400:is(.dark *) {
+ --tw-text-opacity: 1;
+ color: rgb(156 163 175 / var(--tw-text-opacity, 1));
+}
+
+.dark\:text-gray-500:is(.dark *) {
+ --tw-text-opacity: 1;
+ color: rgb(107 114 128 / var(--tw-text-opacity, 1));
+}
+
+.dark\:text-green-300:is(.dark *) {
+ --tw-text-opacity: 1;
+ color: rgb(134 239 172 / var(--tw-text-opacity, 1));
+}
+
+.dark\:text-green-400:is(.dark *) {
+ --tw-text-opacity: 1;
+ color: rgb(74 222 128 / var(--tw-text-opacity, 1));
+}
+
+.dark\:text-orange-400:is(.dark *) {
+ --tw-text-opacity: 1;
+ color: rgb(251 146 60 / var(--tw-text-opacity, 1));
+}
+
+.dark\:text-purple-400:is(.dark *) {
+ --tw-text-opacity: 1;
+ color: rgb(192 132 252 / var(--tw-text-opacity, 1));
+}
+
+.dark\:text-red-300:is(.dark *) {
+ --tw-text-opacity: 1;
+ color: rgb(252 165 165 / var(--tw-text-opacity, 1));
+}
+
+.dark\:text-red-400:is(.dark *) {
+ --tw-text-opacity: 1;
+ color: rgb(248 113 113 / var(--tw-text-opacity, 1));
+}
+
+.dark\:text-white:is(.dark *) {
+ --tw-text-opacity: 1;
+ color: rgb(255 255 255 / var(--tw-text-opacity, 1));
+}
+
+.dark\:text-yellow-300:is(.dark *) {
+ --tw-text-opacity: 1;
+ color: rgb(253 224 71 / var(--tw-text-opacity, 1));
+}
+
+.dark\:text-yellow-400:is(.dark *) {
+ --tw-text-opacity: 1;
+ color: rgb(250 204 21 / var(--tw-text-opacity, 1));
+}
+
+.dark\:placeholder\:text-gray-500:is(.dark *)::-moz-placeholder {
+ --tw-text-opacity: 1;
+ color: rgb(107 114 128 / var(--tw-text-opacity, 1));
+}
+
+.dark\:placeholder\:text-gray-500:is(.dark *)::placeholder {
+ --tw-text-opacity: 1;
+ color: rgb(107 114 128 / var(--tw-text-opacity, 1));
+}
+
+.dark\:hover\:bg-blue-600:hover:is(.dark *) {
+ --tw-bg-opacity: 1;
+ background-color: rgb(37 99 235 / var(--tw-bg-opacity, 1));
+}
+
+.dark\:hover\:bg-blue-900\/20:hover:is(.dark *) {
+ background-color: rgb(30 58 138 / 0.2);
+}
+
+.dark\:hover\:bg-gray-600:hover:is(.dark *) {
+ --tw-bg-opacity: 1;
+ background-color: rgb(75 85 99 / var(--tw-bg-opacity, 1));
+}
+
+.dark\:hover\:bg-gray-700:hover:is(.dark *) {
+ --tw-bg-opacity: 1;
+ background-color: rgb(55 65 81 / var(--tw-bg-opacity, 1));
+}
+
+.dark\:hover\:bg-gray-700\/50:hover:is(.dark *) {
+ background-color: rgb(55 65 81 / 0.5);
+}
+
+.dark\:hover\:bg-gray-800:hover:is(.dark *) {
+ --tw-bg-opacity: 1;
+ background-color: rgb(31 41 55 / var(--tw-bg-opacity, 1));
+}
+
+.dark\:hover\:bg-red-600:hover:is(.dark *) {
+ --tw-bg-opacity: 1;
+ background-color: rgb(220 38 38 / var(--tw-bg-opacity, 1));
+}
+
+.dark\:hover\:bg-red-900\/20:hover:is(.dark *) {
+ background-color: rgb(127 29 29 / 0.2);
+}
+
+.dark\:hover\:text-blue-400:hover:is(.dark *) {
+ --tw-text-opacity: 1;
+ color: rgb(96 165 250 / var(--tw-text-opacity, 1));
+}
+
+.dark\:hover\:text-gray-200:hover:is(.dark *) {
+ --tw-text-opacity: 1;
+ color: rgb(229 231 235 / var(--tw-text-opacity, 1));
+}
+
+.dark\:hover\:text-gray-300:hover:is(.dark *) {
+ --tw-text-opacity: 1;
+ color: rgb(209 213 219 / var(--tw-text-opacity, 1));
+}
+
+.dark\:hover\:text-white:hover:is(.dark *) {
+ --tw-text-opacity: 1;
+ color: rgb(255 255 255 / var(--tw-text-opacity, 1));
+}
+
+.dark\:focus\:border-blue-400:focus:is(.dark *) {
+ --tw-border-opacity: 1;
+ border-color: rgb(96 165 250 / var(--tw-border-opacity, 1));
+}
+
+.dark\:focus\:ring-blue-400\/20:focus:is(.dark *) {
+ --tw-ring-color: rgb(96 165 250 / 0.2);
+}
+
+.dark\:focus\:ring-offset-gray-900:focus:is(.dark *) {
+ --tw-ring-offset-color: #111827;
+}
+
+.group:hover .dark\:group-hover\:text-gray-300:is(.dark *) {
+ --tw-text-opacity: 1;
+ color: rgb(209 213 219 / var(--tw-text-opacity, 1));
+}
+
+@media (min-width: 640px) {
+ .sm\:block {
+ display: block;
+ }
+
+ .sm\:grid-cols-2 {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .sm\:grid-cols-3 {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+
+ .sm\:grid-cols-4 {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ }
+
+ .sm\:flex-row {
+ flex-direction: row;
+ }
+
+ .sm\:items-center {
+ align-items: center;
+ }
+
+ .sm\:justify-center {
+ justify-content: center;
+ }
+
+ .sm\:justify-between {
+ justify-content: space-between;
+ }
+
+ .sm\:px-6 {
+ padding-left: 1.5rem;
+ padding-right: 1.5rem;
+ }
+
+ .sm\:text-3xl {
+ font-size: 1.875rem;
+ line-height: 2.25rem;
+ }
+
+ .sm\:text-6xl {
+ font-size: 3.75rem;
+ line-height: 1;
+ }
+}
+
+@media (min-width: 1024px) {
+ .lg\:col-span-1 {
+ grid-column: span 1 / span 1;
+ }
+
+ .lg\:col-span-3 {
+ grid-column: span 3 / span 3;
+ }
+
+ .lg\:ml-16 {
+ margin-left: 4rem;
+ }
+
+ .lg\:ml-64 {
+ margin-left: 16rem;
+ }
+
+ .lg\:block {
+ display: block;
+ }
+
+ .lg\:flex {
+ display: flex;
+ }
+
+ .lg\:hidden {
+ display: none;
+ }
+
+ .lg\:w-1\/2 {
+ width: 50%;
+ }
+
+ .lg\:w-16 {
+ width: 4rem;
+ }
+
+ .lg\:w-64 {
+ width: 16rem;
+ }
+
+ .lg\:grid-cols-2 {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .lg\:grid-cols-3 {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+
+ .lg\:grid-cols-4 {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ }
+
+ .lg\:flex-row {
+ flex-direction: row;
+ }
+
+ .lg\:flex-col {
+ flex-direction: column;
+ }
+
+ .lg\:justify-between {
+ justify-content: space-between;
+ }
+
+ .lg\:border-r {
+ border-right-width: 1px;
+ }
+
+ .lg\:border-gray-200 {
+ --tw-border-opacity: 1;
+ border-color: rgb(229 231 235 / var(--tw-border-opacity, 1));
+ }
+
+ .lg\:bg-white {
+ --tw-bg-opacity: 1;
+ background-color: rgb(255 255 255 / var(--tw-bg-opacity, 1));
+ }
+
+ .lg\:p-6 {
+ padding: 1.5rem;
+ }
+
+ .lg\:px-10 {
+ padding-left: 2.5rem;
+ padding-right: 2.5rem;
+ }
+
+ .lg\:dark\:border-gray-700:is(.dark *) {
+ --tw-border-opacity: 1;
+ border-color: rgb(55 65 81 / var(--tw-border-opacity, 1));
+ }
+
+ .lg\:dark\:bg-gray-800:is(.dark *) {
+ --tw-bg-opacity: 1;
+ background-color: rgb(31 41 55 / var(--tw-bg-opacity, 1));
+ }
+}
+
+@media (min-width: 1280px) {
+ .xl\:px-16 {
+ padding-left: 4rem;
+ padding-right: 4rem;
+ }
+
+ .xl\:text-6xl {
+ font-size: 3.75rem;
+ line-height: 1;
+ }
+}
diff --git a/static/tailwind-source.css b/static/tailwind-source.css
new file mode 100644
index 0000000..b5c61c9
--- /dev/null
+++ b/static/tailwind-source.css
@@ -0,0 +1,3 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
diff --git a/tests/lib/config.test.ts b/tests/lib/config.test.ts
new file mode 100644
index 0000000..bfd618a
--- /dev/null
+++ b/tests/lib/config.test.ts
@@ -0,0 +1,69 @@
+/**
+ * lib/config.ts 单元测试
+ */
+import { assertEquals, assertExists } from "$std/assert/mod.ts";
+import "../setup.ts";
+
+import type { Permission } from "../../lib/types.ts";
+import { APP_CONFIG, AUTH_CONFIG, hasPermission, THEME_CONFIG } from "../../lib/config.ts";
+
+Deno.test("APP_CONFIG - 应用配置", async (t) => {
+ await t.step("应该有正确的配置结构", () => {
+ assertExists(APP_CONFIG.title);
+ assertExists(APP_CONFIG.description);
+ assertExists(APP_CONFIG.author);
+ assertEquals(typeof APP_CONFIG.title, "string");
+ assertEquals(typeof APP_CONFIG.description, "string");
+ });
+});
+
+Deno.test("AUTH_CONFIG - 认证配置", async (t) => {
+ await t.step("应该有 token 相关配置", () => {
+ assertExists(AUTH_CONFIG.tokenKey);
+ assertExists(AUTH_CONFIG.userKey);
+ assertEquals(typeof AUTH_CONFIG.tokenKey, "string");
+ });
+
+ await t.step("应该有过期时间配置", () => {
+ assertExists(AUTH_CONFIG.tokenExpiresDays);
+ assertExists(AUTH_CONFIG.rememberExpiresDays);
+ assertEquals(typeof AUTH_CONFIG.tokenExpiresDays, "number");
+ });
+});
+
+Deno.test("THEME_CONFIG - 主题配置", async (t) => {
+ await t.step("应该有默认主题模式", () => {
+ assertExists(THEME_CONFIG.defaultMode);
+ assertExists(THEME_CONFIG.storageKey);
+ });
+
+ await t.step("默认模式应该是有效值", () => {
+ const validModes = ["light", "dark", "system"];
+ assertEquals(validModes.includes(THEME_CONFIG.defaultMode), true);
+ });
+});
+
+Deno.test("hasPermission - 权限检查", async (t) => {
+ const userPermissions: Permission[] = ["dashboard:view", "users:view", "users:edit"];
+
+ await t.step("应该返回 true 当用户有权限时", () => {
+ const result = hasPermission(userPermissions, "dashboard:view");
+ assertEquals(result, true);
+ });
+
+ await t.step("应该返回 false 当用户没有权限时", () => {
+ const result = hasPermission(userPermissions, "settings:edit");
+ assertEquals(result, false);
+ });
+
+ await t.step("空权限列表应该返回 false", () => {
+ const result = hasPermission([], "dashboard:view");
+ assertEquals(result, false);
+ });
+
+ await t.step("应该支持通配符权限", () => {
+ const adminPermissions: Permission[] = ["*"];
+ const result = hasPermission(adminPermissions, "dashboard:view");
+ assertEquals(result, true);
+ });
+});
diff --git a/tests/lib/stores.test.ts b/tests/lib/stores.test.ts
new file mode 100644
index 0000000..d64f688
--- /dev/null
+++ b/tests/lib/stores.test.ts
@@ -0,0 +1,145 @@
+/**
+ * lib/stores.ts 单元测试
+ */
+import { assertEquals, assertExists } from "$std/assert/mod.ts";
+import { cleanupMocks, clearAuthState, createMockUser, mockAuthenticatedState } from "../setup.ts";
+
+import {
+ authToken,
+ clearAuthState as clearAuth,
+ currentUser,
+ getActualTheme,
+ getUserPermissions,
+ isAuthenticated,
+ isDarkMode,
+ removeToast,
+ setAuthState,
+ setThemeMode,
+ showToast,
+ themeMode,
+ toasts,
+ toggleSidebar,
+ uiSettings,
+ updateUISettings,
+} from "../../lib/stores.ts";
+
+// 每个测试后清理
+Deno.test("stores cleanup", () => {
+ cleanupMocks();
+ clearAuth();
+});
+
+Deno.test("认证状态管理", async (t) => {
+ await t.step("初始状态应该是未认证", () => {
+ clearAuth();
+ assertEquals(isAuthenticated(), false);
+ assertEquals(currentUser.value, null);
+ assertEquals(authToken.value, null);
+ });
+
+ await t.step("setAuthState 应该设置认证状态", () => {
+ const mockUser = createMockUser();
+ setAuthState(mockUser, "test-token", false);
+
+ assertEquals(currentUser.value?.id, mockUser.id);
+ assertEquals(authToken.value, "test-token");
+ assertEquals(isAuthenticated(), true);
+ });
+
+ await t.step("getUserPermissions 应该返回用户权限", () => {
+ const mockUser = createMockUser();
+ currentUser.value = mockUser;
+
+ const permissions = getUserPermissions();
+ assertEquals(permissions.length, 4);
+ assertEquals(permissions.includes("dashboard:view"), true);
+ });
+
+ await t.step("clearAuthState 应该清除认证状态", () => {
+ setAuthState(createMockUser(), "test-token", false);
+ clearAuth();
+
+ assertEquals(currentUser.value, null);
+ assertEquals(authToken.value, null);
+ assertEquals(isAuthenticated(), false);
+ });
+});
+
+Deno.test("主题状态管理", async (t) => {
+ await t.step("默认主题模式", () => {
+ assertExists(themeMode.value);
+ });
+
+ await t.step("setThemeMode 应该更新主题", () => {
+ setThemeMode("dark");
+ assertEquals(themeMode.value, "dark");
+
+ setThemeMode("light");
+ assertEquals(themeMode.value, "light");
+ });
+
+ await t.step("getActualTheme 应该返回实际主题", () => {
+ setThemeMode("light");
+ assertEquals(getActualTheme(), "light");
+
+ setThemeMode("dark");
+ assertEquals(getActualTheme(), "dark");
+ });
+
+ await t.step("isDarkMode 应该正确判断", () => {
+ setThemeMode("dark");
+ assertEquals(isDarkMode(), true);
+
+ setThemeMode("light");
+ assertEquals(isDarkMode(), false);
+ });
+});
+
+Deno.test("UI 设置管理", async (t) => {
+ await t.step("默认 UI 设置", () => {
+ assertExists(uiSettings.value);
+ assertEquals(typeof uiSettings.value.sidebarCollapsed, "boolean");
+ });
+
+ await t.step("updateUISettings 应该更新设置", () => {
+ updateUISettings({ sidebarCollapsed: true });
+ assertEquals(uiSettings.value.sidebarCollapsed, true);
+
+ updateUISettings({ sidebarCollapsed: false });
+ assertEquals(uiSettings.value.sidebarCollapsed, false);
+ });
+
+ await t.step("toggleSidebar 应该切换侧边栏状态", () => {
+ const before = uiSettings.value.sidebarCollapsed;
+ toggleSidebar();
+ assertEquals(uiSettings.value.sidebarCollapsed, !before);
+ });
+});
+
+Deno.test("Toast 通知管理", async (t) => {
+ await t.step("showToast 应该添加通知", () => {
+ const initialLength = toasts.value.length;
+ showToast({
+ type: "success",
+ title: "测试通知",
+ message: "这是一条测试消息",
+ duration: 0, // 不自动移除
+ });
+
+ assertEquals(toasts.value.length, initialLength + 1);
+ });
+
+ await t.step("removeToast 应该移除通知", () => {
+ showToast({
+ type: "info",
+ title: "待移除",
+ duration: 0,
+ });
+
+ const lastToast = toasts.value[toasts.value.length - 1];
+ const lengthBefore = toasts.value.length;
+
+ removeToast(lastToast.id);
+ assertEquals(toasts.value.length, lengthBefore - 1);
+ });
+});
diff --git a/tests/lib/utils.test.ts b/tests/lib/utils.test.ts
new file mode 100644
index 0000000..7b824be
--- /dev/null
+++ b/tests/lib/utils.test.ts
@@ -0,0 +1,107 @@
+/**
+ * lib/utils.ts 单元测试
+ */
+import { assertEquals, assertExists } from "$std/assert/mod.ts";
+import "../setup.ts";
+
+// 导入被测试的模块
+import {
+ cn,
+ cookies,
+ formatCurrency,
+ formatDate,
+ formatNumber,
+ isBrowser,
+ storage,
+} from "../../lib/utils.ts";
+
+Deno.test("cn - 合并类名", async (t) => {
+ await t.step("应该合并多个类名", () => {
+ const result = cn("foo", "bar");
+ assertEquals(result, "foo bar");
+ });
+
+ await t.step("应该处理条件类名", () => {
+ const result = cn("foo", false && "bar", "baz");
+ assertEquals(result, "foo baz");
+ });
+
+ await t.step("应该处理 Tailwind 冲突", () => {
+ const result = cn("p-4", "p-2");
+ assertEquals(result, "p-2");
+ });
+
+ await t.step("应该处理空值", () => {
+ const result = cn("foo", null, undefined, "bar");
+ assertEquals(result, "foo bar");
+ });
+});
+
+Deno.test("isBrowser - 浏览器环境检测", async (t) => {
+ await t.step("在 Deno 测试环境中应该返回 false", () => {
+ // Deno 测试环境中没有 document
+ const result = isBrowser();
+ assertEquals(typeof result, "boolean");
+ });
+});
+
+Deno.test("storage - localStorage 封装", async (t) => {
+ // 注意:在 Deno 测试环境中 isBrowser() 返回 false,所以 storage 操作返回 undefined
+ // 这些测试验证的是非浏览器环境的行为
+ await t.step("在非浏览器环境中 get 应该返回 undefined", () => {
+ const result = storage.get<{ foo: string }>("test-key");
+ assertEquals(result, undefined);
+ });
+
+ await t.step("在非浏览器环境中 set 不应该抛出错误", () => {
+ // 在非浏览器环境中不会抛出错误,只是不执行
+ storage.set("test-key", { foo: "bar" });
+ // 无法验证存储,因为 isBrowser() 返回 false
+ });
+
+ await t.step("在非浏览器环境中 remove 不应该抛出错误", () => {
+ storage.remove("test-key");
+ // 无法验证删除,因为 isBrowser() 返回 false
+ });
+});
+
+Deno.test("formatDate - 日期格式化", async (t) => {
+ await t.step("应该格式化日期字符串", () => {
+ const result = formatDate("2024-01-15");
+ assertExists(result);
+ assertEquals(typeof result, "string");
+ });
+
+ await t.step("应该格式化 Date 对象", () => {
+ const date = new Date("2024-01-15T10:30:00Z");
+ const result = formatDate(date);
+ assertExists(result);
+ });
+});
+
+Deno.test("formatNumber - 数字格式化", async (t) => {
+ await t.step("应该格式化大数字", () => {
+ const result = formatNumber(1234567);
+ assertExists(result);
+ // 应该包含千分位分隔符
+ assertEquals(typeof result, "string");
+ });
+
+ await t.step("应该处理小数", () => {
+ const result = formatNumber(1234.56);
+ assertExists(result);
+ });
+});
+
+Deno.test("formatCurrency - 货币格式化", async (t) => {
+ await t.step("应该格式化为人民币", () => {
+ const result = formatCurrency(1234.56);
+ assertExists(result);
+ assertEquals(typeof result, "string");
+ });
+
+ await t.step("应该处理整数", () => {
+ const result = formatCurrency(1000);
+ assertExists(result);
+ });
+});
diff --git a/tests/setup.ts b/tests/setup.ts
new file mode 100644
index 0000000..3914508
--- /dev/null
+++ b/tests/setup.ts
@@ -0,0 +1,126 @@
+import type { Permission, User } from "../lib/types.ts";
+
+/**
+ * 测试环境设置
+ * 为 Deno 测试提供必要的 mock 和配置
+ */
+
+// Mock localStorage
+const localStorageMock = (() => {
+ let store: Record
= {};
+ return {
+ getItem: (key: string): string | null => store[key] ?? null,
+ setItem: (key: string, value: string): void => {
+ store[key] = value.toString();
+ },
+ removeItem: (key: string): void => {
+ delete store[key];
+ },
+ clear: (): void => {
+ store = {};
+ },
+ get length(): number {
+ return Object.keys(store).length;
+ },
+ key: (index: number): string | null => {
+ return Object.keys(store)[index] ?? null;
+ },
+ };
+})();
+
+// Mock sessionStorage
+const sessionStorageMock = (() => {
+ let store: Record = {};
+ return {
+ getItem: (key: string): string | null => store[key] ?? null,
+ setItem: (key: string, value: string): void => {
+ store[key] = value.toString();
+ },
+ removeItem: (key: string): void => {
+ delete store[key];
+ },
+ clear: (): void => {
+ store = {};
+ },
+ get length(): number {
+ return Object.keys(store).length;
+ },
+ key: (index: number): string | null => {
+ return Object.keys(store)[index] ?? null;
+ },
+ };
+})();
+
+// Mock matchMedia
+const matchMediaMock = (query: string) => ({
+ matches: query.includes("dark") ? false : true,
+ media: query,
+ onchange: null,
+ addListener: () => {},
+ removeListener: () => {},
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ dispatchEvent: () => true,
+});
+
+// 设置全局 mock
+if (typeof globalThis.localStorage === "undefined") {
+ Object.defineProperty(globalThis, "localStorage", {
+ value: localStorageMock,
+ writable: true,
+ });
+}
+
+if (typeof globalThis.sessionStorage === "undefined") {
+ Object.defineProperty(globalThis, "sessionStorage", {
+ value: sessionStorageMock,
+ writable: true,
+ });
+}
+
+if (typeof globalThis.matchMedia === "undefined") {
+ Object.defineProperty(globalThis, "matchMedia", {
+ value: matchMediaMock,
+ writable: true,
+ });
+}
+
+// 清理函数 - 在每个测试后调用
+export function cleanupMocks(): void {
+ localStorageMock.clear();
+ sessionStorageMock.clear();
+}
+
+// 辅助函数:创建测试用户
+export function createMockUser(overrides: Partial = {}): User {
+ const permissions: Permission[] = ["dashboard:view", "users:view", "users:edit", "settings:view"];
+ return {
+ id: "test-user-1",
+ name: "Test User",
+ email: "test@example.com",
+ avatar: "/avatars/default.png",
+ status: "active",
+ role: {
+ id: "admin",
+ name: "管理员",
+ label: "管理员",
+ permissions,
+ },
+ createdAt: new Date().toISOString(),
+ ...overrides,
+ };
+}
+
+// 辅助函数:模拟登录状态
+export function mockAuthenticatedState(user = createMockUser()): void {
+ localStorageMock.setItem("halolight_user", JSON.stringify(user));
+ localStorageMock.setItem("halolight_token", `mock-token-${Date.now()}`);
+}
+
+// 辅助函数:清除登录状态
+export function clearAuthState(): void {
+ localStorageMock.removeItem("halolight_user");
+ localStorageMock.removeItem("halolight_token");
+}
+
+console.log("测试环境设置完成");
diff --git a/utils.ts b/utils.ts
index c7cfb70..8037124 100644
--- a/utils.ts
+++ b/utils.ts
@@ -1,7 +1,7 @@
-import { createDefine } from "@fresh/core";
-
export interface State {
title?: string;
}
-export const define = createDefine();
+export const define = {
+ page: (Component: any) => Component,
+};