diff --git a/web/bun.lock b/web/bun.lock index e2b707c..37b3e65 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -13,6 +13,7 @@ "autoprefixer": "^10.4.24", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "emoji-picker-react": "^4.18.0", "lucide-react": "^0.563.0", "nanostores": "^1.1.0", "postcss": "^8.5.6", @@ -593,6 +594,8 @@ "electron-to-chromium": ["electron-to-chromium@1.5.286", "", {}, "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A=="], + "emoji-picker-react": ["emoji-picker-react@4.18.0", "", { "dependencies": { "flairup": "1.0.0" }, "peerDependencies": { "react": ">=16" } }, "sha512-vLTrLfApXAIciguGE57pXPWs9lPLBspbEpPMiUq03TIli2dHZBiB+aZ0R9/Wat0xmTfcd4AuEzQgSYxEZ8C88Q=="], + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], @@ -681,6 +684,8 @@ "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + "flairup": ["flairup@1.0.0", "", {}, "sha512-IKlE+pNvL2R+kVL1kEhUYqRxVqeFnjiIvHWDMLFXNaqyUdFXQM2wte44EfMYJNHkW16X991t2Zg8apKkhv7OBA=="], + "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], diff --git a/web/package.json b/web/package.json index df48d08..a4213ad 100644 --- a/web/package.json +++ b/web/package.json @@ -19,6 +19,7 @@ "autoprefixer": "^10.4.24", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "emoji-picker-react": "^4.18.0", "lucide-react": "^0.563.0", "nanostores": "^1.1.0", "postcss": "^8.5.6", diff --git a/web/src/components/modals/AddToCollectionModal.tsx b/web/src/components/modals/AddToCollectionModal.tsx index c35fdb4..223a2c3 100644 --- a/web/src/components/modals/AddToCollectionModal.tsx +++ b/web/src/components/modals/AddToCollectionModal.tsx @@ -9,8 +9,10 @@ import { } from "lucide-react"; import CollectionIcon from "../common/CollectionIcon"; import { ICON_MAP } from "../common/iconMap"; +import EmojiPicker, { Theme } from "emoji-picker-react"; import { useStore } from "@nanostores/react"; import { $user } from "../../store/auth"; +import { $theme } from "../../store/theme"; import { getCollections, addCollectionItem, @@ -31,6 +33,7 @@ export default function AddToCollectionModal({ annotationUri, }: AddToCollectionModalProps) { const user = useStore($user); + const theme = useStore($theme); const [collections, setCollections] = useState([]); const [loading, setLoading] = useState(true); const [addingTo, setAddingTo] = useState(null); @@ -41,6 +44,7 @@ export default function AddToCollectionModal({ const [newName, setNewName] = useState(""); const [newDescription, setNewDescription] = useState(""); const [newIcon, setNewIcon] = useState(""); + const [activeTab, setActiveTab] = useState<"icon" | "emoji">("icon"); const [creating, setCreating] = useState(false); useEffect(() => { @@ -96,7 +100,11 @@ export default function AddToCollectionModal({ if (!newName.trim()) return; try { setCreating(true); - const iconValue = newIcon ? `icon:${newIcon}` : undefined; + const iconValue = newIcon + ? ICON_MAP[newIcon] + ? `icon:${newIcon}` + : newIcon + : undefined; const newCollection = await createCollection( newName.trim(), newDescription.trim() || undefined, @@ -184,29 +192,85 @@ export default function AddToCollectionModal({ -
- {Object.keys(ICON_MAP).map((iconName) => { - const isSelected = newIcon === iconName; - return ( - - ); - })} + +
+ +
+ + {activeTab === "icon" ? ( +
+ {Object.keys(ICON_MAP).map((iconName) => { + const isSelected = newIcon === iconName; + return ( + + ); + })} +
+ ) : ( +
+ setNewIcon(emojiData.emoji)} + autoFocusSearch={false} + width="100%" + height={300} + previewConfig={{ showPreview: false }} + skinTonesDisabled + lazyLoadEmojis + theme={ + theme === "dark" || + (theme === "system" && + window.matchMedia("(prefers-color-scheme: dark)") + .matches) + ? (Theme.DARK as Theme) + : (Theme.LIGHT as Theme) + } + /> +
+ )} + {newIcon && ( -

- Selected: {newIcon} +

+ Selected: + + +

)}
diff --git a/web/src/components/modals/EditCollectionModal.tsx b/web/src/components/modals/EditCollectionModal.tsx index 3ac7868..b3b142c 100644 --- a/web/src/components/modals/EditCollectionModal.tsx +++ b/web/src/components/modals/EditCollectionModal.tsx @@ -2,7 +2,10 @@ import React, { useState, useEffect } from "react"; import { X, Loader2 } from "lucide-react"; import CollectionIcon from "../common/CollectionIcon"; import { ICON_MAP } from "../common/iconMap"; +import EmojiPicker, { Theme } from "emoji-picker-react"; import { updateCollection, type Collection } from "../../api/client"; +import { useStore } from "@nanostores/react"; +import { $theme } from "../../store/theme"; interface EditCollectionModalProps { isOpen: boolean; @@ -19,15 +22,26 @@ export default function EditCollectionModal({ }: EditCollectionModalProps) { const [name, setName] = useState(collection.name); const [description, setDescription] = useState(collection.description || ""); - const [icon, setIcon] = useState(collection.icon?.replace("icon:", "") || ""); + const initialIsIcon = collection.icon?.startsWith("icon:") ?? false; + const initialIconValue = collection.icon?.replace("icon:", "") || ""; + + const [activeTab, setActiveTab] = useState<"icon" | "emoji">( + initialIsIcon || !collection.icon ? "icon" : "emoji", + ); + const [icon, setIcon] = useState(initialIconValue); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const theme = useStore($theme); useEffect(() => { if (isOpen) { setName(collection.name); setDescription(collection.description || ""); + + const isIcon = collection.icon?.startsWith("icon:") ?? false; + setActiveTab(isIcon || !collection.icon ? "icon" : "emoji"); setIcon(collection.icon?.replace("icon:", "") || ""); + setError(null); document.body.style.overflow = "hidden"; } @@ -43,7 +57,11 @@ export default function EditCollectionModal({ try { setLoading(true); setError(null); - const iconValue = icon ? `icon:${icon}` : undefined; + const iconValue = icon + ? ICON_MAP[icon] + ? `icon:${icon}` + : icon + : undefined; const updated = await updateCollection( collection.uri, name.trim(), @@ -121,29 +139,85 @@ export default function EditCollectionModal({ -
- {Object.keys(ICON_MAP).map((iconName) => { - const isSelected = icon === iconName; - return ( - - ); - })} + +
+ +
+ + {activeTab === "icon" ? ( +
+ {Object.keys(ICON_MAP).map((iconName) => { + const isSelected = icon === iconName; + return ( + + ); + })} +
+ ) : ( +
+ setIcon(emojiData.emoji)} + autoFocusSearch={false} + width="100%" + height={300} + previewConfig={{ showPreview: false }} + skinTonesDisabled + lazyLoadEmojis + theme={ + theme === "dark" || + (theme === "system" && + window.matchMedia("(prefers-color-scheme: dark)") + .matches) + ? (Theme.DARK as Theme) + : (Theme.LIGHT as Theme) + } + /> +
+ )} + {icon && ( -

- Selected: {icon} +

+ Selected: + + +

)}
diff --git a/web/src/styles/global.css b/web/src/styles/global.css index fea9f42..6fc8f25 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -71,6 +71,69 @@ .custom-scrollbar::-webkit-scrollbar-thumb { @apply bg-surface-300 dark:bg-surface-700 rounded-full hover:bg-surface-400 dark:hover:bg-surface-600 transition-colors; } + + .custom-emoji-picker { + --epr-bg-color: transparent !important; + --epr-category-label-bg-color: transparent !important; + --epr-text-color: theme("colors.surface.900") !important; + --epr-picker-border-color: theme("colors.surface.200") !important; + + --epr-search-input-bg-color: theme("colors.surface.50") !important; + --epr-search-input-text-color: theme("colors.surface.900") !important; + + --epr-hover-bg-color: theme("colors.surface.200") !important; + --epr-focus-bg-color: theme("colors.primary.100") !important; + --epr-highlight-color: theme("colors.primary.500") !important; + + font-family: inherit !important; + border: none !important; + } + + [data-theme="dark"] .custom-emoji-picker { + --epr-text-color: theme("colors.white") !important; + --epr-picker-border-color: theme("colors.surface.700") !important; + + --epr-search-input-bg-color: theme("colors.surface.800") !important; + --epr-search-input-text-color: theme("colors.white") !important; + + --epr-hover-bg-color: theme("colors.surface.700") !important; + --epr-focus-bg-color: theme("colors.primary.900") !important; + } + + .custom-emoji-picker .epr-search-container input { + border-radius: 0.5rem !important; + border: 1px solid theme("colors.surface.200") !important; + background-color: theme("colors.surface.50") !important; + } + + [data-theme="dark"] .custom-emoji-picker .epr-search-container input { + border: 1px solid theme("colors.surface.700") !important; + background-color: theme("colors.surface.800") !important; + } + + .custom-emoji-picker .epr-header-overlay { + padding: 0 !important; + } + + .custom-emoji-picker .epr-emoji-category-label { + position: static !important; + background-color: transparent !important; + height: 32px !important; + line-height: 32px !important; + } + + .custom-emoji-picker .epr-body::-webkit-scrollbar { + width: 6px; + height: 6px; + } + + .custom-emoji-picker .epr-body::-webkit-scrollbar-track { + @apply bg-transparent; + } + + .custom-emoji-picker .epr-body::-webkit-scrollbar-thumb { + @apply bg-surface-300 dark:bg-surface-700 rounded-full hover:bg-surface-400 dark:hover:bg-surface-600 transition-colors; + } } @keyframes fadeIn { diff --git a/web/src/views/collections/Collections.tsx b/web/src/views/collections/Collections.tsx index c0616ff..8ab4cc2 100644 --- a/web/src/views/collections/Collections.tsx +++ b/web/src/views/collections/Collections.tsx @@ -9,6 +9,8 @@ import CollectionIcon from "../../components/common/CollectionIcon"; import { ICON_MAP } from "../../components/common/iconMap"; import { useStore } from "@nanostores/react"; import { $user } from "../../store/auth"; +import EmojiPicker, { Theme } from "emoji-picker-react"; +import { $theme } from "../../store/theme"; import type { Collection } from "../../types"; import { formatDistanceToNow } from "date-fns"; import { clsx } from "clsx"; @@ -16,12 +18,14 @@ import { Button, Input, EmptyState, Skeleton } from "../../components/ui"; export default function Collections() { const user = useStore($user); + const theme = useStore($theme); const [collections, setCollections] = useState([]); const [loading, setLoading] = useState(true); const [showCreateModal, setShowCreateModal] = useState(false); const [newItemName, setNewItemName] = useState(""); const [newItemDesc, setNewItemDesc] = useState(""); const [newItemIcon, setNewItemIcon] = useState("folder"); + const [activeTab, setActiveTab] = useState<"icon" | "emoji">("icon"); const [creating, setCreating] = useState(false); const fetchCollections = async () => { @@ -45,13 +49,18 @@ export default function Collections() { if (!newItemName.trim()) return; setCreating(true); - const res = await createCollection(newItemName, newItemDesc); + const finalIcon = ICON_MAP[newItemIcon] + ? `icon:${newItemIcon}` + : newItemIcon; + + const res = await createCollection(newItemName, newItemDesc, finalIcon); if (res) { setCollections([res, ...collections]); setShowCreateModal(false); setNewItemName(""); setNewItemDesc(""); setNewItemIcon("folder"); + setActiveTab("icon"); fetchCollections(); } setCreating(false); @@ -187,26 +196,77 @@ export default function Collections() { -
- {Object.keys(ICON_MAP).map((key) => { - const Icon = ICON_MAP[key]; - return ( - - ); - })} + +
+ +
+ + {activeTab === "icon" ? ( +
+ {Object.keys(ICON_MAP).map((key) => { + const Icon = ICON_MAP[key]; + return ( + + ); + })} +
+ ) : ( +
+ + setNewItemIcon(emojiData.emoji) + } + autoFocusSearch={false} + width="100%" + height={300} + previewConfig={{ showPreview: false }} + skinTonesDisabled + lazyLoadEmojis + theme={ + theme === "dark" || + (theme === "system" && + window.matchMedia("(prefers-color-scheme: dark)") + .matches) + ? (Theme.DARK as Theme) + : (Theme.LIGHT as Theme) + } + /> +
+ )}