Hello from MCP server
<template>
<BaseLayout title="Technician - Search">
<!-- Toolbar at top of screen -->
<Toolbar @help-clicked="openInfoModal" />
<div class="find-job-container">
<!-- New Search Bar Component -->
<div class="new-search-bar-section">
<h2 class="new-search-title">What are we doing today?</h2>
<div class="new-search-tags-container">
<!-- Search term chips (without #) -->
<ion-chip
v-for="(term, index) in appliedSearchTerms"
:key="'new-search-term-' + index"
color="secondary"
class="new-search-tag-chip new-search-term-chip"
@click="removeSearchTerm(index)"
>
{{ term }}
</ion-chip>
<!-- Selected tag chips (without #) in alternate color -->
<template v-for="tagId in selectedTags" :key="'selected-tag-' + tagId">
<ion-chip
v-if="getTagNameById(tagId)"
color="tertiary"
class="new-search-tag-chip new-search-term-chip"
@click="removeSelectedTag(tagId)"
>
{{ getTagNameById(tagId) }}
</ion-chip>
</template>
<!-- Available tag chips (with #) -->
<ion-chip
v-for="tag in topTagsForNewSearch"
:key="`new-search-${tag.id}`"
@click="selectTag(tag.id)"
color="primary"
class="new-search-tag-chip"
>
{{ "#" + tag.name }}
</ion-chip>
</div>
<div class="new-search-bar">
<div class="new-search-input-container">
<ion-input
v-model="searchQuery"
placeholder="Search jobs..."
fill="outline"
class="new-search-input"
@keydown="handleSearchKeydown"
@input="handleSearchInput"
@ionFocus="handleSearchFocus"
@ionBlur="handleSearchBlur"
></ion-input>
<!-- Suggestions dropdown -->
<div
v-if="showSuggestions && searchSuggestions.length > 0"
class="suggestions-dropdown"
>
<ion-list>
<ion-item
v-for="(suggestion, index) in searchSuggestions"
:key="`${suggestion.type}-${suggestion.id}`"
button
@click="selectSuggestion(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="applySearch">
Search
</ion-button>
<ion-button fill="outline" color="tertiary" @click="clearSearch">
Clear
</ion-button>
<div v-if="canUndo" class="new-search-undo-wrapper">
<ion-button
@click="undoLastAction"
fill="solid"
color="warning"
class="new-search-undo-button new-search-undo-shadow"
size="default"
>
<ion-icon :icon="arrowUndo" slot="icon-only"></ion-icon>
</ion-button>
<ion-button
@click="undoLastAction"
fill="solid"
color="medium"
class="new-search-undo-button new-search-undo-front"
size="default"
>
<ion-icon
:icon="arrowUndo"
slot="icon-only"
color="tertiary"
></ion-icon>
</ion-button>
</div>
</div>
</div>
<!-- Available jobs list (Google search result style) -->
<div
v-if="selectedTags.length > 0 || appliedSearchQuery.trim() !== ''"
class="available-jobs-section"
>
<div v-if="filteredProblems.length === 0" class="no-jobs-message">
<p>
No jobs match your selected filters. Try removing some tags or
changing your search.
</p>
</div>
<div v-else class="search-results">
<div
v-for="problem in filteredProblems"
:key="problem.id"
@click="selectJob(problem)"
class="search-result-item"
>
<div class="result-content">
<h3 class="result-title">{{ problem.name }}</h3>
<p
class="result-description"
v-html="formatDescriptionWithHashtags(problem.description)"
></p>
</div>
<ion-button
fill="solid"
color="tertiary"
size="small"
class="details-button"
@click.stop="selectJob(problem)"
>
View
</ion-button>
</div>
</div>
</div>
</div>
<!-- Action Bar -->
<div class="action-bar action-bar-disabled">
<div class="action-bar-content">
<h2 class="action-bar-text">Add Job</h2>
</div>
</div>
<!-- Info Modal -->
<ion-modal :is-open="isInfoModalOpen" @didDismiss="closeInfoModal">
<ion-header>
<ion-toolbar>
<ion-title>{{
showDebugInfo ? "Tag Information Gain Data" : "How to Find Your Job"
}}</ion-title>
<ion-buttons slot="start">
<ion-button @click="toggleDebugInfo">
<ion-icon :icon="helpCircleOutline"></ion-icon>
</ion-button>
</ion-buttons>
<ion-buttons slot="end">
<ion-button @click="closeInfoModal">Close</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<!-- Debug Info View -->
<div v-if="showDebugInfo" class="debug-content">
<div class="debug-summary">
<p>
<strong>Total Filtered Problems:</strong>
{{ debugInfoData.totalProblems }}
</p>
<p>
<strong>Current Entropy:</strong>
{{ debugInfoData.currentEntropy.toFixed(3) }}
</p>
<p>
<strong>Showing Top {{ debugInfoData.tags.length }} Tags</strong>
</p>
</div>
<div class="debug-tags-list">
<div
v-for="tag in debugInfoData.tags"
:key="tag.tagId"
class="debug-tag-item"
>
<div class="debug-tag-rank">{{ tag.rank }}</div>
<div class="debug-tag-details">
<div class="debug-tag-name">{{ tag.tagName }}</div>
<div class="debug-tag-stats">
<span>Gain: {{ tag.informationGain.toFixed(3) }}</span>
<span class="debug-divider">|</span>
<span
>Appears in {{ tag.problemCount }}/{{
debugInfoData.totalProblems
}}
jobs</span
>
</div>
</div>
</div>
</div>
</div>
<!-- Info Content View -->
<div v-else class="info-content">
<h3>How to Find Your Job</h3>
<h4>Search by Text</h4>
<p>
Type keywords into the search bar to find jobs. As you type, you'll
see suggestions for matching tags and job titles. Press Enter or
click the Search button to apply your search. Your search terms will
appear as blue chips in the tags area.
</p>
<h4>Filter by Tags</h4>
<p>
Click on any tag (marked with #) to filter the job list. Only jobs
matching your selected tags will be shown. Tags turn green when
selected. The tag list automatically updates to show only relevant
tags based on your current selection.
</p>
<h4>Browse Results</h4>
<p>
Matching jobs appear below the tags as a list. Click any job title
to preview details, then select "Select Job" to add it to your
session.
</p>
<h4>Refine Your Search</h4>
<p>
Click the yellow Undo button to step back through your selections.
Click any search term or tag chip to remove it. Use the Clear button
to start over.
</p>
<hr
style="
margin: 24px 0;
border: 1px solid var(--ion-color-medium);
opacity: 0.3;
"
/>
<h4>How Tags Work (Advanced)</h4>
<p>
Job tags are organized by "information gain" (each tag's information
gain is a measurement of how great its impact is on filtering down
the jobs). Jobs with the highest information gain are shown with
bold text and are mixed into the tag display more frequently. Tags
are also sorted by information gain, so all of the highest
information gain tags appear first.
</p>
<p>
When a tag is selected, all of the jobs that do not match that tag
are removed from the pool of jobs, and the pool of tags is refreshed
to only include tags that appear in the filtered-down pool of jobs.
With this behavior, you should be able to quickly find your job.
</p>
<p>
You can also find job by searching via text. Entered text will match
job titles, descriptions and tags. As you enter text,
auto-suggestions are made available based on tags and job titles.
</p>
<hr
style="
margin: 24px 0;
border: 1px solid var(--ion-color-medium);
opacity: 0.3;
"
/>
<h4>Export Jobs</h4>
<p>
Export the currently filtered jobs (respecting your search terms and
selected tags) to CSV or JSON format.
</p>
<!-- CSV Preview -->
<div class="csv-preview-container">
<pre class="csv-preview">{{ csvPreviewData }}</pre>
</div>
<div class="export-buttons">
<ion-button
expand="block"
color="secondary"
@click="copyCsvToClipboard"
>
Copy CSV to Clipboard
</ion-button>
<ion-button
expand="block"
color="primary"
@click="handleExportJobs"
>
Export JSON
</ion-button>
</div>
</div>
</ion-content>
</ion-modal>
<!-- Bookmarks Modal -->
<ion-modal
:is-open="isBookmarksModalOpen"
@didDismiss="closeBookmarksModal"
>
<ion-header>
<ion-toolbar>
<ion-title>Bookmarks</ion-title>
<ion-buttons slot="end">
<ion-button @click="closeBookmarksModal">Close</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<div class="bookmarks-content">
<div class="bookmarks-list">
<div
v-for="job in bookmarkedJobs"
:key="job.id"
@click="selectBookmarkedJob(job)"
class="bookmark-item"
>
<ion-icon
:icon="star"
color="warning"
class="bookmark-star"
></ion-icon>
<div class="bookmark-details">
<h3 class="bookmark-title">{{ job.name }}</h3>
<p
class="bookmark-description"
v-html="formatDescriptionWithHashtags(job.description)"
></p>
</div>
</div>
</div>
</div>
</ion-content>
</ion-modal>
</BaseLayout>
</template>
<script setup lang="ts">
/*
<ion-chip
v-for="tagItem in topTagsForNewSearch"
:key="`new-search-${tagItem.tag?.id}`"
@click="selectTagProblems(tagItem)"
:color="getChipColor(tagItem)"
class="new-search-tag-chip"
v-if="'false'" <-- tagItem.tag?.id && tagItem.tag?.name" --/>
>
{{ "#" // + tagItem.tag?.name }}
</ion-chip>
*/
import { ref, computed, onMounted, watch } from "vue";
import BaseLayout from "@/components/BaseLayout.vue";
import Toolbar from "@/components/Toolbar.vue";
import type { ProblemsRecord, ProblemTagsRecord } from "@/pocketbase-types";
import {
IonInput,
IonButton,
IonChip,
IonIcon,
IonModal,
IonHeader,
IonToolbar,
IonTitle,
IonButtons,
IonContent,
IonList,
IonItem,
IonLabel,
} from "@ionic/vue";
import {
arrowBack,
star,
pricetag,
briefcase,
helpCircleOutline,
arrowUndo,
} from "ionicons/icons";
import { useRouter, useRoute } from "vue-router";
import { useSessionStore } from "@/stores/session";
const router = useRouter();
const route = useRoute();
const sessionStore = useSessionStore();
const goToDashboard = () => {
router.push("/service-call");
};
// State
const searchQuery = ref("");
const isInfoModalOpen = ref(false);
const showDebugInfo = ref(false);
const isBookmarksModalOpen = ref(false);
const csvPreviewData = ref("");
const isLoadingCsv = ref(false);
const isSyncingFromUrl = ref(false);
// Get data from session store
const problems = computed(() => sessionStore.problems);
const problemTags = computed(() => sessionStore.problemTags);
const selectedTags = computed(() => sessionStore.selectedTags);
const appliedSearchQuery = computed(() => sessionStore.appliedSearchQuery);
const selectionHistory = computed(() => sessionStore.selectionHistory);
// Auto-suggestions state
const showSuggestions = ref(false);
const selectedSuggestionIndex = ref(-1);
// Helper function to get tag IDs from a problem (use store method)
const getProblemTagIds = (problem: ProblemsRecord): string[] => {
return sessionStore.getProblemTagIds(problem);
};
// Helper function to get tag names for a problem
const getTagsForProblem = (problem: ProblemsRecord): string[] => {
const tagIds = getProblemTagIds(problem);
return tagIds
.map((tagId) => problemTags.value.find((tag) => tag.id === tagId))
.filter((tag) => tag !== undefined)
.map((tag) => tag!.name!)
.filter((name) => name !== undefined);
};
// Undo last action (use store)
const undoLastAction = async () => {
await sessionStore.undoSearch();
};
// Check if undo is available
const canUndo = computed(() => selectionHistory.value.length > 0);
// Split applied search query into individual terms for display as chips
const appliedSearchTerms = computed(() => {
if (appliedSearchQuery.value.trim() === "") return [];
return appliedSearchQuery.value
.split(/\s+/)
.filter((term) => term.trim() !== "");
});
// Remove a search term by index (use store)
const removeSearchTerm = async (index: number) => {
await sessionStore.removeSearchTerm(index);
};
// Computed property for filtered problems based on selected tags and search
const filteredProblems = computed(() => {
let filtered = problems.value;
// Filter by selected tags
if (selectedTags.value.length > 0) {
filtered = filtered.filter((problem) => {
const problemTagIds = getProblemTagIds(problem);
// Job must have ALL selected tags (AND logic)
return selectedTags.value.every((tagId) => problemTagIds.includes(tagId));
});
}
// Filter by search query (use appliedSearchQuery)
if (appliedSearchQuery.value.trim() !== "") {
const searchTerms = appliedSearchQuery.value
.toLowerCase()
.split(/\s+/) // Split on any whitespace
.filter((term) => term.trim() !== "");
filtered = filtered.filter((problem) => {
const problemName = problem.name?.toLowerCase() || "";
const problemDescription = problem.description?.toLowerCase() || "";
const searchText = `${problemName} ${problemDescription}`;
// All search terms must be found in the combined text (AND logic)
return searchTerms.every((term) => searchText.includes(term));
});
}
console.log(
"[filteredProblems] appliedSearchQuery:",
appliedSearchQuery.value,
"selectedTags:",
selectedTags.value.length,
"result count:",
filtered.length,
selectedTags
);
return filtered;
});
// Computed property for tags and problems sorted by frequency (only from filtered problems)
const sortedTagsByFrequency = computed(() => {
if (problemTags.value.length === 0 || filteredProblems.value.length === 0)
return [];
const itemFrequency = new Map<
string,
{
tag?: ProblemTagsRecord;
problem?: ProblemsRecord;
problems: ProblemsRecord[];
count: number;
type: "tag" | "problem";
}
>();
// First, collect all tag IDs that exist on filtered problems
const tagIdsInFilteredProblems = new Set<string>();
filteredProblems.value.forEach((problem) => {
const tagIds = getProblemTagIds(problem);
tagIds.forEach((tagId) => tagIdsInFilteredProblems.add(tagId));
});
// Add actual tags (only those that exist on filtered problems, excluding selected tags)
problemTags.value.forEach((tag) => {
// Skip tags that are already selected
if (selectedTags.value.includes(tag.id)) {
return;
}
// Skip tags that don't exist on any filtered problems
if (!tagIdsInFilteredProblems.has(tag.id)) {
return;
}
const problemsWithTag = filteredProblems.value.filter((problem) => {
const tagIds = getProblemTagIds(problem);
return tagIds.includes(tag.id);
});
// Show all tags that exist on the filtered problems (cross-filtering)
if (problemsWithTag.length > 0) {
itemFrequency.set(`tag-${tag.id}`, {
tag,
problems: problemsWithTag,
count: problemsWithTag.length,
type: "tag",
});
}
});
// Add problems as "tags" (job titles)
filteredProblems.value.forEach((problem) => {
itemFrequency.set(`problem-${problem.id}`, {
problem,
problems: [problem],
count: 1,
type: "problem",
});
});
// Calculate information gain for each tag (inline, to avoid circular dependency)
const totalProblems = filteredProblems.value.length;
const currentEntropy = totalProblems > 1 ? Math.log2(totalProblems) : 0;
const infoGainMap = new Map<string, number>();
// Calculate info gain for all tags
Array.from(itemFrequency.values()).forEach((item) => {
if (item.type !== "tag" || !item.tag) return;
const tagId = item.tag.id;
const countWith = item.count;
const countWithout = totalProblems - countWith;
// Calculate weighted entropy after split
let entropyAfterSplit = 0;
if (countWith > 0) {
const probWith = countWith / totalProblems;
const entropyWith = countWith > 1 ? Math.log2(countWith) : 0;
entropyAfterSplit += probWith * entropyWith;
}
if (countWithout > 0) {
const probWithout = countWithout / totalProblems;
const entropyWithout = countWithout > 1 ? Math.log2(countWithout) : 0;
entropyAfterSplit += probWithout * entropyWithout;
}
// Information gain = entropy before - weighted entropy after
const informationGain = currentEntropy - entropyAfterSplit;
infoGainMap.set(tagId, informationGain);
});
// Sort by type first (tags before problems), then by information gain (descending), then by name
const sorted = Array.from(itemFrequency.values()).sort((a, b) => {
// Tags come before problems
if (a.type !== b.type) {
return a.type === "tag" ? -1 : 1;
}
// For tags, sort by information gain
if (a.type === "tag" && b.type === "tag" && a.tag && b.tag) {
const gainA = infoGainMap.get(a.tag.id) || 0;
const gainB = infoGainMap.get(b.tag.id) || 0;
// Use small epsilon for float comparison
if (Math.abs(gainA - gainB) > 0.001) {
return gainB - gainA; // Higher info gain first
}
}
// If info gain is same (or for problems), fall back to alphabetical
const aName = a.tag?.name || a.problem?.name || "";
const bName = b.tag?.name || b.problem?.name || "";
return aName.localeCompare(bName);
});
// Debug: Log first 10 sorted tags with their info gain
console.log("=== SORTED TAGS (First 10) ===");
sorted
.filter((item) => item.type === "tag")
.slice(0, 10)
.forEach((item, index) => {
if (item.tag) {
const gain = infoGainMap.get(item.tag.id) || 0;
console.log(
`${index + 1}. ${item.tag.name}: gain=${gain.toFixed(3)}, count=${item.count}`,
);
}
});
return sorted;
});
// Computed property to calculate information gain for each tag
const tagInformationGain = computed(() => {
if (problemTags.value.length === 0 || filteredProblems.value.length === 0) {
return new Map<string, number>();
}
const totalProblems = filteredProblems.value.length;
const informationGainMap = new Map<string, number>();
// Calculate entropy before adding any tag (current state)
const currentEntropy = totalProblems > 1 ? Math.log2(totalProblems) : 0;
// For each available tag, calculate information gain
sortedTagsByFrequency.value.forEach((item) => {
if (item.type !== "tag" || !item.tag) return;
const tagId = item.tag.id;
// Count problems with and without this tag
const problemsWithTag = filteredProblems.value.filter((problem) => {
const tagIds = getProblemTagIds(problem);
return tagIds.includes(tagId);
});
const problemsWithoutTag = filteredProblems.value.filter((problem) => {
const tagIds = getProblemTagIds(problem);
return !tagIds.includes(tagId);
});
const countWith = problemsWithTag.length;
const countWithout = problemsWithoutTag.length;
// Calculate weighted entropy after split
let entropyAfterSplit = 0;
if (countWith > 0) {
const probWith = countWith / totalProblems;
const entropyWith = countWith > 1 ? Math.log2(countWith) : 0;
entropyAfterSplit += probWith * entropyWith;
}
if (countWithout > 0) {
const probWithout = countWithout / totalProblems;
const entropyWithout = countWithout > 1 ? Math.log2(countWithout) : 0;
entropyAfterSplit += probWithout * entropyWithout;
}
// Information gain = entropy before - weighted entropy after
const informationGain = currentEntropy - entropyAfterSplit;
informationGainMap.set(tagId, informationGain);
});
// Log the information gain for all tags
console.log("=== INFORMATION GAIN ANALYSIS ===");
console.log("Total filtered problems:", totalProblems);
console.log("Current entropy:", currentEntropy.toFixed(3));
const gainArray = Array.from(informationGainMap.entries())
.map(([tagId, gain]) => ({
tagId,
tagName: getTagNameById(tagId),
informationGain: gain,
problemCount:
sortedTagsByFrequency.value.find(
(item) => item.type === "tag" && item.tag?.id === tagId,
)?.count || 0,
}))
.sort((a, b) => b.informationGain - a.informationGain);
console.log("Tags ranked by information gain:");
gainArray.forEach((item, index) => {
console.log(
`${index + 1}. ${item.tagName}: ` +
`gain=${item.informationGain.toFixed(3)}, ` +
`appears in ${item.problemCount}/${totalProblems} problems`,
);
});
return informationGainMap;
});
// Computed property to get top 20 tags by information gain
const top20TagsByInfoGain = computed(() => {
const tagCount = sortedTagsByFrequency.value.filter(
(item) => item.type === "tag",
).length;
if (tagCount <= 20) {
return new Set<string>(); // Don't highlight if 20 or fewer tags
}
const gainMap = tagInformationGain.value;
const sortedTags = sortedTagsByFrequency.value
.filter((item) => item.type === "tag" && item.tag)
.map((item) => ({
tagId: item.tag!.id,
gain: gainMap.get(item.tag!.id) || 0,
}))
.sort((a, b) => b.gain - a.gain)
.slice(0, 20)
.map((item) => item.tagId);
return new Set(sortedTags);
});
// Computed property to get top 15 tags for new search bar
const topTagsForNewSearch = computed(() => {
console.log(sortedTagsByFrequency.value);
const tags = sortedTagsByFrequency.value
.filter((item) => item.type === "tag" && item.tag)
.slice(0, 15)
.map((item) => item.tag!); // Extract just the tag object
console.log("[topTagsForNewSearch]", tags);
// If no tags available, return default placeholder tags
if (tags.length === 0) {
return [
{ id: 'placeholder-1', name: 'no' },
{ id: 'placeholder-2', name: 'top' },
{ id: 'placeholder-3', name: 'tags' },
];
}
return tags;
});
// Computed property for search suggestions
const searchSuggestions = computed(() => {
if (searchQuery.value.trim().length < 2) {
return [];
}
const query = searchQuery.value.toLowerCase();
const suggestions: Array<{
id: string;
name: string;
type: "tag" | "job";
infoGain?: number;
}> = [];
// Get information gain map
const gainMap = tagInformationGain.value;
// Add matching tags
problemTags.value.forEach((tag) => {
if (tag.name?.toLowerCase().includes(query)) {
// Skip already selected tags
if (!selectedTags.value.includes(tag.id)) {
suggestions.push({
id: tag.id,
name: tag.name || "",
type: "tag",
infoGain: gainMap.get(tag.id) || 0,
});
}
}
});
// Add matching job titles
problems.value.forEach((problem) => {
if (problem.name?.toLowerCase().includes(query)) {
suggestions.push({
id: problem.id,
name: problem.name || "",
type: "job",
infoGain: 0, // Jobs don't have info gain
});
}
});
// Sort: tags first (by info gain), then jobs (alphabetically)
return suggestions
.sort((a, b) => {
if (a.type !== b.type) {
return a.type === "tag" ? -1 : 1;
}
if (a.type === "tag") {
return (b.infoGain || 0) - (a.infoGain || 0); // Higher info gain first
}
return a.name.localeCompare(b.name);
})
.slice(0, 10); // Limit to 10 suggestions
});
// Computed property for debug info display
const debugInfoData = computed(() => {
const totalProblems = filteredProblems.value.length;
const currentEntropy = totalProblems > 1 ? Math.log2(totalProblems) : 0;
const gainMap = tagInformationGain.value;
const tagData = filteredTagsForDisplay.value
.filter((item) => item.type === "tag" && item.tag)
.map((item, index) => ({
rank: index + 1,
tagName: item.tag!.name || "",
tagId: item.tag!.id,
informationGain: gainMap.get(item.tag!.id) || 0,
problemCount:
sortedTagsByFrequency.value.find(
(sortedItem) =>
sortedItem.type === "tag" && sortedItem.tag?.id === item.tag!.id,
)?.count || 0,
}));
return {
totalProblems,
currentEntropy,
tags: tagData,
};
});
// Computed property to filter displayed tags by search query (use appliedSearchQuery)
const filteredTagsForDisplay = computed(() => {
let baseList = sortedTagsByFrequency.value;
// Apply search filter if there is one
if (appliedSearchQuery.value.trim() !== "") {
const searchTerms = appliedSearchQuery.value
.toLowerCase()
.split(/\s+/)
.filter((term) => term.trim() !== "");
// Filter tags: keep tags whose names match the search query
baseList = baseList.filter((item) => {
if (item.type === "tag" && item.tag) {
const tagName = item.tag.name?.toLowerCase() || "";
// All search terms must match the tag name
return searchTerms.every((term) => tagName.includes(term));
} else if (item.type === "problem" && item.problem) {
// For problem chips, always show them (they're already filtered via filteredProblems)
return true;
}
return false;
});
console.log(
"[filteredTagsForDisplay] Search query:",
appliedSearchQuery.value,
"result count:",
baseList.length,
);
} else {
console.log(
"[filteredTagsForDisplay] No search query, returning all from sortedTagsByFrequency:",
baseList.length,
);
}
// Only show tags (do not display problem chips)
const tags = baseList.filter((item) => item.type === "tag");
if (tags.length === 0) {
return [];
}
// Get information gain values for all tags
const gainMap = tagInformationGain.value;
const tagsWithGain = tags
.map((item) => ({
item,
gain: gainMap.get(item.tag!.id) || 0,
}))
.filter((t) => t.gain > 0); // Only tags with positive info gain
if (tagsWithGain.length === 0) {
return [];
}
// Calculate meaningful threshold
// We want to filter out tags that provide minimal information gain
const gains = tagsWithGain.map((t) => t.gain).sort((a, b) => b - a);
const maxGain = gains[0];
// Set threshold at 10% of max gain, or use a minimum absolute threshold
const relativeThreshold = maxGain * 0.1;
const absoluteThreshold = 0.05; // Minimum absolute threshold
const threshold = Math.max(relativeThreshold, absoluteThreshold);
// Filter tags above threshold
const impactfulTags = tagsWithGain.filter((t) => t.gain >= threshold);
console.log(
"[filteredTagsForDisplay] Max gain:",
maxGain.toFixed(3),
"Threshold:",
threshold.toFixed(3),
"Impactful tags:",
impactfulTags.length,
"of",
tags.length,
"total tags",
);
// If we have impactful tags, show them (up to 25)
if (impactfulTags.length > 0) {
const result = impactfulTags.slice(0, 25).map((t) => t.item);
console.log(
"[filteredTagsForDisplay] Showing",
result.length,
"impactful tags",
);
return result;
}
// If no tags meet threshold but we have tags, show 1-3 random ones
const randomCount = Math.min(3, tags.length);
const shuffled = [...tags].sort(() => Math.random() - 0.5);
const result = shuffled.slice(0, randomCount);
console.log(
"[filteredTagsForDisplay] No tags above threshold, showing",
result.length,
"random tags",
);
return result;
});
// Helper function to check if a tag is selected
const isTagSelected = (tagId: string): boolean => {
return selectedTags.value.includes(tagId);
};
// Helper function to get chip color based on type and selection state
const getChipColor = (itemData: {
tag?: ProblemTagsRecord;
problem?: ProblemsRecord;
type: "tag" | "problem";
}) => {
if (itemData.type === "problem") {
return "success"; // Green/success color for job titles
} else if (itemData.type === "tag" && itemData.tag) {
return isTagSelected(itemData.tag.id) ? "tertiary" : "primary";
}
return "primary";
};
// Helper function to check if a tag is in top 20 by information gain
const isTop20Tag = (itemData: {
tag?: ProblemTagsRecord;
problem?: ProblemsRecord;
type: "tag" | "problem";
}) => {
if (itemData.type === "tag" && itemData.tag) {
return top20TagsByInfoGain.value.has(itemData.tag.id);
}
return false;
};
// Tag/job selection function (use store for tag selection)
const selectTagProblems = async (itemData: {
tag?: ProblemTagsRecord;
problem?: ProblemsRecord;
problems: ProblemsRecord[];
count: number;
type: "tag" | "problem";
}) => {
if (itemData.type === "problem" && itemData.problem) {
// If it's a job title chip, select it directly
selectJob(itemData.problem);
} else if (itemData.type === "tag" && itemData.tag) {
// If it's a tag, toggle tag selection using store
const tagId = itemData.tag.id;
await sessionStore.selectTag(tagId);
// Log filtered problems after tag selection
console.log("=== TAG CLICKED ===");
console.log("Tag:", itemData.tag.name);
console.log(
"Selected tags:",
selectedTags.value.map((id) => getTagNameById(id)),
);
console.log("Filtered problems count:", filteredProblems.value.length);
console.log(
"Filtered problems:",
filteredProblems.value.map((p) => ({
name: p.name,
description: p.description,
tags: getTagsForProblem(p),
tagIds: getProblemTagIds(p),
})),
);
console.log(
"Shown tags count:",
sortedTagsByFrequency.value.filter((item) => item.type === "tag").length,
);
console.log(
"Shown tags:",
sortedTagsByFrequency.value
.filter((item) => item.type === "tag")
.map((item) => ({
name: item.tag?.name,
id: item.tag?.id,
count: item.count,
})),
);
}
};
// Job selection function
const formatDescriptionWithHashtags = (description: string | undefined) => {
if (!description) return "";
// Find the first # and style everything from there onwards
const hashIndex = description.indexOf("#");
if (hashIndex === -1) return description;
const beforeHash = description.substring(0, hashIndex);
const afterHash = description.substring(hashIndex);
return beforeHash + '<span class="hashtag">' + afterHash + "</span>";
};
const selectJob = (problem: ProblemsRecord) => {
console.log("Selected job:", problem.name);
// Navigate to view job page without animation
router.push({
path: `/view-job/${problem.id}`,
// @ts-ignore - Ionic router options
routerDirection: "none",
});
};
// Info modal functions
const openInfoModal = () => {
isInfoModalOpen.value = true;
showDebugInfo.value = false; // Reset to info view when opening
generateCsvPreview(); // Generate CSV preview when modal opens
};
const closeInfoModal = () => {
isInfoModalOpen.value = false;
showDebugInfo.value = false;
};
const toggleDebugInfo = () => {
showDebugInfo.value = !showDebugInfo.value;
};
// Generate CSV preview
const generateCsvPreview = async () => {
isLoadingCsv.value = true;
csvPreviewData.value = "Loading menu names...";
try {
await sessionStore.initDb();
// CSV header
let csv = "Job Name,Menu Name,Band-Aid Offer Name\n";
// Process each filtered problem (respects search terms and tags)
for (const problem of filteredProblems.value) {
const jobName = problem.name || "Untitled Job";
let menuName = "No Menu";
let bandAidOfferName = "No Offer";
// Use the reusable session store function to fetch menu data
const menuData = await sessionStore.fetchMenuDataForProblem(problem);
if (menuData && menuData.name) {
menuName = menuData.name;
// Find the band-aid tier and get its offer name
if (menuData.tiers && Array.isArray(menuData.tiers)) {
const bandAidTier = menuData.tiers.find(
(tier: any) =>
tier.name && tier.name.toLowerCase().includes("band"),
);
if (bandAidTier && bandAidTier.offer && bandAidTier.offer.name) {
bandAidOfferName = bandAidTier.offer.name;
}
}
}
// Escape quotes and add row to CSV
const escapedJobName = jobName.replace(/"/g, '""');
const escapedMenuName = menuName.replace(/"/g, '""');
const escapedOfferName = bandAidOfferName.replace(/"/g, '""');
csv += `"${escapedJobName}","${escapedMenuName}","${escapedOfferName}"\n`;
}
csvPreviewData.value = csv;
} catch (error) {
console.error("Error generating CSV preview:", error);
csvPreviewData.value = "Error generating CSV preview";
} finally {
isLoadingCsv.value = false;
}
};
// Copy CSV to clipboard
const copyCsvToClipboard = async () => {
try {
await navigator.clipboard.writeText(csvPreviewData.value);
alert("CSV data copied to clipboard!");
} catch (error) {
console.error("Error copying to clipboard:", error);
alert("Failed to copy to clipboard");
}
};
// Export jobs function with menu data
const handleExportJobs = async () => {
try {
await sessionStore.initDb();
// Fetch menu data for each filtered problem (respects search terms and tags)
const enhancedProblems = [];
for (const problem of filteredProblems.value) {
const menuData = await sessionStore.fetchMenuDataForProblem(problem);
enhancedProblems.push({
...problem,
menuData: menuData || null,
});
}
// Create export object with metadata
const exportData = {
exportDate: new Date().toISOString(),
totalProblems: enhancedProblems.length,
filters: {
selectedTags: selectedTags.value,
searchQuery: appliedSearchQuery.value,
},
problems: enhancedProblems,
};
// Log to console
console.log("=== Jobs Export Data ===");
console.log(exportData);
console.log("========================");
// Create a JSON string with pretty formatting
const jsonString = JSON.stringify(exportData, null, 2);
// Create a blob from the JSON string
const blob = new Blob([jsonString], { type: "application/json" });
// Create a download link
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `jobs-export-${new Date().toISOString().split("T")[0]}.json`;
// Trigger the download
document.body.appendChild(link);
link.click();
// Clean up
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch (error) {
console.error("Error exporting jobs:", error);
}
};
// Bookmarks modal functions
const openBookmarksModal = () => {
isBookmarksModalOpen.value = true;
};
const closeBookmarksModal = () => {
isBookmarksModalOpen.value = false;
};
// Get 5 random jobs as bookmarks (mock data)
const bookmarkedJobs = computed(() => {
if (problems.value.length === 0) return [];
// Create a copy and shuffle
const shuffled = [...problems.value].sort(() => 0.5 - Math.random());
// Return first 5
return shuffled.slice(0, 5);
});
// Select a bookmarked job
const selectBookmarkedJob = (problem: ProblemsRecord) => {
closeBookmarksModal();
selectJob(problem);
};
// Apply the search query (use store)
const applySearch = async () => {
if (searchQuery.value.trim() === "") return;
console.log(
"[applySearch] searchQuery:",
searchQuery.value,
"appliedSearchQuery before:",
appliedSearchQuery.value,
);
// Use store action to set search query
await sessionStore.setSearchQuery(searchQuery.value);
// Clear the input for next search term
searchQuery.value = "";
console.log(
"[applySearch] appliedSearchQuery after:",
appliedSearchQuery.value,
);
};
// Handle Enter key on search input
const handleSearchKeydown = (event: KeyboardEvent) => {
if (!showSuggestions.value || searchSuggestions.value.length === 0) {
if (event.key === "Enter") {
applySearch();
}
return;
}
// Handle keyboard navigation in suggestions
if (event.key === "ArrowDown") {
event.preventDefault();
selectedSuggestionIndex.value = Math.min(
selectedSuggestionIndex.value + 1,
searchSuggestions.value.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) {
selectSuggestion(searchSuggestions.value[selectedSuggestionIndex.value]);
} else {
applySearch();
}
} else if (event.key === "Escape") {
showSuggestions.value = false;
selectedSuggestionIndex.value = -1;
}
};
// Handle search input changes
const handleSearchInput = () => {
showSuggestions.value = searchQuery.value.trim().length >= 2;
selectedSuggestionIndex.value = -1;
};
// Handle search input focus
const handleSearchFocus = () => {
if (searchQuery.value.trim().length >= 2) {
showSuggestions.value = true;
}
};
// Handle search input blur (with delay for click handling)
const handleSearchBlur = () => {
setTimeout(() => {
showSuggestions.value = false;
selectedSuggestionIndex.value = -1;
}, 200);
};
// Select a suggestion (use store for tags)
const selectSuggestion = async (suggestion: {
id: string;
name: string;
type: "tag" | "job";
}) => {
if (suggestion.type === "tag") {
// Add tag to selected tags using store
if (!selectedTags.value.includes(suggestion.id)) {
await sessionStore.selectTag(suggestion.id);
}
searchQuery.value = "";
showSuggestions.value = false;
selectedSuggestionIndex.value = -1;
} else if (suggestion.type === "job") {
// Find the problem and select it
const problem = problems.value.find((p) => p.id === suggestion.id);
if (problem) {
selectJob(problem);
}
searchQuery.value = "";
showSuggestions.value = false;
selectedSuggestionIndex.value = -1;
}
};
const clearSearch = async () => {
console.log(
"[clearSearch] Before clear - appliedSearchQuery:",
appliedSearchQuery.value,
"selectedTags:",
selectedTags.value.length,
);
// Reset all state to initial values using store
searchQuery.value = "";
await sessionStore.clearSearch();
console.log(
"[clearSearch] After clear - appliedSearchQuery:",
appliedSearchQuery.value,
"selectedTags:",
selectedTags.value.length,
);
};
// Remove a selected tag (deselect it)
const removeSelectedTag = async (tagId: string) => {
await sessionStore.selectTag(tagId); // Toggle it off
};
// Select a tag (toggle it on/off)
const selectTag = async (tagId: string) => {
await sessionStore.selectTag(tagId);
};
// Get tag name by ID
const getTagNameById = (tagId: string): string => {
const tag = problemTags.value.find((t) => t.id === tagId);
if (!tag || !tag.name) {
console.warn("[getTagNameById] Tag not found or has no name:", tagId);
return tagId; // Fallback to tagId if name not found
}
return tag.name;
};
// Sync selectedTags to URL (using tag names)
watch(selectedTags, (newTags) => {
if (isSyncingFromUrl.value) return;
// Convert tag IDs to tag names
const newTagNames = newTags
.map((tagId) => problemTags.value.find((t) => t.id === tagId)?.name)
.filter(Boolean);
const currentTagNames =
route.query.tags?.toString().split(",").filter(Boolean) || [];
const newTagNamesStr = newTagNames.join(",");
// Only update if different
if (newTagNamesStr !== currentTagNames.join(",")) {
router.replace({
query: {
...route.query,
tags: newTagNamesStr || undefined, // undefined removes param
},
});
}
});
// Sync appliedSearchQuery to URL
watch(appliedSearchQuery, (newQuery) => {
if (isSyncingFromUrl.value) return;
const currentQuery = route.query.q?.toString() || "";
if (newQuery !== currentQuery) {
router.replace({
query: {
...route.query,
q: newQuery || undefined,
},
});
}
});
// Sync from URL to state (handles both initial load and cached view navigation)
watch(
() => route.query,
async (newQuery) => {
// Wait for data to be loaded before syncing
if (problems.value.length === 0 || problemTags.value.length === 0) {
return;
}
isSyncingFromUrl.value = true;
try {
// Parse tag names from URL
const urlTagNames =
newQuery.tags?.toString().split(",").filter(Boolean) || [];
const urlQuery = newQuery.q?.toString() || "";
// Convert tag names to tag IDs
const urlTagIds = urlTagNames
.map(
(tagName) =>
problemTags.value.find((tag) => tag.name === tagName)?.id,
)
.filter(Boolean) as string[];
// Update tags if different
if (
JSON.stringify(urlTagIds.sort()) !==
JSON.stringify([...selectedTags.value].sort())
) {
// Clear existing tags and apply URL tags
const currentTags = [...selectedTags.value];
// Remove tags that aren't in URL
for (const tagId of currentTags) {
if (!urlTagIds.includes(tagId)) {
await sessionStore.selectTag(tagId);
}
}
// Add tags from URL that aren't selected
for (const tagId of urlTagIds) {
if (!selectedTags.value.includes(tagId)) {
await sessionStore.selectTag(tagId);
}
}
}
// Update search query if different
if (urlQuery !== appliedSearchQuery.value) {
if (urlQuery) {
// Clear existing query first
if (appliedSearchQuery.value) {
await sessionStore.clearSearch();
}
await sessionStore.setSearchQuery(urlQuery);
} else if (appliedSearchQuery.value) {
// URL has no query but we have one, clear it
await sessionStore.clearSearch();
}
}
} finally {
isSyncingFromUrl.value = false;
}
},
{ immediate: true },
);
onMounted(async () => {
// Load problems and tags from store (which loads from database)
await sessionStore.loadProblemsAndTags();
});
</script>
<style scoped>
.find-job-container {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
display: flex;
flex-direction: column;
height: calc(100vh - 40px);
}
.title-section {
margin-bottom: 32px;
}
.title-container {
position: relative;
margin-bottom: 24px;
}
.title-icons {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
display: flex;
gap: 8px;
}
.title-icons ion-icon {
font-size: 48px;
color: var(--ion-color-primary);
}
.back-icon-wrapper {
position: relative;
display: inline-block;
background-color: var(--ion-color-tertiary);
padding: 8px;
border-radius: 4px;
}
.star-icon-wrapper {
position: relative;
display: inline-block;
}
.star-icon-shadow {
position: absolute;
top: 2px;
left: -2px;
font-size: 48px;
z-index: 0;
}
.star-icon-front {
position: relative;
z-index: 1;
}
.title-icons-right {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
display: flex;
gap: 8px;
}
.title-icons-right ion-icon {
font-size: 48px;
color: var(--ion-color-primary);
}
.clickable-icon {
cursor: pointer;
transition:
transform 0.2s ease,
color 0.2s ease;
}
.clickable-icon:hover {
transform: scale(1.1);
color: var(--ion-color-primary-shade);
}
.clickable-icon:active {
transform: scale(0.95);
}
.page-title {
margin: 0;
color: #ffffff;
font-size: 48px;
font-weight: 700;
text-align: center;
font-variant: small-caps;
}
/* 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;
}
/* New Search Bar Section */
.new-search-bar-section {
margin-top: 32px;
margin-bottom: 32px;
width: 100%;
}
.new-search-title {
margin: 0 0 24px 0;
color: #ffffff;
font-size: 32px;
font-weight: 600;
text-align: center;
font-variant: small-caps;
}
.new-search-tags-container {
display: flex;
flex-wrap: wrap;
gap: 16px;
justify-content: center;
align-items: center;
padding: 24px;
background-color: #000000;
border-radius: 12px;
}
.new-search-tag-chip {
cursor: pointer;
margin: 0;
text-transform: lowercase;
font-size: 20px;
height: 48px;
padding: 0 24px;
white-space: nowrap;
}
.new-search-tag-chip:hover {
filter: brightness(1.1);
transform: scale(1.02);
transition: all 0.2s ease;
}
.new-search-term-chip {
/* Blue/secondary color chips for search terms */
}
.new-search-bar {
display: flex;
gap: 8px;
align-items: center;
background-color: var(--ion-color-dark);
padding: 12px;
border-radius: 8px;
margin-top: 24px;
}
.new-search-input-container {
flex: 1;
display: flex;
flex-direction: column;
position: relative;
}
.new-search-input {
width: 100%;
font-size: 20px;
min-height: 48px;
}
.new-search-undo-wrapper {
position: relative;
display: inline-block;
}
.new-search-undo-button {
margin: 0;
min-width: 44px;
}
.new-search-undo-shadow {
position: absolute;
top: 2px;
left: -2px;
z-index: 0;
}
.new-search-undo-front {
position: relative;
z-index: 1;
--color: var(--ion-color-tertiary);
}
/* Available jobs section - Google search result style */
.available-jobs-section {
margin-top: 24px;
width: 100%;
}
.search-results {
display: flex;
flex-direction: column;
gap: 20px;
}
.search-result-item {
cursor: pointer;
padding: 12px 0;
border-bottom: 1px solid var(--ion-color-medium);
transition: background-color 0.1s ease;
display: flex;
align-items: center;
gap: 16px;
}
.search-result-item:hover {
background-color: rgba(118, 221, 84, 0.1);
padding-left: 8px;
padding-right: 8px;
margin-left: -8px;
margin-right: -8px;
border-radius: 4px;
}
.search-result-item:last-child {
border-bottom: none;
}
.result-content {
flex: 1;
min-width: 0;
}
.result-title {
margin: 0 0 4px 0;
font-size: 20px;
font-weight: 400;
color: #ffffff;
line-height: 1.3;
}
.search-result-item:hover .result-title {
text-decoration: underline;
}
.result-description {
margin: 0;
font-size: 14px;
line-height: 1.58;
color: var(--ion-color-light);
max-width: 600px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.details-button {
--padding-start: 20px;
--padding-end: 20px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
flex-shrink: 0;
}
.result-description :deep(.hashtag) {
color: var(--ion-color-primary);
font-size: 12px;
}
.no-jobs-message {
text-align: center;
padding: 20px;
color: var(--ion-color-medium);
}
.no-jobs-message p {
margin: 0;
font-size: 14px;
}
/* Tablet adjustments */
@media (min-width: 768px) and (max-width: 1024px) {
.find-job-container {
padding: 20px 12px;
}
}
/* Mobile adjustments */
@media (max-width: 767px) {
.find-job-container {
padding: 16px 16px 140px 16px;
}
}
/* Job Details Modal Styles */
.job-details h2 {
margin: 0 0 16px 0;
font-size: 28px;
font-weight: 700;
color: var(--ion-color-primary);
}
.job-description {
margin-top: 16px;
}
.job-description p {
font-size: 16px;
line-height: 1.6;
color: var(--ion-color-light);
margin: 0;
}
.select-job-button {
margin: 8px;
font-weight: 600;
font-size: 18px;
}
/* Info Modal Styles */
.info-content {
font-size: 16px;
line-height: 1.6;
color: var(--ion-color-light);
}
.info-content p {
margin-bottom: 16px;
}
.info-content p:last-child {
margin-bottom: 0;
}
/* Debug Info Styles */
.debug-content {
font-size: 14px;
color: var(--ion-color-light);
}
.debug-summary {
margin-bottom: 24px;
padding: 16px;
background-color: var(--ion-color-dark);
border-radius: 8px;
}
.debug-summary p {
margin: 8px 0;
color: var(--ion-color-light);
}
.debug-summary p:first-child {
margin-top: 0;
}
.debug-summary p:last-child {
margin-bottom: 0;
}
.debug-summary strong {
color: var(--ion-color-primary);
}
.debug-tags-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.debug-tag-item {
display: flex;
gap: 12px;
padding: 12px;
background-color: var(--ion-color-dark);
border-radius: 8px;
border-left: 3px solid var(--ion-color-primary);
}
.debug-tag-rank {
flex-shrink: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--ion-color-primary);
color: #000000;
border-radius: 50%;
font-weight: 600;
font-size: 14px;
}
.debug-tag-details {
flex: 1;
}
.debug-tag-name {
font-weight: 600;
font-size: 16px;
margin-bottom: 4px;
color: var(--ion-color-light);
}
.debug-tag-stats {
font-size: 13px;
color: var(--ion-color-medium);
}
.debug-divider {
margin: 0 8px;
color: var(--ion-color-medium);
}
/* Action Bar */
.action-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background-color: var(--ion-color-primary);
padding: 20px;
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
z-index: 20001;
cursor: pointer;
transition: transform 0.1s ease;
}
.action-bar:hover {
background-color: var(--ion-color-primary-shade);
}
.action-bar:active {
transform: scale(0.98);
}
.action-bar-disabled {
background-color: var(--ion-color-medium);
cursor: not-allowed;
}
.action-bar-disabled:hover {
background-color: var(--ion-color-medium);
}
.action-bar-disabled:active {
transform: none;
}
.action-bar-content {
max-width: 1000px;
margin: 0 auto;
text-align: center;
}
.action-bar-text {
margin: 0;
font-size: 24px;
font-weight: 600;
color: #ffffff;
font-variant: small-caps;
}
/* Bookmarks Modal Styles */
.bookmarks-content {
font-size: 16px;
line-height: 1.6;
color: var(--ion-color-light);
}
.bookmarks-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.bookmark-item {
display: flex;
gap: 16px;
padding: 16px;
background-color: var(--ion-color-dark);
border-radius: 8px;
cursor: pointer;
transition: background-color 0.2s ease;
border-left: 3px solid var(--ion-color-warning);
}
.bookmark-item:hover {
background-color: rgba(118, 221, 84, 0.1);
}
.bookmark-star {
font-size: 24px;
flex-shrink: 0;
margin-top: 4px;
}
.bookmark-details {
flex: 1;
}
.bookmark-title {
margin: 0 0 8px 0;
font-size: 20px;
font-weight: 600;
color: var(--ion-color-light);
}
.bookmark-description {
margin: 0;
font-size: 14px;
line-height: 1.5;
color: var(--ion-color-medium);
max-width: 600px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.bookmark-description :deep(.hashtag) {
color: var(--ion-color-primary);
font-size: 12px;
}
/* CSV Preview Styles */
.csv-preview-container {
margin: 16px 0;
border: 1px solid var(--ion-color-medium);
border-radius: 8px;
background-color: var(--ion-color-light);
overflow: hidden;
}
.csv-preview {
margin: 0;
padding: 12px;
font-family: "Courier New", monospace;
font-size: 12px;
line-height: 1.4;
color: var(--ion-color-dark);
max-height: 300px;
overflow-y: auto;
overflow-x: auto;
white-space: pre;
}
.export-buttons {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 16px;
}
@media (min-width: 768px) {
.export-buttons {
flex-direction: row;
}
}
</style>