mirror of
https://github.com/lucide-icons/lucide.git
synced 2025-12-14 20:27:42 +01:00
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:
@@ -1,4 +1,4 @@
|
||||
name: Close Issue with Banned Phrases
|
||||
name: Close Icon Requests with Brand Terms
|
||||
|
||||
on:
|
||||
issues:
|
||||
@@ -13,23 +13,36 @@ jobs:
|
||||
- name: Checkout repository
|
||||
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: |
|
||||
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
|
||||
for PHRASE in "${BLOCKED_PHRASES[@]}"
|
||||
do
|
||||
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.
|
||||
jq -r 'to_entries[] | "\(.key) \(.value)"' brand-stopwords.json | while read -r KEY VALUE; do
|
||||
SAFE_KEY=$(printf '%s\n' "$KEY" | sed 's/[][\.^$*]/\\&/g')
|
||||
SAFE_VALUE=$(printf '%s\n' "$VALUE" | sed 's/[][\.^$*]/\\&/g')
|
||||
|
||||
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})
|
||||
|
||||
We’re 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
|
||||
exit 1
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -44,7 +44,7 @@ docs/.vitepress/data/releaseMetaData
|
||||
docs/.vitepress/data/categoriesData.json
|
||||
docs/.vitepress/data/iconDetails
|
||||
docs/.vitepress/data/relatedIcons.json
|
||||
docs/.vitepress/data/brandStopwords.json
|
||||
docs/.vercel
|
||||
docs/.nitro
|
||||
.gitignore
|
||||
|
||||
|
||||
149
brand-stopwords.json
Normal file
149
brand-stopwords.json
Normal 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"
|
||||
}
|
||||
@@ -1,19 +1,19 @@
|
||||
<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 useSearch from '../../composables/useSearch';
|
||||
import InputSearch from '../base/InputSearch.vue';
|
||||
import useSearchInput from '../../composables/useSearchInput';
|
||||
import useSearchShortcut from '../../utils/useSearchShortcut';
|
||||
import StickyBar from './StickyBar.vue';
|
||||
import IconsCategory from './IconsCategory.vue';
|
||||
import IconsCategory, { CategoryRow } from './IconsCategory.vue';
|
||||
import useFetchTags from '../../composables/useFetchTags';
|
||||
import useFetchCategories from '../../composables/useFetchCategories';
|
||||
import { useElementSize, useEventListener, useVirtualList } from '@vueuse/core';
|
||||
import chunkArray from '../../utils/chunkArray';
|
||||
import { CategoryRow } from './IconsCategory.vue';
|
||||
import useScrollToCategory from '../../composables/useScrollToCategory';
|
||||
import CarbonAdOverlay from './CarbonAdOverlay.vue';
|
||||
import useSearchPlaceholder from '../../utils/useSearchPlaceholder.ts';
|
||||
|
||||
const ICON_SIZE = 56;
|
||||
const ICON_GRID_GAP = 8;
|
||||
@@ -40,10 +40,10 @@ const { execute: fetchTags, data: tags } = useFetchTags();
|
||||
const { execute: fetchCategories, data: categoriesMap } = useFetchCategories();
|
||||
|
||||
const overviewEl = ref<HTMLElement | null>(null);
|
||||
const { width: containerWidth } = useElementSize(overviewEl)
|
||||
const { width: containerWidth } = useElementSize(overviewEl);
|
||||
|
||||
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(() => {
|
||||
@@ -71,26 +71,27 @@ const searchResults = useSearch(searchQueryDebounced, mappedIcons, [
|
||||
const categories = computed(() => {
|
||||
if (!props.categories?.length || !props.icons?.length) return [];
|
||||
|
||||
return props.categories
|
||||
.map(({ name, title }) => {
|
||||
const categoryIcons = props.icons.filter((icon) => {
|
||||
const iconCategories = icon?.externalLibrary ? icon.categories : props.iconCategories[icon.name]
|
||||
return props.categories.map(({ name, title }) => {
|
||||
const categoryIcons = props.icons.filter((icon) => {
|
||||
const iconCategories = icon?.externalLibrary
|
||||
? icon.categories
|
||||
: props.iconCategories[icon.name];
|
||||
|
||||
return iconCategories?.includes(name);
|
||||
});
|
||||
return iconCategories?.includes(name);
|
||||
});
|
||||
|
||||
const searchedCategoryIcons = isSearching
|
||||
? categoryIcons.filter((icon) =>
|
||||
searchResults.value.some((item) => item?.name === icon?.name)
|
||||
)
|
||||
: categoryIcons;
|
||||
const searchedCategoryIcons = isSearching
|
||||
? categoryIcons.filter((icon) =>
|
||||
searchResults.value.some((item) => item?.name === icon?.name),
|
||||
)
|
||||
: categoryIcons;
|
||||
|
||||
return {
|
||||
title,
|
||||
name,
|
||||
icons: searchedCategoryIcons,
|
||||
};
|
||||
})
|
||||
return {
|
||||
title,
|
||||
name,
|
||||
icons: searchedCategoryIcons,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const categoriesList = computed(() => {
|
||||
@@ -107,26 +108,24 @@ const categoriesList = computed(() => {
|
||||
return acc;
|
||||
}, []);
|
||||
});
|
||||
const searchPlaceholder = useSearchPlaceholder(searchQuery, searchResults);
|
||||
|
||||
const { list, containerProps, wrapperProps, scrollTo } = useVirtualList(
|
||||
categoriesList,
|
||||
{
|
||||
itemHeight: ICON_SIZE + ICON_GRID_GAP,
|
||||
overscan: 10
|
||||
},
|
||||
)
|
||||
const { list, containerProps, wrapperProps, scrollTo } = useVirtualList(categoriesList, {
|
||||
itemHeight: ICON_SIZE + ICON_GRID_GAP,
|
||||
overscan: 10,
|
||||
});
|
||||
|
||||
useScrollToCategory({
|
||||
categories,
|
||||
categoriesList,
|
||||
scrollTo,
|
||||
searchQueryDebounced,
|
||||
})
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
containerProps.ref.value = document.documentElement;
|
||||
useEventListener(window, 'scroll', containerProps.onScroll)
|
||||
})
|
||||
useEventListener(window, 'scroll', containerProps.onScroll);
|
||||
});
|
||||
|
||||
function onFocusSearchInput() {
|
||||
if (tags.value == null) {
|
||||
@@ -145,16 +144,13 @@ function handleCloseDrawer() {
|
||||
|
||||
window.history.pushState({}, '', '/icons/categories');
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
|
||||
console.log(props.icons.find((icon) => icon.name === 'burger'));
|
||||
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="overviewEl" class="overview-container">
|
||||
<div
|
||||
ref="overviewEl"
|
||||
class="overview-container"
|
||||
>
|
||||
<StickyBar class="category-search">
|
||||
<InputSearch
|
||||
:placeholder="`Search ${icons.length} icons ...`"
|
||||
@@ -166,8 +162,9 @@ watchEffect(() => {
|
||||
/>
|
||||
</StickyBar>
|
||||
<NoResults
|
||||
v-if="categories.length === 0"
|
||||
:searchQuery="searchQuery"
|
||||
v-if="searchPlaceholder.isNoResults"
|
||||
:searchQuery="searchPlaceholder.query"
|
||||
:isBrandSearch="searchPlaceholder.isBrand"
|
||||
@clear="searchQuery = ''"
|
||||
/>
|
||||
<div v-bind="wrapperProps">
|
||||
@@ -208,8 +205,4 @@ watchEffect(() => {
|
||||
.icons {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.overview-container {
|
||||
padding-bottom: 288px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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 { useElementSize, useEventListener, useVirtualList } from '@vueuse/core';
|
||||
import { useRoute } from 'vitepress';
|
||||
@@ -13,17 +13,18 @@ import useFetchTags from '../../composables/useFetchTags';
|
||||
import useFetchCategories from '../../composables/useFetchCategories';
|
||||
import chunkArray from '../../utils/chunkArray';
|
||||
import CarbonAdOverlay from './CarbonAdOverlay.vue';
|
||||
import useSearchPlaceholder from '../../utils/useSearchPlaceholder.ts';
|
||||
|
||||
const ICON_SIZE = 56;
|
||||
const ICON_GRID_GAP = 8;
|
||||
|
||||
const initialGridItems = computed(() => {
|
||||
if (containerWidth.value === 0) return 120;
|
||||
|
||||
|
||||
const itemsPerRow = columnSize.value || 10;
|
||||
const visibleRows = Math.ceil(window.innerHeight / (ICON_SIZE + ICON_GRID_GAP));
|
||||
|
||||
return Math.min(itemsPerRow * (visibleRows + 2), 200);
|
||||
return Math.min(itemsPerRow * (visibleRows + 2), 200);
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -36,10 +37,10 @@ const { execute: fetchTags, data: tags } = useFetchTags();
|
||||
const { execute: fetchCategories, data: categories } = useFetchCategories();
|
||||
|
||||
const overviewEl = ref<HTMLElement | null>(null);
|
||||
const { width: containerWidth } = useElementSize(overviewEl)
|
||||
const { width: containerWidth } = useElementSize(overviewEl);
|
||||
|
||||
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(() => {
|
||||
@@ -71,29 +72,27 @@ const searchResults = useSearch(searchQueryDebounced, mappedIcons, [
|
||||
{ name: 'tags', weight: 2 },
|
||||
{ name: 'categories', weight: 1 },
|
||||
]);
|
||||
const searchPlaceholder = useSearchPlaceholder(searchQuery, searchResults);
|
||||
|
||||
const chunkedIcons = computed(() => {
|
||||
return chunkArray(searchResults.value, columnSize.value);
|
||||
});
|
||||
|
||||
const { list, containerProps, wrapperProps, scrollTo } = useVirtualList(
|
||||
chunkedIcons,
|
||||
{
|
||||
itemHeight: ICON_SIZE + ICON_GRID_GAP,
|
||||
overscan: 10
|
||||
},
|
||||
)
|
||||
const { list, containerProps, wrapperProps, scrollTo } = useVirtualList(chunkedIcons, {
|
||||
itemHeight: ICON_SIZE + ICON_GRID_GAP,
|
||||
overscan: 10,
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
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
|
||||
const route = useRoute()
|
||||
const route = useRoute();
|
||||
if (route.data?.relativePath && window.location.search.includes('focus')) {
|
||||
searchInput.value?.focus()
|
||||
searchInput.value?.focus();
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
function setActiveIconName(name: string) {
|
||||
activeIconName.value = name;
|
||||
@@ -113,8 +112,8 @@ const NoResults = defineAsyncComponent(() => import('./NoResults.vue'));
|
||||
const IconDetailOverlay = defineAsyncComponent(() => import('./IconDetailOverlay.vue'));
|
||||
|
||||
watch(searchQueryDebounced, () => {
|
||||
scrollTo(0)
|
||||
})
|
||||
scrollTo(0);
|
||||
});
|
||||
|
||||
function handleCloseDrawer() {
|
||||
setActiveIconName('');
|
||||
@@ -124,7 +123,10 @@ function handleCloseDrawer() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="overviewEl" class="overview-container">
|
||||
<div
|
||||
ref="overviewEl"
|
||||
class="overview-container"
|
||||
>
|
||||
<StickyBar>
|
||||
<InputSearch
|
||||
:placeholder="`Search ${icons.length} icons ...`"
|
||||
@@ -136,8 +138,9 @@ function handleCloseDrawer() {
|
||||
/>
|
||||
</StickyBar>
|
||||
<NoResults
|
||||
v-if="searchResults.length === 0 && searchQuery !== ''"
|
||||
:searchQuery="searchQuery"
|
||||
v-if="searchPlaceholder.isNoResults"
|
||||
:searchQuery="searchPlaceholder.query"
|
||||
:isBrandSearch="searchPlaceholder.isBrand"
|
||||
@clear="searchQuery = ''"
|
||||
/>
|
||||
<IconGrid
|
||||
@@ -183,8 +186,4 @@ function handleCloseDrawer() {
|
||||
.input-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.overview-container {
|
||||
padding-bottom: 288px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,56 +1,218 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { bird, squirrel, rabbit } 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'
|
||||
import { ref, onMounted, computed, markRaw, shallowReadonly, watch } from 'vue';
|
||||
import {
|
||||
bird,
|
||||
squirrel,
|
||||
rabbit,
|
||||
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<{
|
||||
searchQuery: string
|
||||
}>()
|
||||
const { searchQuery, isBrandSearch } = defineProps<{
|
||||
searchQuery: string;
|
||||
isBrandSearch: boolean;
|
||||
}>();
|
||||
|
||||
defineEmits(['clear'])
|
||||
defineEmits(['clear']);
|
||||
|
||||
const animalIcon = ref<HTMLElement>()
|
||||
const randomAnimal = computed<IconNode>(() => {
|
||||
return Math.random() > 0.5 ? squirrel : Math.random() > 0.5 ? rabbit : bird
|
||||
})
|
||||
const animalComponent = computed(() => createLucideIcon('animal', randomAnimal.value))
|
||||
const flip = ref(false)
|
||||
interface Placeholder {
|
||||
title: string;
|
||||
message: string;
|
||||
icon: IconNode;
|
||||
finePrint?: string;
|
||||
}
|
||||
|
||||
const brandPlaceholders: Placeholder[] = shallowReadonly([
|
||||
{
|
||||
title: 'Boooo! What a scary brand logo!',
|
||||
message:
|
||||
'[name] and its friends often haunt this search box, but you won’t 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 didn’t 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 isn’t.',
|
||||
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: 'It’s 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: 'It’s Dangerous to Go Alone',
|
||||
message: 'Take this icon instead — it’s not [name], but it might help.',
|
||||
icon: markRaw(sword),
|
||||
},
|
||||
]);
|
||||
|
||||
const notFoundPlaceholders: Omit<Placeholder, 'title'>[] = shallowReadonly([
|
||||
{
|
||||
message: 'We’ve looked for this icon for a bird’s eye view, but could not find it.',
|
||||
icon: markRaw(bird),
|
||||
},
|
||||
{
|
||||
message: 'We checked every tree. Only acorns, no results.',
|
||||
icon: markRaw(squirrel),
|
||||
},
|
||||
{
|
||||
message: 'You’ve gone too deep into the rabbit hole — this icon doesn’t 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 hasn’t 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 doesn’t 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(() => {
|
||||
useEventListener(document, 'mousemove', (mouseEvent) => {
|
||||
const {width, height, x, y} = animalIcon.value.getBoundingClientRect()
|
||||
const { width, x } = placeholderIcon.value.getBoundingClientRect();
|
||||
|
||||
const centerX = (width / 2) + x
|
||||
|
||||
flip.value = mouseEvent.x < centerX
|
||||
})
|
||||
})
|
||||
const centerX = width / 2 + x;
|
||||
|
||||
flip.value = !isBrandSearch && mouseEvent.x < centerX;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="no-results">
|
||||
<component
|
||||
:is="animalComponent"
|
||||
class="animal-icon"
|
||||
ref="animalIcon"
|
||||
:is="iconComponent"
|
||||
class="placeholder-icon"
|
||||
ref="placeholderIcon"
|
||||
:class="{ flip }"
|
||||
:strokeWidth="1"
|
||||
/>
|
||||
<h2 class="no-results-text">
|
||||
No icons found for '{{ searchQuery }}'
|
||||
</h2>
|
||||
<h2 class="no-results-text">{{ placeholder.title.replace('[name]', searchQuery) }}</h2>
|
||||
<p class="no-results-message">
|
||||
{{ placeholder.message.replace('[name]', searchQuery) }}
|
||||
</p>
|
||||
<div class="divider"></div>
|
||||
<p
|
||||
v-if="placeholder.finePrint"
|
||||
class="no-results-fine-print"
|
||||
>
|
||||
{{ placeholder.finePrint }}
|
||||
</p>
|
||||
<VPButton
|
||||
text="Clear your search and try again"
|
||||
theme="alt"
|
||||
v-if="isBrandSearch"
|
||||
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')"
|
||||
/>
|
||||
<span class="text-divider">or</span>
|
||||
<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"
|
||||
:href="`https://github.com/lucide-icons/lucide/issues?q=is%3Aopen+${searchQuery}`"
|
||||
target="_blank"
|
||||
@@ -63,33 +225,38 @@ onMounted(() => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding-block: 48px;
|
||||
}
|
||||
|
||||
.animal-icon {
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
color: var(--vp-c-neutral);
|
||||
opacity: 0.8;
|
||||
margin-top: 72px;
|
||||
.placeholder-icon {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.animal-icon.flip {
|
||||
.placeholder-icon.flip {
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.animal-icon {
|
||||
width: 240px;
|
||||
height: 240px;
|
||||
}
|
||||
}
|
||||
|
||||
.no-results-text {
|
||||
line-height: 40px;
|
||||
line-height: 1.35;
|
||||
font-size: 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;
|
||||
text-align: center;
|
||||
color: var(--vp-c-text-2);
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.text-divider {
|
||||
@@ -97,4 +264,10 @@ onMounted(() => {
|
||||
font-size: 16px;
|
||||
color: var(--vp-c-neutral);
|
||||
}
|
||||
.divider {
|
||||
margin: 24px auto 18px;
|
||||
width: 64px;
|
||||
height: 1px;
|
||||
background-color: var(--vp-c-divider);
|
||||
}
|
||||
</style>
|
||||
|
||||
41
docs/.vitepress/theme/utils/useSearchPlaceholder.ts
Normal file
41
docs/.vitepress/theme/utils/useSearchPlaceholder.ts
Normal 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;
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
"prebuild:categoriesJson": "node --experimental-strip-types ./scripts/writeCategoriesMetadata.mjs",
|
||||
"prebuild:relatedIcons": "node --experimental-strip-types ./scripts/writeIconRelatedIcons.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",
|
||||
"dev": "npx nitropack dev",
|
||||
"prebuild:api": "npx nitropack prepare",
|
||||
|
||||
15
docs/scripts/writeBrandStopwords.mjs
Normal file
15
docs/scripts/writeBrandStopwords.mjs
Normal 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}`);
|
||||
});
|
||||
Reference in New Issue
Block a user