mirror of
https://github.com/hpware/news-analyze.git
synced 2025-06-24 00:01:03 +08:00
feat: add AI chat and summarize endpoints with database integration
- Implemented a new chat endpoint that utilizes Groq for chat completions based on news articles. - Added a summarize endpoint that fetches news articles from the database and generates summaries using Groq. - Introduced a new package "@vueuse/core" for improved reactivity. - Created a comprehensive command UI component with various subcomponents for better user interaction. - Developed a scraping module using Scrapy to fetch news articles from Google News. - Added validation and sanitization for slug parameters in the fetch article endpoint.
This commit is contained in:
parent
92a0358744
commit
bf357f1c84
4
.gitignore
vendored
4
.gitignore
vendored
@ -22,3 +22,7 @@ logs
|
|||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
|
# Scraping data
|
||||||
|
.venv
|
||||||
|
__pycache__
|
19
bun.lock
19
bun.lock
@ -15,6 +15,7 @@
|
|||||||
"@nuxtjs/tailwindcss": "6.14.0",
|
"@nuxtjs/tailwindcss": "6.14.0",
|
||||||
"@tailwindcss/vite": "^4.1.5",
|
"@tailwindcss/vite": "^4.1.5",
|
||||||
"@uploadthing/nuxt": "^7.1.7",
|
"@uploadthing/nuxt": "^7.1.7",
|
||||||
|
"@vueuse/core": "^13.1.0",
|
||||||
"animate.css": "^4.1.1",
|
"animate.css": "^4.1.1",
|
||||||
"bootstrap-icons": "^1.12.1",
|
"bootstrap-icons": "^1.12.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@ -614,11 +615,11 @@
|
|||||||
|
|
||||||
"@vue/shared": ["@vue/shared@3.5.13", "", {}, "sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ=="],
|
"@vue/shared": ["@vue/shared@3.5.13", "", {}, "sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ=="],
|
||||||
|
|
||||||
"@vueuse/core": ["@vueuse/core@12.8.2", "", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "12.8.2", "@vueuse/shared": "12.8.2", "vue": "^3.5.13" } }, "sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ=="],
|
"@vueuse/core": ["@vueuse/core@13.1.0", "", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "13.1.0", "@vueuse/shared": "13.1.0" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-PAauvdRXZvTWXtGLg8cPUFjiZEddTqmogdwYpnn60t08AA5a8Q4hZokBnpTOnVNqySlFlTcRYIC8OqreV4hv3Q=="],
|
||||||
|
|
||||||
"@vueuse/metadata": ["@vueuse/metadata@12.8.2", "", {}, "sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A=="],
|
"@vueuse/metadata": ["@vueuse/metadata@13.1.0", "", {}, "sha512-+TDd7/a78jale5YbHX9KHW3cEDav1lz1JptwDvep2zSG8XjCsVE+9mHIzjTOaPbHUAk5XiE4jXLz51/tS+aKQw=="],
|
||||||
|
|
||||||
"@vueuse/shared": ["@vueuse/shared@12.8.2", "", { "dependencies": { "vue": "^3.5.13" } }, "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w=="],
|
"@vueuse/shared": ["@vueuse/shared@13.1.0", "", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-IVS/qRRjhPTZ6C2/AM3jieqXACGwFZwWTdw5sNTSKk2m/ZpkuuN+ri+WCVUP8TqaKwJYt/KuMwmXspMAw8E6ew=="],
|
||||||
|
|
||||||
"@whatwg-node/disposablestack": ["@whatwg-node/disposablestack@0.0.6", "", { "dependencies": { "@whatwg-node/promise-helpers": "^1.0.0", "tslib": "^2.6.3" } }, "sha512-LOtTn+JgJvX8WfBVJtF08TGrdjuFzGJc4mkP8EdDI8ADbvO7kiexYep1o8dwnt0okb0jYclCDXF13xU7Ge4zSw=="],
|
"@whatwg-node/disposablestack": ["@whatwg-node/disposablestack@0.0.6", "", { "dependencies": { "@whatwg-node/promise-helpers": "^1.0.0", "tslib": "^2.6.3" } }, "sha512-LOtTn+JgJvX8WfBVJtF08TGrdjuFzGJc4mkP8EdDI8ADbvO7kiexYep1o8dwnt0okb0jYclCDXF13xU7Ge4zSw=="],
|
||||||
|
|
||||||
@ -2454,8 +2455,6 @@
|
|||||||
|
|
||||||
"nuxt/oxc-parser": ["oxc-parser@0.68.1", "", { "dependencies": { "@oxc-project/types": "^0.68.1" }, "optionalDependencies": { "@oxc-parser/binding-darwin-arm64": "0.68.1", "@oxc-parser/binding-darwin-x64": "0.68.1", "@oxc-parser/binding-linux-arm-gnueabihf": "0.68.1", "@oxc-parser/binding-linux-arm64-gnu": "0.68.1", "@oxc-parser/binding-linux-arm64-musl": "0.68.1", "@oxc-parser/binding-linux-x64-gnu": "0.68.1", "@oxc-parser/binding-linux-x64-musl": "0.68.1", "@oxc-parser/binding-wasm32-wasi": "0.68.1", "@oxc-parser/binding-win32-arm64-msvc": "0.68.1", "@oxc-parser/binding-win32-x64-msvc": "0.68.1" } }, "sha512-dHwz+xP9r1GTvqyywfws4j7EEP/OaeTpHEjTcvIjViB/R2IdUn52AnoUFNjpw8yRU52XVE76rOA4IEj7I0EjnA=="],
|
"nuxt/oxc-parser": ["oxc-parser@0.68.1", "", { "dependencies": { "@oxc-project/types": "^0.68.1" }, "optionalDependencies": { "@oxc-parser/binding-darwin-arm64": "0.68.1", "@oxc-parser/binding-darwin-x64": "0.68.1", "@oxc-parser/binding-linux-arm-gnueabihf": "0.68.1", "@oxc-parser/binding-linux-arm64-gnu": "0.68.1", "@oxc-parser/binding-linux-arm64-musl": "0.68.1", "@oxc-parser/binding-linux-x64-gnu": "0.68.1", "@oxc-parser/binding-linux-x64-musl": "0.68.1", "@oxc-parser/binding-wasm32-wasi": "0.68.1", "@oxc-parser/binding-win32-arm64-msvc": "0.68.1", "@oxc-parser/binding-win32-x64-msvc": "0.68.1" } }, "sha512-dHwz+xP9r1GTvqyywfws4j7EEP/OaeTpHEjTcvIjViB/R2IdUn52AnoUFNjpw8yRU52XVE76rOA4IEj7I0EjnA=="],
|
||||||
|
|
||||||
"nuxt-link-checker/@vueuse/core": ["@vueuse/core@13.1.0", "", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "13.1.0", "@vueuse/shared": "13.1.0" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-PAauvdRXZvTWXtGLg8cPUFjiZEddTqmogdwYpnn60t08AA5a8Q4hZokBnpTOnVNqySlFlTcRYIC8OqreV4hv3Q=="],
|
|
||||||
|
|
||||||
"nypm/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
|
"nypm/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
|
||||||
|
|
||||||
"open/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
|
"open/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
|
||||||
@ -2480,6 +2479,10 @@
|
|||||||
|
|
||||||
"readdir-glob/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="],
|
"readdir-glob/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="],
|
||||||
|
|
||||||
|
"reka-ui/@vueuse/core": ["@vueuse/core@12.8.2", "", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "12.8.2", "@vueuse/shared": "12.8.2", "vue": "^3.5.13" } }, "sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ=="],
|
||||||
|
|
||||||
|
"reka-ui/@vueuse/shared": ["@vueuse/shared@12.8.2", "", { "dependencies": { "vue": "^3.5.13" } }, "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w=="],
|
||||||
|
|
||||||
"replace-in-file/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
"replace-in-file/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||||
|
|
||||||
"replace-in-file/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
|
"replace-in-file/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
|
||||||
@ -2750,10 +2753,6 @@
|
|||||||
|
|
||||||
"mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="],
|
"mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="],
|
||||||
|
|
||||||
"nuxt-link-checker/@vueuse/core/@vueuse/metadata": ["@vueuse/metadata@13.1.0", "", {}, "sha512-+TDd7/a78jale5YbHX9KHW3cEDav1lz1JptwDvep2zSG8XjCsVE+9mHIzjTOaPbHUAk5XiE4jXLz51/tS+aKQw=="],
|
|
||||||
|
|
||||||
"nuxt-link-checker/@vueuse/core/@vueuse/shared": ["@vueuse/shared@13.1.0", "", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-IVS/qRRjhPTZ6C2/AM3jieqXACGwFZwWTdw5sNTSKk2m/ZpkuuN+ri+WCVUP8TqaKwJYt/KuMwmXspMAw8E6ew=="],
|
|
||||||
|
|
||||||
"nuxt/oxc-parser/@oxc-parser/binding-darwin-arm64": ["@oxc-parser/binding-darwin-arm64@0.68.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Y5FBQyPCLsldAZYEd+oZcUboXwpcLf42Lakx3EYtiYDbuK9M3IqBXMGxdM07P4PfGQrKYn6/cC8xAqkVHnbWPw=="],
|
"nuxt/oxc-parser/@oxc-parser/binding-darwin-arm64": ["@oxc-parser/binding-darwin-arm64@0.68.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Y5FBQyPCLsldAZYEd+oZcUboXwpcLf42Lakx3EYtiYDbuK9M3IqBXMGxdM07P4PfGQrKYn6/cC8xAqkVHnbWPw=="],
|
||||||
|
|
||||||
"nuxt/oxc-parser/@oxc-parser/binding-darwin-x64": ["@oxc-parser/binding-darwin-x64@0.68.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-nkiXpEKl8UOhNPdOY5hA2PFq9vQc9xVs7NFu2vUD9eH/j5uYfv8GnNaKkd+v6iH93JwEBxuK5gfwxiiCEMZRyg=="],
|
"nuxt/oxc-parser/@oxc-parser/binding-darwin-x64": ["@oxc-parser/binding-darwin-x64@0.68.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-nkiXpEKl8UOhNPdOY5hA2PFq9vQc9xVs7NFu2vUD9eH/j5uYfv8GnNaKkd+v6iH93JwEBxuK5gfwxiiCEMZRyg=="],
|
||||||
@ -2778,6 +2777,8 @@
|
|||||||
|
|
||||||
"prebuild-install/tar-fs/tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="],
|
"prebuild-install/tar-fs/tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="],
|
||||||
|
|
||||||
|
"reka-ui/@vueuse/core/@vueuse/metadata": ["@vueuse/metadata@12.8.2", "", {}, "sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A=="],
|
||||||
|
|
||||||
"replace-in-file/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
|
"replace-in-file/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
|
||||||
|
|
||||||
"resolve-path/http-errors/depd": ["depd@1.1.2", "", {}, "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ=="],
|
"resolve-path/http-errors/depd": ["depd@1.1.2", "", {}, "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ=="],
|
||||||
|
92
components/ui/command/Command.vue
Normal file
92
components/ui/command/Command.vue
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ListboxRootEmits, ListboxRootProps } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { ListboxRoot, useFilter, useForwardPropsEmits } from 'reka-ui'
|
||||||
|
import { computed, type HTMLAttributes, reactive, ref, watch } from 'vue'
|
||||||
|
import { provideCommandContext } from '.'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<ListboxRootProps & { class?: HTMLAttributes['class'] }>(), {
|
||||||
|
modelValue: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const emits = defineEmits<ListboxRootEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
const { class: _, ...delegated } = props
|
||||||
|
|
||||||
|
return delegated
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelect() {
|
||||||
|
filterState.search = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => filterState.search, () => {
|
||||||
|
filterItems()
|
||||||
|
})
|
||||||
|
|
||||||
|
provideCommandContext({
|
||||||
|
allItems,
|
||||||
|
allGroups,
|
||||||
|
filterState,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ListboxRoot
|
||||||
|
v-bind="forwarded"
|
||||||
|
:class="cn('flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</ListboxRoot>
|
||||||
|
</template>
|
21
components/ui/command/CommandDialog.vue
Normal file
21
components/ui/command/CommandDialog.vue
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogRootEmits, DialogRootProps } from 'reka-ui'
|
||||||
|
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
||||||
|
import { useForwardPropsEmits } from 'reka-ui'
|
||||||
|
import Command from './Command.vue'
|
||||||
|
|
||||||
|
const props = defineProps<DialogRootProps>()
|
||||||
|
const emits = defineEmits<DialogRootEmits>()
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(props, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog v-bind="forwarded">
|
||||||
|
<DialogContent class="overflow-hidden p-0 shadow-lg">
|
||||||
|
<Command class="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||||
|
<slot />
|
||||||
|
</Command>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
25
components/ui/command/CommandEmpty.vue
Normal file
25
components/ui/command/CommandEmpty.vue
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { PrimitiveProps } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Primitive } from 'reka-ui'
|
||||||
|
import { computed, type HTMLAttributes } from 'vue'
|
||||||
|
import { useCommand } from '.'
|
||||||
|
|
||||||
|
const props = defineProps<PrimitiveProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
const { class: _, ...delegated } = props
|
||||||
|
|
||||||
|
return delegated
|
||||||
|
})
|
||||||
|
|
||||||
|
const { filterState } = useCommand()
|
||||||
|
const isRender = computed(() => !!filterState.search && filterState.filtered.count === 0,
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Primitive v-if="isRender" v-bind="delegatedProps" :class="cn('py-6 text-center text-sm', props.class)">
|
||||||
|
<slot />
|
||||||
|
</Primitive>
|
||||||
|
</template>
|
46
components/ui/command/CommandGroup.vue
Normal file
46
components/ui/command/CommandGroup.vue
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ListboxGroupProps } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { ListboxGroup, ListboxGroupLabel, useId } from 'reka-ui'
|
||||||
|
import { computed, type HTMLAttributes, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { provideCommandGroupContext, useCommand } from '.'
|
||||||
|
|
||||||
|
const props = defineProps<ListboxGroupProps & {
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
heading?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
const { class: _, ...delegated } = props
|
||||||
|
|
||||||
|
return delegated
|
||||||
|
})
|
||||||
|
|
||||||
|
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"
|
||||||
|
:class="cn('overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground', props.class)"
|
||||||
|
:hidden="isRender ? undefined : true"
|
||||||
|
>
|
||||||
|
<ListboxGroupLabel v-if="heading" class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
||||||
|
{{ heading }}
|
||||||
|
</ListboxGroupLabel>
|
||||||
|
<slot />
|
||||||
|
</ListboxGroup>
|
||||||
|
</template>
|
37
components/ui/command/CommandInput.vue
Normal file
37
components/ui/command/CommandInput.vue
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Search } from 'lucide-vue-next'
|
||||||
|
import { ListboxFilter, type ListboxFilterProps, useForwardProps } from 'reka-ui'
|
||||||
|
import { computed, type HTMLAttributes } from 'vue'
|
||||||
|
import { useCommand } from '.'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = defineProps<ListboxFilterProps & {
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
const { class: _, ...delegated } = props
|
||||||
|
|
||||||
|
return delegated
|
||||||
|
})
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
|
||||||
|
const { filterState } = useCommand()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex items-center border-b px-3" cmdk-input-wrapper>
|
||||||
|
<Search class="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
<ListboxFilter
|
||||||
|
v-bind="{ ...forwardedProps, ...$attrs }"
|
||||||
|
v-model="filterState.search"
|
||||||
|
auto-focus
|
||||||
|
:class="cn('flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50', props.class)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
78
components/ui/command/CommandItem.vue
Normal file
78
components/ui/command/CommandItem.vue
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ListboxItemEmits, ListboxItemProps } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useCurrentElement } from '@vueuse/core'
|
||||||
|
import { ListboxItem, useForwardPropsEmits, useId } from 'reka-ui'
|
||||||
|
import { computed, type HTMLAttributes, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
import { useCommand, useCommandGroup } from '.'
|
||||||
|
|
||||||
|
const props = defineProps<ListboxItemProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
const emits = defineEmits<ListboxItemEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
const { class: _, ...delegated } = props
|
||||||
|
|
||||||
|
return delegated
|
||||||
|
})
|
||||||
|
|
||||||
|
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"
|
||||||
|
:class="cn('relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0', props.class)"
|
||||||
|
@select="() => {
|
||||||
|
filterState.search = ''
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</ListboxItem>
|
||||||
|
</template>
|
24
components/ui/command/CommandList.vue
Normal file
24
components/ui/command/CommandList.vue
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ListboxContentProps } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { ListboxContent, useForwardProps } from 'reka-ui'
|
||||||
|
import { computed, type HTMLAttributes } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<ListboxContentProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
const { class: _, ...delegated } = props
|
||||||
|
|
||||||
|
return delegated
|
||||||
|
})
|
||||||
|
|
||||||
|
const forwarded = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ListboxContent v-bind="forwarded" :class="cn('max-h-[300px] overflow-y-auto overflow-x-hidden', props.class)">
|
||||||
|
<div role="presentation">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</ListboxContent>
|
||||||
|
</template>
|
23
components/ui/command/CommandSeparator.vue
Normal file
23
components/ui/command/CommandSeparator.vue
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SeparatorProps } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Separator } from 'reka-ui'
|
||||||
|
import { computed, type HTMLAttributes } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<SeparatorProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
const { class: _, ...delegated } = props
|
||||||
|
|
||||||
|
return delegated
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Separator
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="cn('-mx-1 h-px bg-border', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</Separator>
|
||||||
|
</template>
|
14
components/ui/command/CommandShortcut.vue
Normal file
14
components/ui/command/CommandShortcut.vue
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span :class="cn('ml-auto text-xs tracking-widest text-muted-foreground', props.class)">
|
||||||
|
<slot />
|
||||||
|
</span>
|
||||||
|
</template>
|
25
components/ui/command/index.ts
Normal file
25
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')
|
14
components/ui/dialog/Dialog.vue
Normal file
14
components/ui/dialog/Dialog.vue
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { DialogRoot, type DialogRootEmits, type DialogRootProps, useForwardPropsEmits } from 'reka-ui'
|
||||||
|
|
||||||
|
const props = defineProps<DialogRootProps>()
|
||||||
|
const emits = defineEmits<DialogRootEmits>()
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(props, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogRoot v-bind="forwarded">
|
||||||
|
<slot />
|
||||||
|
</DialogRoot>
|
||||||
|
</template>
|
11
components/ui/dialog/DialogClose.vue
Normal file
11
components/ui/dialog/DialogClose.vue
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { DialogClose, type DialogCloseProps } from 'reka-ui'
|
||||||
|
|
||||||
|
const props = defineProps<DialogCloseProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogClose v-bind="props">
|
||||||
|
<slot />
|
||||||
|
</DialogClose>
|
||||||
|
</template>
|
50
components/ui/dialog/DialogContent.vue
Normal file
50
components/ui/dialog/DialogContent.vue
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { X } from 'lucide-vue-next'
|
||||||
|
import {
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
type DialogContentEmits,
|
||||||
|
type DialogContentProps,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
useForwardPropsEmits,
|
||||||
|
} from 'reka-ui'
|
||||||
|
import { computed, type HTMLAttributes } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<DialogContentProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
const emits = defineEmits<DialogContentEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
const { class: _, ...delegated } = props
|
||||||
|
|
||||||
|
return delegated
|
||||||
|
})
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay
|
||||||
|
class="fixed inset-0 z-50 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
|
||||||
|
v-bind="forwarded"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
|
||||||
|
<DialogClose
|
||||||
|
class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"
|
||||||
|
>
|
||||||
|
<X class="w-4 h-4" />
|
||||||
|
<span class="sr-only">Close</span>
|
||||||
|
</DialogClose>
|
||||||
|
</DialogContent>
|
||||||
|
</DialogPortal>
|
||||||
|
</template>
|
24
components/ui/dialog/DialogDescription.vue
Normal file
24
components/ui/dialog/DialogDescription.vue
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { DialogDescription, type DialogDescriptionProps, useForwardProps } from 'reka-ui'
|
||||||
|
import { computed, type HTMLAttributes } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
const { class: _, ...delegated } = props
|
||||||
|
|
||||||
|
return delegated
|
||||||
|
})
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogDescription
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="cn('text-sm text-muted-foreground', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogDescription>
|
||||||
|
</template>
|
19
components/ui/dialog/DialogFooter.vue
Normal file
19
components/ui/dialog/DialogFooter.vue
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
16
components/ui/dialog/DialogHeader.vue
Normal file
16
components/ui/dialog/DialogHeader.vue
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class="cn('flex flex-col gap-y-1.5 text-center sm:text-left', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
59
components/ui/dialog/DialogScrollContent.vue
Normal file
59
components/ui/dialog/DialogScrollContent.vue
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { X } from 'lucide-vue-next'
|
||||||
|
import {
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
type DialogContentEmits,
|
||||||
|
type DialogContentProps,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
useForwardPropsEmits,
|
||||||
|
} from 'reka-ui'
|
||||||
|
import { computed, type HTMLAttributes } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<DialogContentProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
const emits = defineEmits<DialogContentEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
const { class: _, ...delegated } = props
|
||||||
|
|
||||||
|
return delegated
|
||||||
|
})
|
||||||
|
|
||||||
|
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="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>
|
29
components/ui/dialog/DialogTitle.vue
Normal file
29
components/ui/dialog/DialogTitle.vue
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { DialogTitle, type DialogTitleProps, useForwardProps } from 'reka-ui'
|
||||||
|
import { computed, type HTMLAttributes } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
const { class: _, ...delegated } = props
|
||||||
|
|
||||||
|
return delegated
|
||||||
|
})
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogTitle
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'text-lg font-semibold leading-none tracking-tight',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogTitle>
|
||||||
|
</template>
|
11
components/ui/dialog/DialogTrigger.vue
Normal file
11
components/ui/dialog/DialogTrigger.vue
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { DialogTrigger, type DialogTriggerProps } from 'reka-ui'
|
||||||
|
|
||||||
|
const props = defineProps<DialogTriggerProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogTrigger v-bind="props">
|
||||||
|
<slot />
|
||||||
|
</DialogTrigger>
|
||||||
|
</template>
|
9
components/ui/dialog/index.ts
Normal file
9
components/ui/dialog/index.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
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 DialogScrollContent } from './DialogScrollContent.vue'
|
||||||
|
export { default as DialogTitle } from './DialogTitle.vue'
|
||||||
|
export { default as DialogTrigger } from './DialogTrigger.vue'
|
14
components/ui/hover-card/HoverCard.vue
Normal file
14
components/ui/hover-card/HoverCard.vue
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { HoverCardRoot, type HoverCardRootEmits, type HoverCardRootProps, useForwardPropsEmits } from 'reka-ui'
|
||||||
|
|
||||||
|
const props = defineProps<HoverCardRootProps>()
|
||||||
|
const emits = defineEmits<HoverCardRootEmits>()
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(props, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<HoverCardRoot v-bind="forwarded">
|
||||||
|
<slot />
|
||||||
|
</HoverCardRoot>
|
||||||
|
</template>
|
41
components/ui/hover-card/HoverCardContent.vue
Normal file
41
components/ui/hover-card/HoverCardContent.vue
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import {
|
||||||
|
HoverCardContent,
|
||||||
|
type HoverCardContentProps,
|
||||||
|
HoverCardPortal,
|
||||||
|
useForwardProps,
|
||||||
|
} from 'reka-ui'
|
||||||
|
import { computed, type HTMLAttributes } from 'vue'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<HoverCardContentProps & { class?: HTMLAttributes['class'] }>(),
|
||||||
|
{
|
||||||
|
sideOffset: 4,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
const { class: _, ...delegated } = props
|
||||||
|
|
||||||
|
return delegated
|
||||||
|
})
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<HoverCardPortal>
|
||||||
|
<HoverCardContent
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none 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',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</HoverCardContent>
|
||||||
|
</HoverCardPortal>
|
||||||
|
</template>
|
11
components/ui/hover-card/HoverCardTrigger.vue
Normal file
11
components/ui/hover-card/HoverCardTrigger.vue
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { HoverCardTrigger, type HoverCardTriggerProps } from 'reka-ui'
|
||||||
|
|
||||||
|
const props = defineProps<HoverCardTriggerProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<HoverCardTrigger v-bind="props">
|
||||||
|
<slot />
|
||||||
|
</HoverCardTrigger>
|
||||||
|
</template>
|
3
components/ui/hover-card/index.ts
Normal file
3
components/ui/hover-card/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export { default as HoverCard } from './HoverCard.vue'
|
||||||
|
export { default as HoverCardContent } from './HoverCardContent.vue'
|
||||||
|
export { default as HoverCardTrigger } from './HoverCardTrigger.vue'
|
@ -24,6 +24,7 @@
|
|||||||
"@nuxtjs/tailwindcss": "6.14.0",
|
"@nuxtjs/tailwindcss": "6.14.0",
|
||||||
"@tailwindcss/vite": "^4.1.5",
|
"@tailwindcss/vite": "^4.1.5",
|
||||||
"@uploadthing/nuxt": "^7.1.7",
|
"@uploadthing/nuxt": "^7.1.7",
|
||||||
|
"@vueuse/core": "^13.1.0",
|
||||||
"animate.css": "^4.1.1",
|
"animate.css": "^4.1.1",
|
||||||
"bootstrap-icons": "^1.12.1",
|
"bootstrap-icons": "^1.12.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
1
scraping/.python-version
Normal file
1
scraping/.python-version
Normal file
@ -0,0 +1 @@
|
|||||||
|
3.13
|
12
scraping/main.py
Normal file
12
scraping/main.py
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import scrapy
|
||||||
|
|
||||||
|
class BlogSpider(scrapy.Spider):
|
||||||
|
name = 'blogspider'
|
||||||
|
start_urls = ['https://news.google.com/u/4/home?hl=zh-TW&gl=TW&ceid=TW:zh-Hant&pageId=none']
|
||||||
|
|
||||||
|
def parse(self, response):
|
||||||
|
for title in response.css('.oxy-post-title'):
|
||||||
|
yield {'title': title.css('::text').get()}
|
||||||
|
|
||||||
|
for next_page in response.css('a.next'):
|
||||||
|
yield response.follow(next_page, self.parse)
|
7
scraping/pyproject.toml
Normal file
7
scraping/pyproject.toml
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
[project]
|
||||||
|
name = "scraping"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Add your description here"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
dependencies = []
|
1
scraping/requirements.txt
Normal file
1
scraping/requirements.txt
Normal file
@ -0,0 +1 @@
|
|||||||
|
scrapy
|
8
scraping/uv.lock
generated
Normal file
8
scraping/uv.lock
generated
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
version = 1
|
||||||
|
revision = 1
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "scraping"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { virtual = "." }
|
@ -0,0 +1,35 @@
|
|||||||
|
import { Groq } from 'groq-sdk';
|
||||||
|
import sql from "~/server/components/postgres";
|
||||||
|
|
||||||
|
const groq = new Groq();
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const slug = getRouterParam(event, 'slug');
|
||||||
|
const body = await readBody(event);
|
||||||
|
const fetchNewsArticle = await sql`
|
||||||
|
select * from newArticle
|
||||||
|
where slug = ${slug}
|
||||||
|
`;
|
||||||
|
const chatCompletion = await groq.chat.completions.create({
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": `${body}`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": `You are a news chat, the following content will be used to chat with the user title: ${fetchNewsArticle.title}\n content: ${fetchNewsArticle.content}`
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"model": "llama3-70b-8192",
|
||||||
|
"temperature": 1,
|
||||||
|
"max_completion_tokens": 1024,
|
||||||
|
"top_p": 1,
|
||||||
|
"stream": true,
|
||||||
|
"stop": null
|
||||||
|
});
|
||||||
|
|
||||||
|
for await (const chunk of chatCompletion) {
|
||||||
|
process.stdout.write(chunk.choices[0]?.delta?.content || '');
|
||||||
|
}
|
||||||
|
})
|
@ -1,15 +1,19 @@
|
|||||||
import { Groq } from 'groq-sdk';
|
import { Groq } from 'groq-sdk';
|
||||||
|
import sql from "~/server/components/postgres";
|
||||||
|
|
||||||
const groq = new Groq();
|
const groq = new Groq();
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const slug = getRouterParam(event, 'slug');
|
const slug = getRouterParam(event, 'slug');
|
||||||
const fetchNewsArticle = await fetch(`/api/`);
|
const fetchNewsArticle = await sql`
|
||||||
|
select * from newArticle
|
||||||
|
where slug = ${slug}
|
||||||
|
`;
|
||||||
const chatCompletion = await groq.chat.completions.create({
|
const chatCompletion = await groq.chat.completions.create({
|
||||||
"messages": [
|
"messages": [
|
||||||
{
|
{
|
||||||
"role": "user",
|
"role": "user",
|
||||||
"content": ``
|
"content": `${fetchNewsArticle.title}\n${fetchNewsArticle.content}`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "system",
|
"role": "system",
|
||||||
|
28
server/api/fetcharticle/[slug].ts
Normal file
28
server/api/fetcharticle/[slug].ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import sql from "~/server/components/postgres";
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const slug = getRouterParam(event, 'slug');
|
||||||
|
|
||||||
|
// Validate and sanitize the slug
|
||||||
|
if (!slug || typeof slug !== 'string') {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: 'Invalid slug parameter'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const cleanSlug = slug.replace(/[^a-zA-Z0-9-_]/g, '');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await sql`
|
||||||
|
select * from articles
|
||||||
|
where slug = ${cleanSlug}
|
||||||
|
`
|
||||||
|
|
||||||
|
return result.rows[0] || null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Database error:', error);
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
message: 'Internal server error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
Loading…
x
Reference in New Issue
Block a user