* feat: 更新依赖,解决启动报错 * feat: 依赖升级后的样式修改 * refactor: 首页提取样式至单独的文件 * refactor: 重构首页代码,提取处理函数 * feat: 使用web worker来进行文件导入计算 * refactor: 提取组件,优化代码 * refactor: 人员列表代码结构重组 * feat: ✨ 修复问题 * feat: 添加Loading效果 * feat: indexdb存储人员名单,使用dexie * feat(config): 重构界面配置项布局与样式 将原有的线性表单布局调整为分组的 fieldset 布局,提升视觉层次和可维护性。 新增文本设置、布局设置、主题设置、图案设置和其他设置等分类区域。 优化部分输入控件的结构和样式类名,增强用户体验和代码可读性。 * feat(data): 调整默认图案列表顺序 2025改为2026 feat(ui): 添加配置项注释说明 为 FaceConfig.vue 中的各个设置字段添加了 HTML 注释,便于识别和维护不同功能区域。包括: - 文本设置(主标题、语言、文字大小) - 布局设置(列数、卡片宽度、卡片高度) - 主题设置(主题、背景图片) - 图案设置 - 其他设置(是否常显奖项列表、是否显示头像) * feat(PageHeader): 新增 PageHeader 组件并应用于多个配置页面 新增了 PageHeader 通用组件,用于统一页面标题和操作按钮区域的布局。 该组件包含 title 属性以及 buttons 和 alerts 两个具名插槽,便于复用和维护。 已在以下页面中集成使用: - 图片管理页(ImageConfig.vue) - 音乐管理页(MusicConfig.vue) - 人员管理页(PersonAll/index.vue) - 中奖者管理页(PersonAlready.vue) - 奖品管理页(PrizeConfig.vue) * fix(style): 调整 markdown 样式以支持主题变量 移除 media 查询,统一使用 CSS 变量定义颜色;修复组件中 i18n 导入位置问题。 * ``` fix(Config): 注释掉设置卡片颜色的逻辑 将设置卡片颜色的相关代码注释掉,完全使用用户使用的颜色,避免在主题切换时出现异常。 ``` * ``` refactor(FaceConfig): 移除未使用的颜色验证工具函数 从 FaceConfig 组件中移除了不再需要的 isHex 和 isRgbOrRgba 工具函数导入, 因为这些函数当前并未在组件中使用。这有助于减少不必要的代码依赖并提高 组件的简洁性。 ``` * fix(MusicConfig): 优化本地音乐列表项的显示样式 移除列表项中的 divide-y 类,为音乐名称添加 title 属性以显示完整名称, 并将音乐名称渲染为可点击的链接样式。 * build(workflow): 更新Node.js版本到22.x 将GitHub Actions工作流中的Node.js版本从20.x升级到22.x, 以使用最新的稳定版Node.js环境进行构建和测试。 * feat(router、config): 更新配置页面组件路径 将 FaceConfig、ImageConfig 和 MusicConfig 组件的导入路径从直接引用 `.vue` 文件改为引用 `index.vue`。 * build(eslint): 更新 ESLint 配置以忽略更多文件并添加警告规则 在原有忽略文件列表中新增了 '*.config.js' 文件类型,同时添加了 'no-console' 和 'no-debugger' 规则,并将其设置为警告级别,以提高 代码质量和一致性。 * feat(components): 添加图片上传组件及对话框功能 新增 `ImageUpload` 组件用于文件选择与预览,并集成到 `UploadDialog` 中实现图片上传逻辑。 更新了 `Dialog` 组件以支持可选属性和 model 绑定,增强其灵活性和可用性。 引入 `lucide-vue-next` 图标库支持图标渲染。 重构图片配置页面,移除旧上传逻辑,使用新的弹窗方式进行图片上传操作。 * feat(imgConfig): 优化图片存储结构 新增uuid依赖用于生成唯一ID,改进图片上传功能中的本地存储结构, 使用uuid替代时间戳作为键名以避免冲突,并调整从indexedDB读取数据的方式。 * feat: 音乐管理功能优化 * fix(MusicConfig): 移除上传对话框中的图片上传提示功能 移除了 UploadDialog 组件中不再使用的图片上传状态提示逻辑, 包括相关的响应式变量 imgUploadToast 及其对应的模板代码。 此变更简化了组件结构并移除了未使用的错误提示功能。 * fix(Config): 添加图片上传提示信息 在全局人脸配置页面添加了图片上传提示文本,提醒用户需要先上传图片到图片管理模块。 * feat(layout): 重构页面布局与音乐播放组件 将 PlayMusic 组件迁移至 layout/RightButton 并重构成通用右下角按钮组件, 提取音乐播放逻辑到独立 hook `usePlayMusic`,优化模态框提示逻辑并统一滚动行为。 * feat(prize-config): 引入拖拽功能并优化奖品配置界面 - 添加 `vue-draggable-plus` 和 `lodash-es` 依赖以支持列表拖拽排序 - 使用 `cloneDeep` 深拷贝奖品列表,避免直接修改原始数据 - 移除原有的手动排序逻辑(上下移动按钮),改用可视化拖拽方式 - 调整 UI 布局和样式,增强用户体验与可操作性 - 在 Demo 页面中添加 draggable 示例用于验证功能实现 * feat(Config): 动态显示当前年份并更新版权信息 将页脚中的版权年份从硬编码的 2024 改为动态获取的当前年份,并将作者名称添加跳转到 GitHub 主页的链接。 * fix(ui): 优化图片列表下拉选择器及表单项的样式与布局 * feat(person): 导入人员时添加uuid唯一标识字段 #91 * feat(utils): 添加安全洗牌与随机抽样函数 Feature #91 * test(random): 添加随机元素抽取函数的全面测试用例 Test #91 - 新增 Random.test.ts 文件,对 getRandomElements 函数进行详尽测试 - 包括基础功能、边界情况(count 为 0、负数、超出数组长度) - 支持空数组、单元素数组、字符串数组、对象数组等多种数据类型 - 增加概率性测试,验证多次调用结果不完全一致 - 20万次循环验证各元素被抽中概率接近理论值,确保算法公平性 * feat(home): 重构抽奖逻辑并优化初始化流程 Feature #91 * feat(Home): 添加初始化完成状态控制头部标题显示 Fix #91 * feat(HeaderTitle): 添加加载状态显示 #91 * feat(store): 调整默认人员列表类型定义 * feat(deps): 添加tauri打包(遗留pnpm build:file打包好的文件跨域无法访问) #94 * feat(icons): 添加tauri多种平台的应用图标资源 * fix(tauri): 更新 Cargo.toml 配置格式 #94 * build(workflow): 更新 GitHub Actions 工作流配置 #94 * build(workflow): 更新发布流程并移除旧的构建文件 * chore(release): 调整 GitHub Actions 工作流中的缩进格式 * build(deps): 固定 three.js 相关依赖版本 * build(workflows): 添加 fast-glob 依赖到 GitHub 工作流 * build(release): 添加发布名称和文件名以包含版本信息 * build(github): 升级下载构件操作到v4版本 * ci(release): 上传web构建产物作为GitHub Actions工件 * feat(Config): 支持多语言 README 文档展示 * feat(Global/FaceConfig): 添加跳转到图片管理页面的链接 #96 * chore: 格式化 * feat(i18n): 更新抽奖配置相关国际化文案 #96 * build: git钩子 * build: pre-psuh * feat(components): 添加 shadcn-vue 组件配置文件及配套组件库 #96 * feat(font): 添加本地字体选择功能(not done) #96 * feat: 更换字体v,以及页面加载之前就设置字体 #96 * chore: 删除v console.log #96 * build: pre-push * feat(globalConfig): 设置默认字体为微软雅黑 #96 * feat: 设置标题字体与全局字体同步 #96 * build(.gitignore): 添加 husky 目录到忽略文件 * docs(readme): 格式 * docs(readme): 移除关于build:file命令的过时文档 * build: 🏗️ 使用低版本node会报错,强制限制使用node 22.x版本 可在.npmrc里面修改为不强制使用 * feat: ✨ 上传文件时校验是否模板文件,否则提示 #96 * feat(layout): 添加全屏切换功能 #96 * refactor: ♻️ 界面设置页面重构,抽离逻辑 #96 * fix(background): 修复从本地存储获取图片数据的问题 #96 * refactor: ♻️ 重构奖项配置模块 #96 * feat(personalready): ✨ 已中奖的员页面修改switch组件 #96 * refactor(personalready): ♻️ 已中奖的界面代码重构 #96 * style: 💄 删除shadcn-vue的样式预设,只使用daisyui的样式 #96 * feat: ✨ 标题样式跟随主题,设置文本颜色会覆盖主题的标题颜色 #96 * fix(Global/FaceConfig): 调整主题设置中文本颜色按钮的高度样式 #96 * fix(home): 🐛 解决多次切换路由后页面卡顿的问题 #96 (#119) 卸载路由时清除requestAnimationFrame * 96 UI optimization (#122) * fix(home): 🐛 解决多次切换路由后页面卡顿的问题 #96 卸载路由时清除requestAnimationFrame * feat: ✨ 文件存储使用Blob格式 * style: 💄 修改部分类型any为具体类型 * feat: ✨ 界面设置中模块使用瀑布流布局 #96 * fix: 🐛 md文档更换文件夹解决控制台警告 * style: 💄 switch按钮改回使用daisyui组件 * refactor: ♻️ 所有人员列表提取tableColumn * style: 💄 奖项列表中的图片类型修复 * Confilct dev date 12 22 (#131) * fix(home): 🐛 解决多次切换路由后页面卡顿的问题 #96 卸载路由时清除requestAnimationFrame * feat: ✨ 文件存储使用Blob格式 * style: 💄 修改部分类型any为具体类型 * feat: ✨ 界面设置中模块使用瀑布流布局 #96 * fix: 🐛 md文档更换文件夹解决控制台警告 * style: 💄 switch按钮改回使用daisyui组件 * refactor: ♻️ 所有人员列表提取tableColumn * style: 💄 奖项列表中的图片类型修复 * fix(globalConfig): 修复当前音乐项类型缺失问题 * feat: ✨ single person not done * feat: ✨ 可添加单人 #96 * build(.gitignore): 添加 auto-imports.d.ts 到忽略文件 * fix: 🐛 上传、下载excel文件时修复路径错误 打包成应用和网页端的baseUrl不一样,使用环境变量来表示 * fix: 🐛 导入人员列表时处理有值为空的情况 * style: 💄 改变toaster的组件 * fix: 🐛 上传文件、解析数据与存储/读取数据的处理 、 * 96 UI optimization (#132) * fix(home): 🐛 解决多次切换路由后页面卡顿的问题 #96 卸载路由时清除requestAnimationFrame * feat: ✨ 文件存储使用Blob格式 * style: 💄 修改部分类型any为具体类型 * feat: ✨ 界面设置中模块使用瀑布流布局 #96 * fix: 🐛 md文档更换文件夹解决控制台警告 * style: 💄 switch按钮改回使用daisyui组件 * refactor: ♻️ 所有人员列表提取tableColumn * style: 💄 奖项列表中的图片类型修复 * fix(globalConfig): 修复当前音乐项类型缺失问题 * feat: ✨ single person not done * feat: ✨ 可添加单人 #96 * build(.gitignore): 添加 auto-imports.d.ts 到忽略文件 * fix: 🐛 上传、下载excel文件时修复路径错误 打包成应用和网页端的baseUrl不一样,使用环境变量来表示 * fix: 🐛 导入人员列表时处理有值为空的情况 * style: 💄 改变toaster的组件 * fix: 🐛 上传文件、解析数据与存储/读取数据的处理 、 * fix(Config): 更新备案信息链接样式 将备案信息的段落标签替换为可点击的链接标签,使用户能够直接跳转到工信部备案查询页面。同时添加了悬停效果样式,提升用户体验。 * 96 UI optimization (#136) * fix(home): 🐛 解决多次切换路由后页面卡顿的问题 #96 卸载路由时清除requestAnimationFrame * feat: ✨ 文件存储使用Blob格式 * style: 💄 修改部分类型any为具体类型 * feat: ✨ 界面设置中模块使用瀑布流布局 #96 * fix: 🐛 md文档更换文件夹解决控制台警告 * style: 💄 switch按钮改回使用daisyui组件 * refactor: ♻️ 所有人员列表提取tableColumn * style: 💄 奖项列表中的图片类型修复 * fix(globalConfig): 修复当前音乐项类型缺失问题 * feat: ✨ single person not done * feat: ✨ 可添加单人 #96 * build(.gitignore): 添加 auto-imports.d.ts 到忽略文件 * fix: 🐛 上传、下载excel文件时修复路径错误 打包成应用和网页端的baseUrl不一样,使用环境变量来表示 * fix: 🐛 导入人员列表时处理有值为空的情况 * style: 💄 改变toaster的组件 * fix: 🐛 上传文件、解析数据与存储/读取数据的处理 、 * fix(Config): 更新备案信息链接样式 将备案信息的段落标签替换为可点击的链接标签,使用户能够直接跳转到工信部备案查询页面。同时添加了悬停效果样式,提升用户体验。 * feat: ✨ 首页奖项列表样式修改 not done #96 * chore(deps): ✏️ 更新依赖版本 * chore: ✏️ gsap list demo * build: 🏗️ docker构建优化 * chore: ✏️ gsap scroll demo * style: 💄 gsap demno * feat: ✨ demo smooth scroll gsap scrolltrigger * feat(Demo): 添加更多颜色选项并注释GSAP动画 * refactor(PrizeList): 重构奖品列表组件结构 * feat(PrizeList): 重构奖品列表组件并添加滚动动画 * feat: ✨ 增加定时抽取功能 #96 * feat: ✨ 添加定时抽取功能的说明 * feat: ✨ 优化gsap #96 项数不多时不触发gsap * style: 💄 文本修改 * feat: ✨ 优化 * feat: ✨ 优化奖项列表 * fix(Home): 修复奖品列表滚动检测逻辑 * fix(home): 修复抽奖停止逻辑避免重复执行;调整卡片垂直居中位置计算 * feat: ✨ 播放中奖音频 #96 * 96 UI optimization (#137) * fix(home): 🐛 解决多次切换路由后页面卡顿的问题 #96 卸载路由时清除requestAnimationFrame * feat: ✨ 文件存储使用Blob格式 * style: 💄 修改部分类型any为具体类型 * feat: ✨ 界面设置中模块使用瀑布流布局 #96 * fix: 🐛 md文档更换文件夹解决控制台警告 * style: 💄 switch按钮改回使用daisyui组件 * refactor: ♻️ 所有人员列表提取tableColumn * style: 💄 奖项列表中的图片类型修复 * fix(globalConfig): 修复当前音乐项类型缺失问题 * feat: ✨ single person not done * feat: ✨ 可添加单人 #96 * build(.gitignore): 添加 auto-imports.d.ts 到忽略文件 * fix: 🐛 上传、下载excel文件时修复路径错误 打包成应用和网页端的baseUrl不一样,使用环境变量来表示 * fix: 🐛 导入人员列表时处理有值为空的情况 * style: 💄 改变toaster的组件 * fix: 🐛 上传文件、解析数据与存储/读取数据的处理 、 * fix(Config): 更新备案信息链接样式 将备案信息的段落标签替换为可点击的链接标签,使用户能够直接跳转到工信部备案查询页面。同时添加了悬停效果样式,提升用户体验。 * feat: ✨ 首页奖项列表样式修改 not done #96 * chore(deps): ✏️ 更新依赖版本 * chore: ✏️ gsap list demo * build: 🏗️ docker构建优化 * chore: ✏️ gsap scroll demo * style: 💄 gsap demno * feat: ✨ demo smooth scroll gsap scrolltrigger * feat(Demo): 添加更多颜色选项并注释GSAP动画 * refactor(PrizeList): 重构奖品列表组件结构 * feat(PrizeList): 重构奖品列表组件并添加滚动动画 * feat: ✨ 增加定时抽取功能 #96 * feat: ✨ 添加定时抽取功能的说明 * feat: ✨ 优化gsap #96 项数不多时不触发gsap * style: 💄 文本修改 * feat: ✨ 优化 * feat: ✨ 优化奖项列表 * fix(Home): 修复奖品列表滚动检测逻辑 * fix(home): 修复抽奖停止逻辑避免重复执行;调整卡片垂直居中位置计算 * feat: ✨ 播放中奖音频 #96 * style: 💄 下载模板成功后进行提示 * 96 UI optimization (#141) * fix(home): 🐛 解决多次切换路由后页面卡顿的问题 #96 卸载路由时清除requestAnimationFrame * feat: ✨ 文件存储使用Blob格式 * style: 💄 修改部分类型any为具体类型 * feat: ✨ 界面设置中模块使用瀑布流布局 #96 * fix: 🐛 md文档更换文件夹解决控制台警告 * style: 💄 switch按钮改回使用daisyui组件 * refactor: ♻️ 所有人员列表提取tableColumn * style: 💄 奖项列表中的图片类型修复 * fix(globalConfig): 修复当前音乐项类型缺失问题 * feat: ✨ single person not done * feat: ✨ 可添加单人 #96 * build(.gitignore): 添加 auto-imports.d.ts 到忽略文件 * fix: 🐛 上传、下载excel文件时修复路径错误 打包成应用和网页端的baseUrl不一样,使用环境变量来表示 * fix: 🐛 导入人员列表时处理有值为空的情况 * style: 💄 改变toaster的组件 * fix: 🐛 上传文件、解析数据与存储/读取数据的处理 、 * fix(Config): 更新备案信息链接样式 将备案信息的段落标签替换为可点击的链接标签,使用户能够直接跳转到工信部备案查询页面。同时添加了悬停效果样式,提升用户体验。 * feat: ✨ 首页奖项列表样式修改 not done #96 * chore(deps): ✏️ 更新依赖版本 * chore: ✏️ gsap list demo * build: 🏗️ docker构建优化 * chore: ✏️ gsap scroll demo * style: 💄 gsap demno * feat: ✨ demo smooth scroll gsap scrolltrigger * feat(Demo): 添加更多颜色选项并注释GSAP动画 * refactor(PrizeList): 重构奖品列表组件结构 * feat(PrizeList): 重构奖品列表组件并添加滚动动画 * feat: ✨ 增加定时抽取功能 #96 * feat: ✨ 添加定时抽取功能的说明 * feat: ✨ 优化gsap #96 项数不多时不触发gsap * style: 💄 文本修改 * feat: ✨ 优化 * feat: ✨ 优化奖项列表 * fix(Home): 修复奖品列表滚动检测逻辑 * fix(home): 修复抽奖停止逻辑避免重复执行;调整卡片垂直居中位置计算 * feat: ✨ 播放中奖音频 #96 * style: 💄 下载模板成功后进行提示 * docs: 📝 readme更新 * ci: 👷 git action触发改为推送release版本时执行 * Feature action (#143) * ci: 👷 整合github action配置文件 * docs: 📝 贡献文档修改 * Feature action (#144) * ci: 👷 整合github action配置文件 * docs: 📝 贡献文档修改 * style: 💄 更新版本 * style: 💄 cargo.lock版本更新 * Feature action (#149) * ci: 👷 整合github action配置文件 * docs: 📝 贡献文档修改 * style: 💄 更新版本 * style: 💄 cargo.lock版本更新 * feat(husky): 增强Git标签版本校验脚本 添加了对Git标签指向提交与release分支一致性的校验功能。 脚本现在会检查tag指向的提交是否与当前或任何release分支的最新提交一致, 确保发布流程的准确性。如果当前在release分支上,直接比较分支HEAD与tag指向的提交; 如果不在release分支上,则遍历所有release分支查找匹配的提交。 * feat: ✨ 国际化 * Feature action (#150) * ci: 👷 整合github action配置文件 * docs: 📝 贡献文档修改 * style: 💄 更新版本 * style: 💄 cargo.lock版本更新 * feat(husky): 增强Git标签版本校验脚本 添加了对Git标签指向提交与release分支一致性的校验功能。 脚本现在会检查tag指向的提交是否与当前或任何release分支的最新提交一致, 确保发布流程的准确性。如果当前在release分支上,直接比较分支HEAD与tag指向的提交; 如果不在release分支上,则遍历所有release分支查找匹配的提交。 * feat: ✨ 国际化 * fix: 🐛 修复国际化问题;修复字体大小未生效问题 * fix: 🐛 修复部分问题 * docs: 📝 update readme
This commit is contained in:
@@ -47,9 +47,9 @@ const actionsColumns = computed<any[]>(() => {
|
||||
<tbody v-if="data.length > 0">
|
||||
<!-- row -->
|
||||
<tr v-for="item in data" :key="item.id" class="hover">
|
||||
<th>{{ item.id }}</th>
|
||||
<!-- <th>{{ item.id }}</th> -->
|
||||
<td v-for="(column, index) in dataColumns" :key="index">
|
||||
<span v-if="column.formatValue" v-html="column.formatValue(item)"></span>
|
||||
<span v-if="column.formatValue" v-html="column.formatValue(item)" />
|
||||
<span v-else>{{ item[column.props] }}</span>
|
||||
</td>
|
||||
<!-- action -->
|
||||
|
||||
73
src/components/Dialog/index.vue
Normal file
73
src/components/Dialog/index.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<script setup lang='ts'>
|
||||
import { onMounted, ref, toRefs } from 'vue'
|
||||
import i18n from '@/locales/i18n'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
desc?: string
|
||||
cancelText?: string
|
||||
submitText?: string
|
||||
submitFunc?: () => void
|
||||
cancelFunc?: () => void
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
cancelText: i18n.global.t('button.cancel'),
|
||||
submitText: i18n.global.t('button.confirm'),
|
||||
cancelFunc: () => {},
|
||||
})
|
||||
const visible = defineModel('visible', {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
})
|
||||
|
||||
const dialogRef = ref <HTMLDialogElement | null> (null)
|
||||
function defaultCancelFunc() {
|
||||
dialogRef.value?.close()
|
||||
}
|
||||
|
||||
function showDialog() {
|
||||
dialogRef.value?.showModal()
|
||||
}
|
||||
defineExpose({
|
||||
showDialog,
|
||||
closed,
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
dialogRef.value?.addEventListener('close', () => {
|
||||
visible.value = false
|
||||
})
|
||||
})
|
||||
const { title, desc, cancelText, submitText, submitFunc, cancelFunc = defaultCancelFunc } = toRefs(props)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<dialog id="my_modal" ref="dialogRef" class="border-none modal">
|
||||
<div class="modal-box">
|
||||
<h3 v-if="title" class="text-lg font-bold">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<p v-if="desc" class="py-4">
|
||||
{{ desc }}
|
||||
</p>
|
||||
<div>
|
||||
<slot name="content" />
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<form method="dialog" class="flex gap-3">
|
||||
<!-- if there is a button in form, it will close the modal -->
|
||||
<button class="btn" @click="cancelFunc">
|
||||
{{ cancelText }}
|
||||
</button>
|
||||
<button class="btn" @click="submitFunc">
|
||||
{{ submitText }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
38
src/components/Drawer/index.vue
Normal file
38
src/components/Drawer/index.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup lang='ts'>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const drawerTriggerRef = ref <HTMLDialogElement | null> (null)
|
||||
const visible = ref(false)
|
||||
function showDrawer() {
|
||||
drawerTriggerRef.value?.click()
|
||||
visible.value = true
|
||||
}
|
||||
function closeDrawer() {
|
||||
drawerTriggerRef.value?.click()
|
||||
visible.value = false
|
||||
}
|
||||
defineExpose({
|
||||
showDrawer,
|
||||
closeDrawer,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="drawer drawer-end h-0 w-0">
|
||||
<input id="my-drawer-1" type="checkbox" class="drawer-toggle">
|
||||
<div class="drawer-content w-0 h-0 absolute">
|
||||
<!-- Page content here -->
|
||||
<label ref="drawerTriggerRef" for="my-drawer-1" />
|
||||
</div>
|
||||
<div class="drawer-side">
|
||||
<label for="my-drawer-1" aria-label="close sidebar" class="drawer-overlay" />
|
||||
<div v-if="visible" class="menu bg-base-200 min-h-full w-80 p-4">
|
||||
<slot name="content" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
69
src/components/FileUpload/index.vue
Normal file
69
src/components/FileUpload/index.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<script setup lang='ts'>
|
||||
import type { IFileData } from './type'
|
||||
import { ListMusic, Upload, X } from 'lucide-vue-next'
|
||||
import { ref } from 'vue'
|
||||
import { getBlobObjectUrl, readFileAsJsonData, readFileData, readFileDataAsBlob } from '@/utils/file'
|
||||
|
||||
const props = defineProps<{
|
||||
limitType?: string
|
||||
mode?: 'file' | 'json'
|
||||
}>()
|
||||
|
||||
const emits = defineEmits<{
|
||||
uploadFile: [fileData: IFileData | null]
|
||||
}>()
|
||||
const originFileName = ref<string | null>(null)
|
||||
const fileData = ref<IFileData | null>(null)
|
||||
|
||||
async function handleFileChange(e: Event) {
|
||||
const file = ((e.target as HTMLInputElement).files as FileList)[0]
|
||||
const type = file.type
|
||||
if (props.mode === 'json') {
|
||||
const fileRes = await readFileAsJsonData(file)
|
||||
const jsonData = JSON.parse(fileRes)
|
||||
fileData.value = { data: jsonData, fileName: file.name, type }
|
||||
originFileName.value = file.name
|
||||
emits('uploadFile', fileData.value)
|
||||
return
|
||||
}
|
||||
const { data: blobData, fileName } = await readFileDataAsBlob(file)
|
||||
fileData.value = { data: blobData, fileName, type }
|
||||
originFileName.value = fileName
|
||||
emits('uploadFile', fileData.value)
|
||||
}
|
||||
function removeFile() {
|
||||
fileData.value = null
|
||||
emits('uploadFile', null)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full h-full flex flex-col items-center mt-6">
|
||||
<input
|
||||
id="file-upload"
|
||||
:disabled="fileData !== null"
|
||||
type="file" class="w-full bg-red-400/50 max-h-52 cursor-pointer absolute" style="display: none;" :accept="limitType"
|
||||
@change="handleFileChange"
|
||||
>
|
||||
<label for="file-upload" :class="fileData ? 'cursor-not-allowed' : null" class="w-full h-52 cursor-pointer border-2 border-dashed flex items-center justify-center overflow-hidden">
|
||||
<img v-if="fileData && fileData.type.includes('image')" class="w-full object-cover stroke-0" :src="getBlobObjectUrl(fileData.data as Blob)" alt="">
|
||||
<ListMusic v-else-if="fileData && fileData.type.includes('audio')" class="w-2/3 h-2/3 stroke-1 text-gray-500/50" />
|
||||
<div v-else class="w-full h-full flex justify-center items-center flex-col gap-4">
|
||||
<Upload class="w-2/3 h-2/3 stroke-1 text-gray-500/50" />
|
||||
<span class="btn btn-neutral">点击上传</span>
|
||||
</div>
|
||||
</label>
|
||||
<div v-if="fileData" class="w-full flex items-center justify-between mt-2">
|
||||
<p class="max-w-[3/4] truncate text-sm">
|
||||
{{ originFileName }}
|
||||
</p>
|
||||
<button class="btn btn-xs btn-square btn-ghost" @click="removeFile">
|
||||
<X />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
5
src/components/FileUpload/type.ts
Normal file
5
src/components/FileUpload/type.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface IFileData {
|
||||
data: string | Blob | ArrayBuffer
|
||||
fileName: string
|
||||
type: string
|
||||
}
|
||||
@@ -1,35 +1,35 @@
|
||||
<script setup lang='ts'>
|
||||
import type { IFileData } from '../FileUpload/type'
|
||||
import type { IImage } from '@/types/storeType'
|
||||
import localforage from 'localforage'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
imgItem: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
})
|
||||
interface IProps {
|
||||
imgItem: IImage
|
||||
}
|
||||
const props = defineProps<IProps>()
|
||||
const imageDbStore = localforage.createInstance({
|
||||
name: 'imgStore',
|
||||
})
|
||||
|
||||
const imgUrl = ref('')
|
||||
|
||||
async function getImageStoreItem(item: any): Promise<string> {
|
||||
async function getImageStoreItem(item: IImage): Promise<string> {
|
||||
let image = ''
|
||||
if (item.url === 'Storage') {
|
||||
const key = item.id
|
||||
image = await imageDbStore.getItem(key) as string
|
||||
const imageData = await imageDbStore.getItem<IFileData>(key)
|
||||
image = URL.createObjectURL(imageData?.data as Blob)
|
||||
}
|
||||
else {
|
||||
image = item.url
|
||||
image = item.url as string
|
||||
}
|
||||
|
||||
return image
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const image = await getImageStoreItem(props.imgItem)
|
||||
imgUrl.value = image
|
||||
imgUrl.value = await getImageStoreItem(props.imgItem)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
5
src/components/Loading/index.ts
Normal file
5
src/components/Loading/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { LoadingOptions } from './loading-context'
|
||||
import Loading from './index.vue'
|
||||
import { loadingKey, loadingState } from './loading-context'
|
||||
|
||||
export { Loading, loadingKey, LoadingOptions, loadingState }
|
||||
24
src/components/Loading/index.vue
Normal file
24
src/components/Loading/index.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<script setup lang='ts'>
|
||||
import type { LoadingOptions } from './loading-context'
|
||||
import { inject } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { loadingKey } from './loading-context'
|
||||
|
||||
// 注入全局状态
|
||||
const loading = inject(loadingKey) as LoadingOptions
|
||||
|
||||
const { t } = useI18n()
|
||||
// 解构状态(响应式)
|
||||
const { visible, text } = loading
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="visible" class="fixed top-0 left-0 w-screen h-screen bg-[rgba(0,0,0,0.5)] flex flex-col gap-6 justify-center items-center z-50">
|
||||
<span v-if="visible" class="loading loading-spinner loading-xl" />
|
||||
<span>{{ text ? text : t('button.loading') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
61
src/components/Loading/loading-context.ts
Normal file
61
src/components/Loading/loading-context.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
// src/contexts/loading-context.ts
|
||||
import type { InjectionKey, Ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
// 定义 Loading 配置类型
|
||||
export interface LoadingOptions {
|
||||
visible: Ref<boolean>
|
||||
text: Ref<string>
|
||||
fullscreen: Ref<boolean>
|
||||
zIndex: Ref<number>
|
||||
count: Ref<number>
|
||||
show: (options?: Partial<{ text: string, fullscreen: boolean, zIndex: number }>) => void
|
||||
hide: () => void
|
||||
}
|
||||
|
||||
// 注入密钥(Symbol 确保唯一性)
|
||||
export const loadingKey: InjectionKey<LoadingOptions> = Symbol('loading')
|
||||
|
||||
// 全局状态(单例)
|
||||
const visible = ref(false)
|
||||
const text = ref('')
|
||||
const fullscreen = ref(true)
|
||||
const zIndex = ref(9999)
|
||||
const count = ref(0)
|
||||
|
||||
// 显示 Loading
|
||||
function show(options?: Partial<{ text: string, fullscreen: boolean, zIndex: number }>) {
|
||||
count.value++
|
||||
if (count.value > 1)
|
||||
return
|
||||
visible.value = true
|
||||
if (options) {
|
||||
text.value = options.text || ''
|
||||
fullscreen.value = options.fullscreen ?? true
|
||||
zIndex.value = options.zIndex || 9999
|
||||
}
|
||||
}
|
||||
|
||||
// 隐藏 Loading
|
||||
function hide() {
|
||||
if (count.value <= 0)
|
||||
return
|
||||
count.value--
|
||||
if (count.value === 0) {
|
||||
visible.value = false
|
||||
text.value = ''
|
||||
fullscreen.value = true
|
||||
zIndex.value = 9999
|
||||
}
|
||||
}
|
||||
|
||||
// 导出全局状态(供根组件提供)
|
||||
export const loadingState: LoadingOptions = {
|
||||
visible,
|
||||
text,
|
||||
fullscreen,
|
||||
zIndex,
|
||||
count,
|
||||
show,
|
||||
hide,
|
||||
}
|
||||
24
src/components/PageHeader/index.vue
Normal file
24
src/components/PageHeader/index.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<script setup lang='ts'>
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header>
|
||||
<h1 class="text-lg leading-10">
|
||||
{{ props.title }}
|
||||
</h1>
|
||||
<div class="button-group my-4">
|
||||
<slot name="buttons" />
|
||||
</div>
|
||||
<div class="divider mt-0" />
|
||||
<div>
|
||||
<slot name="alerts" />
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -1,135 +0,0 @@
|
||||
<script setup lang='ts'>
|
||||
import useStore from '@/store'
|
||||
import localforage from 'localforage'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const audioDbStore = localforage.createInstance({
|
||||
name: 'audioStore',
|
||||
})
|
||||
const audio = ref(new Audio())
|
||||
const settingRef = ref()
|
||||
// const audio = ref(new Audio())
|
||||
const globalConfig = useStore().globalConfig
|
||||
const { getMusicList: localMusicList, getCurrentMusic: currentMusic } = storeToRefs(globalConfig)
|
||||
// const localMusicListValue = ref(localMusicList)
|
||||
|
||||
async function play(item: any) {
|
||||
if (!item) {
|
||||
return
|
||||
}
|
||||
// if (!audio.value.paused && !skip) {
|
||||
// audio.value.pause()
|
||||
|
||||
// return
|
||||
// }
|
||||
let audioUrl = ''
|
||||
if (!item.url) {
|
||||
return
|
||||
}
|
||||
if (item.url === 'Storage') {
|
||||
audioUrl = await audioDbStore.getItem(item.name) as string
|
||||
}
|
||||
else {
|
||||
audioUrl = item.url
|
||||
}
|
||||
audio.value.pause()
|
||||
audio.value.src = audioUrl
|
||||
audio.value.play()
|
||||
}
|
||||
function playMusic(item: any, skip = false) {
|
||||
if (!item) {
|
||||
return
|
||||
}
|
||||
if (!currentMusic.value.paused && !skip) {
|
||||
globalConfig.setCurrentMusic(item, true)
|
||||
|
||||
return
|
||||
}
|
||||
globalConfig.setCurrentMusic(item, false)
|
||||
}
|
||||
function nextPlay() {
|
||||
// 播放下一首
|
||||
if (localMusicList.value.length >= 1) {
|
||||
let index = localMusicList.value.findIndex((item: any) => item.name === currentMusic.value.item.name)
|
||||
index++
|
||||
if (index >= localMusicList.value.length) {
|
||||
index = 0
|
||||
}
|
||||
globalConfig.setCurrentMusic(localMusicList.value[index], false)
|
||||
}
|
||||
}
|
||||
// 监听播放成后开始下一首
|
||||
function onPlayEnd() {
|
||||
audio.value.addEventListener('ended', nextPlay)
|
||||
}
|
||||
|
||||
function enterConfig() {
|
||||
router.push('/log-lottery/config')
|
||||
}
|
||||
function enterHome() {
|
||||
router.push('/log-lottery')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
globalConfig.setCurrentMusic(localMusicList.value[0], true)
|
||||
onPlayEnd()
|
||||
// 不使用空格控制audio
|
||||
})
|
||||
onUnmounted(() => {
|
||||
audio.value.removeEventListener('ended', nextPlay)
|
||||
})
|
||||
watch(currentMusic, (val: any) => {
|
||||
if (!val.paused && audio.value) {
|
||||
play(val.item)
|
||||
}
|
||||
else {
|
||||
audio.value.pause()
|
||||
}
|
||||
}, { deep: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="settingRef" class="flex flex-col gap-3">
|
||||
<div v-if="route.path.includes('/config')" class="tooltip tooltip-left" :data-tip="t('tooltip.toHome')">
|
||||
<div
|
||||
class="flex items-center justify-center w-10 h-10 p-0 m-0 cursor-pointer setting-container bg-slate-500/50 rounded-l-xl hover:bg-slate-500/80 hover:text-blue-400/90"
|
||||
@click="enterHome"
|
||||
>
|
||||
<svg-icon name="home" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="tooltip tooltip-left" :data-tip="t('tooltip.settingConfiguration')">
|
||||
<div
|
||||
class="flex items-center justify-center w-10 h-10 p-0 m-0 cursor-pointer setting-container bg-slate-500/50 rounded-l-xl hover:bg-slate-500/80 hover:text-blue-400/90"
|
||||
@click="enterConfig"
|
||||
>
|
||||
<svg-icon name="setting" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tooltip tooltip-left" :data-tip="currentMusic.item ? `${currentMusic.item.name}\n\r ${t('tooltip.nextSong')}` : t('tooltip.noSongPlay')">
|
||||
<div
|
||||
class="flex items-center justify-center w-10 h-10 p-0 m-0 cursor-pointer setting-container bg-slate-500/50 rounded-l-xl hover:bg-slate-500/80 hover:text-blue-400/90"
|
||||
@click="playMusic(currentMusic.item)" @click.right.prevent="nextPlay"
|
||||
>
|
||||
<svg-icon :name="currentMusic.paused ? 'play' : 'pause'" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
details {
|
||||
|
||||
// display: none;
|
||||
summary {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,71 +0,0 @@
|
||||
<script setup lang='ts'>
|
||||
import Sparticles from 'sparticles';
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import { useElementSize } from '@vueuse/core';
|
||||
import localforage from 'localforage'
|
||||
const props = defineProps({
|
||||
homeBackground: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
id: '',
|
||||
name: '',
|
||||
url: ''
|
||||
})
|
||||
}
|
||||
})
|
||||
const imageDbStore = localforage.createInstance({
|
||||
name: 'imgStore'
|
||||
})
|
||||
const imgUrl = ref('')
|
||||
const starRef = ref();
|
||||
|
||||
const { width, height } = useElementSize(starRef);
|
||||
let options = ref({ shape: 'star', parallax: 1.2, rotate: true, twinkle: true, speed: 10, count: 200 });
|
||||
function addSparticles(node: any, width: number, height: number) {
|
||||
new Sparticles(node, options.value, width, height);
|
||||
}
|
||||
// 页面大小改变时
|
||||
const listenWindowSize = () => {
|
||||
window.addEventListener('resize', () => {
|
||||
if (width.value && height.value) {
|
||||
addSparticles(starRef.value, width.value, height.value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const getImageStoreItem = async (item: any): Promise<string> => {
|
||||
let image = ''
|
||||
if (item.url == 'Storage') {
|
||||
const key = item.id;
|
||||
image = await imageDbStore.getItem(key) as string
|
||||
}
|
||||
else {
|
||||
image = item.url
|
||||
}
|
||||
|
||||
|
||||
return image
|
||||
}
|
||||
onMounted(() => {
|
||||
getImageStoreItem(props.homeBackground).then((image) => {
|
||||
imgUrl.value = image
|
||||
})
|
||||
addSparticles(starRef.value, width.value, height.value);
|
||||
listenWindowSize()
|
||||
})
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', listenWindowSize)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="home-background w-screen h-screen overflow-hidden" v-if="homeBackground.url">
|
||||
<img :src="imgUrl" class="w-full h-full object-cover" alt="">
|
||||
</div>
|
||||
<div v-else class="w-screen h-screen overflow-hidden" ref="starRef">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
|
||||
</style>
|
||||
@@ -36,8 +36,8 @@ const symbolId = computed(() => `#${props.prefix}-${props.name}`)
|
||||
|
||||
<style scoped>
|
||||
.svg-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
/* width: 24px;
|
||||
height: 24px; */
|
||||
fill: currentColor;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang='ts'>
|
||||
|
||||
import { ChevronUp } from 'lucide-vue-next'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fixed z-50 flex items-center justify-center w-10 h-10 rounded-full shadow-lg cursor-pointer right-12 bottom-12 bg-slate-700 hover:bg-slate-600">
|
||||
<svg-icon name="toTop" />
|
||||
<ChevronUp />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
113
src/components/Waterfall/index.vue
Normal file
113
src/components/Waterfall/index.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<script setup lang="ts">
|
||||
import type { Ref } from 'vue'
|
||||
import { throttle } from 'lodash-es' // lodash-es 节流
|
||||
import Masonry from 'masonry-layout'
|
||||
import { nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
// 布局参数 Props
|
||||
interface MasonryWaterfallProps {
|
||||
columnWidth?: number | string // 列宽(px/选择器)
|
||||
gutter?: number // 列/行间距
|
||||
fitWidth?: boolean // 容器宽度自适应居中
|
||||
}
|
||||
|
||||
// 默认参数
|
||||
const props = withDefaults(defineProps<MasonryWaterfallProps>(), {
|
||||
columnWidth: 120,
|
||||
gutter: 16,
|
||||
fitWidth: true,
|
||||
})
|
||||
|
||||
// Vue Ref 管理 DOM 容器和 masonry 实例
|
||||
const masonryContainer: Ref<HTMLDivElement | null> = ref(null)
|
||||
const masonryInstance: Ref<Masonry | null> = ref(null)
|
||||
|
||||
// 初始化 masonry(仅执行一次,因卡片固定)
|
||||
async function initMasonry() {
|
||||
if (!masonryContainer.value)
|
||||
return
|
||||
|
||||
// 等待插槽内容(固定卡片)完全渲染
|
||||
await nextTick()
|
||||
|
||||
// 初始化 masonry 实例(固定卡片无需销毁旧实例)
|
||||
masonryInstance.value = new Masonry(masonryContainer.value, {
|
||||
itemSelector: '.masonry-container > *', // 匹配所有固定子项
|
||||
columnWidth: props.columnWidth,
|
||||
gutter: props.gutter,
|
||||
fitWidth: props.fitWidth,
|
||||
initLayout: true, // 固定卡片直接初始化布局
|
||||
})
|
||||
}
|
||||
|
||||
// 刷新布局(仅用于卡片内部内容高度变化)
|
||||
async function refreshLayout() {
|
||||
await nextTick()
|
||||
if (masonryInstance.value) {
|
||||
masonryInstance.value.layout?.()
|
||||
}
|
||||
}
|
||||
|
||||
// 窗口缩放节流重排(优化性能)
|
||||
const handleResize = throttle(() => {
|
||||
if (masonryInstance.value) {
|
||||
masonryInstance.value.layout?.()
|
||||
}
|
||||
}, 300)
|
||||
|
||||
// 生命周期:挂载时初始化,卸载时清理
|
||||
onMounted(async () => {
|
||||
await initMasonry()
|
||||
window.addEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// 销毁实例 + 释放内存
|
||||
if (masonryInstance.value) {
|
||||
masonryInstance.value.destroy?.()
|
||||
masonryInstance.value = null
|
||||
}
|
||||
// 移除监听 + 取消节流任务
|
||||
window.removeEventListener('resize', handleResize)
|
||||
handleResize.cancel()
|
||||
})
|
||||
|
||||
// 仅暴露刷新方法(适配卡片内部内容变化)
|
||||
defineExpose({ refreshLayout })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- masonry 容器:ref 绑定,接收固定插槽内容 -->
|
||||
<div ref="masonryContainer" class="masonry-container">
|
||||
<!-- 插槽:直接传入固定的卡片/组件 -->
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.masonry-container {
|
||||
width: 100%;
|
||||
/* max-width: 1400px; */
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 固定卡片基础样式 */
|
||||
.masonry-container > * {
|
||||
margin-bottom: v-bind('`${gutter}px`');
|
||||
box-sizing: border-box;
|
||||
break-inside: avoid; /* 兼容 Safari */
|
||||
min-height: 100px; /* 避免内容过矮导致布局异常 */
|
||||
}
|
||||
|
||||
/* 响应式适配:小屏调整列宽 */
|
||||
/* @media (max-width: 768px) {
|
||||
.masonry-container {
|
||||
padding: 10px;
|
||||
}
|
||||
.masonry-container > * {
|
||||
width: calc(50% - v-bind('`${gutter}px`')) !important;
|
||||
}
|
||||
} */
|
||||
</style>
|
||||
29
src/components/ui/button/Button.vue
Normal file
29
src/components/ui/button/Button.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import type { PrimitiveProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import type { ButtonVariants } from "."
|
||||
import { Primitive } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "."
|
||||
|
||||
interface Props extends PrimitiveProps {
|
||||
variant?: ButtonVariants["variant"]
|
||||
size?: ButtonVariants["size"]
|
||||
class?: HTMLAttributes["class"]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
as: "button",
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="button"
|
||||
:as="as"
|
||||
:as-child="asChild"
|
||||
:class="cn(buttonVariants({ variant, size }), props.class)"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
38
src/components/ui/button/index.ts
Normal file
38
src/components/ui/button/index.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
export { default as Button } from "./Button.vue"
|
||||
|
||||
export const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
"default": "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
"sm": "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
"lg": "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
"icon": "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
export type ButtonVariants = VariantProps<typeof buttonVariants>
|
||||
87
src/components/ui/command/Command.vue
Normal file
87
src/components/ui/command/Command.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import type { ListboxRootEmits, ListboxRootProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { ListboxRoot, useFilter, useForwardPropsEmits } from "reka-ui"
|
||||
import { reactive, ref, watch } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { provideCommandContext } from "."
|
||||
|
||||
const props = withDefaults(defineProps<ListboxRootProps & { class?: HTMLAttributes["class"] }>(), {
|
||||
modelValue: "",
|
||||
})
|
||||
|
||||
const emits = defineEmits<ListboxRootEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
|
||||
const allItems = ref<Map<string, string>>(new Map())
|
||||
const allGroups = ref<Map<string, Set<string>>>(new Map())
|
||||
|
||||
const { contains } = useFilter({ sensitivity: "base" })
|
||||
const filterState = reactive({
|
||||
search: "",
|
||||
filtered: {
|
||||
/** The count of all visible items. */
|
||||
count: 0,
|
||||
/** Map from visible item id to its search score. */
|
||||
items: new Map() as Map<string, number>,
|
||||
/** Set of groups with at least one visible item. */
|
||||
groups: new Set() as Set<string>,
|
||||
},
|
||||
})
|
||||
|
||||
function filterItems() {
|
||||
if (!filterState.search) {
|
||||
filterState.filtered.count = allItems.value.size
|
||||
// Do nothing, each item will know to show itself because search is empty
|
||||
return
|
||||
}
|
||||
|
||||
// Reset the groups
|
||||
filterState.filtered.groups = new Set()
|
||||
let itemCount = 0
|
||||
|
||||
// Check which items should be included
|
||||
for (const [id, value] of allItems.value) {
|
||||
const score = contains(value, filterState.search)
|
||||
filterState.filtered.items.set(id, score ? 1 : 0)
|
||||
if (score)
|
||||
itemCount++
|
||||
}
|
||||
|
||||
// Check which groups have at least 1 item shown
|
||||
for (const [groupId, group] of allGroups.value) {
|
||||
for (const itemId of group) {
|
||||
if (filterState.filtered.items.get(itemId)! > 0) {
|
||||
filterState.filtered.groups.add(groupId)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
filterState.filtered.count = itemCount
|
||||
}
|
||||
|
||||
watch(() => filterState.search, () => {
|
||||
filterItems()
|
||||
})
|
||||
|
||||
provideCommandContext({
|
||||
allItems,
|
||||
allGroups,
|
||||
filterState,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ListboxRoot
|
||||
data-slot="command"
|
||||
v-bind="forwarded"
|
||||
:class="cn('bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</ListboxRoot>
|
||||
</template>
|
||||
31
src/components/ui/command/CommandDialog.vue
Normal file
31
src/components/ui/command/CommandDialog.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogRootEmits, DialogRootProps } from "reka-ui"
|
||||
import { useForwardPropsEmits } from "reka-ui"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import Command from "./Command.vue"
|
||||
|
||||
const props = withDefaults(defineProps<DialogRootProps & {
|
||||
title?: string
|
||||
description?: string
|
||||
}>(), {
|
||||
title: "Command Palette",
|
||||
description: "Search for a command to run...",
|
||||
})
|
||||
const emits = defineEmits<DialogRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-slot="slotProps" v-bind="forwarded">
|
||||
<DialogContent class="overflow-hidden p-0 ">
|
||||
<DialogHeader class="sr-only">
|
||||
<DialogTitle>{{ title }}</DialogTitle>
|
||||
<DialogDescription>{{ description }}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Command>
|
||||
<slot v-bind="slotProps" />
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
27
src/components/ui/command/CommandEmpty.vue
Normal file
27
src/components/ui/command/CommandEmpty.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import type { PrimitiveProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { Primitive } from "reka-ui"
|
||||
import { computed } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useCommand } from "."
|
||||
|
||||
const props = defineProps<PrimitiveProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const { filterState } = useCommand()
|
||||
const isRender = computed(() => !!filterState.search && filterState.filtered.count === 0,
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
v-if="isRender"
|
||||
data-slot="command-empty"
|
||||
v-bind="delegatedProps" :class="cn('py-6 text-center text-sm', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
45
src/components/ui/command/CommandGroup.vue
Normal file
45
src/components/ui/command/CommandGroup.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import type { ListboxGroupProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { ListboxGroup, ListboxGroupLabel, useId } from "reka-ui"
|
||||
import { computed, onMounted, onUnmounted } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { provideCommandGroupContext, useCommand } from "."
|
||||
|
||||
const props = defineProps<ListboxGroupProps & {
|
||||
class?: HTMLAttributes["class"]
|
||||
heading?: string
|
||||
}>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const { allGroups, filterState } = useCommand()
|
||||
const id = useId()
|
||||
|
||||
const isRender = computed(() => !filterState.search ? true : filterState.filtered.groups.has(id))
|
||||
|
||||
provideCommandGroupContext({ id })
|
||||
onMounted(() => {
|
||||
if (!allGroups.value.has(id))
|
||||
allGroups.value.set(id, new Set())
|
||||
})
|
||||
onUnmounted(() => {
|
||||
allGroups.value.delete(id)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ListboxGroup
|
||||
v-bind="delegatedProps"
|
||||
:id="id"
|
||||
data-slot="command-group"
|
||||
:class="cn('text-foreground overflow-hidden p-1', props.class)"
|
||||
:hidden="isRender ? undefined : true"
|
||||
>
|
||||
<ListboxGroupLabel v-if="heading" data-slot="command-group-heading" class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
{{ heading }}
|
||||
</ListboxGroupLabel>
|
||||
<slot />
|
||||
</ListboxGroup>
|
||||
</template>
|
||||
39
src/components/ui/command/CommandInput.vue
Normal file
39
src/components/ui/command/CommandInput.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import type { ListboxFilterProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { Search } from "lucide-vue-next"
|
||||
import { ListboxFilter, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useCommand } from "."
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = defineProps<ListboxFilterProps & {
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
|
||||
const { filterState } = useCommand()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="command-input-wrapper"
|
||||
class="flex h-9 items-center gap-2 border-b px-3"
|
||||
>
|
||||
<Search class="size-4 shrink-0 opacity-50" />
|
||||
<ListboxFilter
|
||||
v-bind="{ ...forwardedProps, ...$attrs }"
|
||||
v-model="filterState.search"
|
||||
data-slot="command-input"
|
||||
auto-focus
|
||||
:class="cn('placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50', props.class)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
76
src/components/ui/command/CommandItem.vue
Normal file
76
src/components/ui/command/CommandItem.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
import type { ListboxItemEmits, ListboxItemProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit, useCurrentElement } from "@vueuse/core"
|
||||
import { ListboxItem, useForwardPropsEmits, useId } from "reka-ui"
|
||||
import { computed, onMounted, onUnmounted, ref } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useCommand, useCommandGroup } from "."
|
||||
|
||||
const props = defineProps<ListboxItemProps & { class?: HTMLAttributes["class"] }>()
|
||||
const emits = defineEmits<ListboxItemEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
|
||||
const id = useId()
|
||||
const { filterState, allItems, allGroups } = useCommand()
|
||||
const groupContext = useCommandGroup()
|
||||
|
||||
const isRender = computed(() => {
|
||||
if (!filterState.search) {
|
||||
return true
|
||||
}
|
||||
else {
|
||||
const filteredCurrentItem = filterState.filtered.items.get(id)
|
||||
// If the filtered items is undefined means not in the all times map yet
|
||||
// Do the first render to add into the map
|
||||
if (filteredCurrentItem === undefined) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check with filter
|
||||
return filteredCurrentItem > 0
|
||||
}
|
||||
})
|
||||
|
||||
const itemRef = ref()
|
||||
const currentElement = useCurrentElement(itemRef)
|
||||
onMounted(() => {
|
||||
if (!(currentElement.value instanceof HTMLElement))
|
||||
return
|
||||
|
||||
// textValue to perform filter
|
||||
allItems.value.set(id, currentElement.value.textContent ?? (props.value?.toString() ?? ""))
|
||||
|
||||
const groupId = groupContext?.id
|
||||
if (groupId) {
|
||||
if (!allGroups.value.has(groupId)) {
|
||||
allGroups.value.set(groupId, new Set([id]))
|
||||
}
|
||||
else {
|
||||
allGroups.value.get(groupId)?.add(id)
|
||||
}
|
||||
}
|
||||
})
|
||||
onUnmounted(() => {
|
||||
allItems.value.delete(id)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ListboxItem
|
||||
v-if="isRender"
|
||||
v-bind="forwarded"
|
||||
:id="id"
|
||||
ref="itemRef"
|
||||
data-slot="command-item"
|
||||
:class="cn('data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4', props.class)"
|
||||
@select="() => {
|
||||
filterState.search = ''
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
</ListboxItem>
|
||||
</template>
|
||||
25
src/components/ui/command/CommandList.vue
Normal file
25
src/components/ui/command/CommandList.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import type { ListboxContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { ListboxContent, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<ListboxContentProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ListboxContent
|
||||
data-slot="command-list"
|
||||
v-bind="forwarded"
|
||||
:class="cn('max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto', props.class)"
|
||||
>
|
||||
<div role="presentation">
|
||||
<slot />
|
||||
</div>
|
||||
</ListboxContent>
|
||||
</template>
|
||||
21
src/components/ui/command/CommandSeparator.vue
Normal file
21
src/components/ui/command/CommandSeparator.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { SeparatorProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { Separator } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<SeparatorProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Separator
|
||||
data-slot="command-separator"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('bg-border -mx-1 h-px', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</Separator>
|
||||
</template>
|
||||
17
src/components/ui/command/CommandShortcut.vue
Normal file
17
src/components/ui/command/CommandShortcut.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
:class="cn('text-muted-foreground ml-auto text-xs tracking-widest', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
25
src/components/ui/command/index.ts
Normal file
25
src/components/ui/command/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { Ref } from "vue"
|
||||
import { createContext } from "reka-ui"
|
||||
|
||||
export { default as Command } from "./Command.vue"
|
||||
export { default as CommandDialog } from "./CommandDialog.vue"
|
||||
export { default as CommandEmpty } from "./CommandEmpty.vue"
|
||||
export { default as CommandGroup } from "./CommandGroup.vue"
|
||||
export { default as CommandInput } from "./CommandInput.vue"
|
||||
export { default as CommandItem } from "./CommandItem.vue"
|
||||
export { default as CommandList } from "./CommandList.vue"
|
||||
export { default as CommandSeparator } from "./CommandSeparator.vue"
|
||||
export { default as CommandShortcut } from "./CommandShortcut.vue"
|
||||
|
||||
export const [useCommand, provideCommandContext] = createContext<{
|
||||
allItems: Ref<Map<string, string>>
|
||||
allGroups: Ref<Map<string, Set<string>>>
|
||||
filterState: {
|
||||
search: string
|
||||
filtered: { count: number, items: Map<string, number>, groups: Set<string> }
|
||||
}
|
||||
}>("Command")
|
||||
|
||||
export const [useCommandGroup, provideCommandGroupContext] = createContext<{
|
||||
id?: string
|
||||
}>("CommandGroup")
|
||||
19
src/components/ui/dialog/Dialog.vue
Normal file
19
src/components/ui/dialog/Dialog.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogRootEmits, DialogRootProps } from "reka-ui"
|
||||
import { DialogRoot, useForwardPropsEmits } from "reka-ui"
|
||||
|
||||
const props = defineProps<DialogRootProps>()
|
||||
const emits = defineEmits<DialogRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="dialog"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</DialogRoot>
|
||||
</template>
|
||||
15
src/components/ui/dialog/DialogClose.vue
Normal file
15
src/components/ui/dialog/DialogClose.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogCloseProps } from "reka-ui"
|
||||
import { DialogClose } from "reka-ui"
|
||||
|
||||
const props = defineProps<DialogCloseProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogClose
|
||||
data-slot="dialog-close"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</DialogClose>
|
||||
</template>
|
||||
53
src/components/ui/dialog/DialogContent.vue
Normal file
53
src/components/ui/dialog/DialogContent.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { X } from "lucide-vue-next"
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import DialogOverlay from "./DialogOverlay.vue"
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = withDefaults(defineProps<DialogContentProps & { class?: HTMLAttributes["class"], showCloseButton?: boolean }>(), {
|
||||
showCloseButton: true,
|
||||
})
|
||||
const emits = defineEmits<DialogContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogContent
|
||||
data-slot="dialog-content"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="
|
||||
cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<DialogClose
|
||||
v-if="showCloseButton"
|
||||
data-slot="dialog-close"
|
||||
class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<X />
|
||||
<span class="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</template>
|
||||
23
src/components/ui/dialog/DialogDescription.vue
Normal file
23
src/components/ui/dialog/DialogDescription.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogDescriptionProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { DialogDescription, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogDescription
|
||||
data-slot="dialog-description"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('text-muted-foreground text-sm', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DialogDescription>
|
||||
</template>
|
||||
15
src/components/ui/dialog/DialogFooter.vue
Normal file
15
src/components/ui/dialog/DialogFooter.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
:class="cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
17
src/components/ui/dialog/DialogHeader.vue
Normal file
17
src/components/ui/dialog/DialogHeader.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
21
src/components/ui/dialog/DialogOverlay.vue
Normal file
21
src/components/ui/dialog/DialogOverlay.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogOverlayProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { DialogOverlay } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogOverlay
|
||||
data-slot="dialog-overlay"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DialogOverlay>
|
||||
</template>
|
||||
59
src/components/ui/dialog/DialogScrollContent.vue
Normal file
59
src/components/ui/dialog/DialogScrollContent.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { X } from "lucide-vue-next"
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = defineProps<DialogContentProps & { class?: HTMLAttributes["class"] }>()
|
||||
const emits = defineEmits<DialogContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogPortal>
|
||||
<DialogOverlay
|
||||
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
||||
>
|
||||
<DialogContent
|
||||
:class="
|
||||
cn(
|
||||
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
@pointer-down-outside="(event) => {
|
||||
const originalEvent = event.detail.originalEvent;
|
||||
const target = originalEvent.target as HTMLElement;
|
||||
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<DialogClose
|
||||
class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
<span class="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</DialogOverlay>
|
||||
</DialogPortal>
|
||||
</template>
|
||||
23
src/components/ui/dialog/DialogTitle.vue
Normal file
23
src/components/ui/dialog/DialogTitle.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogTitleProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { DialogTitle, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogTitle
|
||||
data-slot="dialog-title"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('text-lg leading-none font-semibold', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DialogTitle>
|
||||
</template>
|
||||
15
src/components/ui/dialog/DialogTrigger.vue
Normal file
15
src/components/ui/dialog/DialogTrigger.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogTriggerProps } from "reka-ui"
|
||||
import { DialogTrigger } from "reka-ui"
|
||||
|
||||
const props = defineProps<DialogTriggerProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogTrigger
|
||||
data-slot="dialog-trigger"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</DialogTrigger>
|
||||
</template>
|
||||
10
src/components/ui/dialog/index.ts
Normal file
10
src/components/ui/dialog/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export { default as Dialog } from "./Dialog.vue"
|
||||
export { default as DialogClose } from "./DialogClose.vue"
|
||||
export { default as DialogContent } from "./DialogContent.vue"
|
||||
export { default as DialogDescription } from "./DialogDescription.vue"
|
||||
export { default as DialogFooter } from "./DialogFooter.vue"
|
||||
export { default as DialogHeader } from "./DialogHeader.vue"
|
||||
export { default as DialogOverlay } from "./DialogOverlay.vue"
|
||||
export { default as DialogScrollContent } from "./DialogScrollContent.vue"
|
||||
export { default as DialogTitle } from "./DialogTitle.vue"
|
||||
export { default as DialogTrigger } from "./DialogTrigger.vue"
|
||||
19
src/components/ui/dropdown-menu/DropdownMenu.vue
Normal file
19
src/components/ui/dropdown-menu/DropdownMenu.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuRootEmits, DropdownMenuRootProps } from "reka-ui"
|
||||
import { DropdownMenuRoot, useForwardPropsEmits } from "reka-ui"
|
||||
|
||||
const props = defineProps<DropdownMenuRootProps>()
|
||||
const emits = defineEmits<DropdownMenuRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="dropdown-menu"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</DropdownMenuRoot>
|
||||
</template>
|
||||
39
src/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue
Normal file
39
src/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuCheckboxItemEmits, DropdownMenuCheckboxItemProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { Check } from "lucide-vue-next"
|
||||
import {
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuItemIndicator,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DropdownMenuCheckboxItemProps & { class?: HTMLAttributes["class"] }>()
|
||||
const emits = defineEmits<DropdownMenuCheckboxItemEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuCheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
v-bind="forwarded"
|
||||
:class=" cn(
|
||||
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuItemIndicator>
|
||||
<slot name="indicator-icon">
|
||||
<Check class="size-4" />
|
||||
</slot>
|
||||
</DropdownMenuItemIndicator>
|
||||
</span>
|
||||
<slot />
|
||||
</DropdownMenuCheckboxItem>
|
||||
</template>
|
||||
39
src/components/ui/dropdown-menu/DropdownMenuContent.vue
Normal file
39
src/components/ui/dropdown-menu/DropdownMenuContent.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuContentEmits, DropdownMenuContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<DropdownMenuContentProps & { class?: HTMLAttributes["class"] }>(),
|
||||
{
|
||||
sideOffset: 4,
|
||||
},
|
||||
)
|
||||
const emits = defineEmits<DropdownMenuContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
data-slot="dropdown-menu-content"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="cn('bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</template>
|
||||
15
src/components/ui/dropdown-menu/DropdownMenuGroup.vue
Normal file
15
src/components/ui/dropdown-menu/DropdownMenuGroup.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuGroupProps } from "reka-ui"
|
||||
import { DropdownMenuGroup } from "reka-ui"
|
||||
|
||||
const props = defineProps<DropdownMenuGroupProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuGroup
|
||||
data-slot="dropdown-menu-group"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuGroup>
|
||||
</template>
|
||||
31
src/components/ui/dropdown-menu/DropdownMenuItem.vue
Normal file
31
src/components/ui/dropdown-menu/DropdownMenuItem.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuItemProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { DropdownMenuItem, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = withDefaults(defineProps<DropdownMenuItemProps & {
|
||||
class?: HTMLAttributes["class"]
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}>(), {
|
||||
variant: "default",
|
||||
})
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "inset", "variant", "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuItem
|
||||
data-slot="dropdown-menu-item"
|
||||
:data-inset="inset ? '' : undefined"
|
||||
:data-variant="variant"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
23
src/components/ui/dropdown-menu/DropdownMenuLabel.vue
Normal file
23
src/components/ui/dropdown-menu/DropdownMenuLabel.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuLabelProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { DropdownMenuLabel, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DropdownMenuLabelProps & { class?: HTMLAttributes["class"], inset?: boolean }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class", "inset")
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuLabel
|
||||
data-slot="dropdown-menu-label"
|
||||
:data-inset="inset ? '' : undefined"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuLabel>
|
||||
</template>
|
||||
21
src/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue
Normal file
21
src/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuRadioGroupEmits, DropdownMenuRadioGroupProps } from "reka-ui"
|
||||
import {
|
||||
DropdownMenuRadioGroup,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
|
||||
const props = defineProps<DropdownMenuRadioGroupProps>()
|
||||
const emits = defineEmits<DropdownMenuRadioGroupEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuRadioGroup>
|
||||
</template>
|
||||
40
src/components/ui/dropdown-menu/DropdownMenuRadioItem.vue
Normal file
40
src/components/ui/dropdown-menu/DropdownMenuRadioItem.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuRadioItemEmits, DropdownMenuRadioItemProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { Circle } from "lucide-vue-next"
|
||||
import {
|
||||
DropdownMenuItemIndicator,
|
||||
DropdownMenuRadioItem,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DropdownMenuRadioItemProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const emits = defineEmits<DropdownMenuRadioItemEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
v-bind="forwarded"
|
||||
:class="cn(
|
||||
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuItemIndicator>
|
||||
<slot name="indicator-icon">
|
||||
<Circle class="size-2 fill-current" />
|
||||
</slot>
|
||||
</DropdownMenuItemIndicator>
|
||||
</span>
|
||||
<slot />
|
||||
</DropdownMenuRadioItem>
|
||||
</template>
|
||||
23
src/components/ui/dropdown-menu/DropdownMenuSeparator.vue
Normal file
23
src/components/ui/dropdown-menu/DropdownMenuSeparator.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuSeparatorProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
DropdownMenuSeparator,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DropdownMenuSeparatorProps & {
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuSeparator
|
||||
data-slot="dropdown-menu-separator"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('bg-border -mx-1 my-1 h-px', props.class)"
|
||||
/>
|
||||
</template>
|
||||
17
src/components/ui/dropdown-menu/DropdownMenuShortcut.vue
Normal file
17
src/components/ui/dropdown-menu/DropdownMenuShortcut.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
:class="cn('text-muted-foreground ml-auto text-xs tracking-widest', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
18
src/components/ui/dropdown-menu/DropdownMenuSub.vue
Normal file
18
src/components/ui/dropdown-menu/DropdownMenuSub.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuSubEmits, DropdownMenuSubProps } from "reka-ui"
|
||||
import {
|
||||
DropdownMenuSub,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
|
||||
const props = defineProps<DropdownMenuSubProps>()
|
||||
const emits = defineEmits<DropdownMenuSubEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuSub v-slot="slotProps" data-slot="dropdown-menu-sub" v-bind="forwarded">
|
||||
<slot v-bind="slotProps" />
|
||||
</DropdownMenuSub>
|
||||
</template>
|
||||
27
src/components/ui/dropdown-menu/DropdownMenuSubContent.vue
Normal file
27
src/components/ui/dropdown-menu/DropdownMenuSubContent.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
DropdownMenuSubContent,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DropdownMenuSubContentProps & { class?: HTMLAttributes["class"] }>()
|
||||
const emits = defineEmits<DropdownMenuSubContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuSubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
v-bind="forwarded"
|
||||
:class="cn('bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuSubContent>
|
||||
</template>
|
||||
30
src/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue
Normal file
30
src/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuSubTriggerProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { ChevronRight } from "lucide-vue-next"
|
||||
import {
|
||||
DropdownMenuSubTrigger,
|
||||
useForwardProps,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DropdownMenuSubTriggerProps & { class?: HTMLAttributes["class"], inset?: boolean }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class", "inset")
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuSubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn(
|
||||
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
<ChevronRight class="ml-auto size-4" />
|
||||
</DropdownMenuSubTrigger>
|
||||
</template>
|
||||
17
src/components/ui/dropdown-menu/DropdownMenuTrigger.vue
Normal file
17
src/components/ui/dropdown-menu/DropdownMenuTrigger.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuTriggerProps } from "reka-ui"
|
||||
import { DropdownMenuTrigger, useForwardProps } from "reka-ui"
|
||||
|
||||
const props = defineProps<DropdownMenuTriggerProps>()
|
||||
|
||||
const forwardedProps = useForwardProps(props)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuTrigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuTrigger>
|
||||
</template>
|
||||
16
src/components/ui/dropdown-menu/index.ts
Normal file
16
src/components/ui/dropdown-menu/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export { default as DropdownMenu } from "./DropdownMenu.vue"
|
||||
|
||||
export { default as DropdownMenuCheckboxItem } from "./DropdownMenuCheckboxItem.vue"
|
||||
export { default as DropdownMenuContent } from "./DropdownMenuContent.vue"
|
||||
export { default as DropdownMenuGroup } from "./DropdownMenuGroup.vue"
|
||||
export { default as DropdownMenuItem } from "./DropdownMenuItem.vue"
|
||||
export { default as DropdownMenuLabel } from "./DropdownMenuLabel.vue"
|
||||
export { default as DropdownMenuRadioGroup } from "./DropdownMenuRadioGroup.vue"
|
||||
export { default as DropdownMenuRadioItem } from "./DropdownMenuRadioItem.vue"
|
||||
export { default as DropdownMenuSeparator } from "./DropdownMenuSeparator.vue"
|
||||
export { default as DropdownMenuShortcut } from "./DropdownMenuShortcut.vue"
|
||||
export { default as DropdownMenuSub } from "./DropdownMenuSub.vue"
|
||||
export { default as DropdownMenuSubContent } from "./DropdownMenuSubContent.vue"
|
||||
export { default as DropdownMenuSubTrigger } from "./DropdownMenuSubTrigger.vue"
|
||||
export { default as DropdownMenuTrigger } from "./DropdownMenuTrigger.vue"
|
||||
export { DropdownMenuPortal } from "reka-ui"
|
||||
19
src/components/ui/popover/Popover.vue
Normal file
19
src/components/ui/popover/Popover.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import type { PopoverRootEmits, PopoverRootProps } from "reka-ui"
|
||||
import { PopoverRoot, useForwardPropsEmits } from "reka-ui"
|
||||
|
||||
const props = defineProps<PopoverRootProps>()
|
||||
const emits = defineEmits<PopoverRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopoverRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="popover"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</PopoverRoot>
|
||||
</template>
|
||||
15
src/components/ui/popover/PopoverAnchor.vue
Normal file
15
src/components/ui/popover/PopoverAnchor.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { PopoverAnchorProps } from "reka-ui"
|
||||
import { PopoverAnchor } from "reka-ui"
|
||||
|
||||
const props = defineProps<PopoverAnchorProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopoverAnchor
|
||||
data-slot="popover-anchor"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</PopoverAnchor>
|
||||
</template>
|
||||
45
src/components/ui/popover/PopoverContent.vue
Normal file
45
src/components/ui/popover/PopoverContent.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import type { PopoverContentEmits, PopoverContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
PopoverContent,
|
||||
PopoverPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<PopoverContentProps & { class?: HTMLAttributes["class"] }>(),
|
||||
{
|
||||
align: "center",
|
||||
sideOffset: 4,
|
||||
},
|
||||
)
|
||||
const emits = defineEmits<PopoverContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopoverPortal>
|
||||
<PopoverContent
|
||||
data-slot="popover-content"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="
|
||||
cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 rounded-md border p-4 shadow-md origin-(--reka-popover-content-transform-origin) outline-hidden',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</PopoverContent>
|
||||
</PopoverPortal>
|
||||
</template>
|
||||
15
src/components/ui/popover/PopoverTrigger.vue
Normal file
15
src/components/ui/popover/PopoverTrigger.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { PopoverTriggerProps } from "reka-ui"
|
||||
import { PopoverTrigger } from "reka-ui"
|
||||
|
||||
const props = defineProps<PopoverTriggerProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopoverTrigger
|
||||
data-slot="popover-trigger"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</PopoverTrigger>
|
||||
</template>
|
||||
4
src/components/ui/popover/index.ts
Normal file
4
src/components/ui/popover/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as Popover } from "./Popover.vue"
|
||||
export { default as PopoverAnchor } from "./PopoverAnchor.vue"
|
||||
export { default as PopoverContent } from "./PopoverContent.vue"
|
||||
export { default as PopoverTrigger } from "./PopoverTrigger.vue"
|
||||
42
src/components/ui/sonner/Sonner.vue
Normal file
42
src/components/ui/sonner/Sonner.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ToasterProps } from "vue-sonner"
|
||||
import { CircleCheckIcon, InfoIcon, Loader2Icon, OctagonXIcon, TriangleAlertIcon, XIcon } from "lucide-vue-next"
|
||||
import { Toaster as Sonner } from "vue-sonner"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<ToasterProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Sonner
|
||||
:class="cn('toaster group', props.class)"
|
||||
:style="{
|
||||
'--normal-bg': 'var(--popover)',
|
||||
'--normal-text': 'var(--popover-foreground)',
|
||||
'--normal-border': 'var(--border)',
|
||||
'--border-radius': 'var(--radius)',
|
||||
}"
|
||||
v-bind="props"
|
||||
>
|
||||
<template #success-icon>
|
||||
<CircleCheckIcon class="size-4" />
|
||||
</template>
|
||||
<template #info-icon>
|
||||
<InfoIcon class="size-4" />
|
||||
</template>
|
||||
<template #warning-icon>
|
||||
<TriangleAlertIcon class="size-4" />
|
||||
</template>
|
||||
<template #error-icon>
|
||||
<OctagonXIcon class="size-4" />
|
||||
</template>
|
||||
<template #loading-icon>
|
||||
<div>
|
||||
<Loader2Icon class="size-4 animate-spin" />
|
||||
</div>
|
||||
</template>
|
||||
<template #close-icon>
|
||||
<XIcon class="size-4" />
|
||||
</template>
|
||||
</Sonner>
|
||||
</template>
|
||||
1
src/components/ui/sonner/index.ts
Normal file
1
src/components/ui/sonner/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Toaster } from "./Sonner.vue"
|
||||
38
src/components/ui/switch/Switch.vue
Normal file
38
src/components/ui/switch/Switch.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import type { SwitchRootEmits, SwitchRootProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
SwitchRoot,
|
||||
SwitchThumb,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<SwitchRootProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const emits = defineEmits<SwitchRootEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SwitchRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="switch"
|
||||
v-bind="forwarded"
|
||||
:class="cn(
|
||||
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<SwitchThumb
|
||||
data-slot="switch-thumb"
|
||||
:class="cn('bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0')"
|
||||
>
|
||||
<slot name="thumb" v-bind="slotProps" />
|
||||
</SwitchThumb>
|
||||
</SwitchRoot>
|
||||
</template>
|
||||
1
src/components/ui/switch/index.ts
Normal file
1
src/components/ui/switch/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Switch } from "./Switch.vue"
|
||||
Reference in New Issue
Block a user