diff --git a/assets/css/tailwind.css b/assets/css/tailwind.css index 56a69d9..d306130 100644 --- a/assets/css/tailwind.css +++ b/assets/css/tailwind.css @@ -5,68 +5,88 @@ @layer base { :root { --background: 0 0% 100%; - --foreground: 222.2 84% 4.9%; + --foreground: 0 0% 3.9%; - --muted: 210 40% 96.1%; - --muted-foreground: 215.4 16.3% 46.9%; + --muted: 0 0% 96.1%; + --muted-foreground: 0 0% 45.1%; --popover: 0 0% 100%; - --popover-foreground: 222.2 84% 4.9%; + --popover-foreground: 0 0% 3.9%; --card: 0 0% 100%; - --card-foreground: 222.2 84% 4.9%; + --card-foreground: 0 0% 3.9%; - --border: 214.3 31.8% 91.4%; - --input: 214.3 31.8% 91.4%; + --border: 0 0% 89.8%; + --input: 0 0% 89.8%; - --primary: 222.2 47.4% 11.2%; - --primary-foreground: 210 40% 98%; + --primary: 0 0% 9%; + --primary-foreground: 0 0% 98%; - --secondary: 210 40% 96.1%; - --secondary-foreground: 222.2 47.4% 11.2%; + --secondary: 0 0% 96.1%; + --secondary-foreground: 0 0% 9%; - --accent: 210 40% 96.1%; - --accent-foreground: 222.2 47.4% 11.2%; + --accent: 0 0% 96.1%; + --accent-foreground: 0 0% 9%; --destructive: 0 84.2% 60.2%; - --destructive-foreground: 210 40% 98%; + --destructive-foreground: 0 0% 98%; - --ring: 222.2 84% 4.9%; + --ring: 0 0% 3.9%; --radius: 0.5rem; + + --chart-1: 12 76% 61%; + + --chart-2: 173 58% 39%; + + --chart-3: 197 37% 24%; + + --chart-4: 43 74% 66%; + + --chart-5: 27 87% 67%; } .dark { - --background: 224 71% 4%; - --foreground: 213 31% 91%; + --background: 0 0% 3.9%; + --foreground: 0 0% 98%; - --muted: 223 47% 11%; - --muted-foreground: 215.4 16.3% 70%; + --muted: 0 0% 14.9%; + --muted-foreground: 0 0% 63.9%; - --popover: 224 71% 4%; - --popover-foreground: 215 20.2% 91%; + --popover: 0 0% 3.9%; + --popover-foreground: 0 0% 98%; - --card: 224 71% 4%; - --card-foreground: 213 31% 91%; + --card: 0 0% 3.9%; + --card-foreground: 0 0% 98%; - --border: 216 34% 17%; - --input: 216 34% 17%; + --border: 0 0% 14.9%; + --input: 0 0% 14.9%; - --primary: 210 40% 98%; - --primary-foreground: 222.2 47.4% 1.2%; + --primary: 0 0% 98%; + --primary-foreground: 0 0% 9%; - --secondary: 222 47% 11%; - --secondary-foreground: 210 40% 98%; + --secondary: 0 0% 14.9%; + --secondary-foreground: 0 0% 98%; - --accent: 216 34% 17%; - --accent-foreground: 210 40% 98%; + --accent: 0 0% 14.9%; + --accent-foreground: 0 0% 98%; - --destructive: 0 63% 31%; - --destructive-foreground: 210 40% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; - --ring: 216 34% 17%; + --ring: 0 0% 83.1%; --radius: 0.5rem; + + --chart-1: 220 70% 50%; + + --chart-2: 160 60% 45%; + + --chart-3: 30 80% 55%; + + --chart-4: 280 65% 60%; + + --chart-5: 340 75% 55%; } } diff --git a/components.json b/components.json index 2ffe2f3..487cb42 100644 --- a/components.json +++ b/components.json @@ -2,17 +2,19 @@ "$schema": "https://shadcn-vue.com/schema.json", "style": "new-york", "typescript": true, - "tsConfigPath": ".nuxt/tsconfig.json", "tailwind": { "config": "tailwind.config.ts", "css": "assets/css/tailwind.css", - "baseColor": "slate", + "baseColor": "neutral", "cssVariables": true, "prefix": "" }, - "framework": "nuxt", "aliases": { "components": "@/components", - "utils": "@/lib/utils" - } + "composables": "@/composables", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib" + }, + "iconLibrary": "lucide" } \ No newline at end of file diff --git a/components/ThemeToggle.vue b/components/ThemeToggle.vue index c6103a7..8f0e554 100644 --- a/components/ThemeToggle.vue +++ b/components/ThemeToggle.vue @@ -3,36 +3,85 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge import { Button } from '#components' const colorMode = useColorMode() + +// Función para cambiar el tema de manera inmediata +const changeTheme = (theme: 'light' | 'dark' | 'system') => { + colorMode.preference = theme + // Si es system, usar la preferencia del sistema + if (theme === 'system') { + colorMode.value = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' + } else { + colorMode.value = theme + } + + nextTick(() => { + document.documentElement.setAttribute('data-theme', colorMode.value) + }) +} - - + + + + Toggle theme - + - Light + Light + - + - Dark + Dark + - + - System + System + + + diff --git a/components/icons/IconSearch.vue b/components/icons/IconSearch.vue new file mode 100644 index 0000000..ec68169 --- /dev/null +++ b/components/icons/IconSearch.vue @@ -0,0 +1,14 @@ + + + + + + diff --git a/components/search/SearchInput.vue b/components/search/SearchInput.vue new file mode 100644 index 0000000..cff4a8d --- /dev/null +++ b/components/search/SearchInput.vue @@ -0,0 +1,209 @@ + + + + + + + + + + + + + + + + + Search repositories + + + + + + + + + + + + + + + + + diff --git a/components/search/SearchInputField.vue b/components/search/SearchInputField.vue new file mode 100644 index 0000000..3f85402 --- /dev/null +++ b/components/search/SearchInputField.vue @@ -0,0 +1,134 @@ + + + + + + + + + + + + + + + + + / + + + + + + diff --git a/components/search/SearchSuggestions.vue b/components/search/SearchSuggestions.vue new file mode 100644 index 0000000..6add6f9 --- /dev/null +++ b/components/search/SearchSuggestions.vue @@ -0,0 +1,142 @@ + + + + + + Available filters + + + + + {{ QUALIFIERS[suggestion.qualifier as keyof typeof QUALIFIERS].label }} + + + + {{ suggestion.value }} + + + {{ suggestion.qualifier }} + + + + + + + + + Available filters + + + + + {{ QUALIFIERS[suggestion.qualifier as keyof typeof QUALIFIERS].label }} + + + + {{ suggestion.value }} + + + {{ suggestion.qualifier }} + + + + + + + + diff --git a/components/search/composables/useQualifiers.ts b/components/search/composables/useQualifiers.ts new file mode 100644 index 0000000..7fb2af2 --- /dev/null +++ b/components/search/composables/useQualifiers.ts @@ -0,0 +1,22 @@ +import type { QualifierData } from "~/components/search/search" + +export const QUALIFIERS: QualifierData = { + 'repo:': { label: 'Repository (user/repo)', icon: 'octicon:repo-16' }, + 'user:': { label: 'User', icon: 'octicon:person-16' }, + 'org:': { label: 'Organization', icon: 'octicon:organization-16' }, + 'in:': { label: 'Search in', icon: 'octicon:search-16' }, + 'size:': { label: 'Size', icon: 'octicon:file-16' }, + 'stars:': { label: 'Stars', icon: 'octicon:star-16' }, + 'language:': { label: 'Language', icon: 'octicon:code-16' }, + 'created:': { label: 'Created date', icon: 'octicon:calendar-16' }, + 'pushed:': { label: 'Push date', icon: 'octicon:git-commit-16' }, + 'topic:': { label: 'Topic', icon: 'octicon:hash-16' }, + 'is:': { label: 'State', icon: 'octicon:circle-16' }, + 'fork:': { label: 'Fork', icon: 'octicon:repo-forked-16' }, +} + +export function useQualifiers() { + return { + QUALIFIERS + } +} diff --git a/components/search/composables/useScrollLock.ts b/components/search/composables/useScrollLock.ts new file mode 100644 index 0000000..a71acd1 --- /dev/null +++ b/components/search/composables/useScrollLock.ts @@ -0,0 +1,24 @@ +import { ref } from 'vue' + +export function useScrollLock() { + const originalPosition = ref(0) + + const lockScroll = () => { + originalPosition.value = window.scrollY + document.body.style.position = 'fixed' + document.body.style.top = `-${originalPosition.value}px` + document.body.style.width = '100%' + } + + const unlockScroll = () => { + document.body.style.position = '' + document.body.style.top = '' + document.body.style.width = '' + window.scrollTo(0, originalPosition.value) + } + + return { + lockScroll, + unlockScroll + } +} diff --git a/components/search/composables/useSearchHighlighter.ts b/components/search/composables/useSearchHighlighter.ts new file mode 100644 index 0000000..8f5e979 --- /dev/null +++ b/components/search/composables/useSearchHighlighter.ts @@ -0,0 +1,27 @@ +import { useSearchParser } from './useSearchParser' + +export function useSearchHighlighter() { + const { parseSearchTokens } = useSearchParser() + + const highlightText = (text: string): string => { + if (!text) return '' + + const tokens = parseSearchTokens(text) + return tokens.map(token => { + switch (token.type) { + case 'qualifier': + // Ahora el qualifier se muestra como texto normal + return `${token.qualifier}:` + + (token.value ? `${token.value}` : '') + case 'text': + return `${token.value}` + case 'space': + return ' ' + default: + return token.value + } + }).join('') + } + + return { highlightText } +} diff --git a/components/search/composables/useSearchInput.ts b/components/search/composables/useSearchInput.ts new file mode 100644 index 0000000..1fc6aa5 --- /dev/null +++ b/components/search/composables/useSearchInput.ts @@ -0,0 +1,119 @@ +import { ref, nextTick, onMounted, onUnmounted } from 'vue' + +export function useSearchInput(onHandleFocus?: () => void) { + const input = ref(null) + const cursorPosition = ref(0) + const searchContainer = ref(null) + const containerWidth = ref(0) + const dropdownPosition = ref({ left: 0, top: 0 }) + const isInputFocused = ref(false) + + const updateCursorPosition = () => { + nextTick(() => { + if (input.value) { + const inputElement = input.value + cursorPosition.value = inputElement.selectionStart || 0 + const container = inputElement.parentElement + + if (container) { + const measureElement = document.createElement('span') + measureElement.style.font = window.getComputedStyle(inputElement).font + measureElement.style.visibility = 'hidden' + measureElement.style.position = 'absolute' + measureElement.style.whiteSpace = 'pre' + measureElement.textContent = inputElement.value.substring(0, inputElement.selectionStart || 0) + document.body.appendChild(measureElement) + + const cursorOffset = measureElement.offsetWidth + const containerWidth = container.offsetWidth + const scrollLeft = container.scrollLeft + + document.body.removeChild(measureElement) + + if (cursorOffset > scrollLeft + containerWidth - 80) { + container.scrollLeft = cursorOffset - containerWidth + 80 + } + else if (cursorOffset < scrollLeft + 80) { + container.scrollLeft = Math.max(0, cursorOffset - 80) + } + } + } + }) + } + + const handleInputScroll = (event: Event) => { + const input = event.target as HTMLElement + const mirror = input.previousElementSibling as HTMLElement + if (mirror) { + mirror.scrollLeft = input.scrollLeft + } + } + + const updateContainerWidth = () => { + if (searchContainer.value) { + containerWidth.value = searchContainer.value.offsetWidth + } + } + + const updateDropdownPosition = () => { + if (searchContainer.value) { + containerWidth.value = searchContainer.value.offsetWidth + } + } + + const forceFocus = () => { + if (input.value) { + // Inmediatamente intentar el foco + input.value.focus() + + // Usar múltiples técnicas para asegurar el foco + requestAnimationFrame(() => { + if (input.value) { + input.value.focus() + input.value.click() // Simular click para forzar foco + + // Último intento después de un pequeño delay + setTimeout(() => { + input.value?.focus() + }, 1) + } + }) + } + } + + const handleGlobalShortcut = (event: KeyboardEvent) => { + if (event.key === '/' && !['INPUT', 'TEXTAREA'].includes((event.target as HTMLElement).tagName)) { + event.preventDefault() + event.stopPropagation() + isInputFocused.value = true + onHandleFocus?.() + forceFocus() + } + } + + onMounted(() => { + updateContainerWidth() + window.addEventListener('resize', updateContainerWidth) + window.addEventListener('keydown', handleGlobalShortcut) + }) + + onUnmounted(() => { + window.removeEventListener('resize', updateContainerWidth) + window.removeEventListener('keydown', handleGlobalShortcut) + }) + + return { + input, + cursorPosition, + searchContainer, + containerWidth, + dropdownPosition, + isInputFocused, + updateCursorPosition, + handleInputScroll, + updateContainerWidth, + updateDropdownPosition, + handleGlobalShortcut, + forceFocus, // Exportar para uso externo + } +} diff --git a/components/search/composables/useSearchInteractions.ts b/components/search/composables/useSearchInteractions.ts new file mode 100644 index 0000000..a0d5aec --- /dev/null +++ b/components/search/composables/useSearchInteractions.ts @@ -0,0 +1,211 @@ +import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue' +import type { SearchToken } from '../search' +import { useSearchParser } from './useSearchParser' + +export function useSearchInteractions( + props: { modelValue: string, mode?: 'inline' | 'modal' }, + emit: { (e: 'update:modelValue', value: string): void, (e: 'search'): void }, + input: any, + updateCursorPosition: () => void, + lockScroll: () => void, + unlockScroll: () => void +) { + const { parseSearchTokens } = useSearchParser() + const searchInput = ref(props.modelValue) + const showSuggestions = ref(false) + const isInputFocused = ref(false) + const isExpanded = ref(false) + + const isInlineMode = computed(() => props.mode === 'inline') + const searchTokens = computed(() => parseSearchTokens(searchInput.value)) + + const handleInput = (event: Event) => { + const value = (event.target as HTMLInputElement).value + searchInput.value = value + emit('update:modelValue', value) + updateCursorPosition() + showSuggestions.value = true // Mantener sugerencias visibles al escribir + } + + const handleFocus = () => { + if (!isInlineMode.value && !isExpanded.value) { + isExpanded.value = true + isInputFocused.value = true + showSuggestions.value = true + lockScroll() + } else { + isInputFocused.value = true + showSuggestions.value = true + } + + // Forzar foco inmediatamente + if (input.value) { + input.value.focus() + input.value.click() // Simular click + + requestAnimationFrame(() => { + if (input.value) { + input.value.focus() + + setTimeout(() => { + input.value?.focus() + }, 1) + } + }) + } + } + + const closeSearch = () => { + if (!isInlineMode.value) { + isExpanded.value = false + isInputFocused.value = false + showSuggestions.value = false + unlockScroll() + } else { + isInputFocused.value = false + showSuggestions.value = false + } + } + + const handleBlur = (event: FocusEvent) => { + const dialogContent = document.querySelector('.search-dialog-content') + if (dialogContent?.contains(event.relatedTarget as Node)) { + return + } + + if (!event.relatedTarget || !dialogContent?.contains(event.relatedTarget as Node)) { + closeSearch() + } + } + + const handleClose = () => { + closeSearch() + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter') { + emit('search') + closeSearch() + } else if (event.key === 'Escape' && isExpanded.value) { + closeSearch() + } + updateCursorPosition() + } + + const handleSuggestionClick = (suggestion: string) => { + const currentValue = searchInput.value + const space = currentValue && !currentValue.endsWith(' ') ? ' ' : '' + searchInput.value = currentValue + space + suggestion + emit('update:modelValue', searchInput.value) + input.value?.focus() + updateCursorPosition() + showSuggestions.value = true + } + + const removeQualifier = (qualifierToRemove: string, valueToRemove: string) => { + const tokens = searchTokens.value + let newSearchText = '' + let skipNext = false + let needsSpace = false + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i] + + // Si este token debe ser saltado + if (skipNext) { + skipNext = false + continue + } + + // Si es el qualifier que queremos remover + if (token.type === 'qualifier' && + token.qualifier + ':' === qualifierToRemove && + token.value === valueToRemove) { + // Si hay un espacio después, marcarlo para saltar + if (i + 1 < tokens.length && tokens[i + 1].type === 'space') { + skipNext = true + } + // Si hay un espacio antes, no añadir espacio extra + if (i > 0 && tokens[i - 1].type === 'space') { + needsSpace = false + } + continue + } + + // Añadir espacio si es necesario + if (needsSpace && token.type !== 'space') { + newSearchText += ' ' + } + + // Añadir el token actual + if (token.type === 'qualifier') { + newSearchText += token.qualifier + ':' + token.value + needsSpace = true + } else if (token.type === 'text') { + newSearchText += token.value + needsSpace = true + } else if (token.type === 'space') { + needsSpace = false + } + } + + searchInput.value = newSearchText.trim() + emit('update:modelValue', searchInput.value) + nextTick(() => { + input.value?.focus() + updateCursorPosition() + }) + } + + // Escuchar la tecla '/' directamente aquí para abrir/modal y forzar foco + const handleGlobalShortcut = (event: KeyboardEvent) => { + if ( + event.key === '/' && + !['INPUT', 'TEXTAREA'].includes((event.target as HTMLElement).tagName) + ) { + event.preventDefault() + isInputFocused.value = true + + // Si está en modo modal y cerrado, abrirlo + if (!isInlineMode.value && !isExpanded.value) { + isExpanded.value = true + lockScroll() + } + // Forzar foco en el input + if (input.value) { + input.value.focus() + requestAnimationFrame(() => { + input.value?.focus() + setTimeout(() => { + input.value?.focus() + }, 1) + }) + } + } + } + + onMounted(() => { + window.addEventListener('keydown', handleGlobalShortcut) + }) + + onUnmounted(() => { + window.removeEventListener('keydown', handleGlobalShortcut) + }) + + return { + searchInput, + showSuggestions, + isInputFocused, + isExpanded, + isInlineMode, + searchTokens, // Exportar searchTokens + handleInput, + handleFocus, + closeSearch, + handleBlur, + handleClose, + handleKeyDown, + handleSuggestionClick, + removeQualifier + } +} diff --git a/components/search/composables/useSearchParser.ts b/components/search/composables/useSearchParser.ts new file mode 100644 index 0000000..ddb58c5 --- /dev/null +++ b/components/search/composables/useSearchParser.ts @@ -0,0 +1,123 @@ +import type { ColoredPart, SearchToken } from '~/components/search/search' +import { QUALIFIERS } from './useQualifiers' + +export function useSearchParser() { + const parseSearchTokens = (value: string): SearchToken[] => { + const tokens: SearchToken[] = [] + let i = 0 + + while (i < value.length) { + // Check for qualifiers + const qualifierMatch = Object.keys(QUALIFIERS).find(q => + value.slice(i).toLowerCase().startsWith(q.toLowerCase()) + ) + + if (qualifierMatch) { + // Move past the qualifier + i += qualifierMatch.length + + // Collect the value after the qualifier + let qualifierValue = '' + while (i < value.length && value[i] !== ' ') { + qualifierValue += value[i] + i++ + } + + tokens.push({ + type: 'qualifier', + qualifier: qualifierMatch.slice(0, -1), // Remove trailing colon + value: qualifierValue + }) + + // Add space if there is one + if (i < value.length && value[i] === ' ') { + tokens.push({ type: 'space', value: ' ' }) + i++ + } + } else { + // Handle regular text + if (value[i] === ' ') { + tokens.push({ type: 'space', value: ' ' }) + i++ + } else { + let text = '' + while (i < value.length && + !Object.keys(QUALIFIERS).some(q => value.slice(i).toLowerCase().startsWith(q.toLowerCase())) && + value[i] !== ' ') { + text += value[i] + i++ + } + if (text) { + tokens.push({ type: 'text', value: text }) + } + } + } + } + + return tokens + } + + const colorText = (text: string): ColoredPart[] => { + const parts: ColoredPart[] = [] + let currentPosition = 0 + + while (currentPosition < text.length) { + let matched = false + + for (const qualifier of Object.keys(QUALIFIERS)) { + if (text.slice(currentPosition).toLowerCase().startsWith(qualifier.toLowerCase())) { + parts.push({ + text: qualifier, + type: 'qualifier' + }) + + currentPosition += qualifier.length + + let valueStart = currentPosition + while (currentPosition < text.length && text[currentPosition] !== ' ') { + currentPosition++ + } + + if (currentPosition > valueStart) { + parts.push({ + text: text.slice(valueStart, currentPosition), + type: 'value' + }) + } + + matched = true + break + } + } + + if (!matched) { + let normalText = '' + while (currentPosition < text.length) { + let isQualifier = false + for (const qualifier of Object.keys(QUALIFIERS)) { + if (text.slice(currentPosition).toLowerCase().startsWith(qualifier.toLowerCase())) { + isQualifier = true + break + } + } + if (isQualifier) break + normalText += text[currentPosition] + currentPosition++ + } + if (normalText) { + parts.push({ + text: normalText, + type: 'normal' + }) + } + } + } + + return parts + } + + return { + parseSearchTokens, + colorText + } +} diff --git a/components/search/search.ts b/components/search/search.ts new file mode 100644 index 0000000..ccc4f7d --- /dev/null +++ b/components/search/search.ts @@ -0,0 +1,25 @@ +export interface SearchToken { + type: 'qualifier' | 'text' | 'space'; + value: string; + qualifier?: string; +} + +export interface QualifierInfo { + label: string; + icon: string; +} + +export interface QualifierData { + [key: string]: QualifierInfo; +} + +export interface SuggestionItem { + qualifier: string; + isUsed: boolean; + value?: string; +} + +export interface ColoredPart { + text: string; + type: 'qualifier' | 'value' | 'normal'; +} diff --git a/components/search/types.ts b/components/search/types.ts new file mode 100644 index 0000000..7ad42b7 --- /dev/null +++ b/components/search/types.ts @@ -0,0 +1,17 @@ +import type { ComputedRef, Ref } from 'vue' + +export interface SearchInputFieldProps { + searchValue: string + highlightedHTML: string | ComputedRef + handleInput: (e: Event) => void + handleFocus: () => void + handleBlur: (e: FocusEvent) => void + handleKeyDown: (e: KeyboardEvent) => void + handleInputScroll: (e: Event) => void + input: any +} + +export interface SearchInputProps { + modelValue: string + mode?: 'inline' | 'modal' +} diff --git a/components/ui/command/Command.vue b/components/ui/command/Command.vue new file mode 100644 index 0000000..9597010 --- /dev/null +++ b/components/ui/command/Command.vue @@ -0,0 +1,92 @@ + + + + + + + diff --git a/components/ui/command/CommandDialog.vue b/components/ui/command/CommandDialog.vue new file mode 100644 index 0000000..b7b0a91 --- /dev/null +++ b/components/ui/command/CommandDialog.vue @@ -0,0 +1,21 @@ + + + + + + + + + + + diff --git a/components/ui/command/CommandEmpty.vue b/components/ui/command/CommandEmpty.vue new file mode 100644 index 0000000..58795f2 --- /dev/null +++ b/components/ui/command/CommandEmpty.vue @@ -0,0 +1,25 @@ + + + + + + + diff --git a/components/ui/command/CommandGroup.vue b/components/ui/command/CommandGroup.vue new file mode 100644 index 0000000..fd4f5b3 --- /dev/null +++ b/components/ui/command/CommandGroup.vue @@ -0,0 +1,46 @@ + + + + + + {{ heading }} + + + + diff --git a/components/ui/command/CommandInput.vue b/components/ui/command/CommandInput.vue new file mode 100644 index 0000000..4c3b963 --- /dev/null +++ b/components/ui/command/CommandInput.vue @@ -0,0 +1,37 @@ + + + + + + + + diff --git a/components/ui/command/CommandItem.vue b/components/ui/command/CommandItem.vue new file mode 100644 index 0000000..97365e1 --- /dev/null +++ b/components/ui/command/CommandItem.vue @@ -0,0 +1,78 @@ + + + + { + filterState.search = '' + }" + > + + + diff --git a/components/ui/command/CommandList.vue b/components/ui/command/CommandList.vue new file mode 100644 index 0000000..83c7d95 --- /dev/null +++ b/components/ui/command/CommandList.vue @@ -0,0 +1,24 @@ + + + + + + + + + diff --git a/components/ui/command/CommandSeparator.vue b/components/ui/command/CommandSeparator.vue new file mode 100644 index 0000000..4122020 --- /dev/null +++ b/components/ui/command/CommandSeparator.vue @@ -0,0 +1,23 @@ + + + + + + + diff --git a/components/ui/command/CommandShortcut.vue b/components/ui/command/CommandShortcut.vue new file mode 100644 index 0000000..0d4da92 --- /dev/null +++ b/components/ui/command/CommandShortcut.vue @@ -0,0 +1,14 @@ + + + + + + + diff --git a/components/ui/command/index.ts b/components/ui/command/index.ts new file mode 100644 index 0000000..cb48e1e --- /dev/null +++ b/components/ui/command/index.ts @@ -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> + allGroups: Ref>> + filterState: { + search: string + filtered: { count: number, items: Map, groups: Set } + } +}>('Command') + +export const [useCommandGroup, provideCommandGroupContext] = createContext<{ + id?: string +}>('CommandGroup') diff --git a/components/ui/dialog/Dialog.vue b/components/ui/dialog/Dialog.vue index a04c026..9fc9c7d 100644 --- a/components/ui/dialog/Dialog.vue +++ b/components/ui/dialog/Dialog.vue @@ -1,5 +1,5 @@ diff --git a/components/ui/dialog/DialogContent.vue b/components/ui/dialog/DialogContent.vue index c9fe803..be00c5e 100644 --- a/components/ui/dialog/DialogContent.vue +++ b/components/ui/dialog/DialogContent.vue @@ -1,6 +1,6 @@ diff --git a/components/ui/search/FilterInput.vue b/components/ui/search/FilterInput.vue new file mode 100644 index 0000000..f715e26 --- /dev/null +++ b/components/ui/search/FilterInput.vue @@ -0,0 +1,170 @@ + + + + + + + + + + + + + {{ part.prefix }} + {{ part.value }} + + {{ part.text }} + + + + + + + + + + + + + + + diff --git a/components/ui/search/FilterSuggestions.vue b/components/ui/search/FilterSuggestions.vue new file mode 100644 index 0000000..cdef2aa --- /dev/null +++ b/components/ui/search/FilterSuggestions.vue @@ -0,0 +1,110 @@ + + + + + + + Active filters + + + + + {{ filter.prefix }} + {{ filter.value }} + + + + + + + + + + + Available filters + + + + {{ item.prefix }} + + {{ item.label }} + + + + + + diff --git a/components/ui/search/KeyboardShortcut.vue b/components/ui/search/KeyboardShortcut.vue new file mode 100644 index 0000000..bb02cb0 --- /dev/null +++ b/components/ui/search/KeyboardShortcut.vue @@ -0,0 +1,11 @@ + + + + + {{ shortcut }} + + diff --git a/components/ui/search/composables/useFilterSuggestions.ts b/components/ui/search/composables/useFilterSuggestions.ts new file mode 100644 index 0000000..c1e9753 --- /dev/null +++ b/components/ui/search/composables/useFilterSuggestions.ts @@ -0,0 +1,54 @@ +import { ref } from 'vue' + +export interface FilterSuggestion { + prefix: string + label: string + icon: string +} + +export const useFilterSuggestions = () => { + const suggestions = ref([ + { prefix: 'repo:', label: 'Repository (user/repo)', icon: 'octicon:repo-16' }, + { prefix: 'user:', label: 'User', icon: 'octicon:person-16' }, + { prefix: 'org:', label: 'Organization', icon: 'octicon:organization-16' }, + { prefix: 'in:', label: 'Search in', icon: 'octicon:search-16' }, + { prefix: 'size:', label: 'Size', icon: 'octicon:file-16' }, + { prefix: 'stars:', label: 'Stars', icon: 'octicon:star-16' }, + { prefix: 'language:', label: 'Language', icon: 'octicon:code-16' }, + { prefix: 'created:', label: 'Created date', icon: 'octicon:calendar-16' }, + { prefix: 'pushed:', label: 'Push date', icon: 'octicon:git-commit-16' }, + { prefix: 'topic:', label: 'Topic', icon: 'octicon:hash-16' }, + { prefix: 'is:', label: 'State', icon: 'octicon:circle-16' }, + { prefix: 'fork:', label: 'Fork', icon: 'octicon:repo-forked-16' } + ]) + + const usedFilters = ref([]) + + const addUsedFilter = (filter: string) => { + if (!usedFilters.value.includes(filter)) { + usedFilters.value.unshift(filter) + if (usedFilters.value.length > 5) usedFilters.value.pop() + } + } + + const getMatchingSuggestions = (query: string): FilterSuggestion[] => { + const lastWord = query.split(' ').pop()?.toLowerCase() || '' + if (!lastWord) return suggestions.value + return suggestions.value.filter(s => + s.prefix.toLowerCase().includes(lastWord) || + s.label.toLowerCase().includes(lastWord) + ) + } + + const isValidFilter = (text: string): boolean => { + return suggestions.value.some(s => text.startsWith(s.prefix)) + } + + return { + suggestions, + usedFilters, + addUsedFilter, + getMatchingSuggestions, + isValidFilter + } as const +} diff --git a/components/ui/search/index.ts b/components/ui/search/index.ts new file mode 100644 index 0000000..60f2b14 --- /dev/null +++ b/components/ui/search/index.ts @@ -0,0 +1 @@ +export { default as FilterInput } from '~/components/ui/search/FilterInput.vue'; \ No newline at end of file diff --git a/lib/utils.ts b/lib/utils.ts index d32b0fe..4c51992 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,6 +1,15 @@ +import type { Updater } from '@tanstack/vue-table' +import type { Ref } from 'vue' import { type ClassValue, clsx } from 'clsx' import { twMerge } from 'tailwind-merge' export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } + +export function valueUpdater>(updaterOrValue: T, ref: Ref) { + ref.value + = typeof updaterOrValue === 'function' + ? updaterOrValue(ref.value) + : updaterOrValue +} diff --git a/nuxt.config.ts b/nuxt.config.ts index c1af008..db2a048 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -14,7 +14,8 @@ export default defineNuxtConfig({ '@nuxtjs/robots', 'shadcn-nuxt', '@nuxt/icon', - '@nuxtjs/seo' + '@nuxtjs/seo', + 'nuxt-schema-org' ], tailwindcss: { cssPath: '~/assets/css/tailwind.css', @@ -67,10 +68,11 @@ export default defineNuxtConfig({ identity: { type: 'Organization', name: 'GitHub Open Source Explorer', + url: 'https://github-explorer.nuxt.dev', logo: 'https://github-explorer.nuxt.dev/logo.png' }, - host: process.env.NUXT_PUBLIC_SITE_URL || 'https://github-explorer.nuxt.dev', - includeHomeInBreadcrumb: false + host: 'https://github-explorer.nuxt.dev', + canonicalHost: 'https://github-explorer.nuxt.dev' }, routeRules: { // Página principal diff --git a/package.json b/package.json index 1d04aed..85d4e75 100644 --- a/package.json +++ b/package.json @@ -14,12 +14,16 @@ "@nuxthub/core": "0.8.17", "@octokit/rest": "^21.1.0", "@radix-icons/vue": "^1.0.0", + "@tanstack/vue-table": "^8.21.2", "@vueuse/core": "^12.5.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "lucide-vue-next": "^0.475.0", "nuxt": "^3.15.4", + "nuxt-schema-org": "4.1.3", "pinia": "^3.0.0", "radix-vue": "^1.9.13", + "reka-ui": "^2.0.0", "tailwind-merge": "^3.0.1", "tailwindcss-animate": "^1.0.7", "vite": ">=6.0", diff --git a/pages/index.vue b/pages/index.vue index 85d1272..aef62fc 100644 --- a/pages/index.vue +++ b/pages/index.vue @@ -1,8 +1,8 @@ - - + + + @@ -174,21 +166,18 @@ const clearFilter = (key: keyof Filters) => { - - - + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c098fd9..d2c4385 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@radix-icons/vue': specifier: ^1.0.0 version: 1.0.0(vue@3.5.13(typescript@5.7.3)) + '@tanstack/vue-table': + specifier: ^8.21.2 + version: 8.21.2(vue@3.5.13(typescript@5.7.3)) '@vueuse/core': specifier: ^12.5.0 version: 12.5.0(typescript@5.7.3) @@ -29,15 +32,24 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + lucide-vue-next: + specifier: ^0.475.0 + version: 0.475.0(vue@3.5.13(typescript@5.7.3)) nuxt: specifier: ^3.15.4 version: 3.15.4(@parcel/watcher@2.5.1)(@types/node@22.13.1)(db0@0.2.4)(ioredis@5.5.0)(magicast@0.3.5)(rollup@4.34.6)(terser@5.38.2)(typescript@5.7.3)(vite@6.1.0(@types/node@22.13.1)(jiti@2.4.2)(terser@5.38.2)(yaml@2.7.0))(yaml@2.7.0) + nuxt-schema-org: + specifier: 4.1.3 + version: 4.1.3(@unhead/vue@1.11.18(vue@3.5.13(typescript@5.7.3)))(magicast@0.3.5)(unhead@1.11.18)(vue@3.5.13(typescript@5.7.3)) pinia: specifier: ^3.0.0 version: 3.0.1(typescript@5.7.3)(vue@3.5.13(typescript@5.7.3)) radix-vue: specifier: ^1.9.13 version: 1.9.13(vue@3.5.13(typescript@5.7.3)) + reka-ui: + specifier: ^2.0.0 + version: 2.0.0(typescript@5.7.3)(vue@3.5.13(typescript@5.7.3)) tailwind-merge: specifier: ^3.0.1 version: 3.0.1 @@ -1370,9 +1382,19 @@ packages: peerDependencies: tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' + '@tanstack/table-core@8.21.2': + resolution: {integrity: sha512-uvXk/U4cBiFMxt+p9/G7yUWI/UbHYbyghLCjlpWZ3mLeIZiUBSKcUnw9UnKkdRz7Z/N4UBuFLWQdJCjUe7HjvA==} + engines: {node: '>=12'} + '@tanstack/virtual-core@3.13.0': resolution: {integrity: sha512-NBKJP3OIdmZY3COJdWkSonr50FMVIi+aj5ZJ7hI/DTpEKg2RMfo/KvP8A3B/zOSpMgIe52B5E2yn7rryULzA6g==} + '@tanstack/vue-table@8.21.2': + resolution: {integrity: sha512-KBgOWxha/x4m1EdhVWxOpqHb661UjqAxzPcmXR3QiA7aShZ547x19Gw0UJX9we+m+tVcPuLRZ61JsYW47QZFfQ==} + engines: {node: '>=12'} + peerDependencies: + vue: '>=3.2' + '@tanstack/vue-virtual@3.13.0': resolution: {integrity: sha512-EPgcTc41KGJAK2N2Ux2PeUnG3cPpdkldTib05nwq+0zdS2Ihpbq8BsWXz/eXPyNc5noDBh1GBgAe36yMYiW6WA==} peerDependencies: @@ -1409,8 +1431,8 @@ packages: '@unhead/dom@1.11.18': resolution: {integrity: sha512-zQuJUw/et9zYEV0SZWTDX23IgurwMaXycAuxt4L6OgNL0T4TWP3a0J/Vm3Q02hmdNo/cPKeVBrwBdnFUXjGU4w==} - '@unhead/schema-org@1.11.18': - resolution: {integrity: sha512-0gtolLQh9JEyD52+5Rp6zGppf/ejDDCKasFfoVN9huDe2PtZ44A2MtszjhOJWCqMCMpplWdVKuvnxp87n4+Y1g==} + '@unhead/schema-org@1.11.19': + resolution: {integrity: sha512-1gSEhNGKXLhLbL6Gri3YfWQiEQM+DaGlF4987eIfkTUxlPVwtr5IfDmpbY7TbJaexQlC14p0CfYuPo3RLQlcHQ==} peerDependencies: '@unhead/vue': ^1.11.14 unhead: ^1.11.14 @@ -1421,9 +1443,15 @@ packages: '@unhead/schema@1.11.18': resolution: {integrity: sha512-a3TA/OJCRdfbFhcA3Hq24k1ZU1o9szicESrw8DZcGyQFacHnh84mVgnyqSkMnwgCmfN4kvjSiTBlLEHS6+wATw==} + '@unhead/schema@1.11.19': + resolution: {integrity: sha512-7VhYHWK7xHgljdv+C01MepCSYZO2v6OhgsfKWPxRQBDDGfUKCUaChox0XMq3tFvXP6u4zSp6yzcDw2yxCfVMwg==} + '@unhead/shared@1.11.18': resolution: {integrity: sha512-OsupRQRxJqqnuKiL1Guqipjbl7MndD5DofvmGa3PFGu2qNPmOmH2mxGFjRBBgq2XxY1KalIHl/2I9HV6gbK8cw==} + '@unhead/shared@1.11.19': + resolution: {integrity: sha512-UYE9EIeQLJOhx8vC71bWGkAGY4Zzq/H8qYlihowUg4NiFOfL+KKMnj96datb74PRxSDvHac9V3OLktNcsX2NuA==} + '@unhead/ssr@1.11.18': resolution: {integrity: sha512-uaHPz0RRAb18yKeCmHyHk5QKWRk/uHpOrqSbhRXTOhbrd3Ur3gGTVaAoyUoRYKGPU5B5/pyHh3TfLw0LkfrH1A==} @@ -2876,6 +2904,11 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lucide-vue-next@0.475.0: + resolution: {integrity: sha512-xzotVZV7en58Gm3b60YHlxX2YGlFWSN4D5VMMhU/rC9D/pkeRqve21TPMJdHQsdqFdoRthnaB2HrzL0PQXdNeA==} + peerDependencies: + vue: '>=3.0.1' + magic-string-ast@0.7.0: resolution: {integrity: sha512-686fgAHaJY7wLTFEq7nnKqeQrhqmXB19d1HnqT35Ci7BN6hbAYLZUezTQ062uUHM7ggZEQlqJ94Ftls+KDXU8Q==} engines: {node: '>=16.14.0'} @@ -3122,8 +3155,8 @@ packages: resolution: {integrity: sha512-TeBsI9Ic/ETD4fTpycKW9lYp5Q2hd5ozE5Bt22opTsIuqzSx20ZKzonj0yiHRgShMhGE+YMZhFzk7YvDaNNWGA==} engines: {node: '>=18.0.0'} - nuxt-schema-org@4.1.1: - resolution: {integrity: sha512-2/Nhoh07ZfnwiIDUt5qCGUraqnBf0Pa2UhO7Vf/3U48ayNuq+VepTyFar15EBWLp1hysAEiUSkGAJiJAgqwCxw==} + nuxt-schema-org@4.1.3: + resolution: {integrity: sha512-xUhWCKlWjp6J5ktmmdz4eYPl2fifmqQtVXWharnKXLRATH5z2j4ayA1taZXUuoOZLlKAXIP1HUEm23ZCI+/DhA==} nuxt-seo-utils@6.0.8: resolution: {integrity: sha512-Gx2zqLpHBU5KZM8CZ91V/JxCHXR4sRlRwoPtRfWKqxPH+a35abe2l4YaIBz8YOwL23t9Yo3ww1+zUleLUExYMA==} @@ -3131,9 +3164,15 @@ packages: nuxt-site-config-kit@3.0.6: resolution: {integrity: sha512-QBOFzAIo+D02avFQQ7gNlRyA372/PQlgW2IjL2nttvjfHapqYbvP6tIP55soL+1+D8YvF/bGZgS+vJtfQhroUg==} + nuxt-site-config-kit@3.1.1: + resolution: {integrity: sha512-WA29fs1RnN1vXLEIplLndequS4uSX4Bm5WvhjutnZaL92mtWWblwbF3lbbXY9zNsUDzGlmmrltlbV4dJMVkqrQ==} + nuxt-site-config@3.0.6: resolution: {integrity: sha512-Mkyen81br21/nA2sxlLCOtJZ2L8sGL+YzxHlsVhLhEnC355CP2SwKVtYqJNJ4aYFbxeusqZXJFiD4KbveHhk0w==} + nuxt-site-config@3.1.1: + resolution: {integrity: sha512-dyR0bceTGJPNTrfWVoEoltiveEU2sKUhQXoawkavtmIgUA0nbMXxuMEAXZaCx8xYvfsmSlStfipGlwelpl9faA==} + nuxt@3.15.4: resolution: {integrity: sha512-hSbZO4mR0uAMJtZPNTnCfiAtgleoOu28gvJcBNU7KQHgWnNXPjlWgwMczko2O4Tmnv9zIe/CQged+2HsPwl2ZA==} engines: {node: ^18.20.5 || ^20.9.0 || >=22.0.0} @@ -3635,6 +3674,11 @@ packages: resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} engines: {node: '>=4'} + reka-ui@2.0.0: + resolution: {integrity: sha512-/1amDDvTnDIkSFbTZnUkiFWkxlc/UEe0pZsgvz3nDI15R9+yspQv/gIbM62Mo9LALK6A76NsgMNlXie04kv7PA==} + peerDependencies: + vue: '>= 3.2.0' + replace-in-file@6.3.5: resolution: {integrity: sha512-arB9d3ENdKva2fxRnSjwBEXfK1npgyci7ZZuwysgAp7ORjHSyxz6oqIjTEv8R0Ydl4Ll7uOAZXL4vbkhGIizCg==} engines: {node: '>=10'} @@ -3795,6 +3839,10 @@ packages: resolution: {integrity: sha512-BPwJGUeDaDCHihkORDchNyyTvWFhcusy1XMmhEVTQTwGeybFbp8YEmB+njbPnth1FibULBSBVwCQni25XlCUDg==} engines: {node: '>=18'} + sirv@3.0.1: + resolution: {integrity: sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==} + engines: {node: '>=18'} + sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} @@ -3803,6 +3851,11 @@ packages: peerDependencies: vue: ^3 + site-config-stack@3.1.1: + resolution: {integrity: sha512-hIDGCIsfoOLjni4yy7EC4LpPMF5+Le3PvQ9/YCi/l4F+g7OKNE1ksemnQMsA8gDJ+duFJFWL4k2fO9jSzz3aZw==} + peerDependencies: + vue: ^3 + slash@5.1.0: resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} engines: {node: '>=14.16'} @@ -5528,7 +5581,7 @@ snapshots: '@nuxtjs/sitemap': 7.2.4(h3@1.15.0)(magicast@0.3.5)(vite@6.1.0(@types/node@22.13.1)(jiti@2.4.2)(terser@5.38.2)(yaml@2.7.0))(vue@3.5.13(typescript@5.7.3)) nuxt-link-checker: 4.1.0(magicast@0.3.5)(typescript@5.7.3)(vite@6.1.0(@types/node@22.13.1)(jiti@2.4.2)(terser@5.38.2)(yaml@2.7.0))(vue@3.5.13(typescript@5.7.3)) nuxt-og-image: 4.1.2(magicast@0.3.5)(vite@6.1.0(@types/node@22.13.1)(jiti@2.4.2)(terser@5.38.2)(yaml@2.7.0))(vue@3.5.13(typescript@5.7.3)) - nuxt-schema-org: 4.1.1(@unhead/vue@1.11.18(vue@3.5.13(typescript@5.7.3)))(magicast@0.3.5)(unhead@1.11.18)(vite@6.1.0(@types/node@22.13.1)(jiti@2.4.2)(terser@5.38.2)(yaml@2.7.0))(vue@3.5.13(typescript@5.7.3)) + nuxt-schema-org: 4.1.3(@unhead/vue@1.11.18(vue@3.5.13(typescript@5.7.3)))(magicast@0.3.5)(unhead@1.11.18)(vue@3.5.13(typescript@5.7.3)) nuxt-seo-utils: 6.0.8(magicast@0.3.5)(rollup@4.34.6)(vite@6.1.0(@types/node@22.13.1)(jiti@2.4.2)(terser@5.38.2)(yaml@2.7.0))(vue@3.5.13(typescript@5.7.3)) nuxt-site-config: 3.0.6(magicast@0.3.5)(vite@6.1.0(@types/node@22.13.1)(jiti@2.4.2)(terser@5.38.2)(yaml@2.7.0))(vue@3.5.13(typescript@5.7.3)) transitivePeerDependencies: @@ -5955,8 +6008,15 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 3.4.17 + '@tanstack/table-core@8.21.2': {} + '@tanstack/virtual-core@3.13.0': {} + '@tanstack/vue-table@8.21.2(vue@3.5.13(typescript@5.7.3))': + dependencies: + '@tanstack/table-core': 8.21.2 + vue: 3.5.13(typescript@5.7.3) + '@tanstack/vue-virtual@3.13.0(vue@3.5.13(typescript@5.7.3))': dependencies: '@tanstack/virtual-core': 3.13.0 @@ -6001,10 +6061,10 @@ snapshots: '@unhead/schema': 1.11.18 '@unhead/shared': 1.11.18 - '@unhead/schema-org@1.11.18(@unhead/vue@1.11.18(vue@3.5.13(typescript@5.7.3)))(unhead@1.11.18)': + '@unhead/schema-org@1.11.19(@unhead/vue@1.11.18(vue@3.5.13(typescript@5.7.3)))(unhead@1.11.18)': dependencies: - '@unhead/schema': 1.11.18 - '@unhead/shared': 1.11.18 + '@unhead/schema': 1.11.19 + '@unhead/shared': 1.11.19 defu: 6.1.4 ohash: 1.1.4 ufo: 1.5.4 @@ -6017,11 +6077,21 @@ snapshots: hookable: 5.5.3 zhead: 2.2.4 + '@unhead/schema@1.11.19': + dependencies: + hookable: 5.5.3 + zhead: 2.2.4 + '@unhead/shared@1.11.18': dependencies: '@unhead/schema': 1.11.18 packrup: 0.1.2 + '@unhead/shared@1.11.19': + dependencies: + '@unhead/schema': 1.11.19 + packrup: 0.1.2 + '@unhead/ssr@1.11.18': dependencies: '@unhead/schema': 1.11.18 @@ -7645,6 +7715,10 @@ snapshots: dependencies: yallist: 3.1.1 + lucide-vue-next@0.475.0(vue@3.5.13(typescript@5.7.3)): + dependencies: + vue: 3.5.13(typescript@5.7.3) + magic-string-ast@0.7.0: dependencies: magic-string: 0.30.17 @@ -7993,22 +8067,20 @@ snapshots: - vite - vue - nuxt-schema-org@4.1.1(@unhead/vue@1.11.18(vue@3.5.13(typescript@5.7.3)))(magicast@0.3.5)(unhead@1.11.18)(vite@6.1.0(@types/node@22.13.1)(jiti@2.4.2)(terser@5.38.2)(yaml@2.7.0))(vue@3.5.13(typescript@5.7.3)): + nuxt-schema-org@4.1.3(@unhead/vue@1.11.18(vue@3.5.13(typescript@5.7.3)))(magicast@0.3.5)(unhead@1.11.18)(vue@3.5.13(typescript@5.7.3)): dependencies: - '@nuxt/devtools-kit': 2.0.0(magicast@0.3.5)(vite@6.1.0(@types/node@22.13.1)(jiti@2.4.2)(terser@5.38.2)(yaml@2.7.0)) '@nuxt/kit': 3.15.4(magicast@0.3.5) - '@unhead/schema-org': 1.11.18(@unhead/vue@1.11.18(vue@3.5.13(typescript@5.7.3)))(unhead@1.11.18) + '@unhead/schema-org': 1.11.19(@unhead/vue@1.11.18(vue@3.5.13(typescript@5.7.3)))(unhead@1.11.18) defu: 6.1.4 - nuxt-site-config: 3.0.6(magicast@0.3.5)(vite@6.1.0(@types/node@22.13.1)(jiti@2.4.2)(terser@5.38.2)(yaml@2.7.0))(vue@3.5.13(typescript@5.7.3)) + nuxt-site-config: 3.1.1(magicast@0.3.5)(vue@3.5.13(typescript@5.7.3)) pathe: 2.0.3 pkg-types: 1.3.1 - sirv: 3.0.0 + sirv: 3.0.1 transitivePeerDependencies: - '@unhead/vue' - magicast - supports-color - unhead - - vite - vue nuxt-seo-utils@6.0.8(magicast@0.3.5)(rollup@4.34.6)(vite@6.1.0(@types/node@22.13.1)(jiti@2.4.2)(terser@5.38.2)(yaml@2.7.0))(vue@3.5.13(typescript@5.7.3)): @@ -8045,6 +8117,18 @@ snapshots: - supports-color - vue + nuxt-site-config-kit@3.1.1(magicast@0.3.5)(vue@3.5.13(typescript@5.7.3)): + dependencies: + '@nuxt/kit': 3.15.4(magicast@0.3.5) + pkg-types: 1.3.1 + site-config-stack: 3.1.1(vue@3.5.13(typescript@5.7.3)) + std-env: 3.8.0 + ufo: 1.5.4 + transitivePeerDependencies: + - magicast + - supports-color + - vue + nuxt-site-config@3.0.6(magicast@0.3.5)(vite@6.1.0(@types/node@22.13.1)(jiti@2.4.2)(terser@5.38.2)(yaml@2.7.0))(vue@3.5.13(typescript@5.7.3)): dependencies: '@nuxt/devtools-kit': 1.7.0(magicast@0.3.5)(vite@6.1.0(@types/node@22.13.1)(jiti@2.4.2)(terser@5.38.2)(yaml@2.7.0)) @@ -8062,6 +8146,20 @@ snapshots: - vite - vue + nuxt-site-config@3.1.1(magicast@0.3.5)(vue@3.5.13(typescript@5.7.3)): + dependencies: + '@nuxt/kit': 3.15.4(magicast@0.3.5) + nuxt-site-config-kit: 3.1.1(magicast@0.3.5)(vue@3.5.13(typescript@5.7.3)) + pathe: 2.0.3 + pkg-types: 1.3.1 + sirv: 3.0.1 + site-config-stack: 3.1.1(vue@3.5.13(typescript@5.7.3)) + ufo: 1.5.4 + transitivePeerDependencies: + - magicast + - supports-color + - vue + nuxt@3.15.4(@parcel/watcher@2.5.1)(@types/node@22.13.1)(db0@0.2.4)(ioredis@5.5.0)(magicast@0.3.5)(rollup@4.34.6)(terser@5.38.2)(typescript@5.7.3)(vite@6.1.0(@types/node@22.13.1)(jiti@2.4.2)(terser@5.38.2)(yaml@2.7.0))(yaml@2.7.0): dependencies: '@nuxt/cli': 3.21.1(magicast@0.3.5) @@ -8673,6 +8771,23 @@ snapshots: dependencies: redis-errors: 1.2.0 + reka-ui@2.0.0(typescript@5.7.3)(vue@3.5.13(typescript@5.7.3)): + dependencies: + '@floating-ui/dom': 1.6.13 + '@floating-ui/vue': 1.1.6(vue@3.5.13(typescript@5.7.3)) + '@internationalized/date': 3.7.0 + '@internationalized/number': 3.6.0 + '@tanstack/vue-virtual': 3.13.0(vue@3.5.13(typescript@5.7.3)) + '@vueuse/core': 12.5.0(typescript@5.7.3) + '@vueuse/shared': 12.5.0(typescript@5.7.3) + aria-hidden: 1.2.4 + defu: 6.1.4 + ohash: 1.1.4 + vue: 3.5.13(typescript@5.7.3) + transitivePeerDependencies: + - '@vue/composition-api' + - typescript + replace-in-file@6.3.5: dependencies: chalk: 4.1.2 @@ -8900,6 +9015,12 @@ snapshots: mrmime: 2.0.0 totalist: 3.0.1 + sirv@3.0.1: + dependencies: + '@polka/url': 1.0.0-next.28 + mrmime: 2.0.0 + totalist: 3.0.1 + sisteransi@1.0.5: {} site-config-stack@3.0.6(vue@3.5.13(typescript@5.7.3)): @@ -8907,6 +9028,11 @@ snapshots: ufo: 1.5.4 vue: 3.5.13(typescript@5.7.3) + site-config-stack@3.1.1(vue@3.5.13(typescript@5.7.3)): + dependencies: + ufo: 1.5.4 + vue: 3.5.13(typescript@5.7.3) + slash@5.1.0: {} smob@1.5.0: {} diff --git a/public/logo.png b/public/logo.png new file mode 100644 index 0000000..a484bd7 Binary files /dev/null and b/public/logo.png differ diff --git a/services/github.ts b/services/github.ts index cee3a2b..1fdbc4d 100644 --- a/services/github.ts +++ b/services/github.ts @@ -28,34 +28,47 @@ export class GitHubService { }): string { const conditions: string[] = [] - if (params.minStars && params.minStars > 0) { - conditions.push(`stars:>=${params.minStars}`) - } else { - conditions.push('stars:>100') // filtro por defecto + if (params.query?.trim()) { + // Validar y formatear repo: queries + const formattedQuery = params.query.trim().split(' ').map(part => { + if (part.toLowerCase().startsWith('repo:')) { + const repoValue = part.slice(5) // remover 'repo:' + // Si solo se proporciona un término, tratarlo como una búsqueda general + if (!repoValue.includes('/')) { + return repoValue // quitar el qualifier repo: si no tiene el formato correcto + } + } + return part + }).join(' ') + + conditions.push(formattedQuery) } - if (params.query?.trim()) { - conditions.push(params.query.trim()) + // Solo agregar condiciones si no están ya presentes en el query + const query = params.query?.toLowerCase() || '' + + if (params.minStars && params.minStars > 0 && !query.includes('stars:')) { + conditions.push(`stars:>=${params.minStars}`) } - if (params.language && params.language !== 'all') { + if (params.language && params.language !== 'all' && !query.includes('language:')) { conditions.push(`language:${params.language}`) } - if (params.topics && params.topics.length > 0) { + if (params.topics?.length && !query.includes('topic:')) { params.topics.forEach(topic => { conditions.push(`topic:${topic}`) }) } - if (params.hasTests) { + if (params.hasTests && !query.includes('topic:testing')) { conditions.push('topic:testing') } - if (params.isTemplate) { + + if (params.isTemplate && !query.includes('is:template')) { conditions.push('is:template') } - console.log('Search conditions:', conditions) return conditions.join(' ') } diff --git a/tailwind.config.ts b/tailwind.config.ts index 6fb5006..bf42e7b 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -1,7 +1,7 @@ const animate = require("tailwindcss-animate") export default { - darkMode: 'class', + darkMode: ['class', 'class'], content: [ './components/**/*.{js,vue,ts}', './layouts/**/*.vue', @@ -18,90 +18,121 @@ export default { prefix: "", theme: { - container: { - center: true, - padding: "2rem", - screens: { - "2xl": "1400px", - }, - }, - extend: { - colors: { - border: "hsl(var(--border))", - input: "hsl(var(--input))", - ring: "hsl(var(--ring))", - background: "hsl(var(--background))", - foreground: "hsl(var(--foreground))", - primary: { - DEFAULT: "hsl(var(--primary))", - foreground: "hsl(var(--primary-foreground))", - }, - secondary: { - DEFAULT: "hsl(var(--secondary))", - foreground: "hsl(var(--secondary-foreground))", - }, - destructive: { - DEFAULT: "hsl(var(--destructive))", - foreground: "hsl(var(--destructive-foreground))", - }, - muted: { - DEFAULT: "hsl(var(--muted))", - foreground: "hsl(var(--muted-foreground))", - }, - accent: { - DEFAULT: "hsl(var(--accent))", - foreground: "hsl(var(--accent-foreground))", - }, - popover: { - DEFAULT: "hsl(var(--popover))", - foreground: "hsl(var(--popover-foreground))", - }, - card: { - DEFAULT: "hsl(var(--card))", - foreground: "hsl(var(--card-foreground))", - }, - }, - borderRadius: { - xl: "calc(var(--radius) + 4px)", - lg: "var(--radius)", - md: "calc(var(--radius) - 2px)", - sm: "calc(var(--radius) - 4px)", - }, - keyframes: { - "accordion-down": { - from: { height: 0 }, - to: { height: "var(--radix-accordion-content-height)" }, - }, - "accordion-up": { - from: { height: "var(--radix-accordion-content-height)" }, - to: { height: 0 }, - }, - "collapsible-down": { - from: { height: 0 }, - to: { height: 'var(--radix-collapsible-content-height)' }, - }, - "collapsible-up": { - from: { height: 'var(--radix-collapsible-content-height)' }, - to: { height: 0 }, - }, - "slide-in-from-top": { - "0%": { transform: "translateY(-100%)" }, - "100%": { transform: "translateY(0)" } - }, - "slide-out-to-top": { - "0%": { transform: "translateY(0)" }, - "100%": { transform: "translateY(-100%)" } - } - }, - animation: { - "accordion-down": "accordion-down 0.2s ease-out", - "accordion-up": "accordion-up 0.2s ease-out", - "collapsible-down": "collapsible-down 0.2s ease-in-out", - "collapsible-up": "collapsible-up 0.2s ease-in-out", - "slide-in-from-top": "slide-in-from-top 0.2s ease-out", - "slide-out-to-top": "slide-out-to-top 0.2s ease-out" - }, - }, + container: { + center: true, + padding: '2rem', + screens: { + '2xl': '1400px' + } + }, + extend: { + colors: { + border: 'hsl(var(--border))', + input: 'hsl(var(--input))', + ring: 'hsl(var(--ring))', + background: 'hsl(var(--background))', + foreground: 'hsl(var(--foreground))', + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))' + }, + secondary: { + DEFAULT: 'hsl(var(--secondary))', + foreground: 'hsl(var(--secondary-foreground))' + }, + destructive: { + DEFAULT: 'hsl(var(--destructive))', + foreground: 'hsl(var(--destructive-foreground))' + }, + muted: { + DEFAULT: 'hsl(var(--muted))', + foreground: 'hsl(var(--muted-foreground))' + }, + accent: { + DEFAULT: 'hsl(var(--accent))', + foreground: 'hsl(var(--accent-foreground))' + }, + popover: { + DEFAULT: 'hsl(var(--popover))', + foreground: 'hsl(var(--popover-foreground))' + }, + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))' + }, + chart: { + '1': 'hsl(var(--chart-1))', + '2': 'hsl(var(--chart-2))', + '3': 'hsl(var(--chart-3))', + '4': 'hsl(var(--chart-4))', + '5': 'hsl(var(--chart-5))' + } + }, + borderRadius: { + xl: 'calc(var(--radius) + 4px)', + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)' + }, + keyframes: { + 'accordion-down': { + from: { + height: 0 + }, + to: { + height: 'var(--radix-accordion-content-height)' + } + }, + 'accordion-up': { + from: { + height: 'var(--radix-accordion-content-height)' + }, + to: { + height: 0 + } + }, + 'collapsible-down': { + from: { + height: 0 + }, + to: { + height: 'var(--radix-collapsible-content-height)' + } + }, + 'collapsible-up': { + from: { + height: 'var(--radix-collapsible-content-height)' + }, + to: { + height: 0 + } + }, + 'slide-in-from-top': { + '0%': { + transform: 'translateY(-100%)' + }, + '100%': { + transform: 'translateY(0)' + } + }, + 'slide-out-to-top': { + '0%': { + transform: 'translateY(0)' + }, + '100%': { + transform: 'translateY(-100%)' + } + } + }, + animation: { + 'accordion-down': 'accordion-down 0.2s ease-out', + 'accordion-up': 'accordion-up 0.2s ease-out', + 'collapsible-down': 'collapsible-down 0.2s ease-in-out', + 'collapsible-up': 'collapsible-up 0.2s ease-in-out', + 'slide-in-from-top': 'slide-in-from-top 0.2s ease-out', + 'slide-out-to-top': 'slide-out-to-top 0.2s ease-out' + } + } }, - plugins: [animate], + plugins: [animate, require("tailwindcss-animate")], } \ No newline at end of file