diff --git a/apps/shelve/app/components/team/Manager.vue b/apps/shelve/app/components/team/Manager.vue index 6a18b8fb..266a48fe 100644 --- a/apps/shelve/app/components/team/Manager.vue +++ b/apps/shelve/app/components/team/Manager.vue @@ -1,5 +1,5 @@ @@ -380,7 +211,8 @@ watch(isSearchActive, (newValue) => {
@@ -398,13 +230,12 @@ watch(isSearchActive, (newValue) => {
diff --git a/apps/shelve/app/composables/useAppCommands.ts b/apps/shelve/app/composables/useAppCommands.ts new file mode 100644 index 00000000..9815ce51 --- /dev/null +++ b/apps/shelve/app/composables/useAppCommands.ts @@ -0,0 +1,129 @@ +import type { CommandGroup, CommandItem } from '@types' + +export function useAppCommands() { + const teams = useTeams() + const colorMode = useColorMode() + const { version } = useRuntimeConfig().public + const defaultTeamSlug = useCookie('defaultTeamSlug') + const _currentTeam = useTeam() + const { selectTeam, createTeam } = useTeamsService() + + const currentTeam = computed(() => + _currentTeam.value ?? teams.value.find((team) => team.slug === defaultTeamSlug.value) + ) + + // Theme commands + const themeCommands = ref([ + { + id: 'theme-light', + label: 'Light Mode', + icon: 'lucide:sun', + description: 'Switch to light mode', + action: () => { + colorMode.preference = 'light' + }, + keywords: ['light', 'theme', 'mode', 'day', 'bright'], + active: computed(() => colorMode.preference === 'light').value + }, + { + id: 'theme-dark', + label: 'Dark Mode', + icon: 'lucide:moon', + description: 'Switch to dark mode', + action: () => { + colorMode.preference = 'dark' + }, + keywords: ['dark', 'theme', 'mode', 'night', 'black'], + active: computed(() => colorMode.preference === 'dark').value + } + ]) + + // Navigation commands + const navigationCommands = ref([ + { + id: 'nav-home', + label: 'Go to Dashboard', + icon: 'lucide:layout-dashboard', + description: 'Navigate to the dashboard', + action: () => { + navigateTo(`/${defaultTeamSlug.value}`) + }, + keywords: ['home', 'dashboard', 'main'], + }, + { + id: 'nav-environments', + label: 'Environments', + icon: 'lucide:cloud', + description: 'Navigate to the environments page', + action: () => { + navigateTo(`/${defaultTeamSlug.value}/environments`) + }, + keywords: ['environments', 'projects', 'variables'], + }, + { + id: 'nav-settings', + label: 'Settings', + icon: 'lucide:settings', + description: 'Open settings page', + action: () => { + navigateTo('/user/settings') + }, + keywords: ['settings', 'preferences', 'config'], + }, + { + id: 'nav-profile', + label: 'Profile', + icon: 'lucide:user', + description: 'View your profile', + action: () => { + navigateTo('/user/profile') + }, + keywords: ['profile', 'account', 'user'], + }, + ]) + + // Team commands + const teamCommands = computed(() => { + return teams.value.map(team => ({ + id: `team-${team.id}`, + label: team.name, + icon: team.logo || 'lucide:users', + isAvatar: Boolean(team.logo), + description: `Switch to ${team.name} team`, + action: () => selectTeam(team), + keywords: ['team', 'switch', team.name], + active: team.id === currentTeam.value?.id, + })) + }) + + // Group all commands + const commandGroups = computed(() => [ + { + id: 'teams', + label: 'Teams', + items: teamCommands.value + }, + { + id: 'navigation', + label: 'Navigation', + items: navigationCommands.value + }, + { + id: 'theme', + label: 'Theme', + items: themeCommands.value + } + ]) + + // Helper function to create a team + const createTeamFromSearch = async (teamName: string) => { + if (!teamName) return + await createTeam(teamName) + } + + return { + commandGroups, + createTeamFromSearch, + version + } +} diff --git a/apps/shelve/app/composables/useCommandPalette.ts b/apps/shelve/app/composables/useCommandPalette.ts new file mode 100644 index 00000000..333c20a8 --- /dev/null +++ b/apps/shelve/app/composables/useCommandPalette.ts @@ -0,0 +1,136 @@ +import type { CommandGroup, CommandItem, CommandProviderOptions } from '@types' + +export function useCommandPalette( + searchQuery: Ref, + commandGroups: Ref, + options: CommandProviderOptions = {} +) { + const selectedIndex = ref(0) + const scrollContainerRef = ref(null) + + // Filter commands based on search query + const filteredCommandGroups = computed(() => { + if (!searchQuery.value) { + return commandGroups.value + } + + const searchLower = searchQuery.value.toLowerCase() + + return commandGroups.value.map(group => { + const filteredItems = group.items.filter(item => { + if (item.label.toLowerCase().includes(searchLower)) { + return true + } + + if (item.keywords?.some(keyword => keyword.toLowerCase().includes(searchLower))) { + return true + } + + return !!(item.description && item.description.toLowerCase().includes(searchLower)) + }) + + return { + ...group, + items: filteredItems, + } + }).filter(group => group.items.length > 0) + }) + + // Flatten all filtered items for easier navigation + const allFilteredItems = computed(() => { + return filteredCommandGroups.value.flatMap(group => group.items) + }) + + // Get global index from group and item indices + const getItemGlobalIndex = (groupIndex: number, itemIndex: number): number => { + let globalIndex = 0 + for (let i = 0; i < groupIndex; i++) { + globalIndex += filteredCommandGroups.value[i]?.items.length || 0 + } + return globalIndex + itemIndex + } + + // Scroll to keep selected item visible + const scrollToSelectedItem = () => { + nextTick(() => { + const selectedElement = document.querySelector('.command-item.selected') + if (selectedElement && scrollContainerRef.value) { + const container = scrollContainerRef.value + const containerRect = container.getBoundingClientRect() + const elementRect = selectedElement.getBoundingClientRect() + + if (elementRect.bottom > containerRect.bottom) { + // Element is below the visible area + const scrollOffset = elementRect.bottom - containerRect.bottom + 8 + container.scrollTop += scrollOffset + } else if (elementRect.top < containerRect.top) { + // Element is above the visible area + const scrollOffset = elementRect.top - containerRect.top - 8 + container.scrollTop += scrollOffset + } + } + }) + } + + // Navigation handlers + const navigateUp = () => { + if (selectedIndex.value > 0) { + selectedIndex.value-- + } else { + selectedIndex.value = allFilteredItems.value.length - 1 + } + scrollToSelectedItem() + } + + const navigateDown = () => { + if (selectedIndex.value < allFilteredItems.value.length - 1) { + selectedIndex.value++ + } else { + selectedIndex.value = 0 + } + scrollToSelectedItem() + } + + const selectCurrentItem = async () => { + if (allFilteredItems.value.length > 0) { + const item = allFilteredItems.value[selectedIndex.value] + if (item?.action) { + await item.action() + if (options.onClose) { + options.onClose() + } + } + } + } + + // Reset selection when search changes + watch(searchQuery, () => { + selectedIndex.value = 0 + nextTick(() => { + if (scrollContainerRef.value) { + scrollContainerRef.value.scrollTop = 0 + } + }) + }) + + // Reset scroll when command groups change + watch(commandGroups, () => { + nextTick(() => { + if (scrollContainerRef.value) { + scrollContainerRef.value.scrollTop = 0 + } + }) + }, { deep: true }) + + return { + selectedIndex, + scrollContainerRef, + filteredCommandGroups, + allFilteredItems, + getItemGlobalIndex, + navigateUp, + navigateDown, + selectCurrentItem, + scrollToSelectedItem + } +} diff --git a/apps/shelve/server/api/teams/[slug]/index.delete.ts b/apps/shelve/server/api/teams/[slug]/index.delete.ts index 20d99cd5..b0d004f2 100644 --- a/apps/shelve/server/api/teams/[slug]/index.delete.ts +++ b/apps/shelve/server/api/teams/[slug]/index.delete.ts @@ -1,7 +1,7 @@ export default eventHandler(async (event) => { const team = useCurrentTeam(event) - await new TeamsService().deleteTeam({ teamId: team.id }) + await new TeamsService().deleteTeam({ teamId: team.id, slug: team.slug }) return { statusCode: 200, diff --git a/apps/shelve/server/services/teams.ts b/apps/shelve/server/services/teams.ts index e7e109da..d90235f3 100644 --- a/apps/shelve/server/services/teams.ts +++ b/apps/shelve/server/services/teams.ts @@ -127,8 +127,9 @@ export class TeamsService { } async deleteTeam(input: DeleteTeamInput): Promise { - const { teamId } = input + const { teamId, slug } = input await clearCache('Team', teamId) + await clearCache('Team', slug) const [team] = await useDrizzle().delete(tables.teams) .where(eq(tables.teams.id, teamId)) .returning({ id: tables.teams.id, slug: tables.teams.slug }) diff --git a/packages/types/index.ts b/packages/types/index.ts index 7143c19a..a4c61b00 100644 --- a/packages/types/index.ts +++ b/packages/types/index.ts @@ -9,3 +9,4 @@ export * from './src/Environment' export * from './src/Stats' export * from './src/integrations/Github' export * from './src/Navigation' +export * from './src/Command' diff --git a/packages/types/src/Command.ts b/packages/types/src/Command.ts new file mode 100644 index 00000000..3aeac249 --- /dev/null +++ b/packages/types/src/Command.ts @@ -0,0 +1,20 @@ +export interface CommandItem { + id: string + label: string + icon: string + isAvatar?: boolean + description?: string + action: () => void | Promise + keywords?: string[] + active?: boolean +} + +export interface CommandGroup { + id: string + label: string + items: CommandItem[] +} + +export interface CommandProviderOptions { + onClose?: () => void +} diff --git a/packages/types/src/Team.ts b/packages/types/src/Team.ts index fec8fec5..0d20ab1e 100644 --- a/packages/types/src/Team.ts +++ b/packages/types/src/Team.ts @@ -41,7 +41,8 @@ export type UpdateTeamInput = { } export type DeleteTeamInput = { - teamId: number + teamId: number, + slug: string } export type AddMemberInput = {