diff --git a/extension/README.md b/extension/README.md index e69de29..8b12722 100644 --- a/extension/README.md +++ b/extension/README.md @@ -0,0 +1 @@ +# Slash Browser Extension diff --git a/extension/package.json b/extension/package.json index eb7fad0..ebf6e4d 100644 --- a/extension/package.json +++ b/extension/package.json @@ -17,15 +17,19 @@ "@mui/joy": "5.0.0-beta.0", "@plasmohq/storage": "^1.7.2", "axios": "^1.4.0", + "classnames": "^2.3.2", + "lodash-es": "^4.17.21", "lucide-react": "^0.264.0", "plasmo": "0.82.0", "react": "18.2.0", "react-dom": "18.2.0", - "react-hot-toast": "^2.4.1" + "react-hot-toast": "^2.4.1", + "zustand": "^4.4.1" }, "devDependencies": { "@trivago/prettier-plugin-sort-imports": "4.1.0", "@types/chrome": "0.0.241", + "@types/lodash-es": "^4.17.8", "@types/node": "20.4.2", "@types/react": "18.2.15", "@types/react-dom": "18.2.7", diff --git a/extension/pnpm-lock.yaml b/extension/pnpm-lock.yaml index 62ddffd..8c3d2ad 100644 --- a/extension/pnpm-lock.yaml +++ b/extension/pnpm-lock.yaml @@ -20,6 +20,12 @@ dependencies: axios: specifier: ^1.4.0 version: 1.4.0 + classnames: + specifier: ^2.3.2 + version: 2.3.2 + lodash-es: + specifier: ^4.17.21 + version: 4.17.21 lucide-react: specifier: ^0.264.0 version: 0.264.0(react@18.2.0) @@ -35,6 +41,9 @@ dependencies: react-hot-toast: specifier: ^2.4.1 version: 2.4.1(csstype@3.1.2)(react-dom@18.2.0)(react@18.2.0) + zustand: + specifier: ^4.4.1 + version: 4.4.1(@types/react@18.2.15)(react@18.2.0) devDependencies: '@trivago/prettier-plugin-sort-imports': @@ -43,6 +52,9 @@ devDependencies: '@types/chrome': specifier: 0.0.241 version: 0.0.241 + '@types/lodash-es': + specifier: ^4.17.8 + version: 4.17.8 '@types/node': specifier: 20.4.2 version: 20.4.2 @@ -2849,6 +2861,16 @@ packages: /@types/json-schema@7.0.12: resolution: {integrity: sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==} + /@types/lodash-es@4.17.8: + resolution: {integrity: sha512-euY3XQcZmIzSy7YH5+Unb3b2X12Wtk54YWINBvvGQ5SmMvwb11JQskGsfkH/5HXK77Kr8GF0wkVDIxzAisWtog==} + dependencies: + '@types/lodash': 4.14.196 + dev: true + + /@types/lodash@4.14.196: + resolution: {integrity: sha512-22y3o88f4a94mKljsZcanlNWPzO0uBsBdzLAngf2tp533LzZcQzb6+eZPJ+vCTt+bqF2XnvT9gejTLsAcJAJyQ==} + dev: true + /@types/node@20.4.2: resolution: {integrity: sha512-Dd0BYtWgnWJKwO1jkmTrzofjK2QXXcai0dmtzvIBhcA+RsG5h8R3xlyta0kGOZRNfL9GuRtb1knmPEhQrePCEw==} dev: true @@ -3497,6 +3519,10 @@ packages: engines: {node: '>=6.0'} dev: false + /classnames@2.3.2: + resolution: {integrity: sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==} + dev: false + /cli-cursor@3.1.0: resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} engines: {node: '>=8'} @@ -5259,6 +5285,10 @@ packages: p-locate: 5.0.0 dev: true + /lodash-es@4.17.21: + resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} + dev: false + /lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} dev: true @@ -6922,6 +6952,14 @@ packages: punycode: 2.3.0 dev: true + /use-sync-external-store@1.2.0(react@18.2.0): + resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + react: 18.2.0 + dev: false + /util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -7026,3 +7064,23 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} dev: true + + /zustand@4.4.1(@types/react@18.2.15)(react@18.2.0): + resolution: {integrity: sha512-QCPfstAS4EBiTQzlaGP1gmorkh/UL1Leaj2tdj+zZCZ/9bm0WS7sI2wnfD5lpOszFqWJ1DcPnGoY8RDL61uokw==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + dependencies: + '@types/react': 18.2.15 + react: 18.2.0 + use-sync-external-store: 1.2.0(react@18.2.0) + dev: false diff --git a/extension/src/background.ts b/extension/src/background.ts index 08dbec3..0f228e9 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -1,4 +1,4 @@ -import type { Shortcut } from "./types/proto/api/v2/shortcut_service_pb"; +import type { Shortcut } from "@/types/proto/api/v2/shortcut_service_pb"; import { Storage } from "@plasmohq/storage"; const storage = new Storage(); @@ -9,11 +9,7 @@ chrome.tabs.onUpdated.addListener(async (tabId, _, tab) => { const matchResult = urlRegex.exec(tab.url); const sname = Array.isArray(matchResult) ? matchResult[1] : null; if (sname) { - const shortcuts = (await storage.getItem("shortcuts")) as Shortcut[] | null; - if (!Array.isArray(shortcuts)) { - return; - } - + const shortcuts = (await storage.getItem("shortcuts")) || []; const shortcut = shortcuts.find((shortcut) => shortcut.name === sname); if (!shortcut) { return; diff --git a/extension/src/components/PullShortcutsButton.tsx b/extension/src/components/PullShortcutsButton.tsx index 973b49f..9e7fa9f 100644 --- a/extension/src/components/PullShortcutsButton.tsx +++ b/extension/src/components/PullShortcutsButton.tsx @@ -4,13 +4,13 @@ import axios from "axios"; import { useState } from "react"; import { toast } from "react-hot-toast"; import { Shortcut } from "@/types/proto/api/v2/shortcut_service_pb"; +import "../style.css"; import Icon from "./Icon"; -import "./style.css"; function PullShortcutsButton() { const [domain] = useStorage("domain"); const [accessToken] = useStorage("access_token"); - const [shortcuts, setShortcuts] = useStorage("shortcuts"); + const [, setShortcuts] = useStorage("shortcuts"); const [isPulling, setIsPulling] = useState(false); const handlePullShortcuts = async () => { @@ -30,13 +30,9 @@ function PullShortcutsButton() { }; return ( -
- -
+ ); } diff --git a/extension/src/components/Setting.tsx b/extension/src/components/Setting.tsx deleted file mode 100644 index 6109581..0000000 --- a/extension/src/components/Setting.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { Input } from "@mui/joy"; -import { useStorage } from "@plasmohq/storage/hook"; -import "../style.css"; -import Icon from "./Icon"; - -const Setting = () => { - const [domain, setDomain] = useStorage("domain"); - const [accessToken, setAccessToken] = useStorage("access_token"); - - return ( -
-

- - Setting -

- -
- Domain -
- setDomain(e.target.value)} - /> -
-
- -
- Access Token -
- setAccessToken(e.target.value)} - /> -
-
-
- ); -}; - -export default Setting; diff --git a/extension/src/components/ShortcutView.tsx b/extension/src/components/ShortcutView.tsx new file mode 100644 index 0000000..93a0f14 --- /dev/null +++ b/extension/src/components/ShortcutView.tsx @@ -0,0 +1,77 @@ +import type { Shortcut } from "@/types/proto/api/v2/shortcut_service_pb"; +import { useStorage } from "@plasmohq/storage/hook"; +import classNames from "classnames"; +import { useEffect, useState } from "react"; +import useFaviconStore from "../stores/favicon"; +import Icon from "./Icon"; + +interface Props { + shortcut: Shortcut; +} + +const ShortcutView = (props: Props) => { + const { shortcut } = props; + const faviconStore = useFaviconStore(); + const [domain] = useStorage("domain", ""); + const [favicon, setFavicon] = useState(undefined); + + useEffect(() => { + faviconStore.getOrFetchUrlFavicon(shortcut.link).then((url) => { + if (url) { + setFavicon(url); + } + }); + }, [shortcut.link]); + + const handleShortcutLinkClick = () => { + const shortcutLink = `${domain}/s/${shortcut.name}`; + chrome.tabs.create({ url: shortcutLink }); + }; + + return ( + <> +
+
+ + {favicon ? ( + + ) : ( + + )} + +
+
+ +
+
+
+
+ + ); +}; + +export default ShortcutView; diff --git a/extension/src/components/ShortcutsContainer.tsx b/extension/src/components/ShortcutsContainer.tsx new file mode 100644 index 0000000..76ead45 --- /dev/null +++ b/extension/src/components/ShortcutsContainer.tsx @@ -0,0 +1,18 @@ +import type { Shortcut } from "@/types/proto/api/v2/shortcut_service_pb"; +import { useStorage } from "@plasmohq/storage/hook"; +import classNames from "classnames"; +import ShortcutView from "./ShortcutView"; + +const ShortcutsContainer = () => { + const [shortcuts] = useStorage("shortcuts", (v) => (v ? v : [])); + + return ( +
+ {shortcuts.map((shortcut) => { + return ; + })} +
+ ); +}; + +export default ShortcutsContainer; diff --git a/extension/src/helpers/api.ts b/extension/src/helpers/api.ts new file mode 100644 index 0000000..d6b96dc --- /dev/null +++ b/extension/src/helpers/api.ts @@ -0,0 +1,15 @@ +import type { Shortcut } from "@/types/proto/api/v2/shortcut_service_pb"; +import { Storage } from "@plasmohq/storage"; +import axios from "axios"; + +const storage = new Storage(); + +export const getShortcutList = () => { + const queryList = []; + return axios.get(`/api/v1/shortcut?${queryList.join("&")}`); +}; + +export const getUrlFavicon = async (url: string) => { + const domain = await storage.getItem("domain"); + return axios.get(`${domain}/api/v1/url/favicon?url=${url}`); +}; diff --git a/extension/src/helpers/utils.ts b/extension/src/helpers/utils.ts new file mode 100644 index 0000000..fad7257 --- /dev/null +++ b/extension/src/helpers/utils.ts @@ -0,0 +1,5 @@ +import { isNull, isUndefined } from "lodash-es"; + +export const isNullorUndefined = (value: any) => { + return isNull(value) || isUndefined(value); +}; diff --git a/extension/src/options.tsx b/extension/src/options.tsx index 02085fc..4f38cf8 100644 --- a/extension/src/options.tsx +++ b/extension/src/options.tsx @@ -1,10 +1,93 @@ +import { Button, Input } from "@mui/joy"; +import { useStorage } from "@plasmohq/storage/hook"; +import { useEffect, useState } from "react"; +import { Toaster, toast } from "react-hot-toast"; +import Icon from "./components/Icon"; import "./style.css"; +interface SettingState { + domain: string; + accessToken: string; +} + function IndexOptions() { + const [domain, setDomain] = useStorage("domain", (v) => (v ? v : "")); + const [accessToken, setAccessToken] = useStorage("access_token", (v) => (v ? v : "")); + const [settingState, setSettingState] = useState({ + domain, + accessToken, + }); + + useEffect(() => { + setSettingState({ + domain, + accessToken, + }); + }, [domain, accessToken]); + + const setPartialSettingState = (partialSettingState: Partial) => { + setSettingState((prevState) => ({ + ...prevState, + ...partialSettingState, + })); + }; + + const handleSaveSetting = () => { + if (!settingState.domain || !settingState.accessToken) { + toast.error("Domain and access token are required"); + return; + } + + setDomain(settingState.domain); + setAccessToken(settingState.accessToken); + toast.success("Setting saved"); + }; + return ( -
-

TBC

-
+ <> +
+
+

+ + Slash + / + Setting +

+ +
+ Domain +
+ setPartialSettingState({ domain: e.target.value })} + /> +
+
+ +
+ Access Token +
+ setPartialSettingState({ accessToken: e.target.value })} + /> +
+
+ +
+ +
+
+
+ + + ); } diff --git a/extension/src/popup.tsx b/extension/src/popup.tsx index 6777fc3..a0cd63a 100644 --- a/extension/src/popup.tsx +++ b/extension/src/popup.tsx @@ -1,46 +1,51 @@ import { Button } from "@mui/joy"; import { useStorage } from "@plasmohq/storage/hook"; -import axios from "axios"; -import { Toaster, toast } from "react-hot-toast"; -import Setting from "@/components/Setting"; +import { Toaster } from "react-hot-toast"; import { Shortcut } from "@/types/proto/api/v2/shortcut_service_pb"; import Icon from "./components/Icon"; +import PullShortcutsButton from "./components/PullShortcutsButton"; +import ShortcutsContainer from "./components/ShortcutsContainer"; import "./style.css"; function IndexPopup() { - const [domain] = useStorage("domain"); - const [accessToken] = useStorage("access_token"); - const [shortcuts, setShortcuts] = useStorage("shortcuts"); + const [domain] = useStorage("domain", ""); + const [accessToken] = useStorage("access_token", ""); + const [shortcuts] = useStorage("shortcuts", []); + const isInitialized = domain && accessToken; - const handlePullShortcuts = async () => { - try { - const { data } = await axios.get(`${domain}/api/v1/shortcut`, { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }); - setShortcuts(data); - toast.success("Shortcuts pulled"); - } catch (error) { - toast.error("Failed to pull shortcuts, error: " + error.message); - } + const handleSettingButtonClick = () => { + chrome.runtime.openOptionsPage(); }; return ( <> -
- +
+
+
+ + Slash + {isInitialized && ( + <> + / + Shortcuts + ({shortcuts.length}) + + + )} +
+
+ +
+
- +
- + ); } diff --git a/extension/src/stores/favicon.ts b/extension/src/stores/favicon.ts new file mode 100644 index 0000000..2f1ca54 --- /dev/null +++ b/extension/src/stores/favicon.ts @@ -0,0 +1,41 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; +import { getUrlFavicon } from "../helpers/api"; + +interface FaviconState { + cache: { + [key: string]: string; + }; + getOrFetchUrlFavicon: (url: string) => Promise; +} + +const useFaviconStore = create()( + persist( + (set, get) => ({ + cache: {}, + getOrFetchUrlFavicon: async (url: string) => { + const cache = get().cache; + if (cache[url]) { + return cache[url]; + } + + try { + const { data: favicon } = await getUrlFavicon(url); + if (favicon) { + cache[url] = favicon; + set(cache); + return favicon; + } + } catch (error) { + // do nothing + } + return ""; + }, + }), + { + name: "favicon_cache", + } + ) +); + +export default useFaviconStore;