{
29 | return (emojiMetadata as EmojiMetadata).knownSupportedEmoji;
30 | }
31 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "emoji-kitchen",
3 | "version": "0.1.0",
4 | "private": true,
5 | "type": "module",
6 | "scripts": {
7 | "build": "vite build",
8 | "serve": "vite preview",
9 | "start": "vite"
10 | },
11 | "browserslist": {
12 | "production": [
13 | ">0.2%",
14 | "not dead",
15 | "not op_mini all"
16 | ],
17 | "development": [
18 | "last 1 chrome version",
19 | "last 1 firefox version",
20 | "last 1 safari version"
21 | ]
22 | },
23 | "devDependencies": {
24 | "@emotion/react": "^11.14.0",
25 | "@emotion/styled": "^11.14.1",
26 | "@mui/icons-material": "^7.3.6",
27 | "@mui/lab": "^7.0.0",
28 | "@mui/material": "^7.3.6",
29 | "@primer/octicons-react": "^19.21.1",
30 | "@types/file-saver": "^2.0.7",
31 | "@types/react": "^19.2.7",
32 | "@types/react-dom": "^19.2.3",
33 | "@uidotdev/usehooks": "^2.4.1",
34 | "@vitejs/plugin-react": "^5.1.2",
35 | "eslint-config-react-app": "^7.0.1",
36 | "file-saver": "^2.0.5",
37 | "jszip": "^3.10.1",
38 | "react": "^19.2.3",
39 | "react-dom": "^19.2.3",
40 | "typescript": "^5.9.3",
41 | "uuid": "^13.0.0",
42 | "vite": "^7.3.0",
43 | "vite-tsconfig-paths": "^6.0.3"
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/.github/workflows/deploy.yml:
--------------------------------------------------------------------------------
1 | name: Build and Deploy
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | paths:
8 | - "src/**"
9 | - "public/**"
10 | workflow_dispatch:
11 |
12 | jobs:
13 | build-and-deploy:
14 | runs-on: ubuntu-latest
15 | steps:
16 | - name: Checkout code 🛎
17 | uses: actions/checkout@v6
18 | with:
19 | persist-credentials: false
20 |
21 | - name: Download Emoji Kitchen Metadata 💾
22 | uses: xsalazar/actions/downloader@master
23 | with:
24 | url: "https://raw.githubusercontent.com/xsalazar/emoji-kitchen-backend/main/app/metadata.json"
25 | filename: "src/Components/metadata.json"
26 |
27 | - name: Setup Node 🏗
28 | uses: actions/setup-node@v6
29 | with:
30 | node-version: "24"
31 |
32 | - name: Install and Build 🔧
33 | run: |
34 | export NODE_OPTIONS="--max_old_space_size=8192"
35 | npm install
36 | npm run build
37 |
38 | - name: Deploy 🚀
39 | uses: JamesIves/github-pages-deploy-action@v4.7.6
40 | with:
41 | branch: gh-pages
42 | folder: build
43 | CLEAN: true # Automatically remove deleted files from the deploy branch
44 |
--------------------------------------------------------------------------------
/src/app.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | amber,
3 | blue,
4 | cyan,
5 | deepOrange,
6 | deepPurple,
7 | green,
8 | indigo,
9 | lightBlue,
10 | lightGreen,
11 | lime,
12 | orange,
13 | pink,
14 | purple,
15 | teal,
16 | } from "@mui/material/colors";
17 | import { createTheme, ThemeProvider } from "@mui/material/styles";
18 | import Footer from "./Components/footer";
19 | import Kitchen from "./Components/kitchen";
20 |
21 | // 🌈
22 | const colors = [
23 | amber,
24 | blue,
25 | cyan,
26 | deepOrange,
27 | deepPurple,
28 | green,
29 | indigo,
30 | lightBlue,
31 | lightGreen,
32 | lime,
33 | orange,
34 | pink,
35 | purple,
36 | teal,
37 | ];
38 |
39 | const theme = createTheme({
40 | palette: {
41 | primary: colors[Math.floor(Math.random() * colors.length)],
42 | },
43 | });
44 |
45 | export default function App() {
46 | if (window.self === window.top) {
47 | return (
48 |
56 |
57 |
58 |
59 |
60 |
61 | );
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/.github/workflows/automerge-dependabot.yml:
--------------------------------------------------------------------------------
1 | name: Auto Merge Dependabot PRs
2 |
3 | on:
4 | pull_request_target:
5 | workflow_dispatch:
6 |
7 | permissions:
8 | pull-requests: write
9 | contents: write
10 |
11 | jobs:
12 | auto-merge:
13 | runs-on: ubuntu-latest
14 |
15 | # Checking the actor will prevent your Action run failing on non-Dependabot PRs
16 | if: ${{ github.actor == 'dependabot[bot]' }}
17 |
18 | steps:
19 | - name: Fetch Dependabot PR metadata 🎣
20 | id: dependabot-metadata
21 | uses: dependabot/fetch-metadata@v2.4.0
22 | with:
23 | github-token: "${{ secrets.GITHUB_TOKEN }}"
24 |
25 | - name: Approve Dependabot PR ✅
26 | if: ${{steps.dependabot-metadata.outputs.update-type == 'version-update:semver-minor' || steps.dependabot-metadata.outputs.update-type == 'version-update:semver-patch'}}
27 | run: gh pr review --approve "$PR_URL"
28 | env:
29 | PR_URL: ${{github.event.pull_request.html_url}}
30 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
31 |
32 | - name: Auto-merge Dependabot PR 🪄
33 | if: ${{steps.dependabot-metadata.outputs.update-type == 'version-update:semver-minor' || steps.dependabot-metadata.outputs.update-type == 'version-update:semver-patch'}}
34 | run: gh pr merge --merge "$PR_URL"
35 | env:
36 | PR_URL: ${{github.event.pull_request.html_url}}
37 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
38 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 🧑🍳 Emoji Kitchen
2 |
3 | This repository contains the source code for the website [https://emojikitchen.dev](https://emojikitchen.dev).
4 |
5 | This website allows for quick and easy browsing of the comprehensive list of supported emoji mashups as part of Google's [Emoji Kitchen](https://emojipedia.org/emoji-kitchen/).
6 |
7 | There are currently over 100,000 possible valid combinations showcasing the unique illustrations and combined emoji!
8 |
9 | ## Getting Started
10 |
11 | This repository leverages [VSCode's devcontainer](https://code.visualstudio.com/docs/remote/containers) feature to ensure all necessary dependencies are available inside the container for development.
12 |
13 | ### Application
14 |
15 | To get started, you will need to first download the supporting metadata, then install and start the project normally:
16 |
17 | ```bash
18 | curl -L --compressed https://raw.githubusercontent.com/xsalazar/emoji-kitchen-backend/main/app/metadata.json -o src/Components/metadata.json
19 | npm install && npm start
20 | ```
21 |
22 | This will start the application on your local machine, running on [http://localhost:5173/](http://localhost:5173).
23 |
24 | ### Deployments
25 |
26 | All application deployments are managed via GitHub Actions and the [`./.github/workflows/deploy.yml`](./.github/workflows/deploy.yml) workflow.
27 |
28 | Additionally, application dependencies are automatically managed and updated via Dependabot and the [`./.github/workflows/automerge-dependabot.yml`](./.github/workflows/automerge-dependabot.yml) workflow.
29 |
--------------------------------------------------------------------------------
/src/Components/footer.tsx:
--------------------------------------------------------------------------------
1 | import Box from "@mui/material/Box";
2 | import Link from "@mui/material/Link";
3 | import Stack from "@mui/material/Stack";
4 | import Tooltip from "@mui/material/Tooltip";
5 | import {
6 | FileCodeIcon,
7 | LinkExternalIcon,
8 | MentionIcon,
9 | } from "@primer/octicons-react";
10 |
11 | export default function Footer() {
12 | return (
13 |
14 |
15 |
16 |
17 |
24 |
25 |
26 |
27 |
28 |
35 |
36 |
37 |
38 |
39 |
46 |
47 |
48 |
49 |
50 |
51 |
52 | );
53 | }
54 |
--------------------------------------------------------------------------------
/src/Components/left-emoji-list.tsx:
--------------------------------------------------------------------------------
1 | import { ImageListItem } from "@mui/material";
2 | import React, { Dispatch } from "react";
3 | import { getEmojiData, getNotoEmojiUrl, getSupportedEmoji } from "./utils";
4 |
5 | export default function LeftEmojiList({
6 | handleLeftEmojiClicked,
7 | handleBulkImageDownloadMenuOpen,
8 | leftSearchResults,
9 | selectedLeftEmoji,
10 | }: {
11 | handleLeftEmojiClicked: Dispatch;
12 | handleBulkImageDownloadMenuOpen: Dispatch;
13 | leftSearchResults: Array;
14 | selectedLeftEmoji: string;
15 | }) {
16 | var knownSupportedEmoji = getSupportedEmoji();
17 |
18 | // If we have search results, filter the top-level items down
19 | if (leftSearchResults.length > 0) {
20 | knownSupportedEmoji = knownSupportedEmoji.filter((emoji) =>
21 | leftSearchResults.includes(emoji)
22 | );
23 | }
24 |
25 | return knownSupportedEmoji.map((emojiCodepoint) => {
26 | const data = getEmojiData(emojiCodepoint);
27 |
28 | return (
29 | {}
35 | }
36 | >
37 |
{
39 | handleLeftEmojiClicked(emojiCodepoint);
40 | }}
41 | sx={{
42 | p: 0.5,
43 | borderRadius: 2,
44 | opacity: (_) => {
45 | return 1;
46 | },
47 | backgroundColor: (theme) =>
48 | selectedLeftEmoji === emojiCodepoint
49 | ? theme.palette.action.selected
50 | : theme.palette.background.default,
51 | "&:hover": {
52 | backgroundColor: (theme) => {
53 | return theme.palette.action.hover;
54 | },
55 | },
56 | }}
57 | >
58 |
65 |
66 |
67 | );
68 | });
69 | }
70 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
10 |
14 |
15 |
16 |
17 |
18 | Emoji Kitchen
19 |
20 |
24 |
25 |
26 |
27 |
31 |
32 |
33 |
34 |
38 |
39 |
40 |
41 |
42 |
46 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
65 |
66 |
67 |
68 |
--------------------------------------------------------------------------------
/src/Components/right-emoji-list.tsx:
--------------------------------------------------------------------------------
1 | import { ImageListItem } from "@mui/material";
2 | import { Dispatch } from "react";
3 | import { getEmojiData, getNotoEmojiUrl, getSupportedEmoji } from "./utils";
4 |
5 | export default function RightEmojiList({
6 | handleRightEmojiClicked,
7 | rightSearchResults,
8 | selectedLeftEmoji,
9 | selectedRightEmoji,
10 | }: {
11 | handleRightEmojiClicked: Dispatch;
12 | rightSearchResults: Array;
13 | selectedLeftEmoji: string;
14 | selectedRightEmoji: string;
15 | }) {
16 | var knownSupportedEmoji = getSupportedEmoji();
17 | var hasSelectedLeftEmoji = selectedLeftEmoji !== "";
18 |
19 | // If we have search results, filter the top-level items down
20 | if (rightSearchResults.length > 0) {
21 | knownSupportedEmoji = knownSupportedEmoji.filter((emoji) =>
22 | rightSearchResults.includes(emoji)
23 | );
24 | }
25 |
26 | // If we have a selectedLeftEmoji, save the valid combinations for that emoji
27 | var possibleEmoji: Array = [];
28 | if (hasSelectedLeftEmoji) {
29 | const data = getEmojiData(selectedLeftEmoji);
30 | possibleEmoji = Object.keys(data.combinations);
31 | }
32 |
33 | return knownSupportedEmoji.map((emojiCodepoint) => {
34 | const data = getEmojiData(emojiCodepoint);
35 | // Every right-hand emoji is valid unless we have a selected left-hand emoji
36 | // In which case, we need to explicitly check if it's a valid combination
37 | var isValidCombo = true;
38 | if (hasSelectedLeftEmoji) {
39 | isValidCombo = possibleEmoji.includes(emojiCodepoint);
40 | }
41 |
42 | return (
43 |
44 |
46 | hasSelectedLeftEmoji && isValidCombo
47 | ? handleRightEmojiClicked(emojiCodepoint)
48 | : null
49 | }
50 | sx={{
51 | p: 0.5,
52 | borderRadius: 2,
53 | opacity: (_) => {
54 | if (!hasSelectedLeftEmoji) {
55 | return 0.1;
56 | }
57 |
58 | return isValidCombo ? 1 : 0.1;
59 | },
60 | backgroundColor: (theme) =>
61 | selectedRightEmoji === emojiCodepoint
62 | ? theme.palette.action.selected
63 | : theme.palette.background.default,
64 | "&:hover": {
65 | backgroundColor: (theme) => {
66 | if (hasSelectedLeftEmoji) {
67 | return theme.palette.action.hover;
68 | }
69 | },
70 | },
71 | }}
72 | >
73 |
80 |
81 |
82 | );
83 | });
84 | }
85 |
--------------------------------------------------------------------------------
/src/Components/mobile-emoji-list.tsx:
--------------------------------------------------------------------------------
1 | import { ImageListItem } from "@mui/material";
2 | import { Dispatch } from "react";
3 | import { getEmojiData, getNotoEmojiUrl, getSupportedEmoji } from "./utils";
4 |
5 | export default function MobileEmojiList({
6 | handleEmojiClicked,
7 | searchResults,
8 | selectedEmoji,
9 | selectedOtherEmoji,
10 | selectedMode,
11 | }: {
12 | handleEmojiClicked: Dispatch;
13 | searchResults: Array;
14 | selectedEmoji: string;
15 | selectedOtherEmoji: string;
16 | selectedMode: string;
17 | }) {
18 | var knownSupportedEmoji = getSupportedEmoji();
19 |
20 | if (selectedEmoji === "" || selectedOtherEmoji === "") {
21 | return;
22 | }
23 |
24 | // If we have search results, filter the top-level items down
25 | if (searchResults.length > 0) {
26 | knownSupportedEmoji = knownSupportedEmoji.filter((emoji) =>
27 | searchResults.includes(emoji)
28 | );
29 | }
30 |
31 | if (selectedMode === "combine") {
32 | const data = getEmojiData(selectedOtherEmoji);
33 | var possibleEmoji = Object.keys(data.combinations);
34 |
35 | return knownSupportedEmoji.map((emojiCodepoint) => {
36 | const data = getEmojiData(emojiCodepoint);
37 |
38 | const isValidCombo = possibleEmoji.includes(emojiCodepoint);
39 |
40 | return (
41 |
42 |
{
44 | // On mobile, only return this if it's a valid combination
45 | return isValidCombo ? handleEmojiClicked(emojiCodepoint) : null;
46 | }}
47 | sx={{
48 | p: 0.5,
49 | borderRadius: 2,
50 | opacity: (_) => {
51 | return isValidCombo ? 1 : 0.1;
52 | },
53 | backgroundColor: (theme) =>
54 | selectedEmoji === emojiCodepoint
55 | ? theme.palette.action.selected
56 | : theme.palette.background.default,
57 | "&:hover": {
58 | backgroundColor: (theme) => {
59 | return theme.palette.action.hover;
60 | },
61 | },
62 | }}
63 | >
64 |
71 |
72 |
73 | );
74 | });
75 | } else {
76 | return knownSupportedEmoji.map((emojiCodepoint) => {
77 | const data = getEmojiData(emojiCodepoint);
78 |
79 | return (
80 |
81 |
handleEmojiClicked(emojiCodepoint)}
83 | sx={{
84 | p: 0.5,
85 | borderRadius: 2,
86 | backgroundColor: (theme) =>
87 | selectedEmoji === emojiCodepoint
88 | ? theme.palette.action.selected
89 | : theme.palette.background.default,
90 | "&:hover": {
91 | backgroundColor: (theme) => {
92 | return theme.palette.action.hover;
93 | },
94 | },
95 | }}
96 | >
97 |
104 |
105 |
106 | );
107 | });
108 | }
109 | }
110 |
--------------------------------------------------------------------------------
/src/Components/search.tsx:
--------------------------------------------------------------------------------
1 | import { Close, Search as SearchIcon } from "@mui/icons-material";
2 | import CircularProgress from "@mui/material/CircularProgress";
3 | import Divider from "@mui/material/Divider";
4 | import IconButton from "@mui/material/IconButton";
5 | import InputBase from "@mui/material/InputBase";
6 | import Paper from "@mui/material/Paper";
7 | import Typography from "@mui/material/Typography";
8 | import { useDebounce } from "@uidotdev/usehooks";
9 | import React, { Dispatch, SetStateAction, useEffect, useState } from "react";
10 | import { getEmojiData, getNotoEmojiUrl } from "./utils";
11 |
12 | export default function Search({
13 | disabled,
14 | handleRandomize,
15 | isMobile,
16 | selectedEmoji,
17 | setSearchResults,
18 | uuid,
19 | }: {
20 | disabled?: boolean;
21 | handleRandomize?: () => void;
22 | isMobile: boolean;
23 | selectedEmoji?: string;
24 | setSearchResults: Dispatch>>;
25 | uuid: string;
26 | }) {
27 | const [value, setValue] = useState("");
28 | const [isSearching, setIsSearching] = useState(false);
29 | const debouncedSearchTerm = useDebounce(value, 300);
30 |
31 | const hasSearchValue = value !== "";
32 |
33 | /**
34 | * Hacky input to clear text box when full randomizing from parent element while search results are shown
35 | */
36 | useEffect(() => {
37 | setValue("");
38 | }, [uuid]);
39 |
40 | /**
41 | * Debounce and sanitize search queries
42 | */
43 | useEffect(() => {
44 | async function search() {
45 | let results = [];
46 | setIsSearching(true);
47 | if (debouncedSearchTerm) {
48 | var requestQuery = debouncedSearchTerm.trim().toLowerCase();
49 | requestQuery =
50 | requestQuery.length > 128
51 | ? requestQuery.substring(0, 127)
52 | : requestQuery;
53 | const data = await fetch(
54 | `https://backend.emojikitchen.dev/?q=${requestQuery}`
55 | );
56 | results = await data.json();
57 | }
58 |
59 | setIsSearching(false);
60 | setSearchResults(results);
61 | }
62 |
63 | if (debouncedSearchTerm.trim().length >= 3) {
64 | search();
65 | } else {
66 | setSearchResults([]);
67 | }
68 | }, [debouncedSearchTerm]);
69 |
70 | if (isMobile) {
71 | return (
72 |
84 |
85 | {isSearching ? (
86 |
87 | ) : (
88 |
89 | )}
90 |
91 | ) => {
96 | setValue(event.target.value);
97 | }}
98 | />
99 | {hasSearchValue ? (
100 | setValue("")}
104 | >
105 |
106 |
107 | ) : null}
108 |
109 | );
110 | }
111 |
112 | return (
113 |
125 |
126 | {isSearching ? (
127 |
128 | ) : (
129 |
130 | )}
131 |
132 | ) => {
137 | setValue(event.target.value);
138 | }}
139 | />
140 | {hasSearchValue ? (
141 | setValue("")}
145 | >
146 |
147 |
148 | ) : null}
149 |
150 | null : handleRandomize}
155 | >
156 | {selectedEmoji && selectedEmoji !== "" ? (
157 |
164 | ) : (
165 |
173 | 🎲
174 |
175 | )}
176 |
177 |
178 | );
179 | }
180 |
--------------------------------------------------------------------------------
/assets/social.dio:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/src/Components/kitchen.tsx:
--------------------------------------------------------------------------------
1 | import ContentCopy from "@mui/icons-material/ContentCopy";
2 | import Download from "@mui/icons-material/Download";
3 | import LoadingButton from "@mui/lab/LoadingButton";
4 | import Box from "@mui/material/Box";
5 | import ButtonBase from "@mui/material/ButtonBase";
6 | import Chip from "@mui/material/Chip";
7 | import Container from "@mui/material/Container";
8 | import Fab from "@mui/material/Fab";
9 | import Fade from "@mui/material/Fade";
10 | import Grid from "@mui/material/Grid";
11 | import IconButton from "@mui/material/IconButton";
12 | import ImageList from "@mui/material/ImageList";
13 | import ImageListItem, {
14 | imageListItemClasses,
15 | } from "@mui/material/ImageListItem";
16 | import Menu from "@mui/material/Menu";
17 | import Paper from "@mui/material/Paper";
18 | import Stack from "@mui/material/Stack";
19 | import ToggleButton from "@mui/material/ToggleButton";
20 | import ToggleButtonGroup from "@mui/material/ToggleButtonGroup";
21 | import Typography from "@mui/material/Typography";
22 | import saveAs from "file-saver";
23 | import JSZip from "jszip";
24 | import React, { useEffect, useState } from "react";
25 | import { v4 as uuidv4 } from "uuid";
26 | import LeftEmojiList from "./left-emoji-list";
27 | import MobileEmojiList from "./mobile-emoji-list";
28 | import RightEmojiList from "./right-emoji-list";
29 | import Search from "./search";
30 | import { MouseCoordinates } from "./types";
31 | import { getEmojiData, getNotoEmojiUrl, getSupportedEmoji } from "./utils";
32 |
33 | export default function Kitchen() {
34 | // Selection helpers
35 | var [selectedLeftEmoji, setSelectedLeftEmoji] = useState("");
36 | var [selectedRightEmoji, setSelectedRightEmoji] = useState("");
37 |
38 | // Mobile helpers
39 | const [leftEmojiSelected, setLeftEmojiSelected] = useState(true);
40 | const [isMobile, setIsMobile] = useState(window.innerWidth <= 768);
41 | const [isKeyboardOpen, setIsKeyboardOpen] = useState(
42 | window.innerHeight <= 512
43 | );
44 | const [selectedMode, setSelectedMode] = useState("combine");
45 | const [combinationCopied, setCombinationCopied] = useState(false);
46 |
47 | // Downloading helpers
48 | const [bulkDownloadMenu, setBulkDownloadMenu] = useState<
49 | MouseCoordinates | undefined
50 | >();
51 | const [isBulkDownloading, setIsBulkDownloading] = useState(false);
52 |
53 | // Search results helpers
54 | const [leftSearchResults, setLeftSearchResults] = useState>([]);
55 | const [rightSearchResults, setRightSearchResults] = useState>(
56 | []
57 | );
58 | const [mobileSearchResults, setMobileSearchResults] = useState>(
59 | []
60 | );
61 |
62 | // Search terms helpers
63 | const [leftUuid, setLeftUuid] = useState(uuidv4());
64 | const [rightUuid, setRightUuid] = useState(uuidv4());
65 | const [mobileUuid, setMobileUuid] = useState(uuidv4());
66 |
67 | /**
68 | * 📱 Mobile handler to naively detect if we're on a phone or not
69 | */
70 | function handleWindowWidthChange() {
71 | window.innerWidth <= 768 ? setIsMobile(true) : setIsMobile(false);
72 | }
73 | useEffect(() => {
74 | window.addEventListener("resize", handleWindowWidthChange);
75 | return () => {
76 | window.removeEventListener("resize", handleWindowWidthChange);
77 | };
78 | }, []);
79 |
80 | /**
81 | * 📱 Mobile handler to naively detect if we're on a phone or not
82 | */
83 | function handleWindowHeightChange() {
84 | window.innerHeight <= 512
85 | ? setIsKeyboardOpen(true)
86 | : setIsKeyboardOpen(false);
87 | }
88 | useEffect(() => {
89 | window.addEventListener("resize", handleWindowHeightChange);
90 | return () => {
91 | window.removeEventListener("resize", handleWindowHeightChange);
92 | };
93 | }, []);
94 |
95 | /**
96 | * 📱 Mobile handler to set a random combination on load
97 | */
98 | useEffect(() => {
99 | if (isMobile) {
100 | handleFullEmojiRandomize();
101 | }
102 | }, []);
103 |
104 | /**
105 | * 📱 Mobile handler to reset state when resizing window smaller to trigger mobile view
106 | */
107 | useEffect(() => {
108 | if (!isMobile) {
109 | // Leaving mobile view should always be a subset of desktop functionality
110 | return;
111 | }
112 |
113 | if (selectedLeftEmoji === "" && selectedRightEmoji !== "") {
114 | handleLeftEmojiRandomize();
115 | } else if (selectedLeftEmoji !== "" && selectedRightEmoji === "") {
116 | handleRightEmojiRandomize();
117 | } else if (selectedLeftEmoji === "" && selectedRightEmoji === "") {
118 | handleFullEmojiRandomize();
119 | }
120 | }, [isMobile]);
121 |
122 | /**
123 | * 🖨️ Handler to show the little chip when copying a combination on mobile from the browse tab
124 | */
125 | useEffect(() => {
126 | if (combinationCopied) {
127 | setTimeout(() => {
128 | setCombinationCopied(false);
129 | }, 1000);
130 | }
131 | }, [combinationCopied]);
132 |
133 | /**
134 | * 👈 Handler when an emoji is selected from the left-hand list
135 | */
136 | const handleLeftEmojiClicked = (clickedEmoji: string) => {
137 | if (isMobile) {
138 | // Don't allow columns unselect on mobile
139 | if (selectedLeftEmoji !== clickedEmoji) {
140 | setSelectedLeftEmoji(clickedEmoji);
141 | }
142 | } else {
143 | // If we're unsetting the left column, clear the right column too
144 | if (selectedLeftEmoji === clickedEmoji) {
145 | setSelectedLeftEmoji("");
146 | setSelectedRightEmoji("");
147 | }
148 | // Else we clicked another left emoji while both are selected, set the left column as selected and clear right column
149 | else if (selectedRightEmoji !== "") {
150 | setSelectedLeftEmoji(clickedEmoji);
151 | setSelectedRightEmoji("");
152 | } else {
153 | setSelectedLeftEmoji(clickedEmoji);
154 | }
155 | }
156 | };
157 |
158 | /**
159 | * 🎲 Handler when left-hand randomize button clicked
160 | */
161 | const handleLeftEmojiRandomize = () => {
162 | if (isMobile) {
163 | // On mobile, use the right emoji as a base and select a random left emoji from the supported list
164 | const data = getEmojiData(selectedRightEmoji);
165 | const possibleLeftEmoji = Object.keys(data.combinations).filter(
166 | (codepoint) => codepoint !== selectedLeftEmoji // Don't randomly choose the same left emoji
167 | );
168 |
169 | const randomLeftEmoji =
170 | possibleLeftEmoji[Math.floor(Math.random() * possibleLeftEmoji.length)];
171 |
172 | setSelectedLeftEmoji(randomLeftEmoji);
173 | setLeftEmojiSelected(true); // If you click random on the left emoji, select that one
174 | } else {
175 | // Since we're selecting a new left emoji, clear out the right emoji
176 | var possibleEmoji: Array;
177 |
178 | // Pick a random emoji from all possible emoji
179 | possibleEmoji = getSupportedEmoji().filter(
180 | (codepoint) => codepoint !== selectedLeftEmoji
181 | );
182 |
183 | const randomEmoji =
184 | possibleEmoji[Math.floor(Math.random() * possibleEmoji.length)];
185 | setSelectedLeftEmoji(randomEmoji);
186 | setSelectedRightEmoji("");
187 | }
188 | };
189 |
190 | /**
191 | * 👉 Handler when an emoji is selected from the right-hand list
192 | */
193 | const handleRightEmojiClicked = (clickedEmoji: string) => {
194 | if (isMobile) {
195 | // Don't allow column unselect on mobile
196 | if (selectedRightEmoji !== clickedEmoji) {
197 | setSelectedRightEmoji(clickedEmoji);
198 | }
199 | } else {
200 | setSelectedRightEmoji(
201 | clickedEmoji === selectedRightEmoji ? "" : clickedEmoji
202 | );
203 | }
204 | };
205 |
206 | /**
207 | * 🎲 Handle right-hand randomize button clicked
208 | */
209 | const handleRightEmojiRandomize = () => {
210 | const data = getEmojiData(selectedLeftEmoji);
211 | const possibleEmoji = Object.keys(data.combinations).filter(
212 | (codepoint) => codepoint !== selectedRightEmoji // Don't randomly choose the same right emoji
213 | );
214 |
215 | const randomEmoji =
216 | possibleEmoji[Math.floor(Math.random() * possibleEmoji.length)];
217 |
218 | setSelectedRightEmoji(randomEmoji);
219 |
220 | if (isMobile) {
221 | setLeftEmojiSelected(false);
222 | }
223 | };
224 |
225 | /**
226 | * 🎲 Handle full randomize button clicked
227 | */
228 | const handleFullEmojiRandomize = () => {
229 | const knownSupportedEmoji = getSupportedEmoji();
230 | const randomLeftEmoji =
231 | knownSupportedEmoji[
232 | Math.floor(Math.random() * knownSupportedEmoji.length)
233 | ];
234 |
235 | const data = getEmojiData(randomLeftEmoji);
236 | const possibleRightEmoji = Object.keys(data.combinations).filter(
237 | (codepoint) => codepoint !== randomLeftEmoji
238 | );
239 |
240 | const randomRightEmoji =
241 | possibleRightEmoji[Math.floor(Math.random() * possibleRightEmoji.length)];
242 |
243 | setSelectedLeftEmoji(randomLeftEmoji);
244 | setSelectedRightEmoji(randomRightEmoji);
245 |
246 | if (isMobile) {
247 | setMobileSearchResults([]);
248 | setMobileUuid(uuidv4());
249 | } else {
250 | setLeftSearchResults([]);
251 | setLeftUuid(uuidv4());
252 | setRightSearchResults([]);
253 | setRightUuid(uuidv4());
254 | }
255 | };
256 |
257 | /**
258 | * 💭 Helper function to open the bulk download menu
259 | */
260 | const handleBulkImageDownloadMenuOpen = (event: React.MouseEvent) => {
261 | event.preventDefault();
262 | setBulkDownloadMenu(
263 | bulkDownloadMenu === undefined
264 | ? {
265 | mouseX: event.clientX - 2,
266 | mouseY: event.clientY - 4,
267 | }
268 | : undefined
269 | );
270 | };
271 |
272 | /**
273 | * 💾 Handle bulk combination downloads
274 | */
275 | const handleBulkImageDownload = async () => {
276 | try {
277 | // See: https://github.com/Stuk/jszip/issues/369
278 | // See: https://github.com/Stuk/jszip/issues/690
279 | const currentDate = new Date();
280 | const dateWithOffset = new Date(
281 | currentDate.getTime() - currentDate.getTimezoneOffset() * 60000
282 | );
283 | (JSZip as any).defaults.date = dateWithOffset;
284 |
285 | const zip = new JSZip();
286 | const data = getEmojiData(selectedLeftEmoji);
287 | const photoZip = zip.folder(data.alt)!;
288 |
289 | setIsBulkDownloading(true);
290 |
291 | const combinations = Object.values(data.combinations)
292 | .flat()
293 | .filter((c) => c.isLatest);
294 | for (var i = 0; i < combinations.length; i++) {
295 | const combination = combinations[i];
296 | const image = await fetch(combination.gStaticUrl);
297 | const imageBlob = await image.blob();
298 | photoZip.file(`${combination.alt}.png`, imageBlob);
299 | }
300 |
301 | const archive = await zip.generateAsync({ type: "blob" });
302 | saveAs(archive, data.alt);
303 |
304 | setBulkDownloadMenu(undefined);
305 | setIsBulkDownloading(false);
306 | } catch (e) {
307 | setBulkDownloadMenu(undefined);
308 | setIsBulkDownloading(false);
309 | }
310 | };
311 |
312 | /**
313 | * 💾 Handle single combination downloads
314 | */
315 | const handleImageDownload = () => {
316 | var combination = getEmojiData(selectedLeftEmoji).combinations[
317 | selectedRightEmoji
318 | ].filter((c) => c.isLatest)[0];
319 |
320 | saveAs(combination.gStaticUrl, combination.alt);
321 | };
322 |
323 | /**
324 | * 💾 Handle single image copy-to-clipboard
325 | */
326 | const handleImageCopy = async (url?: string) => {
327 | if (!url) {
328 | url = getEmojiData(selectedLeftEmoji).combinations[
329 | selectedRightEmoji
330 | ].filter((c) => c.isLatest)[0].gStaticUrl;
331 | } else {
332 | setCombinationCopied(true);
333 | }
334 |
335 | const fetchImage = async () => {
336 | const image = await fetch(url);
337 | return await image.blob();
338 | };
339 |
340 | navigator.clipboard
341 | .write([
342 | new ClipboardItem({
343 | "image/png": fetchImage(),
344 | }),
345 | ])
346 | .then(function () {})
347 | .catch(function (error) {
348 | console.log(error);
349 | });
350 | };
351 |
352 | // See: https://caniuse.com/async-clipboard
353 | var hasClipboardSupport = "write" in navigator.clipboard;
354 | var middleList;
355 | var combination;
356 | var showOneCombo = false;
357 |
358 | if (isMobile) {
359 | if (selectedLeftEmoji === "" || selectedRightEmoji === "") {
360 | middleList = ;
361 | } else if (selectedMode === "combine") {
362 | showOneCombo = true;
363 |
364 | // First figure out what your "base" should be
365 | var baseEmoji = leftEmojiSelected
366 | ? selectedLeftEmoji
367 | : selectedRightEmoji;
368 | var otherEmoji = leftEmojiSelected
369 | ? selectedRightEmoji
370 | : selectedLeftEmoji;
371 |
372 | // Get the possible combinations for that base
373 | var combinations = getEmojiData(baseEmoji).combinations;
374 |
375 | // If we're switching out of the browse mode, the resulting leftover combination may no longer be valid
376 | // If so, generate a random pair and set the "other" appropriately
377 | if (!Object.keys(combinations).includes(otherEmoji)) {
378 | var possibleEmoji = Object.keys(combinations);
379 | var otherEmoji =
380 | possibleEmoji[Math.floor(Math.random() * possibleEmoji.length)];
381 |
382 | // Reset the "other" to a random valid combo
383 | if (leftEmojiSelected) {
384 | setSelectedRightEmoji(otherEmoji);
385 | } else {
386 | setSelectedLeftEmoji(otherEmoji);
387 | }
388 | }
389 |
390 | combination = combinations[otherEmoji].filter((c) => c.isLatest)[0];
391 |
392 | middleList = (
393 |
394 |
395 |
396 | );
397 | } else {
398 | // Browse combination browser on mobile
399 | var baseEmoji = leftEmojiSelected
400 | ? selectedLeftEmoji
401 | : selectedRightEmoji;
402 | middleList = Object.values(getEmojiData(baseEmoji).combinations)
403 | .flat()
404 | .filter((combination) => combination.isLatest)
405 | .sort((c1, c2) => c1.gBoardOrder - c2.gBoardOrder)
406 | .map((combination) => {
407 | return (
408 | handleImageCopy(combination.gStaticUrl)}
410 | sx={{
411 | p: 0.5,
412 | borderRadius: 2,
413 | "&:hover": {
414 | backgroundColor: (theme) => {
415 | return theme.palette.action.hover;
416 | },
417 | },
418 | }}
419 | >
420 |
421 |
428 |
429 |
430 | );
431 | });
432 | }
433 | } else {
434 | // Neither are selected, show left list, empty middle list, and disable right list
435 | if (selectedLeftEmoji === "" && selectedRightEmoji === "") {
436 | middleList = ;
437 | }
438 | // Left emoji is selected, but not right, show the full list of combinations
439 | else if (selectedLeftEmoji !== "" && selectedRightEmoji === "") {
440 | middleList = Object.values(getEmojiData(selectedLeftEmoji).combinations)
441 | .flat()
442 | .filter((combination) => combination.isLatest)
443 | .sort((c1, c2) => c1.gBoardOrder - c2.gBoardOrder)
444 | .map((combination) => {
445 | return (
446 |
447 |
454 |
455 | );
456 | });
457 | }
458 | // Both are selected, show the single combo
459 | else {
460 | showOneCombo = true;
461 | combination = getEmojiData(selectedLeftEmoji).combinations[
462 | selectedRightEmoji
463 | ].filter((c) => c.isLatest)[0];
464 |
465 | middleList = (
466 |
467 |
468 |
469 | );
470 | }
471 | }
472 |
473 | if (isMobile) {
474 | return (
475 |
488 | {/* Entire Emoji Column */}
489 |
496 | {/* Top Section */}
497 |
509 |
510 | {/* Mode switcher */}
511 | setSelectedMode(value)}
515 | size="small"
516 | value={selectedMode}
517 | >
518 | Combine
519 | Browse
520 |
521 |
522 | {selectedMode === "combine" ? (
523 |
524 | {/* Left Emoji */}
525 |
526 |
527 | setLeftEmojiSelected(true)}
530 | sx={{
531 | display: "flex",
532 | flexDirection: "column",
533 | flexShrink: 0,
534 | marginBottom: "4px",
535 | backgroundColor: (theme) =>
536 | leftEmojiSelected
537 | ? theme.palette.action.selected
538 | : theme.palette.background.default,
539 | "&:hover": {
540 | backgroundColor: (theme) =>
541 | theme.palette.action.hover,
542 | },
543 | }}
544 | >
545 | {selectedLeftEmoji !== "" ? (
546 |
557 | ) : null}
558 |
559 |
566 |
575 | 🎲
576 |
577 |
578 |
579 |
580 |
581 | {/* Plus sign */}
582 |
590 | +
591 |
592 |
593 | {/* Right Emoji */}
594 |
595 |
596 | setLeftEmojiSelected(false)}
599 | sx={{
600 | display: "flex",
601 | flexDirection: "column",
602 | marginBottom: "4px",
603 | backgroundColor: (theme) =>
604 | leftEmojiSelected
605 | ? theme.palette.background.default
606 | : theme.palette.action.selected,
607 | "&:hover": {
608 | backgroundColor: (theme) =>
609 | theme.palette.action.hover,
610 | },
611 | }}
612 | >
613 | {selectedRightEmoji !== "" ? (
614 |
625 | ) : null}
626 |
627 |
634 |
643 | 🎲
644 |
645 |
646 |
647 |
648 |
649 | {/* Equal sign */}
650 |
658 | =
659 |
660 |
661 | {/* Result */}
662 |
663 |
664 |
672 | {showOneCombo ? (
673 |
674 |

684 |
685 | ) : null}
686 |
687 | handleImageCopy()}
689 | sx={{
690 | height: "40px",
691 | width: "40px",
692 | marginX: "auto",
693 | }}
694 | >
695 |
696 |
697 |
698 |
699 |
700 | ) : (
701 |
702 | {middleList}
703 |
704 | )}
705 |
706 |
707 |
713 |
714 |
715 |
716 |
717 | {/* Search */}
718 |
723 |
724 | {/* Emoji List */}
725 |
740 |
755 |
756 |
757 | {/* Full randomizer */}
758 |
768 |
775 | 🎲
776 |
777 |
778 |
779 |
780 | );
781 | }
782 |
783 | return (
784 |
795 | {/* Left Emoji Column */}
796 |
804 | {/* Left Search */}
805 |
812 |
813 | {/* Left Emoji List */}
814 |
829 |
835 |
836 |
837 | {/* Bulk Download Menu */}
838 | {selectedLeftEmoji !== "" ? (
839 |
862 | ) : undefined}
863 |
864 |
865 | {/* Middle Emoji Column */}
866 |
876 |
883 | 🎲
884 |
885 |
886 |
898 |
911 | {middleList}
912 | {showOneCombo && hasClipboardSupport ? (
913 |
916 | handleImageCopy()}>
917 |
918 |
919 |
920 | ) : null}
921 |
922 | {showOneCombo && !hasClipboardSupport ? (
923 |
926 |
927 |
928 |
929 |
930 | ) : null}
931 |
932 |
933 |
934 | {/* Right Emoji Column */}
935 |
943 | {/* Right Search */}
944 |
952 |
953 | {/* Right Emoji List */}
954 |
969 |
975 |
976 |
977 |
978 | );
979 | }
980 |
--------------------------------------------------------------------------------
/assets/social.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------