From 13c4e0105ca030a96ee2c6025fde701b0f595e6b Mon Sep 17 00:00:00 2001 From: Patrick Bacon-Blaber Date: Sun, 15 Feb 2026 16:07:59 -0500 Subject: [PATCH] feat: allow links to have custom icons via url or emoji --- src/App.css | 209 ++++++++++++++++++ .../components/EmojiPicker.tsx | 111 ++++++++++ .../components/LinksTab.tsx | 140 +++++++++++- src/screens/HomeScreen/HomeScreen.tsx | 19 +- .../HomeScreen/components/LinkItem.test.tsx | 27 ++- .../HomeScreen/components/LinkItem.tsx | 21 +- .../HomeScreen/components/SearchBar.tsx | 21 +- .../HomeScreen/components/iconDisplay.ts | 21 ++ src/test/fixtures.ts | 12 + src/types/config.ts | 2 + 10 files changed, 545 insertions(+), 38 deletions(-) create mode 100644 src/screens/ConfigEditorScreen/components/EmojiPicker.tsx create mode 100644 src/screens/HomeScreen/components/iconDisplay.ts diff --git a/src/App.css b/src/App.css index 146ac4e..e932a57 100644 --- a/src/App.css +++ b/src/App.css @@ -54,6 +54,11 @@ border-radius: 4px; } +.navigating-emoji { + font-size: 1.5rem; + line-height: 1; +} + .navigating-text { font-size: 1.3rem; opacity: 0.6; @@ -201,6 +206,14 @@ border-radius: 3px; } +.search-dropdown-emoji { + flex-shrink: 0; + font-size: 1rem; + line-height: 1; + width: 16px; + text-align: center; +} + .search-dropdown-label { font-size: 0.95rem; } @@ -266,6 +279,14 @@ border-radius: 4px; } +.link-item-emoji { + flex-shrink: 0; + font-size: 1.15rem; + line-height: 1; + width: 20px; + text-align: center; +} + .link-item-label { font-size: 0.95rem; } @@ -1026,6 +1047,194 @@ background: rgba(var(--fg), 0.18); } +/* Emoji Picker */ +.emoji-picker { + width: 90%; + max-width: 380px; + max-height: 420px; + background: rgba(var(--dropdown-bg), 0.97); + border: 1px solid rgba(var(--fg), 0.1); + border-radius: 14px; + padding: 16px; + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.3); + display: flex; + flex-direction: column; +} + +.emoji-picker-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; +} + +.emoji-picker-search { + flex: 1; +} + +.emoji-picker-tabs { + display: flex; + gap: 2px; + margin-bottom: 12px; + overflow-x: auto; + padding-bottom: 4px; +} + +.emoji-picker-tab { + padding: 5px 10px; + font-size: 0.75rem; + font-family: inherit; + border: none; + border-radius: 6px; + background: rgba(var(--fg), 0.05); + color: rgba(var(--fg), 0.45); + cursor: pointer; + transition: background 150ms, color 150ms; + white-space: nowrap; + flex-shrink: 0; +} + +.emoji-picker-tab:hover { + background: rgba(var(--fg), 0.1); + color: rgba(var(--fg), 0.7); +} + +.emoji-picker-tab.active { + background: rgba(var(--fg), 0.12); + color: rgb(var(--fg)); +} + +.emoji-picker-grid { + overflow-y: auto; + flex: 1; +} + +.emoji-picker-category-label { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + opacity: 0.4; + margin-bottom: 6px; + margin-top: 8px; +} + +.emoji-picker-category-label:first-child { + margin-top: 0; +} + +.emoji-picker-emojis { + display: flex; + flex-wrap: wrap; + gap: 2px; +} + +.emoji-picker-emoji { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + font-size: 1.3rem; + border: none; + border-radius: 6px; + background: none; + cursor: pointer; + transition: background 150ms; +} + +.emoji-picker-emoji:hover { + background: rgba(var(--fg), 0.1); +} + +.emoji-picker-empty { + text-align: center; + padding: 24px; + opacity: 0.4; + font-size: 0.9rem; +} + +/* Link Icon Selector */ +.config-editor-icon-row { + display: flex; + align-items: center; + gap: 6px; + margin-top: 4px; +} + +.config-editor-icon-type-select { + padding: 6px 10px; + font-size: 0.8rem; + font-family: inherit; + color: rgb(var(--fg)); + background: rgba(var(--fg), 0.08); + border: 1px solid rgba(var(--fg), 0.1); + border-radius: 6px; + outline: none; + cursor: pointer; + flex-shrink: 0; +} + +.config-editor-icon-type-select option { + background: rgb(var(--dropdown-bg)); + color: rgb(var(--fg)); +} + +.config-editor-icon-value { + flex: 1; + min-width: 0; +} + +.config-editor-emoji-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + height: 36px; + padding: 0 12px; + font-size: 0.85rem; + font-family: inherit; + border: 1px solid rgba(var(--fg), 0.1); + border-radius: 8px; + background: rgba(var(--fg), 0.08); + color: rgba(var(--fg), 0.6); + cursor: pointer; + transition: background 150ms, border-color 150ms; + flex: 1; + min-width: 0; +} + +.config-editor-emoji-btn:hover { + background: rgba(var(--fg), 0.12); + border-color: rgba(var(--fg), 0.2); +} + +.config-editor-emoji-preview { + font-size: 1.1rem; + line-height: 1; +} + +.config-editor-icon-clear { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + border: 1px solid rgba(var(--fg), 0.08); + border-radius: 6px; + background: rgba(var(--fg), 0.05); + color: rgba(var(--fg), 0.4); + cursor: pointer; + transition: background 150ms, color 150ms; + flex-shrink: 0; +} + +.config-editor-icon-clear:hover { + background: rgba(220, 50, 50, 0.15); + color: rgba(255, 120, 120, 0.9); +} + /* Responsive */ @media (max-width: 600px) { .content { diff --git a/src/screens/ConfigEditorScreen/components/EmojiPicker.tsx b/src/screens/ConfigEditorScreen/components/EmojiPicker.tsx new file mode 100644 index 0000000..90b0291 --- /dev/null +++ b/src/screens/ConfigEditorScreen/components/EmojiPicker.tsx @@ -0,0 +1,111 @@ +import { useState } from 'react'; + +const EMOJI_CATEGORIES: { label: string; emojis: string[] }[] = [ + { + label: 'Smileys', + emojis: ['๐Ÿ˜€', '๐Ÿ˜ƒ', '๐Ÿ˜„', '๐Ÿ˜', '๐Ÿ˜†', '๐Ÿ˜…', '๐Ÿคฃ', '๐Ÿ˜‚', '๐Ÿ™‚', '๐Ÿ˜‰', '๐Ÿ˜Š', '๐Ÿ˜‡', '๐Ÿฅฐ', '๐Ÿ˜', '๐Ÿ˜Ž', '๐Ÿคฉ', '๐Ÿฅณ', '๐Ÿ˜ค', '๐Ÿคฏ', '๐Ÿฅบ', '๐Ÿ˜ฑ', '๐Ÿ’€', '๐Ÿ‘ป', '๐Ÿค–', '๐Ÿ‘ฝ', '๐Ÿ’ฉ'], + }, + { + label: 'Hands', + emojis: ['๐Ÿ‘', '๐Ÿ‘Ž', '๐Ÿ‘Š', 'โœŠ', '๐Ÿคž', 'โœŒ๏ธ', '๐ŸคŸ', '๐Ÿ‘‹', '๐Ÿคš', '๐Ÿ–๏ธ', 'โœ‹', '๐Ÿ‘', '๐Ÿ™Œ', '๐Ÿค', '๐Ÿ™', '๐Ÿ’ช', '๐Ÿซถ'], + }, + { + label: 'Objects', + emojis: ['๐Ÿ’ป', '๐Ÿ–ฅ๏ธ', '๐Ÿ“ฑ', '๐Ÿ“ง', '๐Ÿ“', '๐Ÿ“', '๐Ÿ“‚', '๐Ÿ“Œ', '๐Ÿ“Ž', '๐Ÿ”—', '๐Ÿ”’', '๐Ÿ”‘', '๐Ÿ”ง', '๐Ÿ”จ', 'โš™๏ธ', '๐Ÿ› ๏ธ', '๐Ÿ“Š', '๐Ÿ“ˆ', '๐Ÿ“‰', '๐Ÿ’ก', '๐Ÿ””', '๐Ÿ“ฆ', '๐Ÿ—‚๏ธ', '๐Ÿท๏ธ', '๐Ÿ’พ', '๐Ÿ–จ๏ธ'], + }, + { + label: 'Symbols', + emojis: ['โค๏ธ', '๐Ÿงก', '๐Ÿ’›', '๐Ÿ’š', '๐Ÿ’™', '๐Ÿ’œ', 'โญ', '๐ŸŒŸ', 'โœจ', '๐Ÿ’ฅ', '๐Ÿ”ฅ', 'โšก', 'โ—', 'โ“', 'โœ…', 'โŒ', 'โฌ†๏ธ', 'โžก๏ธ', 'โฌ‡๏ธ', 'โฌ…๏ธ', '๐Ÿ”ด', '๐ŸŸ ', '๐ŸŸก', '๐ŸŸข', '๐Ÿ”ต', '๐ŸŸฃ'], + }, + { + label: 'Nature', + emojis: ['๐ŸŒ', '๐ŸŒŽ', '๐ŸŒ', '๐ŸŒž', '๐ŸŒ™', 'โ›…', '๐ŸŒˆ', '๐ŸŒŠ', '๐ŸŒธ', '๐ŸŒบ', '๐ŸŒป', '๐Ÿ€', '๐ŸŒฒ', '๐ŸŒด', '๐Ÿ„', '๐Ÿถ', '๐Ÿฑ', '๐Ÿธ', '๐ŸฆŠ', '๐Ÿป', '๐Ÿผ', '๐Ÿจ', '๐Ÿฆ', '๐Ÿฏ', '๐Ÿต'], + }, + { + label: 'Food', + emojis: ['๐ŸŽ', '๐ŸŠ', '๐Ÿ‹', '๐Ÿ‡', '๐Ÿ“', '๐Ÿ•', '๐Ÿ”', '๐ŸŒฎ', '๐Ÿฃ', '๐Ÿฉ', '๐Ÿช', '๐ŸŽ‚', '๐Ÿฐ', 'โ˜•', '๐Ÿต', '๐Ÿงƒ', '๐Ÿบ', '๐Ÿท', '๐Ÿฅค', '๐Ÿง'], + }, + { + label: 'Travel', + emojis: ['๐Ÿ ', '๐Ÿข', '๐Ÿซ', '๐Ÿฅ', '๐Ÿช', '๐Ÿš—', '๐Ÿš•', '๐ŸšŒ', '๐Ÿš€', 'โœˆ๏ธ', '๐Ÿšข', '๐Ÿšฒ', '๐Ÿ–๏ธ', '๐Ÿ”๏ธ', 'โ›บ', '๐Ÿ—ผ', '๐Ÿ—ฝ', '๐ŸŽก'], + }, + { + label: 'Activities', + emojis: ['โšฝ', '๐Ÿ€', '๐Ÿˆ', 'โšพ', '๐ŸŽพ', '๐ŸŽฎ', '๐ŸŽฒ', '๐ŸŽฏ', '๐ŸŽจ', '๐ŸŽฌ', '๐ŸŽค', '๐ŸŽง', '๐ŸŽต', '๐ŸŽน', '๐Ÿ“š', '๐ŸŽ“', '๐Ÿ†', '๐Ÿฅ‡', '๐ŸŽช', '๐ŸŽญ'], + }, +]; + +interface EmojiPickerProps { + onSelect: (emoji: string) => void; + onClose: () => void; +} + +export function EmojiPicker({ onSelect, onClose }: EmojiPickerProps) { + const [search, setSearch] = useState(''); + const [activeCategory, setActiveCategory] = useState(0); + + const filteredCategories = search + ? EMOJI_CATEGORIES.map((cat) => ({ + ...cat, + emojis: cat.emojis.filter((e) => e.includes(search)), + })).filter((cat) => cat.emojis.length > 0) + : EMOJI_CATEGORIES; + + return ( +
+
e.stopPropagation()}> +
+ setSearch(e.target.value)} + autoFocus + /> + +
+ {!search && ( +
+ {EMOJI_CATEGORIES.map((cat, i) => ( + + ))} +
+ )} +
+ {(search ? filteredCategories : [EMOJI_CATEGORIES[activeCategory]]).map((cat) => ( +
+ {search &&
{cat.label}
} +
+ {cat.emojis.map((emoji) => ( + + ))} +
+
+ ))} + {search && filteredCategories.length === 0 && ( +
No emojis found
+ )} +
+
+
+ ); +} diff --git a/src/screens/ConfigEditorScreen/components/LinksTab.tsx b/src/screens/ConfigEditorScreen/components/LinksTab.tsx index fe1adce..e84ccf1 100644 --- a/src/screens/ConfigEditorScreen/components/LinksTab.tsx +++ b/src/screens/ConfigEditorScreen/components/LinksTab.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react'; -import type { AppConfig, ModuleConfig } from '../../../types/config.ts'; +import type { AppConfig, ModuleConfig, LinkConfig } from '../../../types/config.ts'; +import { EmojiPicker } from './EmojiPicker.tsx'; const MAX_SECTION_NAME = 50; const MAX_LINK_LABEL = 80; @@ -24,6 +25,7 @@ export function LinksTab({ config, onSave, onClose, onConfigChange }: LinksTabPr ); const [submitted, setSubmitted] = useState(false); const [showClearConfirm, setShowClearConfirm] = useState(false); + const [emojiPickerTarget, setEmojiPickerTarget] = useState<{ si: number; li: number } | null>(null); // Push link changes to the shared draft so they persist across tab switches useEffect(() => { @@ -114,6 +116,75 @@ export function LinksTab({ config, onSave, onClose, onConfigChange }: LinksTabPr )); }; + // --- Icon handlers --- + + type IconType = 'favicon' | 'url' | 'emoji'; + + const getIconType = (link: LinkConfig): IconType => { + if (link.iconEmoji) return 'emoji'; + if (link.iconUrl) return 'url'; + return 'favicon'; + }; + + const setLinkIconType = (sectionIndex: number, linkIndex: number, iconType: IconType) => { + setModules(modules.map((m, si) => + si === sectionIndex + ? { + ...m, + links: m.links.map((l, li) => { + if (li !== linkIndex) return l; + const updated = { ...l }; + delete updated.iconUrl; + delete updated.iconEmoji; + if (iconType === 'url') updated.iconUrl = ''; + return updated; + }), + } + : m, + )); + }; + + const setLinkIconUrl = (sectionIndex: number, linkIndex: number, iconUrl: string) => { + setModules(modules.map((m, si) => + si === sectionIndex + ? { ...m, links: m.links.map((l, li) => (li === linkIndex ? { ...l, iconUrl } : l)) } + : m, + )); + }; + + const setLinkIconEmoji = (sectionIndex: number, linkIndex: number, iconEmoji: string) => { + setModules(modules.map((m, si) => + si === sectionIndex + ? { + ...m, + links: m.links.map((l, li) => { + if (li !== linkIndex) return l; + const updated = { ...l, iconEmoji }; + delete updated.iconUrl; + return updated; + }), + } + : m, + )); + }; + + const clearLinkIcon = (sectionIndex: number, linkIndex: number) => { + setModules(modules.map((m, si) => + si === sectionIndex + ? { + ...m, + links: m.links.map((l, li) => { + if (li !== linkIndex) return l; + const updated = { ...l }; + delete updated.iconUrl; + delete updated.iconEmoji; + return updated; + }), + } + : m, + )); + }; + // --- Validation --- const getErrors = () => { @@ -283,7 +354,9 @@ export function LinksTab({ config, onSave, onClose, onConfigChange }: LinksTabPr {mod.links.length > 0 &&
} - {mod.links.map((link, li) => ( + {mod.links.map((link, li) => { + const iconType = getIconType(link); + return (
{getFieldError(si, 'url', li)}
)} +
+ + {iconType === 'url' && ( + setLinkIconUrl(si, li, e.target.value)} + placeholder="https://example.com/icon.png" + /> + )} + {iconType === 'emoji' && ( + + )} + {iconType !== 'favicon' && ( + + )} +
- ))} + ); + })} + {emojiPickerTarget && ( + { + setLinkIconEmoji(emojiPickerTarget.si, emojiPickerTarget.li, emoji); + setEmojiPickerTarget(null); + }} + onClose={() => setEmojiPickerTarget(null)} + /> + )} + {showClearConfirm && (
setShowClearConfirm(false)}>
e.stopPropagation()}> diff --git a/src/screens/HomeScreen/HomeScreen.tsx b/src/screens/HomeScreen/HomeScreen.tsx index 3be7265..0fb8a05 100644 --- a/src/screens/HomeScreen/HomeScreen.tsx +++ b/src/screens/HomeScreen/HomeScreen.tsx @@ -1,9 +1,10 @@ import { useState, useCallback, useMemo } from 'react'; -import type { AppConfig } from '../../types/config.ts'; +import type { AppConfig, LinkConfig } from '../../types/config.ts'; import { isHosted } from '../../env.ts'; import { SearchBar } from './components/SearchBar.tsx'; import { ModuleGrid } from './components/ModuleGrid.tsx'; import { AboutModal } from './components/AboutModal.tsx'; +import { getIconDisplay } from './components/iconDisplay.ts'; interface HomeScreenProps { config: AppConfig; @@ -25,16 +26,18 @@ export function HomeScreen({ config, onOpenSettings }: HomeScreenProps) { ); if (navigating) { - let favicon = ''; - try { - const domain = new URL(navigating.url).hostname; - favicon = `https://www.google.com/s2/favicons?domain=${domain}&sz=32`; - } catch { /* ignore */ } + const matchedLink: LinkConfig | undefined = config.modules + .flatMap((m) => m.links) + .find((l) => l.url === navigating.url); + const navIcon = matchedLink ? getIconDisplay(matchedLink) : null; return (
- {favicon && ( - + {navIcon?.type === 'emoji' && ( + + )} + {navIcon?.type === 'img' && ( + )} Navigating to {navigating.label}
diff --git a/src/screens/HomeScreen/components/LinkItem.test.tsx b/src/screens/HomeScreen/components/LinkItem.test.tsx index 3470491..98d3b40 100644 --- a/src/screens/HomeScreen/components/LinkItem.test.tsx +++ b/src/screens/HomeScreen/components/LinkItem.test.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { LinkItem } from './LinkItem.tsx'; -import { mockLink, mockLinkWithIcon } from '../../../test/fixtures.ts'; +import { mockLink, mockLinkWithIcon, mockLinkWithIconUrl, mockLinkWithEmoji } from '../../../test/fixtures.ts'; describe('LinkItem', () => { it('renders the link label', () => { @@ -26,6 +26,31 @@ describe('LinkItem', () => { expect(img).toHaveAttribute('src', 'https://example.com/icon.png'); }); + it('renders a custom icon when link.iconUrl is set', () => { + const { container } = render(); + const img = container.querySelector('.link-item-icon'); + expect(img).toBeInTheDocument(); + expect(img).toHaveAttribute('src', 'https://example.com/custom-icon.png'); + }); + + it('renders an emoji icon when link.iconEmoji is set', () => { + const { container } = render(); + const emoji = container.querySelector('.link-item-emoji'); + expect(emoji).toBeInTheDocument(); + expect(emoji).toHaveTextContent('๐Ÿš€'); + // Should not render an img icon + expect(container.querySelector('.link-item-icon')).not.toBeInTheDocument(); + }); + + it('prefers iconEmoji over iconUrl', () => { + const link = { url: 'https://example.com', label: 'Both', iconUrl: 'https://example.com/icon.png', iconEmoji: '๐ŸŽฏ' }; + const { container } = render(); + const emoji = container.querySelector('.link-item-emoji'); + expect(emoji).toBeInTheDocument(); + expect(emoji).toHaveTextContent('๐ŸŽฏ'); + expect(container.querySelector('.link-item-icon')).not.toBeInTheDocument(); + }); + it('calls onNavigate with url and label on click', async () => { const user = userEvent.setup(); const onNavigate = vi.fn(); diff --git a/src/screens/HomeScreen/components/LinkItem.tsx b/src/screens/HomeScreen/components/LinkItem.tsx index 7085cc1..8504c76 100644 --- a/src/screens/HomeScreen/components/LinkItem.tsx +++ b/src/screens/HomeScreen/components/LinkItem.tsx @@ -1,21 +1,13 @@ import type { LinkConfig } from '../../../types/config.ts'; +import { getIconDisplay } from './iconDisplay.ts'; interface LinkItemProps { link: LinkConfig; onNavigate: (url: string, label: string) => void; } -function getFaviconUrl(url: string): string { - try { - const domain = new URL(url).hostname; - return `https://www.google.com/s2/favicons?domain=${domain}&sz=32`; - } catch { - return ''; - } -} - export function LinkItem({ link, onNavigate }: LinkItemProps) { - const iconSrc = link.icon || getFaviconUrl(link.url); + const icon = getIconDisplay(link); return ( - {iconSrc && ( + {icon?.type === 'emoji' && ( + + )} + {icon?.type === 'img' && ( - {link.favicon && ( - - )} + {(() => { + const icon = getIconDisplay(link); + if (icon?.type === 'emoji') return {icon.emoji}; + if (icon?.type === 'img') return ; + return null; + })()} {link.label} {link.moduleTitle} diff --git a/src/screens/HomeScreen/components/iconDisplay.ts b/src/screens/HomeScreen/components/iconDisplay.ts new file mode 100644 index 0000000..d66aa3a --- /dev/null +++ b/src/screens/HomeScreen/components/iconDisplay.ts @@ -0,0 +1,21 @@ +import type { LinkConfig } from '../../../types/config.ts'; + +function getFaviconUrl(url: string): string { + try { + const domain = new URL(url).hostname; + return `https://www.google.com/s2/favicons?domain=${domain}&sz=32`; + } catch { + return ''; + } +} + +export function getIconDisplay(link: LinkConfig): { type: 'emoji'; emoji: string } | { type: 'img'; src: string } | null { + if (link.iconEmoji) { + return { type: 'emoji', emoji: link.iconEmoji }; + } + const imgSrc = link.iconUrl || link.icon || getFaviconUrl(link.url); + if (imgSrc) { + return { type: 'img', src: imgSrc }; + } + return null; +} diff --git a/src/test/fixtures.ts b/src/test/fixtures.ts index 8b69c4d..bc7cd11 100644 --- a/src/test/fixtures.ts +++ b/src/test/fixtures.ts @@ -11,6 +11,18 @@ export const mockLinkWithIcon: LinkConfig = { icon: 'https://example.com/icon.png', }; +export const mockLinkWithIconUrl: LinkConfig = { + url: 'https://example.com', + label: 'Custom Icon URL', + iconUrl: 'https://example.com/custom-icon.png', +}; + +export const mockLinkWithEmoji: LinkConfig = { + url: 'https://example.com', + label: 'Emoji Link', + iconEmoji: '๐Ÿš€', +}; + export const mockHiddenLink: LinkConfig = { url: 'https://hidden.com', label: 'Hidden Link', diff --git a/src/types/config.ts b/src/types/config.ts index 39e04c3..fd6c302 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -2,6 +2,8 @@ export interface LinkConfig { url: string; label: string; icon?: string; + iconUrl?: string; + iconEmoji?: string; hidden?: boolean; }