feat(site): add brand stop words to icon search (#3824)

* feat(site): added extended no results placeholder with brand icon stop words

* feat(site): fix grammatical error

* feat: extract brand stopwords & update github action to use these stopwords

* Apply suggestions from code review

Co-authored-by: Jakob Guddas <github@jguddas.de>

* feat: only use icon name section for closing brand request issues

* feat: added mcp brand stopword

---------

Co-authored-by: Jakob Guddas <github@jguddas.de>
Co-authored-by: Eric Fennis <eric.fennis@gmail.com>
This commit is contained in:
Karsa
2025-12-05 14:18:46 +01:00
committed by GitHub
parent 9076da5f1b
commit b4405f05ab
9 changed files with 515 additions and 131 deletions

View File

@@ -1,4 +1,4 @@
name: Close Issue with Banned Phrases name: Close Icon Requests with Brand Terms
on: on:
issues: issues:
@@ -13,23 +13,36 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@v6
- name: Check for blocked phrases in issue title - name: Load stopwords from JSON & check issue title & body
if: contains(github.event.issue.labels.*.name, '🙌 icon request')
run: | run: |
ISSUE_TITLE=$(jq -r '.issue.title' "$GITHUB_EVENT_PATH") ISSUE_TITLE=$(jq -r '.issue.title' "$GITHUB_EVENT_PATH")
BLOCKED_PHRASES=("twitter" "whatsapp" "logo" "google" "tiktok" "facebook" "slack" "discord" "bluesky" "spotify" "behance" "pix" "x.com" "telegram") ISSUE_BODY=$(jq -r '.issue.body // ""' "$GITHUB_EVENT_PATH")
ICON_NAME_SECTION=$(printf '%s\n' "$ISSUE_BODY" | awk '/### Icon name/{flag=1; next} /^### /{flag=0} flag')
# Check title and body for blocked phrases jq -r 'to_entries[] | "\(.key) \(.value)"' brand-stopwords.json | while read -r KEY VALUE; do
for PHRASE in "${BLOCKED_PHRASES[@]}" SAFE_KEY=$(printf '%s\n' "$KEY" | sed 's/[][\.^$*]/\\&/g')
do SAFE_VALUE=$(printf '%s\n' "$VALUE" | sed 's/[][\.^$*]/\\&/g')
if echo "$ISSUE_TITLE" | grep -i "$PHRASE"; then
gh issue close ${{ github.event.issue.number }} --reason "not planned" --comment "This looks like a duplicate, use the [search](https://github.com/lucide-icons/lucide/issues?q=is%3Aissue+$PHRASE) to find similar issues.
Read [our official statement about brand logos in Lucide](https://github.com/lucide-icons/lucide/blob/main/BRAND_LOGOS_STATEMENT.md). if echo "$ISSUE_TITLE" | grep -iqE "$SAFE_KEY|$SAFE_VALUE" || \
{ [ -n "$ICON_NAME_SECTION" ] && echo "$ICON_NAME_SECTION" | grep -iqE "$SAFE_KEY|$SAFE_VALUE"; }; then
gh issue close ${{ github.event.issue.number }} \
--reason "not_planned" \
--comment "It looks like this request is about **${VALUE}**, which is a brand logo.
Lucide **does not accept** brand logos, and we do not plan to add them in the future. This is due to a combination of **legal restrictions**, **design consistency concerns**, and **practical maintenance reasons**.
[Click here to read our official statement about brand logos in Lucide.](./BRAND_LOGOS_STATEMENT.md)
You can [search for similar issues.](https://github.com/lucide-icons/lucide/issues?q=is%3Aissue+${VALUE})
Were always happy to help on [Discord](https://discord.gg/EH6nSts)."
Always happy to help on [Discord](https://discord.gg/EH6nSts)."
gh issue lock ${{ github.event.issue.number }} --reason spam gh issue lock ${{ github.event.issue.number }} --reason spam
exit 1 exit 0
fi fi
done done
env:
env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}

2
.gitignore vendored
View File

@@ -44,7 +44,7 @@ docs/.vitepress/data/releaseMetaData
docs/.vitepress/data/categoriesData.json docs/.vitepress/data/categoriesData.json
docs/.vitepress/data/iconDetails docs/.vitepress/data/iconDetails
docs/.vitepress/data/relatedIcons.json docs/.vitepress/data/relatedIcons.json
docs/.vitepress/data/brandStopwords.json
docs/.vercel docs/.vercel
docs/.nitro docs/.nitro
.gitignore .gitignore

149
brand-stopwords.json Normal file
View File

@@ -0,0 +1,149 @@
{
"adobe": "Adobe",
"airplay": "AirPlay",
"amazon": "Amazon",
"angular": "Angular",
"aws": "AWS",
"azure": "Azure",
"bandcamp": "Bandcamp",
"behance": "Behance",
"bitbucket": "Bitbucket",
"blender": "Blender",
"bluesky": "BlueSky",
"bootstrap": "Bootstrap",
"brave": "Brave",
"chakra": "Chakra UI",
"chrome": "Chrome",
"codepen": "Codepen",
"codesandbox": "CodeSandbox",
"csharp": "C#",
"cypress": "Cypress",
"dart": "Dart",
"deezer": "Deezer",
"deno": "Deno",
"discord": "Discord",
"docker": "Docker",
"dribbble": "Dribbble",
"dropbox": "Dropbox",
"edge": "Edge",
"ember": "Ember",
"epic": "Epic Games",
"erlang": "Erlang",
"esbuild": "esbuild",
"eslint": "ESLint",
"facebook": "Facebook",
"figjam": "FigJam",
"figma": "Figma",
"firebase": "Firebase",
"firefox": "Firefox",
"framer": "Framer",
"gatsby": "Gatsby",
"gcp": "Google Cloud",
"github": "GitHub",
"gitlab": "GitLab",
"golang": "GoLang",
"google": "Google",
"gmail": "Gmail",
"gravatar": "Gravatar",
"haskell": "Haskell",
"instagram": "Instagram",
"java": "Java",
"javascript": "JavaScript",
"jest": "Jest",
"jira": "Jira",
"kotlin": "Kotlin",
"kubernetes": "Kubernetes",
"less": "Less",
"leetcode": "LeetCode",
"leet-code": "LeetCode",
"line": "LINE",
"linkedin": "LinkedIn",
"lua": "Lua",
"mariadb": "MariaDB",
"mcp": "MCP",
"messenger": "Messenger",
"microsoft": "Microsoft",
"mongodb": "MongoDB",
"mui": "Material UI",
"mysql": "MySQL",
"nestjs": "NestJS",
"netflix": "Netflix",
"netlify": "Netlify",
"next": "Next.js",
"nodejs": "Node.js",
"notion": "Notion",
"nostr": "Nostr",
"npm": "npm",
"nuxt": "Nuxt",
"opera": "Opera",
"oracle": "Oracle",
"patreon": "Patreon",
"paypal": "PayPal",
"perl": "Perl",
"php": "PHP",
"pinterest": "Pinterest",
"pix": "PiX",
"playstation": "PlayStation",
"playwright": "Playwright",
"pnpm": "pnpm",
"postcss": "PostCSS",
"postgresql": "PostgreSQL",
"prettier": "Prettier",
"prisma": "Prisma",
"python": "Python",
"qwik": "Qwik",
"react": "React",
"reddit": "Reddit",
"redis": "Redis",
"rollup": "Rollup",
"rust": "Rust",
"safari": "Safari",
"sass": "Sass",
"scala": "Scala",
"scss": "Sass",
"semantic": "Semantic UI",
"shopify": "Shopify",
"skype": "Skype",
"slack": "Slack",
"solid": "SolidJS",
"soundcloud": "SoundCloud",
"spotify": "Spotify",
"sqlite": "SQLite",
"squarespace": "Squarespace",
"steam": "Steam",
"stripe": "Stripe",
"substack": "Substack",
"supabase": "Supabase",
"surge": "Surge",
"svelte": "Svelte",
"swift": "Swift",
"tailwind": "Tailwind CSS",
"telegram": "Telegram",
"terraform": "Terraform",
"tesla": "Tesla",
"tidal": "Tidal",
"tiktok": "TikTok",
"trello": "Trello",
"twitch": "Twitch",
"twitter": "Twitter",
"typescript": "TypeScript",
"unity": "Unity",
"unreal": "Unreal Engine",
"vercel": "Vercel",
"vimeo": "Vimeo",
"vite": "Vite",
"vitest": "Vitest",
"vue": "Vue",
"webpack": "Webpack",
"wechat": "WeChat",
"whatsapp": "WhatsApp",
"windows": "Windows",
"wix": "Wix",
"x.com": "X.com",
"x-social": "X.com",
"xbox": "Xbox",
"yarn": "Yarn",
"youtube": "YouTube",
"zig": "Zig",
"zoom": "Zoom"
}

View File

@@ -1,19 +1,19 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, defineAsyncComponent, onMounted, watch, watchEffect } from 'vue'; import { ref, computed, defineAsyncComponent, onMounted } from 'vue';
import type { IconEntity, Category } from '../../types'; import type { IconEntity, Category } from '../../types';
import useSearch from '../../composables/useSearch'; import useSearch from '../../composables/useSearch';
import InputSearch from '../base/InputSearch.vue'; import InputSearch from '../base/InputSearch.vue';
import useSearchInput from '../../composables/useSearchInput'; import useSearchInput from '../../composables/useSearchInput';
import useSearchShortcut from '../../utils/useSearchShortcut'; import useSearchShortcut from '../../utils/useSearchShortcut';
import StickyBar from './StickyBar.vue'; import StickyBar from './StickyBar.vue';
import IconsCategory from './IconsCategory.vue'; import IconsCategory, { CategoryRow } from './IconsCategory.vue';
import useFetchTags from '../../composables/useFetchTags'; import useFetchTags from '../../composables/useFetchTags';
import useFetchCategories from '../../composables/useFetchCategories'; import useFetchCategories from '../../composables/useFetchCategories';
import { useElementSize, useEventListener, useVirtualList } from '@vueuse/core'; import { useElementSize, useEventListener, useVirtualList } from '@vueuse/core';
import chunkArray from '../../utils/chunkArray'; import chunkArray from '../../utils/chunkArray';
import { CategoryRow } from './IconsCategory.vue';
import useScrollToCategory from '../../composables/useScrollToCategory'; import useScrollToCategory from '../../composables/useScrollToCategory';
import CarbonAdOverlay from './CarbonAdOverlay.vue'; import CarbonAdOverlay from './CarbonAdOverlay.vue';
import useSearchPlaceholder from '../../utils/useSearchPlaceholder.ts';
const ICON_SIZE = 56; const ICON_SIZE = 56;
const ICON_GRID_GAP = 8; const ICON_GRID_GAP = 8;
@@ -40,10 +40,10 @@ const { execute: fetchTags, data: tags } = useFetchTags();
const { execute: fetchCategories, data: categoriesMap } = useFetchCategories(); const { execute: fetchCategories, data: categoriesMap } = useFetchCategories();
const overviewEl = ref<HTMLElement | null>(null); const overviewEl = ref<HTMLElement | null>(null);
const { width: containerWidth } = useElementSize(overviewEl) const { width: containerWidth } = useElementSize(overviewEl);
const columnSize = computed(() => { const columnSize = computed(() => {
return Math.floor((containerWidth.value) / ((ICON_SIZE + ICON_GRID_GAP))); return Math.floor(containerWidth.value / (ICON_SIZE + ICON_GRID_GAP));
}); });
const mappedIcons = computed(() => { const mappedIcons = computed(() => {
@@ -71,17 +71,18 @@ const searchResults = useSearch(searchQueryDebounced, mappedIcons, [
const categories = computed(() => { const categories = computed(() => {
if (!props.categories?.length || !props.icons?.length) return []; if (!props.categories?.length || !props.icons?.length) return [];
return props.categories return props.categories.map(({ name, title }) => {
.map(({ name, title }) => {
const categoryIcons = props.icons.filter((icon) => { const categoryIcons = props.icons.filter((icon) => {
const iconCategories = icon?.externalLibrary ? icon.categories : props.iconCategories[icon.name] const iconCategories = icon?.externalLibrary
? icon.categories
: props.iconCategories[icon.name];
return iconCategories?.includes(name); return iconCategories?.includes(name);
}); });
const searchedCategoryIcons = isSearching const searchedCategoryIcons = isSearching
? categoryIcons.filter((icon) => ? categoryIcons.filter((icon) =>
searchResults.value.some((item) => item?.name === icon?.name) searchResults.value.some((item) => item?.name === icon?.name),
) )
: categoryIcons; : categoryIcons;
@@ -90,7 +91,7 @@ const categories = computed(() => {
name, name,
icons: searchedCategoryIcons, icons: searchedCategoryIcons,
}; };
}) });
}); });
const categoriesList = computed(() => { const categoriesList = computed(() => {
@@ -107,26 +108,24 @@ const categoriesList = computed(() => {
return acc; return acc;
}, []); }, []);
}); });
const searchPlaceholder = useSearchPlaceholder(searchQuery, searchResults);
const { list, containerProps, wrapperProps, scrollTo } = useVirtualList( const { list, containerProps, wrapperProps, scrollTo } = useVirtualList(categoriesList, {
categoriesList,
{
itemHeight: ICON_SIZE + ICON_GRID_GAP, itemHeight: ICON_SIZE + ICON_GRID_GAP,
overscan: 10 overscan: 10,
}, });
)
useScrollToCategory({ useScrollToCategory({
categories, categories,
categoriesList, categoriesList,
scrollTo, scrollTo,
searchQueryDebounced, searchQueryDebounced,
}) });
onMounted(() => { onMounted(() => {
containerProps.ref.value = document.documentElement; containerProps.ref.value = document.documentElement;
useEventListener(window, 'scroll', containerProps.onScroll) useEventListener(window, 'scroll', containerProps.onScroll);
}) });
function onFocusSearchInput() { function onFocusSearchInput() {
if (tags.value == null) { if (tags.value == null) {
@@ -145,16 +144,13 @@ function handleCloseDrawer() {
window.history.pushState({}, '', '/icons/categories'); window.history.pushState({}, '', '/icons/categories');
} }
watchEffect(() => {
console.log(props.icons.find((icon) => icon.name === 'burger'));
});
</script> </script>
<template> <template>
<div ref="overviewEl" class="overview-container"> <div
ref="overviewEl"
class="overview-container"
>
<StickyBar class="category-search"> <StickyBar class="category-search">
<InputSearch <InputSearch
:placeholder="`Search ${icons.length} icons ...`" :placeholder="`Search ${icons.length} icons ...`"
@@ -166,8 +162,9 @@ watchEffect(() => {
/> />
</StickyBar> </StickyBar>
<NoResults <NoResults
v-if="categories.length === 0" v-if="searchPlaceholder.isNoResults"
:searchQuery="searchQuery" :searchQuery="searchPlaceholder.query"
:isBrandSearch="searchPlaceholder.isBrand"
@clear="searchQuery = ''" @clear="searchQuery = ''"
/> />
<div v-bind="wrapperProps"> <div v-bind="wrapperProps">
@@ -208,8 +205,4 @@ watchEffect(() => {
.icons { .icons {
margin-bottom: 8px; margin-bottom: 8px;
} }
.overview-container {
padding-bottom: 288px;
}
</style> </style>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, defineAsyncComponent, onMounted, onBeforeUnmount, watch } from 'vue'; import { ref, computed, defineAsyncComponent, onMounted, watch } from 'vue';
import type { IconEntity } from '../../types'; import type { IconEntity } from '../../types';
import { useElementSize, useEventListener, useVirtualList } from '@vueuse/core'; import { useElementSize, useEventListener, useVirtualList } from '@vueuse/core';
import { useRoute } from 'vitepress'; import { useRoute } from 'vitepress';
@@ -13,6 +13,7 @@ import useFetchTags from '../../composables/useFetchTags';
import useFetchCategories from '../../composables/useFetchCategories'; import useFetchCategories from '../../composables/useFetchCategories';
import chunkArray from '../../utils/chunkArray'; import chunkArray from '../../utils/chunkArray';
import CarbonAdOverlay from './CarbonAdOverlay.vue'; import CarbonAdOverlay from './CarbonAdOverlay.vue';
import useSearchPlaceholder from '../../utils/useSearchPlaceholder.ts';
const ICON_SIZE = 56; const ICON_SIZE = 56;
const ICON_GRID_GAP = 8; const ICON_GRID_GAP = 8;
@@ -36,10 +37,10 @@ const { execute: fetchTags, data: tags } = useFetchTags();
const { execute: fetchCategories, data: categories } = useFetchCategories(); const { execute: fetchCategories, data: categories } = useFetchCategories();
const overviewEl = ref<HTMLElement | null>(null); const overviewEl = ref<HTMLElement | null>(null);
const { width: containerWidth } = useElementSize(overviewEl) const { width: containerWidth } = useElementSize(overviewEl);
const columnSize = computed(() => { const columnSize = computed(() => {
return Math.floor((containerWidth.value) / ((ICON_SIZE + ICON_GRID_GAP))); return Math.floor(containerWidth.value / (ICON_SIZE + ICON_GRID_GAP));
}); });
const mappedIcons = computed(() => { const mappedIcons = computed(() => {
@@ -71,29 +72,27 @@ const searchResults = useSearch(searchQueryDebounced, mappedIcons, [
{ name: 'tags', weight: 2 }, { name: 'tags', weight: 2 },
{ name: 'categories', weight: 1 }, { name: 'categories', weight: 1 },
]); ]);
const searchPlaceholder = useSearchPlaceholder(searchQuery, searchResults);
const chunkedIcons = computed(() => { const chunkedIcons = computed(() => {
return chunkArray(searchResults.value, columnSize.value); return chunkArray(searchResults.value, columnSize.value);
}); });
const { list, containerProps, wrapperProps, scrollTo } = useVirtualList( const { list, containerProps, wrapperProps, scrollTo } = useVirtualList(chunkedIcons, {
chunkedIcons,
{
itemHeight: ICON_SIZE + ICON_GRID_GAP, itemHeight: ICON_SIZE + ICON_GRID_GAP,
overscan: 10 overscan: 10,
}, });
)
onMounted(() => { onMounted(() => {
containerProps.ref.value = document.documentElement; containerProps.ref.value = document.documentElement;
useEventListener(window, 'scroll', containerProps.onScroll) useEventListener(window, 'scroll', containerProps.onScroll);
// Check if we should focus the search input from URL parameter // Check if we should focus the search input from URL parameter
const route = useRoute() const route = useRoute();
if (route.data?.relativePath && window.location.search.includes('focus')) { if (route.data?.relativePath && window.location.search.includes('focus')) {
searchInput.value?.focus() searchInput.value?.focus();
} }
}) });
function setActiveIconName(name: string) { function setActiveIconName(name: string) {
activeIconName.value = name; activeIconName.value = name;
@@ -113,8 +112,8 @@ const NoResults = defineAsyncComponent(() => import('./NoResults.vue'));
const IconDetailOverlay = defineAsyncComponent(() => import('./IconDetailOverlay.vue')); const IconDetailOverlay = defineAsyncComponent(() => import('./IconDetailOverlay.vue'));
watch(searchQueryDebounced, () => { watch(searchQueryDebounced, () => {
scrollTo(0) scrollTo(0);
}) });
function handleCloseDrawer() { function handleCloseDrawer() {
setActiveIconName(''); setActiveIconName('');
@@ -124,7 +123,10 @@ function handleCloseDrawer() {
</script> </script>
<template> <template>
<div ref="overviewEl" class="overview-container"> <div
ref="overviewEl"
class="overview-container"
>
<StickyBar> <StickyBar>
<InputSearch <InputSearch
:placeholder="`Search ${icons.length} icons ...`" :placeholder="`Search ${icons.length} icons ...`"
@@ -136,8 +138,9 @@ function handleCloseDrawer() {
/> />
</StickyBar> </StickyBar>
<NoResults <NoResults
v-if="searchResults.length === 0 && searchQuery !== ''" v-if="searchPlaceholder.isNoResults"
:searchQuery="searchQuery" :searchQuery="searchPlaceholder.query"
:isBrandSearch="searchPlaceholder.isBrand"
@clear="searchQuery = ''" @clear="searchQuery = ''"
/> />
<IconGrid <IconGrid
@@ -183,8 +186,4 @@ function handleCloseDrawer() {
.input-wrapper { .input-wrapper {
width: 100%; width: 100%;
} }
.overview-container {
padding-bottom: 288px;
}
</style> </style>

View File

@@ -1,56 +1,218 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed, markRaw, shallowReadonly, watch } from 'vue';
import { bird, squirrel, rabbit } from '../../../data/iconNodes' import {
import createLucideIcon from 'lucide-vue-next/src/createLucideIcon' bird,
import {useEventListener} from '@vueuse/core' squirrel,
import VPButton from 'vitepress/dist/client/theme-default/components/VPButton.vue' rabbit,
import { IconNode } from '../../types' ghost,
castle,
drama,
dog,
cat,
wandSparkles,
save,
snowflake,
cake,
fish,
turtle,
rat,
worm,
testTubeDiagonal,
sword,
} from '../../../data/iconNodes';
import createLucideIcon from 'lucide-vue-next/src/createLucideIcon';
import { useEventListener } from '@vueuse/core';
import VPButton from 'vitepress/dist/client/theme-default/components/VPButton.vue';
import { IconNode } from '../../types';
defineProps<{ const { searchQuery, isBrandSearch } = defineProps<{
searchQuery: string searchQuery: string;
}>() isBrandSearch: boolean;
}>();
defineEmits(['clear']) defineEmits(['clear']);
const animalIcon = ref<HTMLElement>() interface Placeholder {
const randomAnimal = computed<IconNode>(() => { title: string;
return Math.random() > 0.5 ? squirrel : Math.random() > 0.5 ? rabbit : bird message: string;
}) icon: IconNode;
const animalComponent = computed(() => createLucideIcon('animal', randomAnimal.value)) finePrint?: string;
const flip = ref(false) }
const brandPlaceholders: Placeholder[] = shallowReadonly([
{
title: 'Boooo! What a scary brand logo!',
message:
'[name] and its friends often haunt this search box, but you wont ever find them here.',
icon: markRaw(ghost),
},
{
title: 'Thank You Mario!',
message: 'But [name] is in another castle!',
icon: markRaw(castle),
},
{
title: '[name] did audition for our icon set',
message: '...but didnt make the callback.',
icon: markRaw(drama),
},
{
title: 'Such Search. Very [name].',
message: 'Much not here. So Wow.',
icon: markRaw(dog),
},
{
title: 'I Can Has [name]?',
message: 'No [name] for you in here.',
icon: markRaw(cat),
},
{
title: 'Loading [name]...',
message: 'Fatal error: our cartridge contains only open-source pixels.',
icon: markRaw(save),
},
{
title: '[name] Shall Not Pass',
message: 'Do not look to its coming at first light of any day.',
icon: markRaw(wandSparkles),
},
{
title: 'Winter is coming',
message: 'But [name] sure isnt.',
icon: markRaw(snowflake),
},
{
title: 'The cake is a lie',
message: 'And so is the promise of an icon for [name] at Lucide.',
icon: markRaw(cake),
},
{
title: 'Its not a bug',
message: 'Having no [name] icon around is a feature.',
icon: markRaw(worm),
},
{
title: 'The lab exploded',
message: 'We tried mixing [name] with open-source icons.',
icon: markRaw(testTubeDiagonal),
},
{
title: 'Its Dangerous to Go Alone',
message: 'Take this icon instead — its not [name], but it might help.',
icon: markRaw(sword),
},
]);
const notFoundPlaceholders: Omit<Placeholder, 'title'>[] = shallowReadonly([
{
message: 'Weve looked for this icon for a birds eye view, but could not find it.',
icon: markRaw(bird),
},
{
message: 'We checked every tree. Only acorns, no results.',
icon: markRaw(squirrel),
},
{
message: 'Youve gone too deep into the rabbit hole — this icon doesnt exist.',
icon: markRaw(rabbit),
},
{
message: 'This icon seems to have slipped through the net.',
icon: markRaw(fish),
},
{
message: 'This icon might exist in the future… but it hasnt arrived yet.',
icon: markRaw(turtle),
},
{
message: 'Rats! This icon seems to have slipped through the cracks.',
icon: markRaw(rat),
},
]);
function randomItem<T>(arr: T[]): T {
return arr[Math.floor(Math.random() * arr.length)];
}
const placeholderIcon = ref<HTMLElement>();
const placeholder = ref<Placeholder>();
watch(
() => isBrandSearch,
() => {
placeholder.value = isBrandSearch
? {
...randomItem(brandPlaceholders),
finePrint:
'Lucide does not accept brand logos, and we do not plan to add them in the future. This is due to a combination of legal restrictions, design consistency concerns, and practical maintenance reasons.',
}
: {
title: `No results for “[name]”`,
finePrint:
'This icon doesnt seem to exist… yet. Try searching similar terms, browsing existing requests, or opening a new one.',
...randomItem(notFoundPlaceholders),
};
},
{ immediate: true },
);
const iconComponent = computed(() => createLucideIcon('placeholder', placeholder.value.icon));
const flip = ref(false);
onMounted(() => { onMounted(() => {
useEventListener(document, 'mousemove', (mouseEvent) => { useEventListener(document, 'mousemove', (mouseEvent) => {
const {width, height, x, y} = animalIcon.value.getBoundingClientRect() const { width, x } = placeholderIcon.value.getBoundingClientRect();
const centerX = (width / 2) + x const centerX = width / 2 + x;
flip.value = mouseEvent.x < centerX
})
})
flip.value = !isBrandSearch && mouseEvent.x < centerX;
});
});
</script> </script>
<template> <template>
<div class="no-results"> <div class="no-results">
<component <component
:is="animalComponent" :is="iconComponent"
class="animal-icon" class="placeholder-icon"
ref="animalIcon" ref="placeholderIcon"
:class="{ flip }" :class="{ flip }"
:strokeWidth="1" :strokeWidth="1"
/> />
<h2 class="no-results-text"> <h2 class="no-results-text">{{ placeholder.title.replace('[name]', searchQuery) }}</h2>
No icons found for '{{ searchQuery }}' <p class="no-results-message">
</h2> {{ placeholder.message.replace('[name]', searchQuery) }}
</p>
<div class="divider"></div>
<p
v-if="placeholder.finePrint"
class="no-results-fine-print"
>
{{ placeholder.finePrint }}
</p>
<VPButton <VPButton
text="Clear your search and try again" v-if="isBrandSearch"
theme="alt" text="Head over to Simple Icons"
theme="brand"
:href="`https://simpleicons.org/?q=${searchQuery}`"
target="_blank"
/>
<VPButton
v-else
text="Clear search & try again"
theme="brand"
@click="$emit('clear')" @click="$emit('clear')"
/> />
<span class="text-divider">or</span> <span class="text-divider">or</span>
<VPButton <VPButton
text="Search on Github issues" v-if="isBrandSearch"
text="Read our statement on brand logos"
theme="alt"
href="https://github.com/lucide-icons/lucide/blob/main/BRAND_LOGOS_STATEMENT.md"
target="_blank"
/>
<VPButton
v-else
text="Search GitHub issues"
theme="alt" theme="alt"
:href="`https://github.com/lucide-icons/lucide/issues?q=is%3Aopen+${searchQuery}`" :href="`https://github.com/lucide-icons/lucide/issues?q=is%3Aopen+${searchQuery}`"
target="_blank" target="_blank"
@@ -63,33 +225,38 @@ onMounted(() => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
text-align: center;
padding-block: 48px;
} }
.animal-icon { .placeholder-icon {
width: 160px; width: 96px;
height: 160px; height: 96px;
color: var(--vp-c-neutral); color: var(--vp-c-text-1);
opacity: 0.8;
margin-top: 72px;
} }
.animal-icon.flip { .placeholder-icon.flip {
transform: rotateY(180deg); transform: rotateY(180deg);
} }
@media (min-width: 960px) {
.animal-icon {
width: 240px;
height: 240px;
}
}
.no-results-text { .no-results-text {
line-height: 40px; line-height: 1.35;
font-size: 24px; font-size: 24px;
margin-top: 24px; margin-top: 24px;
margin-bottom: 8px;
text-wrap: balance;
}
.no-results-message {
text-wrap: balance;
}
.no-results-fine-print {
max-inline-size: 60ch;
font-size: 14px;
margin-bottom: 32px; margin-bottom: 32px;
text-align: center; color: var(--vp-c-text-2);
text-wrap: balance;
} }
.text-divider { .text-divider {
@@ -97,4 +264,10 @@ onMounted(() => {
font-size: 16px; font-size: 16px;
color: var(--vp-c-neutral); color: var(--vp-c-neutral);
} }
.divider {
margin: 24px auto 18px;
width: 64px;
height: 1px;
background-color: var(--vp-c-divider);
}
</style> </style>

View File

@@ -0,0 +1,41 @@
import { ref, Ref, watch } from 'vue';
import BRAND_STOPWORDS from '../../data/brandStopwords.json' with { type: 'json' };
export default function useSearchPlaceholder(
searchQuery: Ref<string, string>,
results: Ref<{ name: string }[]>,
) {
const state = ref({
isNoResults: false,
isBrand: false,
query: '',
});
watch(
results,
() => {
const query = searchQuery.value;
const searchResults = results.value;
if (query.length > 0 && searchResults.length === 0) {
for (const stopword of Object.keys(BRAND_STOPWORDS)) {
if (stopword.startsWith(query)) {
state.value = {
isNoResults: true,
isBrand: true,
query: BRAND_STOPWORDS[stopword],
};
return;
}
}
}
state.value = {
isNoResults: query in BRAND_STOPWORDS || (searchResults.length === 0 && query !== ''),
isBrand: query in BRAND_STOPWORDS,
query: BRAND_STOPWORDS[query] ?? query,
};
},
{ immediate: true },
);
return state;
}

View File

@@ -15,6 +15,7 @@
"prebuild:categoriesJson": "node --experimental-strip-types ./scripts/writeCategoriesMetadata.mjs", "prebuild:categoriesJson": "node --experimental-strip-types ./scripts/writeCategoriesMetadata.mjs",
"prebuild:relatedIcons": "node --experimental-strip-types ./scripts/writeIconRelatedIcons.mjs", "prebuild:relatedIcons": "node --experimental-strip-types ./scripts/writeIconRelatedIcons.mjs",
"prebuild:iconDetails": "node --experimental-strip-types ./scripts/writeIconDetails.mjs", "prebuild:iconDetails": "node --experimental-strip-types ./scripts/writeIconDetails.mjs",
"prebuild:brandStopwords": "node --experimental-strip-types ./scripts/writeBrandStopwords.mjs",
"postbuild:vercelJson": "node --experimental-strip-types ./scripts/writeVercelOutput.mjs", "postbuild:vercelJson": "node --experimental-strip-types ./scripts/writeVercelOutput.mjs",
"dev": "npx nitropack dev", "dev": "npx nitropack dev",
"prebuild:api": "npx nitropack prepare", "prebuild:api": "npx nitropack prepare",

View File

@@ -0,0 +1,15 @@
import fs from 'fs/promises';
import path from 'path';
const currentDir = process.cwd();
const dataDirectory = path.resolve(currentDir, '.vitepress/data');
const stopwordsSource = path.resolve(currentDir, `../brand-stopwords.json`);
const stopwordsFile = path.resolve(dataDirectory, `brandStopwords.json`);
fs.copyFile(stopwordsSource, stopwordsFile)
.then(() => {
console.log('Successfully copied brandStopwords.json file');
})
.catch((error) => {
throw new Error(`Something went wrong generating the brandStopwords.json file,\n ${error}`);
});