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 (
- ))}
+ );
+ })}
+ {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.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.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;
}