Hello from MCP server

List Files | Just Commands | Repo | Logs

← back |
<template>
  <div class="search-section">
    <h2 v-if="title" class="search-title">{{ title }}</h2>
    <div class="search-bar">
      <div class="search-input-container">
        <ion-input
          v-model="searchQuery"
          placeholder="Search key words..."
          fill="outline"
          class="search-input"
          @keydown="handleSearchKeydown"
          @input="handleSearchInput"
          @ionFocus="handleSearchFocus"
          @ionBlur="handleSearchBlur"
        ></ion-input>
        <!-- Suggestions dropdown -->
        <div v-if="showSuggestions && suggestions.length > 0" class="suggestions-dropdown">
          <ion-list>
            <ion-item
              v-for="(suggestion, index) in suggestions"
              :key="`${suggestion.type}-${suggestion.id}`"
              button
              @click="handleSelectSuggestion(suggestion)"
              :class="{ 'suggestion-selected': index === selectedSuggestionIndex }"
              class="suggestion-item"
            >
              <ion-icon
                :icon="suggestion.type === 'tag' ? pricetag : briefcase"
                slot="start"
                :class="suggestion.type === 'tag' ? 'tag-icon' : 'job-icon'"
              ></ion-icon>
              <ion-label>{{ suggestion.name }}</ion-label>
            </ion-item>
          </ion-list>
        </div>
      </div>
      <ion-button fill="solid" color="secondary" @click="handleApplySearch">
        {{ buttonText }}
      </ion-button>
      <ion-button v-if="showClear" fill="outline" color="tertiary" @click="handleClear">
        Clear
      </ion-button>
    </div>
    <div class="search-tags-container">
      <!-- Search term chips (without #) -->
      <ion-chip
        v-for="(term, index) in searchTerms"
        :key="'search-term-' + index"
        color="tertiary"
        class="search-tag-chip"
        @click="handleRemoveSearchTerm(index)"
      >
        {{ term }}
      </ion-chip>
      <!-- Selected tag chips (without #) -->
      <ion-chip
        v-for="tag in selectedTags"
        :key="`selected-tag-${tag.id}`"
        color="tertiary"
        class="search-tag-chip"
        @click="handleRemoveSelectedTag(tag.id)"
      >
        {{ tag.name }}
      </ion-chip>
      <!-- Available tag chips (with #) - only shown when showTags is true -->
      <template v-if="showTags">
        <ion-chip
          v-for="tag in availableTags"
          :key="`available-tag-${tag.id}`"
          color="secondary"
          class="search-tag-chip"
          @click="handleTagClick(tag)"
        >
          {{ '#' + tag.name }}
        </ion-chip>
      </template>
      <!-- Quick access chips - displayed alongside tags -->
      <template v-if="quickAccessItems.length > 0">
        <ion-chip
          v-for="item in quickAccessItems"
          :key="`quick-access-${item.id}`"
          class="search-tag-chip quick-access-chip"
          @click="handleQuickAccessClick(item)"
        >
          {{ item.name }}
        </ion-chip>
      </template>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue';
import {
  IonInput,
  IonButton,
  IonChip,
  IonIcon,
  IonList,
  IonItem,
  IonLabel,
} from '@ionic/vue';
import { pricetag, briefcase } from 'ionicons/icons';

interface Tag {
  id: string;
  name: string;
}

interface Suggestion {
  id: string;
  name: string;
  type: 'tag' | 'job';
}

interface QuickAccessItem {
  id: string;
  name: string;
  refId?: string;
}

const props = withDefaults(defineProps<{
  title?: string;
  buttonText?: string;
  searchTerms?: string[];
  selectedTags?: Tag[];
  availableTags?: Tag[];
  suggestions?: Suggestion[];
  navigatePath?: string;
  showClear?: boolean;
  showTags?: boolean;
  category?: string | null;
  topLevel?: string | null;
  quickAccessItems?: QuickAccessItem[];
}>(), {
  title: 'What are we doing today?',
  buttonText: 'Find Job',
  searchTerms: () => [],
  selectedTags: () => [],
  availableTags: () => [],
  suggestions: () => [],
  navigatePath: '/find-job',
  showClear: false,
  showTags: true,
  category: null,
  topLevel: null,
  quickAccessItems: () => []
});

const emit = defineEmits([
  'navigate',
  'search-input',
  'quick-access-click'
]);

// Local UI state
const searchQuery = ref('');
const showSuggestions = ref(false);
const selectedSuggestionIndex = ref(-1);

// Log all props data for debugging
console.log('[SearchBar] Props:', {
  title: props.title,
  buttonText: props.buttonText,
  searchTerms: props.searchTerms,
  selectedTags: props.selectedTags,
  availableTags: props.availableTags,
  suggestions: props.suggestions,
  navigatePath: props.navigatePath
});

// Helper to build URL with query params
const buildUrl = (tags: string[], searchTerms: string[], clearFilters = false) => {
  const query = new URLSearchParams();

  // Always preserve top-level category (never clear it)
  if (props.topLevel) {
    query.set('top', props.topLevel);
  }

  // If searching or clearing, don't include category/tags
  if (!clearFilters && searchTerms.length === 0) {
    // Preserve category if set (only when not searching)
    if (props.category) {
      query.set('category', props.category);
    }

    if (tags.length > 0) {
      query.set('tags', tags.join(','));
    }
  }

  if (searchTerms.length > 0) {
    query.set('q', searchTerms.join(' '));
  }

  const queryString = query.toString();
  const url = queryString ? `${props.navigatePath}?${queryString}` : props.navigatePath;

  console.log('[SearchBar] buildUrl:', { tags, searchTerms, category: props.category, topLevel: props.topLevel, clearFilters, url });

  return url;
};

// Event handlers
const handleRemoveSearchTerm = (index: number) => {
  // Remove the search term at the specified index
  const newSearchTerms = props.searchTerms.filter((_, i) => i !== index);
  // Keep existing selected tags
  const currentTags = props.selectedTags.map(t => t.name);
  const url = buildUrl(currentTags, newSearchTerms);
  emit('navigate', url);
};

const handleRemoveSelectedTag = (tagId: string) => {
  // Remove the selected tag
  const newTags = props.selectedTags.filter(t => t.id !== tagId).map(t => t.name);
  // Keep existing search terms
  const url = buildUrl(newTags, props.searchTerms);
  emit('navigate', url);
};

const handleTagClick = (tag: Tag) => {
  // Keep existing selected tags and add the new one
  const currentTags = [...props.selectedTags.map(t => t.name), tag.name];
  // Keep existing search terms
  const url = buildUrl(currentTags, props.searchTerms);
  emit('navigate', url);
};

const handleApplySearch = () => {
  if (searchQuery.value.trim() === "") return;

  // Keep existing search terms and add the new one
  const newSearchTerms = [...props.searchTerms, searchQuery.value.trim()];
  // Keep existing selected tags
  const currentTags = props.selectedTags.map(t => t.name);
  const url = buildUrl(currentTags, newSearchTerms);

  emit('navigate', url);
  searchQuery.value = "";
};

const handleSearchKeydown = (event: KeyboardEvent) => {
  if (!showSuggestions.value || props.suggestions.length === 0) {
    if (event.key === "Enter") {
      handleApplySearch();
    }
    return;
  }

  if (event.key === "ArrowDown") {
    event.preventDefault();
    selectedSuggestionIndex.value = Math.min(
      selectedSuggestionIndex.value + 1,
      props.suggestions.length - 1
    );
  } else if (event.key === "ArrowUp") {
    event.preventDefault();
    selectedSuggestionIndex.value = Math.max(selectedSuggestionIndex.value - 1, -1);
  } else if (event.key === "Enter") {
    event.preventDefault();
    if (selectedSuggestionIndex.value >= 0) {
      handleSelectSuggestion(props.suggestions[selectedSuggestionIndex.value]);
    } else {
      handleApplySearch();
    }
  } else if (event.key === "Escape") {
    showSuggestions.value = false;
    selectedSuggestionIndex.value = -1;
  }
};

const handleSearchInput = () => {
  const query = searchQuery.value.trim();
  showSuggestions.value = query.length >= 2;
  selectedSuggestionIndex.value = -1;
  emit('search-input', query);
};

const handleSearchFocus = () => {
  if (searchQuery.value.trim().length >= 2) {
    showSuggestions.value = true;
  }
};

const handleSearchBlur = () => {
  setTimeout(() => {
    showSuggestions.value = false;
    selectedSuggestionIndex.value = -1;
  }, 200);
};

const handleSelectSuggestion = (suggestion: Suggestion) => {
  if (suggestion.type === 'tag') {
    // Keep existing selected tags and add the suggested tag
    const currentTags = [...props.selectedTags.map(t => t.name), suggestion.name];
    // Keep existing search terms
    const url = buildUrl(currentTags, props.searchTerms);
    emit('navigate', url);
  } else if (suggestion.type === 'job') {
    // Navigate directly to view job page
    emit('navigate', `/view-job/${suggestion.id}`);
  }

  searchQuery.value = "";
  showSuggestions.value = false;
  selectedSuggestionIndex.value = -1;
};

const handleClear = () => {
  // Clear all filters (search terms, tags, and category)
  const url = buildUrl([], [], true);
  emit('navigate', url);
};

const handleQuickAccessClick = (item: QuickAccessItem) => {
  emit('quick-access-click', item);
};
</script>

<style scoped>
.search-section {
  margin-top: 0;
  margin-bottom: 32px;
  width: 100%;
  max-width: 1000px;
  margin-left: auto;
  margin-right: auto;
}

.search-title {
  margin: 0 0 24px 0;
  color: var(--ion-text-color);
  font-size: 32px;
  font-weight: 600;
  text-align: center;
  font-variant: small-caps;
}

.search-tags-container {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  justify-content: center;
  align-items: center;
  padding: 8px;
  background-color: var(--ion-background-color-step-50);
  border-radius: 8px;
  margin-top: 8px;
}

.search-tag-chip {
  cursor: pointer;
  margin: 0;
  text-transform: lowercase;
  font-size: 12px;
  font-weight: 600;
  height: 24px;
  padding: 0 10px;
  white-space: nowrap;
}

.search-tag-chip:hover {
  filter: brightness(1.1);
  transform: scale(1.02);
  transition: all 0.2s ease;
}

.search-term-chip {
  /* Blue/secondary color chips for search terms */
}

.search-bar {
  display: flex;
  gap: 8px;
  align-items: center;
  background-color: var(--ion-background-color-step-50);
  padding: 12px;
  border-radius: 8px;
}

.search-input-container {
  flex: 1;
  display: flex;
  flex-direction: column;
  position: relative;
}

.search-input {
  width: 100%;
  font-size: 20px;
  min-height: 48px;
}

.search-undo-wrapper {
  position: relative;
  display: inline-block;
}

.search-undo-button {
  margin: 0;
  min-width: 44px;
}

.search-undo-shadow {
  position: absolute;
  top: 2px;
  left: -2px;
  z-index: 0;
}

.search-undo-front {
  position: relative;
  z-index: 1;
  --color: var(--ion-color-tertiary);
}

/* Suggestions dropdown */
.suggestions-dropdown {
  position: absolute;
  top: 100%;
  left: 0;
  right: 0;
  z-index: 1000;
  margin-top: 4px;
  background: var(--ion-color-dark);
  border-radius: 8px;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
  border: 1px solid var(--ion-color-medium);
  max-height: 400px;
  overflow-y: auto;
}

.suggestions-dropdown ion-list {
  background: transparent;
  padding: 0;
}

.suggestion-item {
  --background: var(--ion-color-dark);
  --border-color: transparent;
  cursor: pointer;
  transition: background 0.2s ease;
}

.suggestion-item:hover,
.suggestion-item.suggestion-selected {
  --background: var(--ion-color-medium);
}

.suggestion-item ion-label {
  font-size: 15px;
  color: var(--ion-color-light);
}

.suggestion-item .tag-icon {
  color: var(--ion-color-primary);
  font-size: 20px;
}

.suggestion-item .job-icon {
  color: var(--ion-color-success);
  font-size: 20px;
}

.quick-access-chip {
  font-weight: 500;
  text-transform: none;
  font-size: 12px;
  height: auto;
  padding: 4px 10px;
  border-radius: 12px;
  --background: var(--ion-color-primary);
  --color: var(--ion-color-primary-contrast);
}
</style>