mirror of
https://github.com/aykhans/slash-e.git
synced 2025-07-02 04:01:35 +00:00
chore: update frontend folder
This commit is contained in:
33
frontend/extension/.eslintrc.json
Normal file
33
frontend/extension/.eslintrc.json
Normal file
@ -0,0 +1,33 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2021": true
|
||||
},
|
||||
"extends": ["eslint:recommended", "plugin:react/recommended", "plugin:@typescript-eslint/recommended", "plugin:prettier/recommended"],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaFeatures": {
|
||||
"jsx": true
|
||||
},
|
||||
"ecmaVersion": "latest",
|
||||
"sourceType": "module"
|
||||
},
|
||||
"plugins": ["react", "@typescript-eslint", "prettier"],
|
||||
"ignorePatterns": ["node_modules", "dist", "public"],
|
||||
"rules": {
|
||||
"prettier/prettier": [
|
||||
"error",
|
||||
{
|
||||
"endOfLine": "auto"
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/no-explicit-any": ["off"],
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/jsx-no-target-blank": "off"
|
||||
},
|
||||
"settings": {
|
||||
"react": {
|
||||
"version": "detect"
|
||||
}
|
||||
}
|
||||
}
|
38
frontend/extension/.gitignore
vendored
Normal file
38
frontend/extension/.gitignore
vendored
Normal file
@ -0,0 +1,38 @@
|
||||
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
#cache
|
||||
.turbo
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# local env files
|
||||
.env*
|
||||
|
||||
out/
|
||||
build/
|
||||
dist/
|
||||
|
||||
.plasmo
|
||||
|
||||
# bpp - http://bpp.browser.market/
|
||||
keys.json
|
||||
|
||||
# typescript
|
||||
.tsbuildinfo
|
8
frontend/extension/.prettierrc.js
Normal file
8
frontend/extension/.prettierrc.js
Normal file
@ -0,0 +1,8 @@
|
||||
module.exports = {
|
||||
printWidth: 140,
|
||||
useTabs: false,
|
||||
semi: true,
|
||||
singleQuote: false,
|
||||
plugins: [require.resolve("@trivago/prettier-plugin-sort-imports")],
|
||||
importOrder: ["<BUILTIN_MODULES>", "<THIRD_PARTY_MODULES>", "^@/((?!css).+)", "^[./]", "^[../]", "^(.+).css"],
|
||||
};
|
1
frontend/extension/README.md
Normal file
1
frontend/extension/README.md
Normal file
@ -0,0 +1 @@
|
||||
# Slash Browser Extension
|
BIN
frontend/extension/assets/icon.png
Normal file
BIN
frontend/extension/assets/icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 83 KiB |
57
frontend/extension/package.json
Normal file
57
frontend/extension/package.json
Normal file
@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "slash-extension",
|
||||
"displayName": "Slash",
|
||||
"version": "0.1.4",
|
||||
"description": "An open source, self-hosted bookmarks and link sharing platform. Save and share your links very easily.",
|
||||
"scripts": {
|
||||
"dev": "plasmo dev",
|
||||
"build": "plasmo build",
|
||||
"package": "plasmo package",
|
||||
"lint": "eslint --ext .js,.ts,.tsx, src",
|
||||
"lint-fix": "eslint --ext .js,.ts,.tsx, src --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "^1.3.0",
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@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",
|
||||
"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",
|
||||
"@typescript-eslint/eslint-plugin": "^6.2.0",
|
||||
"@typescript-eslint/parser": "^6.2.0",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"eslint": "^8.46.0",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"eslint-plugin-react": "^7.27.1",
|
||||
"postcss": "^8.4.27",
|
||||
"prettier": "2.6.2",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"typescript": "5.1.6"
|
||||
},
|
||||
"manifest": {
|
||||
"omnibox": {
|
||||
"keyword": "s"
|
||||
},
|
||||
"permissions": [
|
||||
"tabs",
|
||||
"storage"
|
||||
]
|
||||
}
|
||||
}
|
7114
frontend/extension/pnpm-lock.yaml
generated
Normal file
7114
frontend/extension/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
10
frontend/extension/postcss.config.js
Normal file
10
frontend/extension/postcss.config.js
Normal file
@ -0,0 +1,10 @@
|
||||
/* eslint-disable no-undef */
|
||||
/**
|
||||
* @type {import('postcss').ProcessOptions}
|
||||
*/
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
62
frontend/extension/src/background.ts
Normal file
62
frontend/extension/src/background.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import type { Shortcut } from "../../types/proto/api/v2/shortcut_service_pb";
|
||||
import { Storage } from "@plasmohq/storage";
|
||||
|
||||
const storage = new Storage();
|
||||
const urlRegex = /https?:\/\/s\/(.+)/;
|
||||
|
||||
chrome.tabs.onUpdated.addListener(async (tabId, _, tab) => {
|
||||
if (!tab.url) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shortcutName = getShortcutNameFromUrl(tab.url);
|
||||
if (shortcutName) {
|
||||
const shortcuts = (await storage.getItem<Shortcut[]>("shortcuts")) || [];
|
||||
const shortcut = shortcuts.find((shortcut) => shortcut.name === shortcutName);
|
||||
if (!shortcut) {
|
||||
return;
|
||||
}
|
||||
return chrome.tabs.update(tabId, { url: shortcut.link });
|
||||
}
|
||||
});
|
||||
|
||||
chrome.omnibox.onInputEntered.addListener(async (text) => {
|
||||
const shortcuts = (await storage.getItem<Shortcut[]>("shortcuts")) || [];
|
||||
const shortcut = shortcuts.find((shortcut) => shortcut.name === text);
|
||||
if (!shortcut) {
|
||||
return;
|
||||
}
|
||||
return chrome.tabs.update({ url: shortcut.link });
|
||||
});
|
||||
|
||||
const getShortcutNameFromUrl = (urlString: string) => {
|
||||
const matchResult = urlRegex.exec(urlString);
|
||||
if (matchResult === null) {
|
||||
return getShortcutNameFromSearchUrl(urlString);
|
||||
}
|
||||
return matchResult[1];
|
||||
};
|
||||
|
||||
const getShortcutNameFromSearchUrl = (urlString: string) => {
|
||||
const url = new URL(urlString);
|
||||
if ((url.hostname === "www.google.com" || url.hostname === "www.bing.com") && url.pathname === "/search") {
|
||||
const params = new URLSearchParams(url.search);
|
||||
const shortcutName = params.get("q");
|
||||
if (typeof shortcutName === "string" && shortcutName.startsWith("s/")) {
|
||||
return shortcutName.slice(2);
|
||||
}
|
||||
} else if (url.hostname === "www.baidu.com" && url.pathname === "/s") {
|
||||
const params = new URLSearchParams(url.search);
|
||||
const shortcutName = params.get("wd");
|
||||
if (typeof shortcutName === "string" && shortcutName.startsWith("s/")) {
|
||||
return shortcutName.slice(2);
|
||||
}
|
||||
} else if (url.hostname === "duckduckgo.com" && url.pathname === "/") {
|
||||
const params = new URLSearchParams(url.search);
|
||||
const shortcutName = params.get("q");
|
||||
if (typeof shortcutName === "string" && shortcutName.startsWith("s/")) {
|
||||
return shortcutName.slice(2);
|
||||
}
|
||||
}
|
||||
return "";
|
||||
};
|
173
frontend/extension/src/components/CreateShortcutsButton.tsx
Normal file
173
frontend/extension/src/components/CreateShortcutsButton.tsx
Normal file
@ -0,0 +1,173 @@
|
||||
import { Button, IconButton, Input, Modal, ModalDialog } from "@mui/joy";
|
||||
import { useStorage } from "@plasmohq/storage/hook";
|
||||
import axios from "axios";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { CreateShortcutResponse, OpenGraphMetadata, Visibility } from "../../../types/proto/api/v2/shortcut_service_pb";
|
||||
import Icon from "./Icon";
|
||||
|
||||
const generateTempName = (length = 6) => {
|
||||
let result = "";
|
||||
const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
const charactersLength = characters.length;
|
||||
let counter = 0;
|
||||
while (counter < length) {
|
||||
result += characters.charAt(Math.floor(Math.random() * charactersLength));
|
||||
counter += 1;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
interface State {
|
||||
name: string;
|
||||
title: string;
|
||||
link: string;
|
||||
}
|
||||
|
||||
const CreateShortcutsButton = () => {
|
||||
const [domain] = useStorage("domain");
|
||||
const [accessToken] = useStorage("access_token");
|
||||
const [shortcuts, setShortcuts] = useStorage("shortcuts");
|
||||
const [state, setState] = useState<State>({
|
||||
name: "",
|
||||
title: "",
|
||||
link: "",
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (showModal) {
|
||||
document.body.style.height = "384px";
|
||||
} else {
|
||||
document.body.style.height = "auto";
|
||||
}
|
||||
}, [showModal]);
|
||||
|
||||
const handleCreateShortcutButtonClick = async () => {
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, async (tabs) => {
|
||||
if (tabs.length === 0) {
|
||||
toast.error("No active tab found");
|
||||
return;
|
||||
}
|
||||
const tab = tabs[0];
|
||||
setState((state) => ({
|
||||
...state,
|
||||
name: generateTempName().toLowerCase() + "-temp",
|
||||
title: tab.title || "",
|
||||
link: tab.url || "",
|
||||
}));
|
||||
setShowModal(true);
|
||||
});
|
||||
};
|
||||
|
||||
const handleNameInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setState((state) => ({
|
||||
...state,
|
||||
name: e.target.value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleTitleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setState((state) => ({
|
||||
...state,
|
||||
title: e.target.value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleLinkInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setState((state) => ({
|
||||
...state,
|
||||
link: e.target.value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSaveBtnClick = async () => {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
if (!state.name) {
|
||||
toast.error("Name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const {
|
||||
data: { shortcut },
|
||||
} = await axios.post<CreateShortcutResponse>(
|
||||
`${domain}/api/v2/shortcuts`,
|
||||
{
|
||||
name: state.name,
|
||||
title: state.title,
|
||||
link: state.link,
|
||||
visibility: Visibility.PRIVATE,
|
||||
ogMetadata: OpenGraphMetadata.fromJsonString("{}"),
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
setShortcuts([shortcut, ...shortcuts]);
|
||||
toast.success("Shortcut created successfully");
|
||||
setShowModal(false);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toast.error(error.response.data.message);
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton color="primary" variant="solid" size="sm" onClick={() => handleCreateShortcutButtonClick()}>
|
||||
<Icon.Plus className="w-5 h-auto" />
|
||||
</IconButton>
|
||||
|
||||
<Modal container={() => document.body} open={showModal} onClose={() => setShowModal(false)}>
|
||||
<ModalDialog className="w-3/4">
|
||||
<div className="w-full flex flex-row justify-between items-center mb-2">
|
||||
<span className="text-base font-medium">Create Shortcut</span>
|
||||
<Button size="sm" variant="plain" onClick={() => setShowModal(false)}>
|
||||
<Icon.X className="w-5 h-auto text-gray-600" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-x-hidden w-full flex flex-col justify-start items-center">
|
||||
<div className="w-full flex flex-row justify-start items-center mb-2">
|
||||
<span className="block w-12 mr-2 shrink-0">Name</span>
|
||||
<Input className="grow" type="text" placeholder="Unique shortcut name" value={state.name} onChange={handleNameInputChange} />
|
||||
</div>
|
||||
<div className="w-full flex flex-row justify-start items-center mb-2">
|
||||
<span className="block w-12 mr-2 shrink-0">Title</span>
|
||||
<Input className="grow" type="text" placeholder="Shortcut title" value={state.title} onChange={handleTitleInputChange} />
|
||||
</div>
|
||||
<div className="w-full flex flex-row justify-start items-center mb-2">
|
||||
<span className="block w-12 mr-2 shrink-0">Link</span>
|
||||
<Input
|
||||
className="grow"
|
||||
type="text"
|
||||
placeholder="https://github.com/boojack/slash"
|
||||
value={state.link}
|
||||
onChange={handleLinkInputChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex flex-row justify-end items-center mt-2 space-x-2">
|
||||
<Button color="neutral" variant="plain" onClick={() => setShowModal(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="primary" disabled={isLoading} loading={isLoading} onClick={handleSaveBtnClick}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateShortcutsButton;
|
3
frontend/extension/src/components/Icon.ts
Normal file
3
frontend/extension/src/components/Icon.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import * as Icon from "lucide-react";
|
||||
|
||||
export default Icon;
|
12
frontend/extension/src/components/Logo.tsx
Normal file
12
frontend/extension/src/components/Logo.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import classNames from "classnames";
|
||||
import LogoBase64 from "data-base64:../..//assets/icon.png";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Logo = ({ className }: Props) => {
|
||||
return <img className={classNames(className)} src={LogoBase64} alt="" />;
|
||||
};
|
||||
|
||||
export default Logo;
|
45
frontend/extension/src/components/PullShortcutsButton.tsx
Normal file
45
frontend/extension/src/components/PullShortcutsButton.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { IconButton } from "@mui/joy";
|
||||
import { useStorage } from "@plasmohq/storage/hook";
|
||||
import axios from "axios";
|
||||
import { useEffect } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { ListShortcutsResponse } from "../../../types/proto/api/v2/shortcut_service_pb";
|
||||
import Icon from "./Icon";
|
||||
|
||||
const PullShortcutsButton = () => {
|
||||
const [domain] = useStorage("domain");
|
||||
const [accessToken] = useStorage("access_token");
|
||||
const [, setShortcuts] = useStorage("shortcuts");
|
||||
|
||||
useEffect(() => {
|
||||
if (domain && accessToken) {
|
||||
handlePullShortcuts(true);
|
||||
}
|
||||
}, [domain, accessToken]);
|
||||
|
||||
const handlePullShortcuts = async (silence = false) => {
|
||||
try {
|
||||
const {
|
||||
data: { shortcuts },
|
||||
} = await axios.get<ListShortcutsResponse>(`${domain}/api/v2/shortcuts`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
setShortcuts(shortcuts);
|
||||
if (!silence) {
|
||||
toast.success("Shortcuts pulled");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Failed to pull shortcuts, error: " + error.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<IconButton color="neutral" variant="plain" size="sm" onClick={() => handlePullShortcuts()}>
|
||||
<Icon.RefreshCcw className="w-4 h-auto" />
|
||||
</IconButton>
|
||||
);
|
||||
};
|
||||
|
||||
export default PullShortcutsButton;
|
77
frontend/extension/src/components/ShortcutView.tsx
Normal file
77
frontend/extension/src/components/ShortcutView.tsx
Normal file
@ -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<string>("domain", "");
|
||||
const [favicon, setFavicon] = useState<string | undefined>(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 (
|
||||
<>
|
||||
<div
|
||||
className={classNames(
|
||||
"group w-full px-3 py-2 flex flex-col justify-start items-start border rounded-lg hover:bg-gray-100 hover:shadow"
|
||||
)}
|
||||
>
|
||||
<div className="w-full flex flex-row justify-start items-center">
|
||||
<span className={classNames("w-5 h-5 flex justify-center items-center overflow-clip shrink-0")}>
|
||||
{favicon ? (
|
||||
<img className="w-full h-auto rounded-full" src={favicon} decoding="async" loading="lazy" />
|
||||
) : (
|
||||
<Icon.CircleSlash className="w-full h-auto text-gray-400" />
|
||||
)}
|
||||
</span>
|
||||
<div className="ml-1 w-[calc(100%-20px)] flex flex-col justify-start items-start">
|
||||
<div className="w-full flex flex-row justify-start items-center">
|
||||
<button
|
||||
className={classNames(
|
||||
"max-w-full flex flex-row px-1 mr-1 justify-start items-center cursor-pointer rounded-md hover:underline"
|
||||
)}
|
||||
onClick={handleShortcutLinkClick}
|
||||
>
|
||||
<div className="truncate">
|
||||
<span>{shortcut.title}</span>
|
||||
{shortcut.title ? (
|
||||
<span className="text-gray-400">(s/{shortcut.name})</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-gray-400">s/</span>
|
||||
<span className="truncate">{shortcut.name}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<span className="hidden group-hover:block ml-1 cursor-pointer shrink-0">
|
||||
<Icon.ExternalLink className="w-4 h-auto text-gray-600" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShortcutView;
|
18
frontend/extension/src/components/ShortcutsContainer.tsx
Normal file
18
frontend/extension/src/components/ShortcutsContainer.tsx
Normal file
@ -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<Shortcut[]>("shortcuts", (v) => (v ? v : []));
|
||||
|
||||
return (
|
||||
<div className={classNames("w-full grid grid-cols-2 gap-2")}>
|
||||
{shortcuts.map((shortcut) => {
|
||||
return <ShortcutView key={shortcut.id} shortcut={shortcut} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShortcutsContainer;
|
14
frontend/extension/src/helpers/api.ts
Normal file
14
frontend/extension/src/helpers/api.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { Storage } from "@plasmohq/storage";
|
||||
import axios from "axios";
|
||||
|
||||
const storage = new Storage();
|
||||
|
||||
export const getUrlFavicon = async (url: string) => {
|
||||
const domain = await storage.getItem<string>("domain");
|
||||
const accessToken = await storage.getItem<string>("access_token");
|
||||
return axios.get<string>(`${domain}/api/v1/url/favicon?url=${url}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
};
|
5
frontend/extension/src/helpers/utils.ts
Normal file
5
frontend/extension/src/helpers/utils.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { isNull, isUndefined } from "lodash-es";
|
||||
|
||||
export const isNullorUndefined = (value: any) => {
|
||||
return isNull(value) || isUndefined(value);
|
||||
};
|
134
frontend/extension/src/options.tsx
Normal file
134
frontend/extension/src/options.tsx
Normal file
@ -0,0 +1,134 @@
|
||||
import type { Shortcut } from "../../types/proto/api/v2/shortcut_service_pb";
|
||||
import { Button, Divider, 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 Logo from "./components/Logo";
|
||||
import PullShortcutsButton from "./components/PullShortcutsButton";
|
||||
import ShortcutsContainer from "./components/ShortcutsContainer";
|
||||
import "./style.css";
|
||||
|
||||
interface SettingState {
|
||||
domain: string;
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
const IndexOptions = () => {
|
||||
const [domain, setDomain] = useStorage<string>("domain", (v) => (v ? v : ""));
|
||||
const [accessToken, setAccessToken] = useStorage<string>("access_token", (v) => (v ? v : ""));
|
||||
const [settingState, setSettingState] = useState<SettingState>({
|
||||
domain,
|
||||
accessToken,
|
||||
});
|
||||
const [shortcuts] = useStorage<Shortcut[]>("shortcuts", []);
|
||||
const isInitialized = domain && accessToken;
|
||||
|
||||
useEffect(() => {
|
||||
setSettingState({
|
||||
domain,
|
||||
accessToken,
|
||||
});
|
||||
}, [domain, accessToken]);
|
||||
|
||||
const setPartialSettingState = (partialSettingState: Partial<SettingState>) => {
|
||||
setSettingState((prevState) => ({
|
||||
...prevState,
|
||||
...partialSettingState,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSaveSetting = () => {
|
||||
setDomain(settingState.domain);
|
||||
setAccessToken(settingState.accessToken);
|
||||
toast.success("Setting saved");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full">
|
||||
<div className="w-full flex flex-row justify-center items-center">
|
||||
<a
|
||||
className="bg-yellow-100 mt-12 py-2 px-3 rounded-full border flex flex-row justify-start items-center cursor-pointer shadow hover:underline hover:text-blue-600"
|
||||
href="https://github.com/boojack/slash#browser-extension"
|
||||
target="_blank"
|
||||
>
|
||||
<Icon.HelpCircle className="w-4 h-auto" />
|
||||
<span className="mx-1 text-sm">Need help? Check out the docs</span>
|
||||
<Icon.ExternalLink className="w-4 h-auto" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-lg mx-auto flex flex-col justify-start items-start mt-12">
|
||||
<h2 className="flex flex-row justify-start items-center mb-6 text-2xl">
|
||||
<Logo className="w-10 h-auto mr-2" />
|
||||
<span>Slash</span>
|
||||
<span className="mx-2 text-gray-400">/</span>
|
||||
<span>Setting</span>
|
||||
</h2>
|
||||
|
||||
<div className="w-full flex flex-col justify-start items-start">
|
||||
<div className="w-full flex flex-col justify-start items-start mb-4">
|
||||
<div className="mb-2 text-base w-full flex flex-row justify-between items-center">
|
||||
<span>Domain</span>
|
||||
{domain !== "" && (
|
||||
<a
|
||||
className="text-sm flex flex-row justify-start items-center hover:underline hover:text-blue-600"
|
||||
href={domain}
|
||||
target="_blank"
|
||||
>
|
||||
<span className="mr-1">Go to my Slash</span>
|
||||
<Icon.ExternalLink className="w-4 h-auto" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
className="w-full"
|
||||
type="text"
|
||||
placeholder="The domain of your Slash instance"
|
||||
value={settingState.domain}
|
||||
onChange={(e) => setPartialSettingState({ domain: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex flex-col justify-start items-start">
|
||||
<span className="mb-2 text-base">Access Token</span>
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
className="w-full"
|
||||
type="text"
|
||||
placeholder="The access token of your Slash instance"
|
||||
value={settingState.accessToken}
|
||||
onChange={(e) => setPartialSettingState({ accessToken: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full mt-6">
|
||||
<Button onClick={handleSaveSetting}>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isInitialized && (
|
||||
<>
|
||||
<Divider className="!my-6" />
|
||||
|
||||
<h2 className="flex flex-row justify-start items-center mb-4">
|
||||
<span className="text-lg">Shortcuts</span>
|
||||
<span className="text-gray-500 mr-1">({shortcuts.length})</span>
|
||||
<PullShortcutsButton />
|
||||
</h2>
|
||||
<ShortcutsContainer />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Toaster position="top-center" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default IndexOptions;
|
109
frontend/extension/src/popup.tsx
Normal file
109
frontend/extension/src/popup.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
import type { Shortcut } from "../../types/proto/api/v2/shortcut_service_pb";
|
||||
import { Button, Divider, IconButton } from "@mui/joy";
|
||||
import { useStorage } from "@plasmohq/storage/hook";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
import CreateShortcutsButton from "@/components/CreateShortcutsButton";
|
||||
import Icon from "@/components/Icon";
|
||||
import Logo from "@/components/Logo";
|
||||
import PullShortcutsButton from "@/components/PullShortcutsButton";
|
||||
import ShortcutsContainer from "@/components/ShortcutsContainer";
|
||||
import "./style.css";
|
||||
|
||||
const IndexPopup = () => {
|
||||
const [domain] = useStorage<string>("domain", "");
|
||||
const [accessToken] = useStorage<string>("access_token", "");
|
||||
const [shortcuts] = useStorage<Shortcut[]>("shortcuts", []);
|
||||
const isInitialized = domain && accessToken;
|
||||
|
||||
const handleSettingButtonClick = () => {
|
||||
chrome.runtime.openOptionsPage();
|
||||
};
|
||||
|
||||
const handleRefreshButtonClick = () => {
|
||||
chrome.runtime.reload();
|
||||
chrome.browserAction.setPopup({ popup: "" });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full min-w-[512px] px-4 pt-4">
|
||||
<div className="w-full flex flex-row justify-between items-center">
|
||||
<div className="flex flex-row justify-start items-center">
|
||||
<Logo className="w-6 h-auto mr-2" />
|
||||
<span className="">Slash</span>
|
||||
{isInitialized && (
|
||||
<>
|
||||
<span className="mx-1 text-gray-400">/</span>
|
||||
<span>Shortcuts</span>
|
||||
<span className="text-gray-500 mr-0.5">({shortcuts.length})</span>
|
||||
<PullShortcutsButton />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div>{isInitialized && <CreateShortcutsButton />}</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full mt-4">
|
||||
{isInitialized ? (
|
||||
<>
|
||||
{shortcuts.length !== 0 ? (
|
||||
<ShortcutsContainer />
|
||||
) : (
|
||||
<div className="w-full flex flex-col justify-center items-center">
|
||||
<p>No shortcut found.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Divider className="!mt-4 !mb-2 opacity-40" />
|
||||
|
||||
<div className="w-full flex flex-row justify-between items-center mb-2">
|
||||
<div className="flex flex-row justify-start items-center">
|
||||
<IconButton size="sm" variant="plain" color="neutral" onClick={handleSettingButtonClick}>
|
||||
<Icon.Settings className="w-5 h-auto text-gray-500" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="plain"
|
||||
color="neutral"
|
||||
component="a"
|
||||
href="https://github.com/boojack/slash"
|
||||
target="_blank"
|
||||
>
|
||||
<Icon.Github className="w-5 h-auto text-gray-500" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="flex flex-row justify-end items-center">
|
||||
<a
|
||||
className="text-sm flex flex-row justify-start items-center text-gray-500 hover:underline hover:text-blue-600"
|
||||
href={domain}
|
||||
target="_blank"
|
||||
>
|
||||
<span className="mr-1">Go to my Slash</span>
|
||||
<Icon.ExternalLink className="w-4 h-auto" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="w-full flex flex-col justify-start items-center">
|
||||
<p>No domain and access token found.</p>
|
||||
<div className="w-full flex flex-row justify-center items-center py-4">
|
||||
<Button size="sm" color="primary" onClick={handleSettingButtonClick}>
|
||||
<Icon.Settings className="w-5 h-auto mr-1" /> Setting
|
||||
</Button>
|
||||
<span className="mx-2">Or</span>
|
||||
<Button size="sm" variant="outlined" color="neutral" onClick={handleRefreshButtonClick}>
|
||||
<Icon.RefreshCcw className="w-5 h-auto mr-1" /> Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Toaster position="top-right" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default IndexPopup;
|
41
frontend/extension/src/stores/favicon.ts
Normal file
41
frontend/extension/src/stores/favicon.ts
Normal file
@ -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<string>;
|
||||
}
|
||||
|
||||
const useFaviconStore = create<FaviconState>()(
|
||||
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;
|
25
frontend/extension/src/style.css
Normal file
25
frontend/extension/src/style.css
Normal file
@ -0,0 +1,25 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body,
|
||||
html,
|
||||
#root {
|
||||
@apply text-base;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Noto Sans", "Noto Sans CJK SC", "Microsoft YaHei UI", "Microsoft YaHei",
|
||||
"WenQuanYi Micro Hei", sans-serif, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
|
||||
"Noto Color Emoji";
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Hide scrollbar for IE, Edge and Firefox */
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
}
|
8
frontend/extension/tailwind.config.js
Normal file
8
frontend/extension/tailwind.config.js
Normal file
@ -0,0 +1,8 @@
|
||||
/* eslint-disable no-undef */
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
mode: "jit",
|
||||
darkMode: "class",
|
||||
content: ["./**/*.tsx"],
|
||||
plugins: [],
|
||||
};
|
19
frontend/extension/tsconfig.json
Normal file
19
frontend/extension/tsconfig.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "plasmo/templates/tsconfig.base",
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
],
|
||||
"include": [
|
||||
".plasmo/index.d.ts",
|
||||
"./**/*.ts",
|
||||
"./**/*.tsx"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
},
|
||||
"baseUrl": "."
|
||||
}
|
||||
}
|
38
frontend/locales/en.json
Normal file
38
frontend/locales/en.json
Normal file
@ -0,0 +1,38 @@
|
||||
{
|
||||
"common": {
|
||||
"about": "About",
|
||||
"loading": "Loading",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"create": "Create",
|
||||
"download": "Download",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete"
|
||||
},
|
||||
"analytics": {
|
||||
"self": "Analytics",
|
||||
"top-sources": "Top sources",
|
||||
"source": "Source",
|
||||
"visitors": "Visitors",
|
||||
"devices": "Devices",
|
||||
"browser": "Browser",
|
||||
"browsers": "Browsers",
|
||||
"operating-system": "Operating System"
|
||||
},
|
||||
"shortcut": {
|
||||
"visibility": {
|
||||
"private": {
|
||||
"self": "Private",
|
||||
"description": "Only you can access"
|
||||
},
|
||||
"workspace": {
|
||||
"self": "Workspace",
|
||||
"description": "Workspace members can access"
|
||||
},
|
||||
"public": {
|
||||
"self": "Public",
|
||||
"description": "Visible to everyone on the internet"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
38
frontend/locales/zh.json
Normal file
38
frontend/locales/zh.json
Normal file
@ -0,0 +1,38 @@
|
||||
{
|
||||
"common": {
|
||||
"about": "关于",
|
||||
"loading": "加载中",
|
||||
"cancel": "取消",
|
||||
"save": "保存",
|
||||
"create": "创建",
|
||||
"download": "下载",
|
||||
"edit": "编辑",
|
||||
"delete": "删除"
|
||||
},
|
||||
"analytics": {
|
||||
"self": "分析",
|
||||
"top-sources": "热门来源",
|
||||
"source": "来源",
|
||||
"visitors": "访客数",
|
||||
"devices": "设备",
|
||||
"browser": "浏览器",
|
||||
"browsers": "浏览器",
|
||||
"operating-system": "操作系统"
|
||||
},
|
||||
"shortcut": {
|
||||
"visibility": {
|
||||
"private": {
|
||||
"self": "私有的",
|
||||
"description": "仅您可以访问"
|
||||
},
|
||||
"workspace": {
|
||||
"self": "工作区",
|
||||
"description": "工作区成员可以访问"
|
||||
},
|
||||
"public": {
|
||||
"self": "公开的",
|
||||
"description": "对任何人可见"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
25
frontend/types/proto/api/v2/common_pb.d.ts
vendored
Normal file
25
frontend/types/proto/api/v2/common_pb.d.ts
vendored
Normal file
@ -0,0 +1,25 @@
|
||||
// @generated by protoc-gen-es v1.3.0
|
||||
// @generated from file api/v2/common.proto (package slash.api.v2, syntax proto3)
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
|
||||
/**
|
||||
* @generated from enum slash.api.v2.RowStatus
|
||||
*/
|
||||
export declare enum RowStatus {
|
||||
/**
|
||||
* @generated from enum value: ROW_STATUS_UNSPECIFIED = 0;
|
||||
*/
|
||||
ROW_STATUS_UNSPECIFIED = 0,
|
||||
|
||||
/**
|
||||
* @generated from enum value: NORMAL = 1;
|
||||
*/
|
||||
NORMAL = 1,
|
||||
|
||||
/**
|
||||
* @generated from enum value: ARCHIVED = 2;
|
||||
*/
|
||||
ARCHIVED = 2,
|
||||
}
|
||||
|
19
frontend/types/proto/api/v2/common_pb.js
Normal file
19
frontend/types/proto/api/v2/common_pb.js
Normal file
@ -0,0 +1,19 @@
|
||||
// @generated by protoc-gen-es v1.3.0
|
||||
// @generated from file api/v2/common.proto (package slash.api.v2, syntax proto3)
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
|
||||
import { proto3 } from "@bufbuild/protobuf";
|
||||
|
||||
/**
|
||||
* @generated from enum slash.api.v2.RowStatus
|
||||
*/
|
||||
export const RowStatus = proto3.makeEnum(
|
||||
"slash.api.v2.RowStatus",
|
||||
[
|
||||
{no: 0, name: "ROW_STATUS_UNSPECIFIED"},
|
||||
{no: 1, name: "NORMAL"},
|
||||
{no: 2, name: "ARCHIVED"},
|
||||
],
|
||||
);
|
||||
|
329
frontend/types/proto/api/v2/shortcut_service_pb.d.ts
vendored
Normal file
329
frontend/types/proto/api/v2/shortcut_service_pb.d.ts
vendored
Normal file
@ -0,0 +1,329 @@
|
||||
// @generated by protoc-gen-es v1.3.0
|
||||
// @generated from file api/v2/shortcut_service.proto (package slash.api.v2, syntax proto3)
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
|
||||
import type { BinaryReadOptions, FieldList, JsonReadOptions, JsonValue, PartialMessage, PlainMessage } from "@bufbuild/protobuf";
|
||||
import { Message, proto3 } from "@bufbuild/protobuf";
|
||||
import type { RowStatus } from "./common_pb.js";
|
||||
|
||||
/**
|
||||
* @generated from enum slash.api.v2.Visibility
|
||||
*/
|
||||
export declare enum Visibility {
|
||||
/**
|
||||
* @generated from enum value: VISIBILITY_UNSPECIFIED = 0;
|
||||
*/
|
||||
VISIBILITY_UNSPECIFIED = 0,
|
||||
|
||||
/**
|
||||
* @generated from enum value: PRIVATE = 1;
|
||||
*/
|
||||
PRIVATE = 1,
|
||||
|
||||
/**
|
||||
* @generated from enum value: WORKSPACE = 2;
|
||||
*/
|
||||
WORKSPACE = 2,
|
||||
|
||||
/**
|
||||
* @generated from enum value: PUBLIC = 3;
|
||||
*/
|
||||
PUBLIC = 3,
|
||||
}
|
||||
|
||||
/**
|
||||
* @generated from message slash.api.v2.Shortcut
|
||||
*/
|
||||
export declare class Shortcut extends Message<Shortcut> {
|
||||
/**
|
||||
* @generated from field: int32 id = 1;
|
||||
*/
|
||||
id: number;
|
||||
|
||||
/**
|
||||
* @generated from field: int32 creator_id = 2;
|
||||
*/
|
||||
creatorId: number;
|
||||
|
||||
/**
|
||||
* @generated from field: int64 created_ts = 3;
|
||||
*/
|
||||
createdTs: bigint;
|
||||
|
||||
/**
|
||||
* @generated from field: int64 updated_ts = 4;
|
||||
*/
|
||||
updatedTs: bigint;
|
||||
|
||||
/**
|
||||
* @generated from field: slash.api.v2.RowStatus row_status = 5;
|
||||
*/
|
||||
rowStatus: RowStatus;
|
||||
|
||||
/**
|
||||
* @generated from field: string name = 6;
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* @generated from field: string link = 7;
|
||||
*/
|
||||
link: string;
|
||||
|
||||
/**
|
||||
* @generated from field: string title = 8;
|
||||
*/
|
||||
title: string;
|
||||
|
||||
/**
|
||||
* @generated from field: repeated string tags = 9;
|
||||
*/
|
||||
tags: string[];
|
||||
|
||||
/**
|
||||
* @generated from field: string description = 10;
|
||||
*/
|
||||
description: string;
|
||||
|
||||
/**
|
||||
* @generated from field: slash.api.v2.Visibility visibility = 11;
|
||||
*/
|
||||
visibility: Visibility;
|
||||
|
||||
/**
|
||||
* @generated from field: slash.api.v2.OpenGraphMetadata og_metadata = 12;
|
||||
*/
|
||||
ogMetadata?: OpenGraphMetadata;
|
||||
|
||||
constructor(data?: PartialMessage<Shortcut>);
|
||||
|
||||
static readonly runtime: typeof proto3;
|
||||
static readonly typeName = "slash.api.v2.Shortcut";
|
||||
static readonly fields: FieldList;
|
||||
|
||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): Shortcut;
|
||||
|
||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): Shortcut;
|
||||
|
||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): Shortcut;
|
||||
|
||||
static equals(a: Shortcut | PlainMessage<Shortcut> | undefined, b: Shortcut | PlainMessage<Shortcut> | undefined): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @generated from message slash.api.v2.OpenGraphMetadata
|
||||
*/
|
||||
export declare class OpenGraphMetadata extends Message<OpenGraphMetadata> {
|
||||
/**
|
||||
* @generated from field: string title = 1;
|
||||
*/
|
||||
title: string;
|
||||
|
||||
/**
|
||||
* @generated from field: string description = 2;
|
||||
*/
|
||||
description: string;
|
||||
|
||||
/**
|
||||
* @generated from field: string image = 3;
|
||||
*/
|
||||
image: string;
|
||||
|
||||
constructor(data?: PartialMessage<OpenGraphMetadata>);
|
||||
|
||||
static readonly runtime: typeof proto3;
|
||||
static readonly typeName = "slash.api.v2.OpenGraphMetadata";
|
||||
static readonly fields: FieldList;
|
||||
|
||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): OpenGraphMetadata;
|
||||
|
||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): OpenGraphMetadata;
|
||||
|
||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): OpenGraphMetadata;
|
||||
|
||||
static equals(a: OpenGraphMetadata | PlainMessage<OpenGraphMetadata> | undefined, b: OpenGraphMetadata | PlainMessage<OpenGraphMetadata> | undefined): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @generated from message slash.api.v2.ListShortcutsRequest
|
||||
*/
|
||||
export declare class ListShortcutsRequest extends Message<ListShortcutsRequest> {
|
||||
constructor(data?: PartialMessage<ListShortcutsRequest>);
|
||||
|
||||
static readonly runtime: typeof proto3;
|
||||
static readonly typeName = "slash.api.v2.ListShortcutsRequest";
|
||||
static readonly fields: FieldList;
|
||||
|
||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): ListShortcutsRequest;
|
||||
|
||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): ListShortcutsRequest;
|
||||
|
||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): ListShortcutsRequest;
|
||||
|
||||
static equals(a: ListShortcutsRequest | PlainMessage<ListShortcutsRequest> | undefined, b: ListShortcutsRequest | PlainMessage<ListShortcutsRequest> | undefined): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @generated from message slash.api.v2.ListShortcutsResponse
|
||||
*/
|
||||
export declare class ListShortcutsResponse extends Message<ListShortcutsResponse> {
|
||||
/**
|
||||
* @generated from field: repeated slash.api.v2.Shortcut shortcuts = 1;
|
||||
*/
|
||||
shortcuts: Shortcut[];
|
||||
|
||||
constructor(data?: PartialMessage<ListShortcutsResponse>);
|
||||
|
||||
static readonly runtime: typeof proto3;
|
||||
static readonly typeName = "slash.api.v2.ListShortcutsResponse";
|
||||
static readonly fields: FieldList;
|
||||
|
||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): ListShortcutsResponse;
|
||||
|
||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): ListShortcutsResponse;
|
||||
|
||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): ListShortcutsResponse;
|
||||
|
||||
static equals(a: ListShortcutsResponse | PlainMessage<ListShortcutsResponse> | undefined, b: ListShortcutsResponse | PlainMessage<ListShortcutsResponse> | undefined): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @generated from message slash.api.v2.GetShortcutRequest
|
||||
*/
|
||||
export declare class GetShortcutRequest extends Message<GetShortcutRequest> {
|
||||
/**
|
||||
* @generated from field: string name = 1;
|
||||
*/
|
||||
name: string;
|
||||
|
||||
constructor(data?: PartialMessage<GetShortcutRequest>);
|
||||
|
||||
static readonly runtime: typeof proto3;
|
||||
static readonly typeName = "slash.api.v2.GetShortcutRequest";
|
||||
static readonly fields: FieldList;
|
||||
|
||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): GetShortcutRequest;
|
||||
|
||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): GetShortcutRequest;
|
||||
|
||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): GetShortcutRequest;
|
||||
|
||||
static equals(a: GetShortcutRequest | PlainMessage<GetShortcutRequest> | undefined, b: GetShortcutRequest | PlainMessage<GetShortcutRequest> | undefined): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @generated from message slash.api.v2.GetShortcutResponse
|
||||
*/
|
||||
export declare class GetShortcutResponse extends Message<GetShortcutResponse> {
|
||||
/**
|
||||
* @generated from field: slash.api.v2.Shortcut shortcut = 1;
|
||||
*/
|
||||
shortcut?: Shortcut;
|
||||
|
||||
constructor(data?: PartialMessage<GetShortcutResponse>);
|
||||
|
||||
static readonly runtime: typeof proto3;
|
||||
static readonly typeName = "slash.api.v2.GetShortcutResponse";
|
||||
static readonly fields: FieldList;
|
||||
|
||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): GetShortcutResponse;
|
||||
|
||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): GetShortcutResponse;
|
||||
|
||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): GetShortcutResponse;
|
||||
|
||||
static equals(a: GetShortcutResponse | PlainMessage<GetShortcutResponse> | undefined, b: GetShortcutResponse | PlainMessage<GetShortcutResponse> | undefined): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @generated from message slash.api.v2.CreateShortcutRequest
|
||||
*/
|
||||
export declare class CreateShortcutRequest extends Message<CreateShortcutRequest> {
|
||||
/**
|
||||
* @generated from field: slash.api.v2.Shortcut shortcut = 1;
|
||||
*/
|
||||
shortcut?: Shortcut;
|
||||
|
||||
constructor(data?: PartialMessage<CreateShortcutRequest>);
|
||||
|
||||
static readonly runtime: typeof proto3;
|
||||
static readonly typeName = "slash.api.v2.CreateShortcutRequest";
|
||||
static readonly fields: FieldList;
|
||||
|
||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): CreateShortcutRequest;
|
||||
|
||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): CreateShortcutRequest;
|
||||
|
||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): CreateShortcutRequest;
|
||||
|
||||
static equals(a: CreateShortcutRequest | PlainMessage<CreateShortcutRequest> | undefined, b: CreateShortcutRequest | PlainMessage<CreateShortcutRequest> | undefined): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @generated from message slash.api.v2.CreateShortcutResponse
|
||||
*/
|
||||
export declare class CreateShortcutResponse extends Message<CreateShortcutResponse> {
|
||||
/**
|
||||
* @generated from field: slash.api.v2.Shortcut shortcut = 1;
|
||||
*/
|
||||
shortcut?: Shortcut;
|
||||
|
||||
constructor(data?: PartialMessage<CreateShortcutResponse>);
|
||||
|
||||
static readonly runtime: typeof proto3;
|
||||
static readonly typeName = "slash.api.v2.CreateShortcutResponse";
|
||||
static readonly fields: FieldList;
|
||||
|
||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): CreateShortcutResponse;
|
||||
|
||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): CreateShortcutResponse;
|
||||
|
||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): CreateShortcutResponse;
|
||||
|
||||
static equals(a: CreateShortcutResponse | PlainMessage<CreateShortcutResponse> | undefined, b: CreateShortcutResponse | PlainMessage<CreateShortcutResponse> | undefined): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @generated from message slash.api.v2.DeleteShortcutRequest
|
||||
*/
|
||||
export declare class DeleteShortcutRequest extends Message<DeleteShortcutRequest> {
|
||||
/**
|
||||
* @generated from field: string name = 1;
|
||||
*/
|
||||
name: string;
|
||||
|
||||
constructor(data?: PartialMessage<DeleteShortcutRequest>);
|
||||
|
||||
static readonly runtime: typeof proto3;
|
||||
static readonly typeName = "slash.api.v2.DeleteShortcutRequest";
|
||||
static readonly fields: FieldList;
|
||||
|
||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): DeleteShortcutRequest;
|
||||
|
||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): DeleteShortcutRequest;
|
||||
|
||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): DeleteShortcutRequest;
|
||||
|
||||
static equals(a: DeleteShortcutRequest | PlainMessage<DeleteShortcutRequest> | undefined, b: DeleteShortcutRequest | PlainMessage<DeleteShortcutRequest> | undefined): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @generated from message slash.api.v2.DeleteShortcutResponse
|
||||
*/
|
||||
export declare class DeleteShortcutResponse extends Message<DeleteShortcutResponse> {
|
||||
constructor(data?: PartialMessage<DeleteShortcutResponse>);
|
||||
|
||||
static readonly runtime: typeof proto3;
|
||||
static readonly typeName = "slash.api.v2.DeleteShortcutResponse";
|
||||
static readonly fields: FieldList;
|
||||
|
||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): DeleteShortcutResponse;
|
||||
|
||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): DeleteShortcutResponse;
|
||||
|
||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): DeleteShortcutResponse;
|
||||
|
||||
static equals(a: DeleteShortcutResponse | PlainMessage<DeleteShortcutResponse> | undefined, b: DeleteShortcutResponse | PlainMessage<DeleteShortcutResponse> | undefined): boolean;
|
||||
}
|
||||
|
130
frontend/types/proto/api/v2/shortcut_service_pb.js
Normal file
130
frontend/types/proto/api/v2/shortcut_service_pb.js
Normal file
@ -0,0 +1,130 @@
|
||||
// @generated by protoc-gen-es v1.3.0
|
||||
// @generated from file api/v2/shortcut_service.proto (package slash.api.v2, syntax proto3)
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
|
||||
import { proto3 } from "@bufbuild/protobuf";
|
||||
import { RowStatus } from "./common_pb.js";
|
||||
|
||||
/**
|
||||
* @generated from enum slash.api.v2.Visibility
|
||||
*/
|
||||
export const Visibility = proto3.makeEnum(
|
||||
"slash.api.v2.Visibility",
|
||||
[
|
||||
{no: 0, name: "VISIBILITY_UNSPECIFIED"},
|
||||
{no: 1, name: "PRIVATE"},
|
||||
{no: 2, name: "WORKSPACE"},
|
||||
{no: 3, name: "PUBLIC"},
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* @generated from message slash.api.v2.Shortcut
|
||||
*/
|
||||
export const Shortcut = proto3.makeMessageType(
|
||||
"slash.api.v2.Shortcut",
|
||||
() => [
|
||||
{ no: 1, name: "id", kind: "scalar", T: 5 /* ScalarType.INT32 */ },
|
||||
{ no: 2, name: "creator_id", kind: "scalar", T: 5 /* ScalarType.INT32 */ },
|
||||
{ no: 3, name: "created_ts", kind: "scalar", T: 3 /* ScalarType.INT64 */ },
|
||||
{ no: 4, name: "updated_ts", kind: "scalar", T: 3 /* ScalarType.INT64 */ },
|
||||
{ no: 5, name: "row_status", kind: "enum", T: proto3.getEnumType(RowStatus) },
|
||||
{ no: 6, name: "name", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
||||
{ no: 7, name: "link", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
||||
{ no: 8, name: "title", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
||||
{ no: 9, name: "tags", kind: "scalar", T: 9 /* ScalarType.STRING */, repeated: true },
|
||||
{ no: 10, name: "description", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
||||
{ no: 11, name: "visibility", kind: "enum", T: proto3.getEnumType(Visibility) },
|
||||
{ no: 12, name: "og_metadata", kind: "message", T: OpenGraphMetadata },
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* @generated from message slash.api.v2.OpenGraphMetadata
|
||||
*/
|
||||
export const OpenGraphMetadata = proto3.makeMessageType(
|
||||
"slash.api.v2.OpenGraphMetadata",
|
||||
() => [
|
||||
{ no: 1, name: "title", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
||||
{ no: 2, name: "description", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
||||
{ no: 3, name: "image", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* @generated from message slash.api.v2.ListShortcutsRequest
|
||||
*/
|
||||
export const ListShortcutsRequest = proto3.makeMessageType(
|
||||
"slash.api.v2.ListShortcutsRequest",
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* @generated from message slash.api.v2.ListShortcutsResponse
|
||||
*/
|
||||
export const ListShortcutsResponse = proto3.makeMessageType(
|
||||
"slash.api.v2.ListShortcutsResponse",
|
||||
() => [
|
||||
{ no: 1, name: "shortcuts", kind: "message", T: Shortcut, repeated: true },
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* @generated from message slash.api.v2.GetShortcutRequest
|
||||
*/
|
||||
export const GetShortcutRequest = proto3.makeMessageType(
|
||||
"slash.api.v2.GetShortcutRequest",
|
||||
() => [
|
||||
{ no: 1, name: "name", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* @generated from message slash.api.v2.GetShortcutResponse
|
||||
*/
|
||||
export const GetShortcutResponse = proto3.makeMessageType(
|
||||
"slash.api.v2.GetShortcutResponse",
|
||||
() => [
|
||||
{ no: 1, name: "shortcut", kind: "message", T: Shortcut },
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* @generated from message slash.api.v2.CreateShortcutRequest
|
||||
*/
|
||||
export const CreateShortcutRequest = proto3.makeMessageType(
|
||||
"slash.api.v2.CreateShortcutRequest",
|
||||
() => [
|
||||
{ no: 1, name: "shortcut", kind: "message", T: Shortcut },
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* @generated from message slash.api.v2.CreateShortcutResponse
|
||||
*/
|
||||
export const CreateShortcutResponse = proto3.makeMessageType(
|
||||
"slash.api.v2.CreateShortcutResponse",
|
||||
() => [
|
||||
{ no: 1, name: "shortcut", kind: "message", T: Shortcut },
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* @generated from message slash.api.v2.DeleteShortcutRequest
|
||||
*/
|
||||
export const DeleteShortcutRequest = proto3.makeMessageType(
|
||||
"slash.api.v2.DeleteShortcutRequest",
|
||||
() => [
|
||||
{ no: 1, name: "name", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* @generated from message slash.api.v2.DeleteShortcutResponse
|
||||
*/
|
||||
export const DeleteShortcutResponse = proto3.makeMessageType(
|
||||
"slash.api.v2.DeleteShortcutResponse",
|
||||
[],
|
||||
);
|
||||
|
466
frontend/types/proto/api/v2/user_service_pb.d.ts
vendored
Normal file
466
frontend/types/proto/api/v2/user_service_pb.d.ts
vendored
Normal file
@ -0,0 +1,466 @@
|
||||
// @generated by protoc-gen-es v1.3.0
|
||||
// @generated from file api/v2/user_service.proto (package slash.api.v2, syntax proto3)
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
|
||||
import type { BinaryReadOptions, FieldList, JsonReadOptions, JsonValue, PartialMessage, PlainMessage, Timestamp } from "@bufbuild/protobuf";
|
||||
import { Message, proto3 } from "@bufbuild/protobuf";
|
||||
import type { RowStatus } from "./common_pb.js";
|
||||
|
||||
/**
|
||||
* @generated from enum slash.api.v2.Role
|
||||
*/
|
||||
export declare enum Role {
|
||||
/**
|
||||
* @generated from enum value: ROLE_UNSPECIFIED = 0;
|
||||
*/
|
||||
ROLE_UNSPECIFIED = 0,
|
||||
|
||||
/**
|
||||
* @generated from enum value: ADMIN = 1;
|
||||
*/
|
||||
ADMIN = 1,
|
||||
|
||||
/**
|
||||
* @generated from enum value: USER = 2;
|
||||
*/
|
||||
USER = 2,
|
||||
}
|
||||
|
||||
/**
|
||||
* @generated from message slash.api.v2.User
|
||||
*/
|
||||
export declare class User extends Message<User> {
|
||||
/**
|
||||
* @generated from field: int32 id = 1;
|
||||
*/
|
||||
id: number;
|
||||
|
||||
/**
|
||||
* @generated from field: slash.api.v2.RowStatus row_status = 2;
|
||||
*/
|
||||
rowStatus: RowStatus;
|
||||
|
||||
/**
|
||||
* @generated from field: int64 created_ts = 3;
|
||||
*/
|
||||
createdTs: bigint;
|
||||
|
||||
/**
|
||||
* @generated from field: int64 updated_ts = 4;
|
||||
*/
|
||||
updatedTs: bigint;
|
||||
|
||||
/**
|
||||
* @generated from field: slash.api.v2.Role role = 6;
|
||||
*/
|
||||
role: Role;
|
||||
|
||||
/**
|
||||
* @generated from field: string email = 7;
|
||||
*/
|
||||
email: string;
|
||||
|
||||
/**
|
||||
* @generated from field: string nickname = 8;
|
||||
*/
|
||||
nickname: string;
|
||||
|
||||
/**
|
||||
* @generated from field: string password = 9;
|
||||
*/
|
||||
password: string;
|
||||
|
||||
constructor(data?: PartialMessage<User>);
|
||||
|
||||
static readonly runtime: typeof proto3;
|
||||
static readonly typeName = "slash.api.v2.User";
|
||||
static readonly fields: FieldList;
|
||||
|
||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): User;
|
||||
|
||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): User;
|
||||
|
||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): User;
|
||||
|
||||
static equals(a: User | PlainMessage<User> | undefined, b: User | PlainMessage<User> | undefined): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @generated from message slash.api.v2.ListUsersRequest
|
||||
*/
|
||||
export declare class ListUsersRequest extends Message<ListUsersRequest> {
|
||||
constructor(data?: PartialMessage<ListUsersRequest>);
|
||||
|
||||
static readonly runtime: typeof proto3;
|
||||
static readonly typeName = "slash.api.v2.ListUsersRequest";
|
||||
static readonly fields: FieldList;
|
||||
|
||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): ListUsersRequest;
|
||||
|
||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): ListUsersRequest;
|
||||
|
||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): ListUsersRequest;
|
||||
|
||||
static equals(a: ListUsersRequest | PlainMessage<ListUsersRequest> | undefined, b: ListUsersRequest | PlainMessage<ListUsersRequest> | undefined): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @generated from message slash.api.v2.ListUsersResponse
|
||||
*/
|
||||
export declare class ListUsersResponse extends Message<ListUsersResponse> {
|
||||
/**
|
||||
* @generated from field: repeated slash.api.v2.User users = 1;
|
||||
*/
|
||||
users: User[];
|
||||
|
||||
constructor(data?: PartialMessage<ListUsersResponse>);
|
||||
|
||||
static readonly runtime: typeof proto3;
|
||||
static readonly typeName = "slash.api.v2.ListUsersResponse";
|
||||
static readonly fields: FieldList;
|
||||
|
||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): ListUsersResponse;
|
||||
|
||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): ListUsersResponse;
|
||||
|
||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): ListUsersResponse;
|
||||
|
||||
static equals(a: ListUsersResponse | PlainMessage<ListUsersResponse> | undefined, b: ListUsersResponse | PlainMessage<ListUsersResponse> | undefined): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @generated from message slash.api.v2.GetUserRequest
|
||||
*/
|
||||
export declare class GetUserRequest extends Message<GetUserRequest> {
|
||||
/**
|
||||
* @generated from field: int32 id = 1;
|
||||
*/
|
||||
id: number;
|
||||
|
||||
constructor(data?: PartialMessage<GetUserRequest>);
|
||||
|
||||
static readonly runtime: typeof proto3;
|
||||
static readonly typeName = "slash.api.v2.GetUserRequest";
|
||||
static readonly fields: FieldList;
|
||||
|
||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): GetUserRequest;
|
||||
|
||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): GetUserRequest;
|
||||
|
||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): GetUserRequest;
|
||||
|
||||
static equals(a: GetUserRequest | PlainMessage<GetUserRequest> | undefined, b: GetUserRequest | PlainMessage<GetUserRequest> | undefined): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @generated from message slash.api.v2.GetUserResponse
|
||||
*/
|
||||
export declare class GetUserResponse extends Message<GetUserResponse> {
|
||||
/**
|
||||
* @generated from field: slash.api.v2.User user = 1;
|
||||
*/
|
||||
user?: User;
|
||||
|
||||
constructor(data?: PartialMessage<GetUserResponse>);
|
||||
|
||||
static readonly runtime: typeof proto3;
|
||||
static readonly typeName = "slash.api.v2.GetUserResponse";
|
||||
static readonly fields: FieldList;
|
||||
|
||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): GetUserResponse;
|
||||
|
||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): GetUserResponse;
|
||||
|
||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): GetUserResponse;
|
||||
|
||||
static equals(a: GetUserResponse | PlainMessage<GetUserResponse> | undefined, b: GetUserResponse | PlainMessage<GetUserResponse> | undefined): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @generated from message slash.api.v2.CreateUserRequest
|
||||
*/
|
||||
export declare class CreateUserRequest extends Message<CreateUserRequest> {
|
||||
/**
|
||||
* @generated from field: slash.api.v2.User user = 1;
|
||||
*/
|
||||
user?: User;
|
||||
|
||||
constructor(data?: PartialMessage<CreateUserRequest>);
|
||||
|
||||
static readonly runtime: typeof proto3;
|
||||
static readonly typeName = "slash.api.v2.CreateUserRequest";
|
||||
static readonly fields: FieldList;
|
||||
|
||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): CreateUserRequest;
|
||||
|
||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): CreateUserRequest;
|
||||
|
||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): CreateUserRequest;
|
||||
|
||||
static equals(a: CreateUserRequest | PlainMessage<CreateUserRequest> | undefined, b: CreateUserRequest | PlainMessage<CreateUserRequest> | undefined): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @generated from message slash.api.v2.CreateUserResponse
|
||||
*/
|
||||
export declare class CreateUserResponse extends Message<CreateUserResponse> {
|
||||
/**
|
||||
* @generated from field: slash.api.v2.User user = 1;
|
||||
*/
|
||||
user?: User;
|
||||
|
||||
constructor(data?: PartialMessage<CreateUserResponse>);
|
||||
|
||||
static readonly runtime: typeof proto3;
|
||||
static readonly typeName = "slash.api.v2.CreateUserResponse";
|
||||
static readonly fields: FieldList;
|
||||
|
||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): CreateUserResponse;
|
||||
|
||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): CreateUserResponse;
|
||||
|
||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): CreateUserResponse;
|
||||
|
||||
static equals(a: CreateUserResponse | PlainMessage<CreateUserResponse> | undefined, b: CreateUserResponse | PlainMessage<CreateUserResponse> | undefined): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @generated from message slash.api.v2.DeleteUserRequest
|
||||
*/
|
||||
export declare class DeleteUserRequest extends Message<DeleteUserRequest> {
|
||||
/**
|
||||
* @generated from field: int32 id = 1;
|
||||
*/
|
||||
id: number;
|
||||
|
||||
constructor(data?: PartialMessage<DeleteUserRequest>);
|
||||
|
||||
static readonly runtime: typeof proto3;
|
||||
static readonly typeName = "slash.api.v2.DeleteUserRequest";
|
||||
static readonly fields: FieldList;
|
||||
|
||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): DeleteUserRequest;
|
||||
|
||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): DeleteUserRequest;
|
||||
|
||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): DeleteUserRequest;
|
||||
|
||||
static equals(a: DeleteUserRequest | PlainMessage<DeleteUserRequest> | undefined, b: DeleteUserRequest | PlainMessage<DeleteUserRequest> | undefined): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @generated from message slash.api.v2.DeleteUserResponse
|
||||
*/
|
||||
export declare class DeleteUserResponse extends Message<DeleteUserResponse> {
|
||||
constructor(data?: PartialMessage<DeleteUserResponse>);
|
||||
|
||||
static readonly runtime: typeof proto3;
|
||||
static readonly typeName = "slash.api.v2.DeleteUserResponse";
|
||||
static readonly fields: FieldList;
|
||||
|
||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): DeleteUserResponse;
|
||||
|
||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): DeleteUserResponse;
|
||||
|
||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): DeleteUserResponse;
|
||||
|
||||
static equals(a: DeleteUserResponse | PlainMessage<DeleteUserResponse> | undefined, b: DeleteUserResponse | PlainMessage<DeleteUserResponse> | undefined): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @generated from message slash.api.v2.ListUserAccessTokensRequest
|
||||
*/
|
||||
export declare class ListUserAccessTokensRequest extends Message<ListUserAccessTokensRequest> {
|
||||
/**
|
||||
* id is the user id.
|
||||
*
|
||||
* @generated from field: int32 id = 1;
|
||||
*/
|
||||
id: number;
|
||||
|
||||
constructor(data?: PartialMessage<ListUserAccessTokensRequest>);
|
||||
|
||||
static readonly runtime: typeof proto3;
|
||||
static readonly typeName = "slash.api.v2.ListUserAccessTokensRequest";
|
||||
static readonly fields: FieldList;
|
||||
|
||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): ListUserAccessTokensRequest;
|
||||
|
||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): ListUserAccessTokensRequest;
|
||||
|
||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): ListUserAccessTokensRequest;
|
||||
|
||||
static equals(a: ListUserAccessTokensRequest | PlainMessage<ListUserAccessTokensRequest> | undefined, b: ListUserAccessTokensRequest | PlainMessage<ListUserAccessTokensRequest> | undefined): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @generated from message slash.api.v2.ListUserAccessTokensResponse
|
||||
*/
|
||||
export declare class ListUserAccessTokensResponse extends Message<ListUserAccessTokensResponse> {
|
||||
/**
|
||||
* @generated from field: repeated slash.api.v2.UserAccessToken access_tokens = 1;
|
||||
*/
|
||||
accessTokens: UserAccessToken[];
|
||||
|
||||
constructor(data?: PartialMessage<ListUserAccessTokensResponse>);
|
||||
|
||||
static readonly runtime: typeof proto3;
|
||||
static readonly typeName = "slash.api.v2.ListUserAccessTokensResponse";
|
||||
static readonly fields: FieldList;
|
||||
|
||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): ListUserAccessTokensResponse;
|
||||
|
||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): ListUserAccessTokensResponse;
|
||||
|
||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): ListUserAccessTokensResponse;
|
||||
|
||||
static equals(a: ListUserAccessTokensResponse | PlainMessage<ListUserAccessTokensResponse> | undefined, b: ListUserAccessTokensResponse | PlainMessage<ListUserAccessTokensResponse> | undefined): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @generated from message slash.api.v2.CreateUserAccessTokenRequest
|
||||
*/
|
||||
export declare class CreateUserAccessTokenRequest extends Message<CreateUserAccessTokenRequest> {
|
||||
/**
|
||||
* id is the user id.
|
||||
*
|
||||
* @generated from field: int32 id = 1;
|
||||
*/
|
||||
id: number;
|
||||
|
||||
/**
|
||||
* @generated from field: slash.api.v2.UserAccessToken user_access_token = 2;
|
||||
*/
|
||||
userAccessToken?: UserAccessToken;
|
||||
|
||||
constructor(data?: PartialMessage<CreateUserAccessTokenRequest>);
|
||||
|
||||
static readonly runtime: typeof proto3;
|
||||
static readonly typeName = "slash.api.v2.CreateUserAccessTokenRequest";
|
||||
static readonly fields: FieldList;
|
||||
|
||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): CreateUserAccessTokenRequest;
|
||||
|
||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): CreateUserAccessTokenRequest;
|
||||
|
||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): CreateUserAccessTokenRequest;
|
||||
|
||||
static equals(a: CreateUserAccessTokenRequest | PlainMessage<CreateUserAccessTokenRequest> | undefined, b: CreateUserAccessTokenRequest | PlainMessage<CreateUserAccessTokenRequest> | undefined): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @generated from message slash.api.v2.CreateUserAccessTokenResponse
|
||||
*/
|
||||
export declare class CreateUserAccessTokenResponse extends Message<CreateUserAccessTokenResponse> {
|
||||
/**
|
||||
* @generated from field: slash.api.v2.UserAccessToken access_token = 1;
|
||||
*/
|
||||
accessToken?: UserAccessToken;
|
||||
|
||||
constructor(data?: PartialMessage<CreateUserAccessTokenResponse>);
|
||||
|
||||
static readonly runtime: typeof proto3;
|
||||
static readonly typeName = "slash.api.v2.CreateUserAccessTokenResponse";
|
||||
static readonly fields: FieldList;
|
||||
|
||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): CreateUserAccessTokenResponse;
|
||||
|
||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): CreateUserAccessTokenResponse;
|
||||
|
||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): CreateUserAccessTokenResponse;
|
||||
|
||||
static equals(a: CreateUserAccessTokenResponse | PlainMessage<CreateUserAccessTokenResponse> | undefined, b: CreateUserAccessTokenResponse | PlainMessage<CreateUserAccessTokenResponse> | undefined): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @generated from message slash.api.v2.DeleteUserAccessTokenRequest
|
||||
*/
|
||||
export declare class DeleteUserAccessTokenRequest extends Message<DeleteUserAccessTokenRequest> {
|
||||
/**
|
||||
* id is the user id.
|
||||
*
|
||||
* @generated from field: int32 id = 1;
|
||||
*/
|
||||
id: number;
|
||||
|
||||
/**
|
||||
* access_token is the access token to delete.
|
||||
*
|
||||
* @generated from field: string access_token = 2;
|
||||
*/
|
||||
accessToken: string;
|
||||
|
||||
constructor(data?: PartialMessage<DeleteUserAccessTokenRequest>);
|
||||
|
||||
static readonly runtime: typeof proto3;
|
||||
static readonly typeName = "slash.api.v2.DeleteUserAccessTokenRequest";
|
||||
static readonly fields: FieldList;
|
||||
|
||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): DeleteUserAccessTokenRequest;
|
||||
|
||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): DeleteUserAccessTokenRequest;
|
||||
|
||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): DeleteUserAccessTokenRequest;
|
||||
|
||||
static equals(a: DeleteUserAccessTokenRequest | PlainMessage<DeleteUserAccessTokenRequest> | undefined, b: DeleteUserAccessTokenRequest | PlainMessage<DeleteUserAccessTokenRequest> | undefined): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @generated from message slash.api.v2.DeleteUserAccessTokenResponse
|
||||
*/
|
||||
export declare class DeleteUserAccessTokenResponse extends Message<DeleteUserAccessTokenResponse> {
|
||||
constructor(data?: PartialMessage<DeleteUserAccessTokenResponse>);
|
||||
|
||||
static readonly runtime: typeof proto3;
|
||||
static readonly typeName = "slash.api.v2.DeleteUserAccessTokenResponse";
|
||||
static readonly fields: FieldList;
|
||||
|
||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): DeleteUserAccessTokenResponse;
|
||||
|
||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): DeleteUserAccessTokenResponse;
|
||||
|
||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): DeleteUserAccessTokenResponse;
|
||||
|
||||
static equals(a: DeleteUserAccessTokenResponse | PlainMessage<DeleteUserAccessTokenResponse> | undefined, b: DeleteUserAccessTokenResponse | PlainMessage<DeleteUserAccessTokenResponse> | undefined): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @generated from message slash.api.v2.UserAccessToken
|
||||
*/
|
||||
export declare class UserAccessToken extends Message<UserAccessToken> {
|
||||
/**
|
||||
* @generated from field: string access_token = 1;
|
||||
*/
|
||||
accessToken: string;
|
||||
|
||||
/**
|
||||
* @generated from field: string description = 2;
|
||||
*/
|
||||
description: string;
|
||||
|
||||
/**
|
||||
* @generated from field: google.protobuf.Timestamp issued_at = 3;
|
||||
*/
|
||||
issuedAt?: Timestamp;
|
||||
|
||||
/**
|
||||
* @generated from field: google.protobuf.Timestamp expires_at = 4;
|
||||
*/
|
||||
expiresAt?: Timestamp;
|
||||
|
||||
constructor(data?: PartialMessage<UserAccessToken>);
|
||||
|
||||
static readonly runtime: typeof proto3;
|
||||
static readonly typeName = "slash.api.v2.UserAccessToken";
|
||||
static readonly fields: FieldList;
|
||||
|
||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): UserAccessToken;
|
||||
|
||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): UserAccessToken;
|
||||
|
||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): UserAccessToken;
|
||||
|
||||
static equals(a: UserAccessToken | PlainMessage<UserAccessToken> | undefined, b: UserAccessToken | PlainMessage<UserAccessToken> | undefined): boolean;
|
||||
}
|
||||
|
186
frontend/types/proto/api/v2/user_service_pb.js
Normal file
186
frontend/types/proto/api/v2/user_service_pb.js
Normal file
@ -0,0 +1,186 @@
|
||||
// @generated by protoc-gen-es v1.3.0
|
||||
// @generated from file api/v2/user_service.proto (package slash.api.v2, syntax proto3)
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
|
||||
import { proto3, Timestamp } from "@bufbuild/protobuf";
|
||||
import { RowStatus } from "./common_pb.js";
|
||||
|
||||
/**
|
||||
* @generated from enum slash.api.v2.Role
|
||||
*/
|
||||
export const Role = proto3.makeEnum(
|
||||
"slash.api.v2.Role",
|
||||
[
|
||||
{no: 0, name: "ROLE_UNSPECIFIED"},
|
||||
{no: 1, name: "ADMIN"},
|
||||
{no: 2, name: "USER"},
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* @generated from message slash.api.v2.User
|
||||
*/
|
||||
export const User = proto3.makeMessageType(
|
||||
"slash.api.v2.User",
|
||||
() => [
|
||||
{ no: 1, name: "id", kind: "scalar", T: 5 /* ScalarType.INT32 */ },
|
||||
{ no: 2, name: "row_status", kind: "enum", T: proto3.getEnumType(RowStatus) },
|
||||
{ no: 3, name: "created_ts", kind: "scalar", T: 3 /* ScalarType.INT64 */ },
|
||||
{ no: 4, name: "updated_ts", kind: "scalar", T: 3 /* ScalarType.INT64 */ },
|
||||
{ no: 6, name: "role", kind: "enum", T: proto3.getEnumType(Role) },
|
||||
{ no: 7, name: "email", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
||||
{ no: 8, name: "nickname", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
||||
{ no: 9, name: "password", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* @generated from message slash.api.v2.ListUsersRequest
|
||||
*/
|
||||
export const ListUsersRequest = proto3.makeMessageType(
|
||||
"slash.api.v2.ListUsersRequest",
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* @generated from message slash.api.v2.ListUsersResponse
|
||||
*/
|
||||
export const ListUsersResponse = proto3.makeMessageType(
|
||||
"slash.api.v2.ListUsersResponse",
|
||||
() => [
|
||||
{ no: 1, name: "users", kind: "message", T: User, repeated: true },
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* @generated from message slash.api.v2.GetUserRequest
|
||||
*/
|
||||
export const GetUserRequest = proto3.makeMessageType(
|
||||
"slash.api.v2.GetUserRequest",
|
||||
() => [
|
||||
{ no: 1, name: "id", kind: "scalar", T: 5 /* ScalarType.INT32 */ },
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* @generated from message slash.api.v2.GetUserResponse
|
||||
*/
|
||||
export const GetUserResponse = proto3.makeMessageType(
|
||||
"slash.api.v2.GetUserResponse",
|
||||
() => [
|
||||
{ no: 1, name: "user", kind: "message", T: User },
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* @generated from message slash.api.v2.CreateUserRequest
|
||||
*/
|
||||
export const CreateUserRequest = proto3.makeMessageType(
|
||||
"slash.api.v2.CreateUserRequest",
|
||||
() => [
|
||||
{ no: 1, name: "user", kind: "message", T: User },
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* @generated from message slash.api.v2.CreateUserResponse
|
||||
*/
|
||||
export const CreateUserResponse = proto3.makeMessageType(
|
||||
"slash.api.v2.CreateUserResponse",
|
||||
() => [
|
||||
{ no: 1, name: "user", kind: "message", T: User },
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* @generated from message slash.api.v2.DeleteUserRequest
|
||||
*/
|
||||
export const DeleteUserRequest = proto3.makeMessageType(
|
||||
"slash.api.v2.DeleteUserRequest",
|
||||
() => [
|
||||
{ no: 1, name: "id", kind: "scalar", T: 5 /* ScalarType.INT32 */ },
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* @generated from message slash.api.v2.DeleteUserResponse
|
||||
*/
|
||||
export const DeleteUserResponse = proto3.makeMessageType(
|
||||
"slash.api.v2.DeleteUserResponse",
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* @generated from message slash.api.v2.ListUserAccessTokensRequest
|
||||
*/
|
||||
export const ListUserAccessTokensRequest = proto3.makeMessageType(
|
||||
"slash.api.v2.ListUserAccessTokensRequest",
|
||||
() => [
|
||||
{ no: 1, name: "id", kind: "scalar", T: 5 /* ScalarType.INT32 */ },
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* @generated from message slash.api.v2.ListUserAccessTokensResponse
|
||||
*/
|
||||
export const ListUserAccessTokensResponse = proto3.makeMessageType(
|
||||
"slash.api.v2.ListUserAccessTokensResponse",
|
||||
() => [
|
||||
{ no: 1, name: "access_tokens", kind: "message", T: UserAccessToken, repeated: true },
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* @generated from message slash.api.v2.CreateUserAccessTokenRequest
|
||||
*/
|
||||
export const CreateUserAccessTokenRequest = proto3.makeMessageType(
|
||||
"slash.api.v2.CreateUserAccessTokenRequest",
|
||||
() => [
|
||||
{ no: 1, name: "id", kind: "scalar", T: 5 /* ScalarType.INT32 */ },
|
||||
{ no: 2, name: "user_access_token", kind: "message", T: UserAccessToken },
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* @generated from message slash.api.v2.CreateUserAccessTokenResponse
|
||||
*/
|
||||
export const CreateUserAccessTokenResponse = proto3.makeMessageType(
|
||||
"slash.api.v2.CreateUserAccessTokenResponse",
|
||||
() => [
|
||||
{ no: 1, name: "access_token", kind: "message", T: UserAccessToken },
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* @generated from message slash.api.v2.DeleteUserAccessTokenRequest
|
||||
*/
|
||||
export const DeleteUserAccessTokenRequest = proto3.makeMessageType(
|
||||
"slash.api.v2.DeleteUserAccessTokenRequest",
|
||||
() => [
|
||||
{ no: 1, name: "id", kind: "scalar", T: 5 /* ScalarType.INT32 */ },
|
||||
{ no: 2, name: "access_token", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* @generated from message slash.api.v2.DeleteUserAccessTokenResponse
|
||||
*/
|
||||
export const DeleteUserAccessTokenResponse = proto3.makeMessageType(
|
||||
"slash.api.v2.DeleteUserAccessTokenResponse",
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* @generated from message slash.api.v2.UserAccessToken
|
||||
*/
|
||||
export const UserAccessToken = proto3.makeMessageType(
|
||||
"slash.api.v2.UserAccessToken",
|
||||
() => [
|
||||
{ no: 1, name: "access_token", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
||||
{ no: 2, name: "description", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
||||
{ no: 3, name: "issued_at", kind: "message", T: Timestamp },
|
||||
{ no: 4, name: "expires_at", kind: "message", T: Timestamp },
|
||||
],
|
||||
);
|
||||
|
32
frontend/types/proto/store/activity_pb.d.ts
vendored
Normal file
32
frontend/types/proto/store/activity_pb.d.ts
vendored
Normal file
@ -0,0 +1,32 @@
|
||||
// @generated by protoc-gen-es v1.3.0
|
||||
// @generated from file store/activity.proto (package slash.store, syntax proto3)
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
|
||||
import type { BinaryReadOptions, FieldList, JsonReadOptions, JsonValue, PartialMessage, PlainMessage } from "@bufbuild/protobuf";
|
||||
import { Message, proto3 } from "@bufbuild/protobuf";
|
||||
|
||||
/**
|
||||
* @generated from message slash.store.ActivityShorcutCreatePayload
|
||||
*/
|
||||
export declare class ActivityShorcutCreatePayload extends Message<ActivityShorcutCreatePayload> {
|
||||
/**
|
||||
* @generated from field: int32 shortcut_id = 1;
|
||||
*/
|
||||
shortcutId: number;
|
||||
|
||||
constructor(data?: PartialMessage<ActivityShorcutCreatePayload>);
|
||||
|
||||
static readonly runtime: typeof proto3;
|
||||
static readonly typeName = "slash.store.ActivityShorcutCreatePayload";
|
||||
static readonly fields: FieldList;
|
||||
|
||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): ActivityShorcutCreatePayload;
|
||||
|
||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): ActivityShorcutCreatePayload;
|
||||
|
||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): ActivityShorcutCreatePayload;
|
||||
|
||||
static equals(a: ActivityShorcutCreatePayload | PlainMessage<ActivityShorcutCreatePayload> | undefined, b: ActivityShorcutCreatePayload | PlainMessage<ActivityShorcutCreatePayload> | undefined): boolean;
|
||||
}
|
||||
|
17
frontend/types/proto/store/activity_pb.js
Normal file
17
frontend/types/proto/store/activity_pb.js
Normal file
@ -0,0 +1,17 @@
|
||||
// @generated by protoc-gen-es v1.3.0
|
||||
// @generated from file store/activity.proto (package slash.store, syntax proto3)
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
|
||||
import { proto3 } from "@bufbuild/protobuf";
|
||||
|
||||
/**
|
||||
* @generated from message slash.store.ActivityShorcutCreatePayload
|
||||
*/
|
||||
export const ActivityShorcutCreatePayload = proto3.makeMessageType(
|
||||
"slash.store.ActivityShorcutCreatePayload",
|
||||
() => [
|
||||
{ no: 1, name: "shortcut_id", kind: "scalar", T: 5 /* ScalarType.INT32 */ },
|
||||
],
|
||||
);
|
||||
|
25
frontend/types/proto/store/common_pb.d.ts
vendored
Normal file
25
frontend/types/proto/store/common_pb.d.ts
vendored
Normal file
@ -0,0 +1,25 @@
|
||||
// @generated by protoc-gen-es v1.3.0
|
||||
// @generated from file store/common.proto (package slash.store, syntax proto3)
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
|
||||
/**
|
||||
* @generated from enum slash.store.RowStatus
|
||||
*/
|
||||
export declare enum RowStatus {
|
||||
/**
|
||||
* @generated from enum value: ROW_STATUS_UNSPECIFIED = 0;
|
||||
*/
|
||||
ROW_STATUS_UNSPECIFIED = 0,
|
||||
|
||||
/**
|
||||
* @generated from enum value: NORMAL = 1;
|
||||
*/
|
||||
NORMAL = 1,
|
||||
|
||||
/**
|
||||
* @generated from enum value: ARCHIVED = 2;
|
||||
*/
|
||||
ARCHIVED = 2,
|
||||
}
|
||||
|
19
frontend/types/proto/store/common_pb.js
Normal file
19
frontend/types/proto/store/common_pb.js
Normal file
@ -0,0 +1,19 @@
|
||||
// @generated by protoc-gen-es v1.3.0
|
||||
// @generated from file store/common.proto (package slash.store, syntax proto3)
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
|
||||
import { proto3 } from "@bufbuild/protobuf";
|
||||
|
||||
/**
|
||||
* @generated from enum slash.store.RowStatus
|
||||
*/
|
||||
export const RowStatus = proto3.makeEnum(
|
||||
"slash.store.RowStatus",
|
||||
[
|
||||
{no: 0, name: "ROW_STATUS_UNSPECIFIED"},
|
||||
{no: 1, name: "NORMAL"},
|
||||
{no: 2, name: "ARCHIVED"},
|
||||
],
|
||||
);
|
||||
|
147
frontend/types/proto/store/shortcut_pb.d.ts
vendored
Normal file
147
frontend/types/proto/store/shortcut_pb.d.ts
vendored
Normal file
@ -0,0 +1,147 @@
|
||||
// @generated by protoc-gen-es v1.3.0
|
||||
// @generated from file store/shortcut.proto (package slash.store, syntax proto3)
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
|
||||
import type { BinaryReadOptions, FieldList, JsonReadOptions, JsonValue, PartialMessage, PlainMessage } from "@bufbuild/protobuf";
|
||||
import { Message, proto3 } from "@bufbuild/protobuf";
|
||||
import type { RowStatus } from "./common_pb.js";
|
||||
|
||||
/**
|
||||
* @generated from enum slash.store.Visibility
|
||||
*/
|
||||
export declare enum Visibility {
|
||||
/**
|
||||
* @generated from enum value: VISIBILITY_UNSPECIFIED = 0;
|
||||
*/
|
||||
VISIBILITY_UNSPECIFIED = 0,
|
||||
|
||||
/**
|
||||
* @generated from enum value: PRIVATE = 1;
|
||||
*/
|
||||
PRIVATE = 1,
|
||||
|
||||
/**
|
||||
* @generated from enum value: WORKSPACE = 2;
|
||||
*/
|
||||
WORKSPACE = 2,
|
||||
|
||||
/**
|
||||
* @generated from enum value: PUBLIC = 3;
|
||||
*/
|
||||
PUBLIC = 3,
|
||||
}
|
||||
|
||||
/**
|
||||
* @generated from message slash.store.Shortcut
|
||||
*/
|
||||
export declare class Shortcut extends Message<Shortcut> {
|
||||
/**
|
||||
* @generated from field: int32 id = 1;
|
||||
*/
|
||||
id: number;
|
||||
|
||||
/**
|
||||
* @generated from field: int32 creator_id = 2;
|
||||
*/
|
||||
creatorId: number;
|
||||
|
||||
/**
|
||||
* @generated from field: int64 created_ts = 3;
|
||||
*/
|
||||
createdTs: bigint;
|
||||
|
||||
/**
|
||||
* @generated from field: int64 updated_ts = 4;
|
||||
*/
|
||||
updatedTs: bigint;
|
||||
|
||||
/**
|
||||
* @generated from field: slash.store.RowStatus row_status = 5;
|
||||
*/
|
||||
rowStatus: RowStatus;
|
||||
|
||||
/**
|
||||
* @generated from field: string name = 6;
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* @generated from field: string link = 7;
|
||||
*/
|
||||
link: string;
|
||||
|
||||
/**
|
||||
* @generated from field: string title = 8;
|
||||
*/
|
||||
title: string;
|
||||
|
||||
/**
|
||||
* @generated from field: repeated string tags = 9;
|
||||
*/
|
||||
tags: string[];
|
||||
|
||||
/**
|
||||
* @generated from field: string description = 10;
|
||||
*/
|
||||
description: string;
|
||||
|
||||
/**
|
||||
* @generated from field: slash.store.Visibility visibility = 11;
|
||||
*/
|
||||
visibility: Visibility;
|
||||
|
||||
/**
|
||||
* @generated from field: slash.store.OpenGraphMetadata og_metadata = 12;
|
||||
*/
|
||||
ogMetadata?: OpenGraphMetadata;
|
||||
|
||||
constructor(data?: PartialMessage<Shortcut>);
|
||||
|
||||
static readonly runtime: typeof proto3;
|
||||
static readonly typeName = "slash.store.Shortcut";
|
||||
static readonly fields: FieldList;
|
||||
|
||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): Shortcut;
|
||||
|
||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): Shortcut;
|
||||
|
||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): Shortcut;
|
||||
|
||||
static equals(a: Shortcut | PlainMessage<Shortcut> | undefined, b: Shortcut | PlainMessage<Shortcut> | undefined): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @generated from message slash.store.OpenGraphMetadata
|
||||
*/
|
||||
export declare class OpenGraphMetadata extends Message<OpenGraphMetadata> {
|
||||
/**
|
||||
* @generated from field: string title = 1;
|
||||
*/
|
||||
title: string;
|
||||
|
||||
/**
|
||||
* @generated from field: string description = 2;
|
||||
*/
|
||||
description: string;
|
||||
|
||||
/**
|
||||
* @generated from field: string image = 3;
|
||||
*/
|
||||
image: string;
|
||||
|
||||
constructor(data?: PartialMessage<OpenGraphMetadata>);
|
||||
|
||||
static readonly runtime: typeof proto3;
|
||||
static readonly typeName = "slash.store.OpenGraphMetadata";
|
||||
static readonly fields: FieldList;
|
||||
|
||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): OpenGraphMetadata;
|
||||
|
||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): OpenGraphMetadata;
|
||||
|
||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): OpenGraphMetadata;
|
||||
|
||||
static equals(a: OpenGraphMetadata | PlainMessage<OpenGraphMetadata> | undefined, b: OpenGraphMetadata | PlainMessage<OpenGraphMetadata> | undefined): boolean;
|
||||
}
|
||||
|
54
frontend/types/proto/store/shortcut_pb.js
Normal file
54
frontend/types/proto/store/shortcut_pb.js
Normal file
@ -0,0 +1,54 @@
|
||||
// @generated by protoc-gen-es v1.3.0
|
||||
// @generated from file store/shortcut.proto (package slash.store, syntax proto3)
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
|
||||
import { proto3 } from "@bufbuild/protobuf";
|
||||
import { RowStatus } from "./common_pb.js";
|
||||
|
||||
/**
|
||||
* @generated from enum slash.store.Visibility
|
||||
*/
|
||||
export const Visibility = proto3.makeEnum(
|
||||
"slash.store.Visibility",
|
||||
[
|
||||
{no: 0, name: "VISIBILITY_UNSPECIFIED"},
|
||||
{no: 1, name: "PRIVATE"},
|
||||
{no: 2, name: "WORKSPACE"},
|
||||
{no: 3, name: "PUBLIC"},
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* @generated from message slash.store.Shortcut
|
||||
*/
|
||||
export const Shortcut = proto3.makeMessageType(
|
||||
"slash.store.Shortcut",
|
||||
() => [
|
||||
{ no: 1, name: "id", kind: "scalar", T: 5 /* ScalarType.INT32 */ },
|
||||
{ no: 2, name: "creator_id", kind: "scalar", T: 5 /* ScalarType.INT32 */ },
|
||||
{ no: 3, name: "created_ts", kind: "scalar", T: 3 /* ScalarType.INT64 */ },
|
||||
{ no: 4, name: "updated_ts", kind: "scalar", T: 3 /* ScalarType.INT64 */ },
|
||||
{ no: 5, name: "row_status", kind: "enum", T: proto3.getEnumType(RowStatus) },
|
||||
{ no: 6, name: "name", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
||||
{ no: 7, name: "link", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
||||
{ no: 8, name: "title", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
||||
{ no: 9, name: "tags", kind: "scalar", T: 9 /* ScalarType.STRING */, repeated: true },
|
||||
{ no: 10, name: "description", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
||||
{ no: 11, name: "visibility", kind: "enum", T: proto3.getEnumType(Visibility) },
|
||||
{ no: 12, name: "og_metadata", kind: "message", T: OpenGraphMetadata },
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* @generated from message slash.store.OpenGraphMetadata
|
||||
*/
|
||||
export const OpenGraphMetadata = proto3.makeMessageType(
|
||||
"slash.store.OpenGraphMetadata",
|
||||
() => [
|
||||
{ no: 1, name: "title", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
||||
{ no: 2, name: "description", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
||||
{ no: 3, name: "image", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
||||
],
|
||||
);
|
||||
|
116
frontend/types/proto/store/user_setting_pb.d.ts
vendored
Normal file
116
frontend/types/proto/store/user_setting_pb.d.ts
vendored
Normal file
@ -0,0 +1,116 @@
|
||||
// @generated by protoc-gen-es v1.3.0
|
||||
// @generated from file store/user_setting.proto (package slash.store, syntax proto3)
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
|
||||
import type { BinaryReadOptions, FieldList, JsonReadOptions, JsonValue, PartialMessage, PlainMessage } from "@bufbuild/protobuf";
|
||||
import { Message, proto3 } from "@bufbuild/protobuf";
|
||||
|
||||
/**
|
||||
* @generated from enum slash.store.UserSettingKey
|
||||
*/
|
||||
export declare enum UserSettingKey {
|
||||
/**
|
||||
* @generated from enum value: USER_SETTING_KEY_UNSPECIFIED = 0;
|
||||
*/
|
||||
USER_SETTING_KEY_UNSPECIFIED = 0,
|
||||
|
||||
/**
|
||||
* @generated from enum value: USER_SETTING_ACCESS_TOKENS = 1;
|
||||
*/
|
||||
USER_SETTING_ACCESS_TOKENS = 1,
|
||||
}
|
||||
|
||||
/**
|
||||
* @generated from message slash.store.UserSetting
|
||||
*/
|
||||
export declare class UserSetting extends Message<UserSetting> {
|
||||
/**
|
||||
* @generated from field: int32 user_id = 1;
|
||||
*/
|
||||
userId: number;
|
||||
|
||||
/**
|
||||
* @generated from field: slash.store.UserSettingKey key = 2;
|
||||
*/
|
||||
key: UserSettingKey;
|
||||
|
||||
/**
|
||||
* @generated from oneof slash.store.UserSetting.value
|
||||
*/
|
||||
value: {
|
||||
/**
|
||||
* @generated from field: slash.store.AccessTokensUserSetting access_tokens_user_setting = 3;
|
||||
*/
|
||||
value: AccessTokensUserSetting;
|
||||
case: "accessTokensUserSetting";
|
||||
} | { case: undefined; value?: undefined };
|
||||
|
||||
constructor(data?: PartialMessage<UserSetting>);
|
||||
|
||||
static readonly runtime: typeof proto3;
|
||||
static readonly typeName = "slash.store.UserSetting";
|
||||
static readonly fields: FieldList;
|
||||
|
||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): UserSetting;
|
||||
|
||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): UserSetting;
|
||||
|
||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): UserSetting;
|
||||
|
||||
static equals(a: UserSetting | PlainMessage<UserSetting> | undefined, b: UserSetting | PlainMessage<UserSetting> | undefined): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @generated from message slash.store.AccessTokensUserSetting
|
||||
*/
|
||||
export declare class AccessTokensUserSetting extends Message<AccessTokensUserSetting> {
|
||||
/**
|
||||
* @generated from field: repeated slash.store.AccessTokensUserSetting.AccessToken access_tokens = 1;
|
||||
*/
|
||||
accessTokens: AccessTokensUserSetting_AccessToken[];
|
||||
|
||||
constructor(data?: PartialMessage<AccessTokensUserSetting>);
|
||||
|
||||
static readonly runtime: typeof proto3;
|
||||
static readonly typeName = "slash.store.AccessTokensUserSetting";
|
||||
static readonly fields: FieldList;
|
||||
|
||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): AccessTokensUserSetting;
|
||||
|
||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): AccessTokensUserSetting;
|
||||
|
||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): AccessTokensUserSetting;
|
||||
|
||||
static equals(a: AccessTokensUserSetting | PlainMessage<AccessTokensUserSetting> | undefined, b: AccessTokensUserSetting | PlainMessage<AccessTokensUserSetting> | undefined): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @generated from message slash.store.AccessTokensUserSetting.AccessToken
|
||||
*/
|
||||
export declare class AccessTokensUserSetting_AccessToken extends Message<AccessTokensUserSetting_AccessToken> {
|
||||
/**
|
||||
* @generated from field: string access_token = 1;
|
||||
*/
|
||||
accessToken: string;
|
||||
|
||||
/**
|
||||
* @generated from field: string description = 2;
|
||||
*/
|
||||
description: string;
|
||||
|
||||
constructor(data?: PartialMessage<AccessTokensUserSetting_AccessToken>);
|
||||
|
||||
static readonly runtime: typeof proto3;
|
||||
static readonly typeName = "slash.store.AccessTokensUserSetting.AccessToken";
|
||||
static readonly fields: FieldList;
|
||||
|
||||
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): AccessTokensUserSetting_AccessToken;
|
||||
|
||||
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): AccessTokensUserSetting_AccessToken;
|
||||
|
||||
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): AccessTokensUserSetting_AccessToken;
|
||||
|
||||
static equals(a: AccessTokensUserSetting_AccessToken | PlainMessage<AccessTokensUserSetting_AccessToken> | undefined, b: AccessTokensUserSetting_AccessToken | PlainMessage<AccessTokensUserSetting_AccessToken> | undefined): boolean;
|
||||
}
|
||||
|
52
frontend/types/proto/store/user_setting_pb.js
Normal file
52
frontend/types/proto/store/user_setting_pb.js
Normal file
@ -0,0 +1,52 @@
|
||||
// @generated by protoc-gen-es v1.3.0
|
||||
// @generated from file store/user_setting.proto (package slash.store, syntax proto3)
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
|
||||
import { proto3 } from "@bufbuild/protobuf";
|
||||
|
||||
/**
|
||||
* @generated from enum slash.store.UserSettingKey
|
||||
*/
|
||||
export const UserSettingKey = proto3.makeEnum(
|
||||
"slash.store.UserSettingKey",
|
||||
[
|
||||
{no: 0, name: "USER_SETTING_KEY_UNSPECIFIED"},
|
||||
{no: 1, name: "USER_SETTING_ACCESS_TOKENS"},
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* @generated from message slash.store.UserSetting
|
||||
*/
|
||||
export const UserSetting = proto3.makeMessageType(
|
||||
"slash.store.UserSetting",
|
||||
() => [
|
||||
{ no: 1, name: "user_id", kind: "scalar", T: 5 /* ScalarType.INT32 */ },
|
||||
{ no: 2, name: "key", kind: "enum", T: proto3.getEnumType(UserSettingKey) },
|
||||
{ no: 3, name: "access_tokens_user_setting", kind: "message", T: AccessTokensUserSetting, oneof: "value" },
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* @generated from message slash.store.AccessTokensUserSetting
|
||||
*/
|
||||
export const AccessTokensUserSetting = proto3.makeMessageType(
|
||||
"slash.store.AccessTokensUserSetting",
|
||||
() => [
|
||||
{ no: 1, name: "access_tokens", kind: "message", T: AccessTokensUserSetting_AccessToken, repeated: true },
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* @generated from message slash.store.AccessTokensUserSetting.AccessToken
|
||||
*/
|
||||
export const AccessTokensUserSetting_AccessToken = proto3.makeMessageType(
|
||||
"slash.store.AccessTokensUserSetting.AccessToken",
|
||||
() => [
|
||||
{ no: 1, name: "access_token", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
||||
{ no: 2, name: "description", kind: "scalar", T: 9 /* ScalarType.STRING */ },
|
||||
],
|
||||
{localName: "AccessTokensUserSetting_AccessToken"},
|
||||
);
|
||||
|
33
frontend/web/.eslintrc.json
Normal file
33
frontend/web/.eslintrc.json
Normal file
@ -0,0 +1,33 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2021": true
|
||||
},
|
||||
"extends": ["eslint:recommended", "plugin:react/recommended", "plugin:@typescript-eslint/recommended", "plugin:prettier/recommended"],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaFeatures": {
|
||||
"jsx": true
|
||||
},
|
||||
"ecmaVersion": "latest",
|
||||
"sourceType": "module"
|
||||
},
|
||||
"plugins": ["react", "@typescript-eslint", "prettier"],
|
||||
"ignorePatterns": ["node_modules", "dist", "public"],
|
||||
"rules": {
|
||||
"prettier/prettier": [
|
||||
"error",
|
||||
{
|
||||
"endOfLine": "auto"
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/no-explicit-any": ["off"],
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/jsx-no-target-blank": "off"
|
||||
},
|
||||
"settings": {
|
||||
"react": {
|
||||
"version": "detect"
|
||||
}
|
||||
}
|
||||
}
|
5
frontend/web/.gitignore
vendored
Normal file
5
frontend/web/.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
8
frontend/web/.prettierrc.js
Normal file
8
frontend/web/.prettierrc.js
Normal file
@ -0,0 +1,8 @@
|
||||
module.exports = {
|
||||
printWidth: 140,
|
||||
useTabs: false,
|
||||
semi: true,
|
||||
singleQuote: false,
|
||||
plugins: [require.resolve("@trivago/prettier-plugin-sort-imports")],
|
||||
importOrder: ["<BUILTIN_MODULES>", "<THIRD_PARTY_MODULES>", "^@/((?!less).+)", "^[./]", "^(.+).less"],
|
||||
};
|
6
frontend/web/.vscode/setting.json
vendored
Normal file
6
frontend/web/.vscode/setting.json
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"],
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": true
|
||||
}
|
||||
}
|
1
frontend/web/README.md
Normal file
1
frontend/web/README.md
Normal file
@ -0,0 +1 @@
|
||||
# Slash
|
14
frontend/web/index.html
Normal file
14
frontend/web/index.html
Normal file
@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/logo.png" type="image/*" />
|
||||
<meta name="theme-color" content="#FFFFFF" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
|
||||
<title>Slash</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
52
frontend/web/package.json
Normal file
52
frontend/web/package.json
Normal file
@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "slash",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"serve": "vite preview",
|
||||
"lint": "eslint --ext .js,.ts,.tsx, src",
|
||||
"lint-fix": "eslint --ext .js,.ts,.tsx, src --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "^1.3.0",
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@mui/joy": "5.0.0-beta.2",
|
||||
"@reduxjs/toolkit": "^1.9.5",
|
||||
"axios": "^0.27.2",
|
||||
"classnames": "^2.3.2",
|
||||
"copy-to-clipboard": "^3.3.2",
|
||||
"dayjs": "^1.11.3",
|
||||
"i18next": "^23.2.3",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lucide-react": "^0.263.1",
|
||||
"qrcode.react": "^3.1.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hot-toast": "^2.4.0",
|
||||
"react-i18next": "^13.0.1",
|
||||
"react-redux": "^8.0.2",
|
||||
"react-router-dom": "^6.13.0",
|
||||
"tailwindcss": "^3.3.2",
|
||||
"zustand": "^4.3.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@trivago/prettier-plugin-sort-imports": "^4.1.0",
|
||||
"@types/lodash-es": "^4.17.5",
|
||||
"@types/node": "^20.3.1",
|
||||
"@types/react": "^18.2.18",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@typescript-eslint/eslint-plugin": "^6.2.0",
|
||||
"@typescript-eslint/parser": "^6.2.0",
|
||||
"@vitejs/plugin-react-swc": "^3.0.0",
|
||||
"autoprefixer": "^10.4.2",
|
||||
"eslint": "^8.46.0",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"eslint-plugin-react": "^7.27.1",
|
||||
"postcss": "^8.4.21",
|
||||
"prettier": "2.6.2",
|
||||
"typescript": "^5.0.4",
|
||||
"vite": "^4.2.3"
|
||||
}
|
||||
}
|
3381
frontend/web/pnpm-lock.yaml
generated
Normal file
3381
frontend/web/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
7
frontend/web/postcss.config.js
Normal file
7
frontend/web/postcss.config.js
Normal file
@ -0,0 +1,7 @@
|
||||
/* eslint-disable no-undef */
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
BIN
frontend/web/public/logo.png
Normal file
BIN
frontend/web/public/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 83 KiB |
41
frontend/web/src/App.tsx
Normal file
41
frontend/web/src/App.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import DemoBanner from "./components/DemoBanner";
|
||||
import { globalService } from "./services";
|
||||
import useUserStore from "./stores/v1/user";
|
||||
|
||||
function App() {
|
||||
const userStore = useUserStore();
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const initialState = async () => {
|
||||
try {
|
||||
await globalService.initialState();
|
||||
} catch (error) {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
try {
|
||||
await userStore.fetchCurrentUser();
|
||||
} catch (error) {
|
||||
// do nothing.
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
initialState();
|
||||
}, []);
|
||||
|
||||
return !loading ? (
|
||||
<>
|
||||
<DemoBanner />
|
||||
<Outlet />
|
||||
</>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
38
frontend/web/src/components/AboutDialog.tsx
Normal file
38
frontend/web/src/components/AboutDialog.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import { Button, Link, Modal, ModalDialog } from "@mui/joy";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Icon from "./Icon";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const AboutDialog: React.FC<Props> = (props: Props) => {
|
||||
const { onClose } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Modal open={true}>
|
||||
<ModalDialog>
|
||||
<div className="w-full flex flex-row justify-between items-center">
|
||||
<span className="text-lg font-medium">{t("common.about")}</span>
|
||||
<Button variant="plain" onClick={onClose}>
|
||||
<Icon.X className="w-5 h-auto text-gray-600" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-w-full w-80 sm:w-96">
|
||||
<p>
|
||||
<span className="font-medium">Slash</span>: An open source, self-hosted bookmarks and link sharing platform.
|
||||
</p>
|
||||
<div className="mt-1">
|
||||
<span className="mr-2">See more in</span>
|
||||
<Link variant="plain" href="https://github.com/boojack/slash" target="_blank">
|
||||
GitHub
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default AboutDialog;
|
95
frontend/web/src/components/Alert.tsx
Normal file
95
frontend/web/src/components/Alert.tsx
Normal file
@ -0,0 +1,95 @@
|
||||
import { Button, Modal, ModalDialog } from "@mui/joy";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import Icon from "./Icon";
|
||||
|
||||
type AlertStyle = "primary" | "warning" | "danger";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
content: string;
|
||||
style?: AlertStyle;
|
||||
closeBtnText?: string;
|
||||
confirmBtnText?: string;
|
||||
onClose?: () => void;
|
||||
onConfirm?: () => void;
|
||||
}
|
||||
|
||||
const defaultProps: Props = {
|
||||
title: "",
|
||||
content: "",
|
||||
style: "primary",
|
||||
closeBtnText: "Close",
|
||||
confirmBtnText: "Confirm",
|
||||
onClose: () => null,
|
||||
onConfirm: () => null,
|
||||
};
|
||||
|
||||
const Alert: React.FC<Props> = (props: Props) => {
|
||||
const { title, content, closeBtnText, confirmBtnText, onClose, onConfirm, style } = {
|
||||
...defaultProps,
|
||||
...props,
|
||||
};
|
||||
|
||||
const handleCloseBtnClick = () => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmBtnClick = async () => {
|
||||
if (onConfirm) {
|
||||
onConfirm();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={true}>
|
||||
<ModalDialog>
|
||||
<div className="flex flex-row justify-between items-center w-80 mb-4">
|
||||
<span className="text-lg font-medium">{title}</span>
|
||||
<Button variant="plain" onClick={handleCloseBtnClick}>
|
||||
<Icon.X className="w-5 h-auto text-gray-600" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="w-80">
|
||||
<p className="content-text mb-4">{content}</p>
|
||||
<div className="w-full flex flex-row justify-end items-center space-x-2">
|
||||
<Button variant="plain" color="neutral" onClick={handleCloseBtnClick}>
|
||||
{closeBtnText}
|
||||
</Button>
|
||||
<Button color={style} onClick={handleConfirmBtnClick}>
|
||||
{confirmBtnText}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export const showCommonDialog = (props: Props) => {
|
||||
const tempDiv = document.createElement("div");
|
||||
const dialog = createRoot(tempDiv);
|
||||
document.body.append(tempDiv);
|
||||
|
||||
const destory = () => {
|
||||
dialog.unmount();
|
||||
tempDiv.remove();
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
if (props.onClose) {
|
||||
props.onClose();
|
||||
}
|
||||
destory();
|
||||
};
|
||||
|
||||
const onConfirm = () => {
|
||||
if (props.onConfirm) {
|
||||
props.onConfirm();
|
||||
}
|
||||
destory();
|
||||
};
|
||||
|
||||
dialog.render(<Alert {...props} onClose={onClose} onConfirm={onConfirm} />);
|
||||
};
|
129
frontend/web/src/components/AnalyticsView.tsx
Normal file
129
frontend/web/src/components/AnalyticsView.tsx
Normal file
@ -0,0 +1,129 @@
|
||||
import classNames from "classnames";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as api from "../helpers/api";
|
||||
import Icon from "./Icon";
|
||||
|
||||
interface Props {
|
||||
shortcutId: ShortcutId;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const AnalyticsView: React.FC<Props> = (props: Props) => {
|
||||
const { shortcutId, className } = props;
|
||||
const { t } = useTranslation();
|
||||
const [analytics, setAnalytics] = useState<AnalysisData | null>(null);
|
||||
const [selectedDeviceTab, setSelectedDeviceTab] = useState<"os" | "browser">("browser");
|
||||
|
||||
useEffect(() => {
|
||||
api.getShortcutAnalytics(shortcutId).then(({ data }) => {
|
||||
setAnalytics(data);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={classNames("w-full", className)}>
|
||||
{analytics ? (
|
||||
<>
|
||||
<div className="w-full">
|
||||
<p className="w-full h-8 px-2">{t("analytics.top-sources")}</p>
|
||||
<div className="w-full mt-1 overflow-hidden shadow ring-1 ring-black ring-opacity-5 rounded-lg">
|
||||
<div className="w-full divide-y divide-gray-300">
|
||||
<div className="w-full flex flex-row justify-between items-center">
|
||||
<span className="py-2 px-2 text-left font-semibold text-sm text-gray-500">{t("analytics.source")}</span>
|
||||
<span className="py-2 pr-2 text-right font-semibold text-sm text-gray-500">{t("analytics.visitors")}</span>
|
||||
</div>
|
||||
<div className="w-full divide-y divide-gray-200">
|
||||
{analytics.referenceData.map((reference) => (
|
||||
<div key={reference.name} className="w-full flex flex-row justify-between items-center">
|
||||
<span className="whitespace-nowrap py-2 px-2 text-sm truncate text-gray-900">
|
||||
{reference.name ? (
|
||||
<a className="hover:underline hover:text-blue-600" href={reference.name} target="_blank">
|
||||
{reference.name}
|
||||
</a>
|
||||
) : (
|
||||
"Direct"
|
||||
)}
|
||||
</span>
|
||||
<span className="whitespace-nowrap py-2 pr-2 text-sm text-gray-500 text-right shrink-0">{reference.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<div className="w-full h-8 px-2 flex flex-row justify-between items-center">
|
||||
<span>{t("analytics.devices")}</span>
|
||||
<div>
|
||||
<button
|
||||
className={`whitespace-nowrap border-b-2 px-1 text-sm font-medium ${
|
||||
selectedDeviceTab === "browser"
|
||||
? "border-blue-600 text-blue-600"
|
||||
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700"
|
||||
}`}
|
||||
onClick={() => setSelectedDeviceTab("browser")}
|
||||
>
|
||||
{t("analytics.browser")}
|
||||
</button>
|
||||
<span className="text-gray-200 font-mono mx-1">/</span>
|
||||
<button
|
||||
className={`whitespace-nowrap border-b-2 px-1 text-sm font-medium ${
|
||||
selectedDeviceTab === "os"
|
||||
? "border-blue-600 text-blue-600"
|
||||
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700"
|
||||
}`}
|
||||
onClick={() => setSelectedDeviceTab("os")}
|
||||
>
|
||||
OS
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full mt-1 overflow-hidden shadow ring-1 ring-black ring-opacity-5 rounded-lg">
|
||||
{selectedDeviceTab === "browser" ? (
|
||||
<div className="w-full divide-y divide-gray-300">
|
||||
<div className="w-full flex flex-row justify-between items-center">
|
||||
<span className="py-2 px-2 text-left text-sm font-semibold text-gray-500">{t("analytics.browsers")}</span>
|
||||
<span className="py-2 pr-2 text-right text-sm font-semibold text-gray-500">{t("analytics.visitors")}</span>
|
||||
</div>
|
||||
<div className="w-full divide-y divide-gray-200">
|
||||
{analytics.browserData.map((reference) => (
|
||||
<div key={reference.name} className="w-full flex flex-row justify-between items-center">
|
||||
<span className="whitespace-nowrap py-2 px-2 text-sm text-gray-900 truncate">{reference.name || "Unknown"}</span>
|
||||
<span className="whitespace-nowrap py-2 pr-2 text-sm text-gray-500 text-right shrink-0">{reference.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full divide-y divide-gray-300">
|
||||
<div className="w-full flex flex-row justify-between items-center">
|
||||
<span className="py-2 px-2 text-left text-sm font-semibold text-gray-500">{t("analytics.operating-system")}</span>
|
||||
<span className="py-2 pr-2 text-right text-sm font-semibold text-gray-500">{t("analytics.visitors")}</span>
|
||||
</div>
|
||||
<div className="w-full divide-y divide-gray-200">
|
||||
{analytics.deviceData.map((device) => (
|
||||
<div key={device.name} className="w-full flex flex-row justify-between items-center">
|
||||
<span className="whitespace-nowrap py-2 px-2 text-sm text-gray-900 truncate">{device.name || "Unknown"}</span>
|
||||
<span className="whitespace-nowrap py-2 pr-2 text-sm text-gray-500 text-right shrink-0">{device.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="py-12 w-full flex flex-row justify-center items-center opacity-80">
|
||||
<Icon.Loader className="mr-2 w-5 h-auto animate-spin" />
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnalyticsView;
|
94
frontend/web/src/components/ChangePasswordDialog.tsx
Normal file
94
frontend/web/src/components/ChangePasswordDialog.tsx
Normal file
@ -0,0 +1,94 @@
|
||||
import { Button, Input, Modal, ModalDialog } from "@mui/joy";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import useUserStore from "../stores/v1/user";
|
||||
import Icon from "./Icon";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ChangePasswordDialog: React.FC<Props> = (props: Props) => {
|
||||
const { onClose } = props;
|
||||
const { t } = useTranslation();
|
||||
const userStore = useUserStore();
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [newPasswordAgain, setNewPasswordAgain] = useState("");
|
||||
const requestState = useLoading(false);
|
||||
|
||||
const handleCloseBtnClick = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleNewPasswordChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const text = e.target.value as string;
|
||||
setNewPassword(text);
|
||||
};
|
||||
|
||||
const handleNewPasswordAgainChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const text = e.target.value as string;
|
||||
setNewPasswordAgain(text);
|
||||
};
|
||||
|
||||
const handleSaveBtnClick = async () => {
|
||||
if (newPassword === "" || newPasswordAgain === "") {
|
||||
toast.error("Please fill all inputs");
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== newPasswordAgain) {
|
||||
toast.error("Not matched");
|
||||
setNewPasswordAgain("");
|
||||
return;
|
||||
}
|
||||
|
||||
requestState.setLoading();
|
||||
try {
|
||||
userStore.patchUser({
|
||||
id: userStore.getCurrentUser().id,
|
||||
password: newPassword,
|
||||
});
|
||||
onClose();
|
||||
toast("Password changed");
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toast.error(error.response.data.message);
|
||||
}
|
||||
requestState.setFinish();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={true}>
|
||||
<ModalDialog>
|
||||
<div className="flex flex-row justify-between items-center w-80 mb-4">
|
||||
<span className="text-lg font-medium">Change Password</span>
|
||||
<Button variant="plain" onClick={handleCloseBtnClick}>
|
||||
<Icon.X className="w-5 h-auto text-gray-600" />
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||
<span className="mb-2">New Password</span>
|
||||
<Input className="w-full" type="text" value={newPassword} onChange={handleNewPasswordChanged} />
|
||||
</div>
|
||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||
<span className="mb-2">New Password Again</span>
|
||||
<Input className="w-full" type="text" value={newPasswordAgain} onChange={handleNewPasswordAgainChanged} />
|
||||
</div>
|
||||
<div className="w-full flex flex-row justify-end items-center space-x-2">
|
||||
<Button variant="plain" disabled={requestState.isLoading} onClick={handleCloseBtnClick}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button color="primary" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={handleSaveBtnClick}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChangePasswordDialog;
|
136
frontend/web/src/components/CreateAccessTokenDialog.tsx
Normal file
136
frontend/web/src/components/CreateAccessTokenDialog.tsx
Normal file
@ -0,0 +1,136 @@
|
||||
import { Button, Input, Modal, ModalDialog, Radio, RadioGroup } from "@mui/joy";
|
||||
import axios from "axios";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import useUserStore from "../stores/v1/user";
|
||||
import Icon from "./Icon";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onConfirm?: () => void;
|
||||
}
|
||||
|
||||
const expirationOptions = [
|
||||
{
|
||||
label: "8 hours",
|
||||
value: 3600 * 8,
|
||||
},
|
||||
{
|
||||
label: "1 month",
|
||||
value: 3600 * 24 * 30,
|
||||
},
|
||||
{
|
||||
label: "Never",
|
||||
value: 0,
|
||||
},
|
||||
];
|
||||
|
||||
interface State {
|
||||
description: string;
|
||||
expiration: number;
|
||||
}
|
||||
|
||||
const CreateAccessTokenDialog: React.FC<Props> = (props: Props) => {
|
||||
const { onClose, onConfirm } = props;
|
||||
const { t } = useTranslation();
|
||||
const currentUser = useUserStore().getCurrentUser();
|
||||
const [state, setState] = useState({
|
||||
description: "",
|
||||
expiration: 3600 * 8,
|
||||
});
|
||||
const requestState = useLoading(false);
|
||||
|
||||
const setPartialState = (partialState: Partial<State>) => {
|
||||
setState({
|
||||
...state,
|
||||
...partialState,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDescriptionInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPartialState({
|
||||
description: e.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleRoleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPartialState({
|
||||
expiration: Number(e.target.value),
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveBtnClick = async () => {
|
||||
if (!state.description) {
|
||||
toast.error("Description is required");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await axios.post(`/api/v2/users/${currentUser.id}/access_tokens`, {
|
||||
description: state.description,
|
||||
expiresAt: new Date(Date.now() + state.expiration * 1000),
|
||||
});
|
||||
|
||||
if (onConfirm) {
|
||||
onConfirm();
|
||||
}
|
||||
onClose();
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toast.error(error.response.data.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={true}>
|
||||
<ModalDialog>
|
||||
<div className="flex flex-row justify-between items-center w-80 sm:w-96 mb-4">
|
||||
<span className="text-lg font-medium">Create Access Token</span>
|
||||
<Button variant="plain" onClick={onClose}>
|
||||
<Icon.X className="w-5 h-auto text-gray-600" />
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||
<span className="mb-2">
|
||||
Description <span className="text-red-600">*</span>
|
||||
</span>
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
className="w-full"
|
||||
type="text"
|
||||
placeholder="Some description"
|
||||
value={state.description}
|
||||
onChange={handleDescriptionInputChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||
<span className="mb-2">
|
||||
Expiration <span className="text-red-600">*</span>
|
||||
</span>
|
||||
<div className="w-full flex flex-row justify-start items-center text-base">
|
||||
<RadioGroup orientation="horizontal" value={state.expiration} onChange={handleRoleInputChange}>
|
||||
{expirationOptions.map((option) => (
|
||||
<Radio key={option.value} value={option.value} label={option.label} />
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex flex-row justify-end items-center mt-4 space-x-2">
|
||||
<Button color="neutral" variant="plain" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button color="primary" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={handleSaveBtnClick}>
|
||||
{t("common.create")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateAccessTokenDialog;
|
346
frontend/web/src/components/CreateShortcutDialog.tsx
Normal file
346
frontend/web/src/components/CreateShortcutDialog.tsx
Normal file
@ -0,0 +1,346 @@
|
||||
import { Button, Divider, Input, Modal, ModalDialog, Radio, RadioGroup, Textarea } from "@mui/joy";
|
||||
import classnames from "classnames";
|
||||
import { isUndefined } from "lodash-es";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import { shortcutService } from "../services";
|
||||
import Icon from "./Icon";
|
||||
|
||||
interface Props {
|
||||
shortcutId?: ShortcutId;
|
||||
onClose: () => void;
|
||||
onConfirm?: () => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
shortcutCreate: ShortcutCreate;
|
||||
}
|
||||
|
||||
const visibilities: Visibility[] = ["PRIVATE", "WORKSPACE", "PUBLIC"];
|
||||
|
||||
const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
||||
const { onClose, onConfirm, shortcutId } = props;
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<State>({
|
||||
shortcutCreate: {
|
||||
name: "",
|
||||
link: "",
|
||||
title: "",
|
||||
description: "",
|
||||
visibility: "PRIVATE",
|
||||
tags: [],
|
||||
openGraphMetadata: {
|
||||
title: "",
|
||||
description: "",
|
||||
image: "",
|
||||
},
|
||||
},
|
||||
});
|
||||
const [showAdditionalFields, setShowAdditionalFields] = useState<boolean>(false);
|
||||
const [showOpenGraphMetadata, setShowOpenGraphMetadata] = useState<boolean>(false);
|
||||
const [tag, setTag] = useState<string>("");
|
||||
const requestState = useLoading(false);
|
||||
const isCreating = isUndefined(shortcutId);
|
||||
|
||||
useEffect(() => {
|
||||
if (shortcutId) {
|
||||
const shortcut = shortcutService.getShortcutById(shortcutId);
|
||||
if (shortcut) {
|
||||
setState({
|
||||
...state,
|
||||
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||
name: shortcut.name,
|
||||
link: shortcut.link,
|
||||
title: shortcut.title,
|
||||
description: shortcut.description,
|
||||
visibility: shortcut.visibility,
|
||||
openGraphMetadata: shortcut.openGraphMetadata,
|
||||
}),
|
||||
});
|
||||
setTag(shortcut.tags.join(" "));
|
||||
}
|
||||
}
|
||||
}, [shortcutId]);
|
||||
|
||||
const setPartialState = (partialState: Partial<State>) => {
|
||||
setState({
|
||||
...state,
|
||||
...partialState,
|
||||
});
|
||||
};
|
||||
|
||||
const handleNameInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPartialState({
|
||||
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||
name: e.target.value.replace(/\s+/g, "-").toLowerCase(),
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const handleLinkInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPartialState({
|
||||
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||
link: e.target.value,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const handleTitleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPartialState({
|
||||
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||
title: e.target.value,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const handleVisibilityInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPartialState({
|
||||
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||
visibility: e.target.value,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const handleDescriptionInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPartialState({
|
||||
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||
description: e.target.value,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const handleTagsInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const text = e.target.value as string;
|
||||
setTag(text);
|
||||
};
|
||||
|
||||
const handleOpenGraphMetadataImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPartialState({
|
||||
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||
openGraphMetadata: {
|
||||
...state.shortcutCreate.openGraphMetadata,
|
||||
image: e.target.value,
|
||||
},
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenGraphMetadataTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPartialState({
|
||||
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||
openGraphMetadata: {
|
||||
...state.shortcutCreate.openGraphMetadata,
|
||||
title: e.target.value,
|
||||
},
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenGraphMetadataDescriptionChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setPartialState({
|
||||
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||
openGraphMetadata: {
|
||||
...state.shortcutCreate.openGraphMetadata,
|
||||
description: e.target.value,
|
||||
},
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveBtnClick = async () => {
|
||||
if (!state.shortcutCreate.name) {
|
||||
toast.error("Name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (shortcutId) {
|
||||
await shortcutService.patchShortcut({
|
||||
id: shortcutId,
|
||||
name: state.shortcutCreate.name,
|
||||
link: state.shortcutCreate.link,
|
||||
title: state.shortcutCreate.title,
|
||||
description: state.shortcutCreate.description,
|
||||
visibility: state.shortcutCreate.visibility,
|
||||
tags: tag.split(" ").filter(Boolean),
|
||||
openGraphMetadata: state.shortcutCreate.openGraphMetadata,
|
||||
});
|
||||
} else {
|
||||
await shortcutService.createShortcut({
|
||||
...state.shortcutCreate,
|
||||
tags: tag.split(" ").filter(Boolean),
|
||||
});
|
||||
}
|
||||
|
||||
if (onConfirm) {
|
||||
onConfirm();
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toast.error(error.response.data.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={true}>
|
||||
<ModalDialog>
|
||||
<div className="flex flex-row justify-between items-center w-80 sm:w-96 mb-4">
|
||||
<span className="text-lg font-medium">{isCreating ? "Create Shortcut" : "Edit Shortcut"}</span>
|
||||
<Button variant="plain" onClick={onClose}>
|
||||
<Icon.X className="w-5 h-auto text-gray-600" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-y-auto overflow-x-hidden">
|
||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||
<span className="mb-2">Name</span>
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
className="w-full"
|
||||
type="text"
|
||||
placeholder="Unique shortcut name"
|
||||
value={state.shortcutCreate.name}
|
||||
onChange={handleNameInputChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||
<span className="mb-2">Destination URL</span>
|
||||
<Input
|
||||
className="w-full"
|
||||
type="text"
|
||||
placeholder="https://github.com/boojack/slash"
|
||||
value={state.shortcutCreate.link}
|
||||
onChange={handleLinkInputChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||
<span className="mb-2">Tags</span>
|
||||
<Input className="w-full" type="text" placeholder="github slash" value={tag} onChange={handleTagsInputChange} />
|
||||
</div>
|
||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||
<span className="mb-2">Visibility</span>
|
||||
<div className="w-full flex flex-row justify-start items-center text-base">
|
||||
<RadioGroup orientation="horizontal" value={state.shortcutCreate.visibility} onChange={handleVisibilityInputChange}>
|
||||
{visibilities.map((visibility) => (
|
||||
<Radio key={visibility} value={visibility} label={t(`shortcut.visibility.${visibility.toLowerCase()}.self`)} />
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-gray-500 w-full bg-gray-100 border border-gray-200 px-2 py-1 rounded-md">
|
||||
{t(`shortcut.visibility.${state.shortcutCreate.visibility.toLowerCase()}.description`)}
|
||||
</p>
|
||||
</div>
|
||||
<Divider className="text-gray-500">Optional</Divider>
|
||||
<div className="w-full flex flex-col justify-start items-start border rounded-md overflow-hidden my-3">
|
||||
<div
|
||||
className={classnames(
|
||||
"w-full flex flex-row justify-between items-center px-2 py-1 cursor-pointer hover:bg-gray-100",
|
||||
showAdditionalFields ? "bg-gray-100 border-b" : ""
|
||||
)}
|
||||
onClick={() => setShowAdditionalFields(!showAdditionalFields)}
|
||||
>
|
||||
<span className="text-sm">Additional fields</span>
|
||||
<button className="w-7 h-7 p-1 rounded-md">
|
||||
<Icon.ChevronDown className={classnames("w-4 h-auto text-gray-500", showAdditionalFields ? "transform rotate-180" : "")} />
|
||||
</button>
|
||||
</div>
|
||||
{showAdditionalFields && (
|
||||
<div className="w-full px-2 py-1">
|
||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||
<span className="mb-2 text-sm">Title</span>
|
||||
<Input
|
||||
className="w-full"
|
||||
type="text"
|
||||
placeholder="Title"
|
||||
size="sm"
|
||||
value={state.shortcutCreate.title}
|
||||
onChange={handleTitleInputChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||
<span className="mb-2 text-sm">Description</span>
|
||||
<Input
|
||||
className="w-full"
|
||||
type="text"
|
||||
placeholder="Github repo for slash"
|
||||
size="sm"
|
||||
value={state.shortcutCreate.description}
|
||||
onChange={handleDescriptionInputChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full flex flex-col justify-start items-start border rounded-md overflow-hidden">
|
||||
<div
|
||||
className={`w-full flex flex-row justify-between items-center px-2 py-1 cursor-pointer hover:bg-gray-100 ${
|
||||
showOpenGraphMetadata ? "bg-gray-100 border-b" : ""
|
||||
}`}
|
||||
onClick={() => setShowOpenGraphMetadata(!showOpenGraphMetadata)}
|
||||
>
|
||||
<span className="text-sm flex flex-row justify-start items-center">
|
||||
Social media metadata
|
||||
<Icon.Sparkles className="ml-1 w-4 h-auto text-blue-600" />
|
||||
</span>
|
||||
<button className="w-7 h-7 p-1 rounded-md">
|
||||
<Icon.ChevronDown className={classnames("w-4 h-auto text-gray-500", showOpenGraphMetadata ? "transform rotate-180" : "")} />
|
||||
</button>
|
||||
</div>
|
||||
{showOpenGraphMetadata && (
|
||||
<div className="w-full px-2 py-1">
|
||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||
<span className="mb-2 text-sm">Image URL</span>
|
||||
<Input
|
||||
className="w-full"
|
||||
type="text"
|
||||
placeholder="https://the.link.to/the/image.png"
|
||||
size="sm"
|
||||
value={state.shortcutCreate.openGraphMetadata.image}
|
||||
onChange={handleOpenGraphMetadataImageChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||
<span className="mb-2 text-sm">Title</span>
|
||||
<Input
|
||||
className="w-full"
|
||||
type="text"
|
||||
placeholder="Slash - An open source, self-hosted bookmarks and link sharing platform"
|
||||
size="sm"
|
||||
value={state.shortcutCreate.openGraphMetadata.title}
|
||||
onChange={handleOpenGraphMetadataTitleChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||
<span className="mb-2 text-sm">Description</span>
|
||||
<Textarea
|
||||
className="w-full"
|
||||
placeholder="An open source, self-hosted bookmarks and link sharing platform."
|
||||
size="sm"
|
||||
maxRows={3}
|
||||
value={state.shortcutCreate.openGraphMetadata.description}
|
||||
onChange={handleOpenGraphMetadataDescriptionChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-full flex flex-row justify-end items-center mt-4 space-x-2">
|
||||
<Button color="neutral" variant="plain" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button color="primary" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={handleSaveBtnClick}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateShortcutDialog;
|
202
frontend/web/src/components/CreateUserDialog.tsx
Normal file
202
frontend/web/src/components/CreateUserDialog.tsx
Normal file
@ -0,0 +1,202 @@
|
||||
import { Button, Input, Modal, ModalDialog, Radio, RadioGroup } from "@mui/joy";
|
||||
import { isUndefined } from "lodash-es";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import useUserStore from "../stores/v1/user";
|
||||
import Icon from "./Icon";
|
||||
|
||||
interface Props {
|
||||
user?: User;
|
||||
onClose: () => void;
|
||||
onConfirm?: () => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
userCreate: UserCreate;
|
||||
}
|
||||
|
||||
const roles: Role[] = ["USER", "ADMIN"];
|
||||
|
||||
const CreateUserDialog: React.FC<Props> = (props: Props) => {
|
||||
const { onClose, onConfirm, user } = props;
|
||||
const { t } = useTranslation();
|
||||
const userStore = useUserStore();
|
||||
const [state, setState] = useState<State>({
|
||||
userCreate: {
|
||||
email: "",
|
||||
nickname: "",
|
||||
password: "",
|
||||
role: "USER",
|
||||
},
|
||||
});
|
||||
const requestState = useLoading(false);
|
||||
const isCreating = isUndefined(user);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setState({
|
||||
...state,
|
||||
userCreate: Object.assign(state.userCreate, {
|
||||
email: user.email,
|
||||
nickname: user.nickname,
|
||||
password: "",
|
||||
role: user.role,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const setPartialState = (partialState: Partial<State>) => {
|
||||
setState({
|
||||
...state,
|
||||
...partialState,
|
||||
});
|
||||
};
|
||||
|
||||
const handleEmailInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPartialState({
|
||||
userCreate: Object.assign(state.userCreate, {
|
||||
email: e.target.value.toLowerCase(),
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const handleNicknameInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPartialState({
|
||||
userCreate: Object.assign(state.userCreate, {
|
||||
nickname: e.target.value,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const handlePasswordInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPartialState({
|
||||
userCreate: Object.assign(state.userCreate, {
|
||||
password: e.target.value,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const handleRoleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPartialState({
|
||||
userCreate: Object.assign(state.userCreate, {
|
||||
role: e.target.value,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveBtnClick = async () => {
|
||||
if (isCreating && (!state.userCreate.email || !state.userCreate.nickname || !state.userCreate.password)) {
|
||||
toast.error("Please fill all inputs");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (user) {
|
||||
const userPatch: UserPatch = {
|
||||
id: user.id,
|
||||
};
|
||||
if (user.email !== state.userCreate.email) {
|
||||
userPatch.email = state.userCreate.email;
|
||||
}
|
||||
if (user.nickname !== state.userCreate.nickname) {
|
||||
userPatch.nickname = state.userCreate.nickname;
|
||||
}
|
||||
if (user.role !== state.userCreate.role) {
|
||||
userPatch.role = state.userCreate.role;
|
||||
}
|
||||
await userStore.patchUser(userPatch);
|
||||
} else {
|
||||
await userStore.createUser(state.userCreate);
|
||||
}
|
||||
|
||||
if (onConfirm) {
|
||||
onConfirm();
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toast.error(error.response.data.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={true}>
|
||||
<ModalDialog>
|
||||
<div className="flex flex-row justify-between items-center w-80 sm:w-96 mb-4">
|
||||
<span className="text-lg font-medium">{isCreating ? "Create User" : "Edit User"}</span>
|
||||
<Button variant="plain" onClick={onClose}>
|
||||
<Icon.X className="w-5 h-auto text-gray-600" />
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||
<span className="mb-2">
|
||||
Email <span className="text-red-600">*</span>
|
||||
</span>
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
className="w-full"
|
||||
type="email"
|
||||
placeholder="Unique user email"
|
||||
value={state.userCreate.email}
|
||||
onChange={handleEmailInputChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||
<span className="mb-2">
|
||||
Nickname <span className="text-red-600">*</span>
|
||||
</span>
|
||||
<Input
|
||||
className="w-full"
|
||||
type="text"
|
||||
placeholder="Nickname"
|
||||
value={state.userCreate.nickname}
|
||||
onChange={handleNicknameInputChange}
|
||||
/>
|
||||
</div>
|
||||
{isCreating && (
|
||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||
<span className="mb-2">
|
||||
Password <span className="text-red-600">*</span>
|
||||
</span>
|
||||
<Input
|
||||
className="w-full"
|
||||
type="password"
|
||||
placeholder=""
|
||||
value={state.userCreate.password}
|
||||
onChange={handlePasswordInputChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||
<span className="mb-2">
|
||||
Role <span className="text-red-600">*</span>
|
||||
</span>
|
||||
<div className="w-full flex flex-row justify-start items-center text-base">
|
||||
<RadioGroup orientation="horizontal" value={state.userCreate.role} onChange={handleRoleInputChange}>
|
||||
{roles.map((role) => (
|
||||
<Radio key={role} value={role} label={role} />
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex flex-row justify-end items-center mt-4 space-x-2">
|
||||
<Button color="neutral" variant="plain" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button color="primary" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={handleSaveBtnClick}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateUserDialog;
|
31
frontend/web/src/components/DemoBanner.tsx
Normal file
31
frontend/web/src/components/DemoBanner.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import { globalService } from "../services";
|
||||
import Icon from "./Icon";
|
||||
|
||||
const DemoBanner: React.FC = () => {
|
||||
const {
|
||||
workspaceProfile: {
|
||||
profile: { mode },
|
||||
},
|
||||
} = globalService.getState();
|
||||
const shouldShow = mode === "demo";
|
||||
|
||||
if (!shouldShow) return null;
|
||||
|
||||
return (
|
||||
<div className="z-10 relative flex flex-row items-center justify-center w-full py-2 text-sm sm:text-lg font-medium dark:text-gray-300 bg-white dark:bg-zinc-700 shadow">
|
||||
<div className="w-full max-w-6xl px-4 md:px-12 flex flex-row justify-between items-center gap-x-3">
|
||||
<span>✨🔗 Slash - An open source, self-hosted bookmarks and link sharing platform</span>
|
||||
<a
|
||||
className="shadow flex flex-row justify-center items-center px-2 py-1 rounded-md text-sm sm:text-base text-white bg-blue-600 hover:bg-blue-700"
|
||||
href="https://github.com/boojack/slash#deploy-with-docker-in-seconds"
|
||||
target="_blank"
|
||||
>
|
||||
Install
|
||||
<Icon.ExternalLink className="w-4 h-auto ml-1" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DemoBanner;
|
90
frontend/web/src/components/EditUserinfoDialog.tsx
Normal file
90
frontend/web/src/components/EditUserinfoDialog.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
import { Button, Input, Modal, ModalDialog } from "@mui/joy";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import useUserStore from "../stores/v1/user";
|
||||
import Icon from "./Icon";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const EditUserinfoDialog: React.FC<Props> = (props: Props) => {
|
||||
const { onClose } = props;
|
||||
const { t } = useTranslation();
|
||||
const userStore = useUserStore();
|
||||
const currentUser = userStore.getCurrentUser();
|
||||
const [email, setEmail] = useState(currentUser.email);
|
||||
const [nickname, setNickname] = useState(currentUser.nickname);
|
||||
const requestState = useLoading(false);
|
||||
|
||||
const handleCloseBtnClick = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleEmailChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const text = e.target.value as string;
|
||||
setEmail(text);
|
||||
};
|
||||
|
||||
const handleNicknameChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const text = e.target.value as string;
|
||||
setNickname(text);
|
||||
};
|
||||
|
||||
const handleSaveBtnClick = async () => {
|
||||
if (email === "" || nickname === "") {
|
||||
toast.error("Please fill all fields");
|
||||
return;
|
||||
}
|
||||
|
||||
requestState.setLoading();
|
||||
try {
|
||||
await userStore.patchUser({
|
||||
id: currentUser.id,
|
||||
email,
|
||||
nickname,
|
||||
});
|
||||
onClose();
|
||||
toast("User information updated");
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toast.error(error.response.data.message);
|
||||
}
|
||||
requestState.setFinish();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={true}>
|
||||
<ModalDialog>
|
||||
<div className="flex flex-row justify-between items-center w-80 mb-4">
|
||||
<span className="text-lg font-medium">Edit Userinfo</span>
|
||||
<Button variant="plain" onClick={handleCloseBtnClick}>
|
||||
<Icon.X className="w-5 h-auto text-gray-600" />
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||
<span className="mb-2">Email</span>
|
||||
<Input className="w-full" type="text" value={email} onChange={handleEmailChanged} />
|
||||
</div>
|
||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||
<span className="mb-2">Nickname</span>
|
||||
<Input className="w-full" type="text" value={nickname} onChange={handleNicknameChanged} />
|
||||
</div>
|
||||
<div className="w-full flex flex-row justify-end items-center space-x-2">
|
||||
<Button variant="plain" disabled={requestState.isLoading} onClick={handleCloseBtnClick}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button color="primary" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={handleSaveBtnClick}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditUserinfoDialog;
|
43
frontend/web/src/components/FilterView.tsx
Normal file
43
frontend/web/src/components/FilterView.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useViewStore from "../stores/v1/view";
|
||||
import Icon from "./Icon";
|
||||
import VisibilityIcon from "./VisibilityIcon";
|
||||
|
||||
const FilterView = () => {
|
||||
const { t } = useTranslation();
|
||||
const viewStore = useViewStore();
|
||||
const filter = viewStore.filter;
|
||||
const shouldShowFilters = filter.tag !== undefined || filter.visibility !== undefined;
|
||||
|
||||
if (!shouldShowFilters) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-row justify-start items-center mb-4 pl-2">
|
||||
<span className="text-gray-400">Filters:</span>
|
||||
{filter.tag && (
|
||||
<button
|
||||
className="ml-2 px-2 py-1 flex flex-row justify-center items-center bg-gray-100 rounded-full text-gray-500 text-sm hover:line-through"
|
||||
onClick={() => viewStore.setFilter({ tag: undefined })}
|
||||
>
|
||||
<Icon.Tag className="w-4 h-auto mr-1" />
|
||||
<span className="max-w-[8rem] truncate">#{filter.tag}</span>
|
||||
<Icon.X className="w-4 h-auto ml-1" />
|
||||
</button>
|
||||
)}
|
||||
{filter.visibility && (
|
||||
<button
|
||||
className="ml-2 px-2 py-1 flex flex-row justify-center items-center bg-gray-100 rounded-full text-gray-500 text-sm hover:line-through"
|
||||
onClick={() => viewStore.setFilter({ visibility: undefined })}
|
||||
>
|
||||
<VisibilityIcon className="w-4 h-auto mr-1" visibility={filter.visibility} />
|
||||
{t(`shortcut.visibility.${filter.visibility.toLowerCase()}.self`)}
|
||||
<Icon.X className="w-4 h-auto ml-1" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterView;
|
63
frontend/web/src/components/GenerateQRCodeDialog.tsx
Normal file
63
frontend/web/src/components/GenerateQRCodeDialog.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import { Button, Modal, ModalDialog } from "@mui/joy";
|
||||
import { QRCodeCanvas } from "qrcode.react";
|
||||
import { useRef } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { absolutifyLink } from "../helpers/utils";
|
||||
import Icon from "./Icon";
|
||||
|
||||
interface Props {
|
||||
shortcut: Shortcut;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const GenerateQRCodeDialog: React.FC<Props> = (props: Props) => {
|
||||
const { shortcut, onClose } = props;
|
||||
const { t } = useTranslation();
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const shortcutLink = absolutifyLink(`/s/${shortcut.name}`);
|
||||
|
||||
const handleCloseBtnClick = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleDownloadQRCodeClick = () => {
|
||||
const canvas = containerRef.current?.querySelector("canvas");
|
||||
if (!canvas) {
|
||||
toast.error("Failed to get qr code canvas");
|
||||
return;
|
||||
}
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.download = "filename.png";
|
||||
link.href = canvas.toDataURL();
|
||||
link.click();
|
||||
handleCloseBtnClick();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={true}>
|
||||
<ModalDialog>
|
||||
<div className="flex flex-row justify-between items-center w-64 mb-4">
|
||||
<span className="text-lg font-medium">QR Code</span>
|
||||
<Button variant="plain" onClick={handleCloseBtnClick}>
|
||||
<Icon.X className="w-5 h-auto text-gray-600" />
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<div ref={containerRef} className="w-full flex flex-row justify-center items-center mt-2 mb-6">
|
||||
<QRCodeCanvas value={shortcutLink} size={128} bgColor={"#ffffff"} fgColor={"#000000"} includeMargin={false} level={"L"} />
|
||||
</div>
|
||||
<div className="w-full flex flex-row justify-center items-center px-4">
|
||||
<Button className="w-full" color="neutral" onClick={handleDownloadQRCodeClick}>
|
||||
<Icon.Download className="w-4 h-auto mr-1" />
|
||||
{t("common.download")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default GenerateQRCodeDialog;
|
71
frontend/web/src/components/Header.tsx
Normal file
71
frontend/web/src/components/Header.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import { Avatar } from "@mui/joy";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import * as api from "../helpers/api";
|
||||
import useUserStore from "../stores/v1/user";
|
||||
import AboutDialog from "./AboutDialog";
|
||||
import Icon from "./Icon";
|
||||
import Dropdown from "./common/Dropdown";
|
||||
|
||||
const Header: React.FC = () => {
|
||||
const currentUser = useUserStore().getCurrentUser();
|
||||
const [showAboutDialog, setShowAboutDialog] = useState<boolean>(false);
|
||||
|
||||
const handleSignOutButtonClick = async () => {
|
||||
await api.signout();
|
||||
window.location.href = "/auth";
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full bg-gray-50 border-b border-b-gray-200">
|
||||
<div className="w-full max-w-6xl mx-auto px-3 md:px-12 py-5 flex flex-row justify-between items-center">
|
||||
<div className="flex flex-row justify-start items-center shrink mr-2">
|
||||
<Link to="/" className="text-lg cursor-pointer flex flex-row justify-start items-center">
|
||||
<img src="/logo.png" className="w-8 h-auto mr-2 -mt-0.5" alt="" />
|
||||
Slash
|
||||
</Link>
|
||||
</div>
|
||||
<div className="relative flex-shrink-0">
|
||||
<Dropdown
|
||||
trigger={
|
||||
<button className="flex flex-row justify-end items-center cursor-pointer">
|
||||
<Avatar size="sm" variant="plain" />
|
||||
<span>{currentUser.nickname}</span>
|
||||
<Icon.ChevronDown className="ml-2 w-5 h-auto text-gray-600" />
|
||||
</button>
|
||||
}
|
||||
actionsClassName="!w-32"
|
||||
actions={
|
||||
<>
|
||||
<Link
|
||||
to="/setting"
|
||||
className="w-full px-2 flex flex-row justify-start items-center text-left leading-8 cursor-pointer rounded hover:bg-gray-100 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
|
||||
>
|
||||
<Icon.Settings className="w-4 h-auto mr-2" /> Setting
|
||||
</Link>
|
||||
<button
|
||||
className="w-full px-2 flex flex-row justify-start items-center text-left leading-8 cursor-pointer rounded hover:bg-gray-100 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
|
||||
onClick={() => setShowAboutDialog(true)}
|
||||
>
|
||||
<Icon.Info className="w-4 h-auto mr-2" /> About
|
||||
</button>
|
||||
<button
|
||||
className="w-full px-2 flex flex-row justify-start items-center text-left leading-8 cursor-pointer rounded hover:bg-gray-100 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
|
||||
onClick={() => handleSignOutButtonClick()}
|
||||
>
|
||||
<Icon.LogOut className="w-4 h-auto mr-2" /> Sign out
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
></Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showAboutDialog && <AboutDialog onClose={() => setShowAboutDialog(false)} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
3
frontend/web/src/components/Icon.ts
Normal file
3
frontend/web/src/components/Icon.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import * as Icon from "lucide-react";
|
||||
|
||||
export default Icon;
|
62
frontend/web/src/components/Navigator.tsx
Normal file
62
frontend/web/src/components/Navigator.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
import classNames from "classnames";
|
||||
import { useAppSelector } from "../stores";
|
||||
import useViewStore from "../stores/v1/view";
|
||||
import Icon from "./Icon";
|
||||
|
||||
const Navigator = () => {
|
||||
const viewStore = useViewStore();
|
||||
const { shortcutList } = useAppSelector((state) => state.shortcut);
|
||||
const tags = shortcutList.map((shortcut) => shortcut.tags).flat();
|
||||
const currentTab = viewStore.filter.tab || `tab:all`;
|
||||
const sortedTagMap = sortTags(tags);
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-row justify-start items-center mb-4 gap-1 sm:flex-wrap overflow-x-auto no-scrollbar">
|
||||
<button
|
||||
className={classNames(
|
||||
"flex flex-row justify-center items-center px-2 leading-7 text-sm rounded-md hover:bg-gray-200",
|
||||
currentTab === "tab:all" ? "!bg-gray-600 text-white shadow" : ""
|
||||
)}
|
||||
onClick={() => viewStore.setFilter({ tab: "tab:all" })}
|
||||
>
|
||||
<Icon.CircleSlash className="w-4 h-auto mr-1" />
|
||||
<span className="font-normal">All</span>
|
||||
</button>
|
||||
<button
|
||||
className={classNames(
|
||||
"flex flex-row justify-center items-center px-2 leading-7 text-sm rounded-md hover:bg-gray-200",
|
||||
currentTab === "tab:mine" ? "!bg-gray-600 text-white shadow" : ""
|
||||
)}
|
||||
onClick={() => viewStore.setFilter({ tab: "tab:mine" })}
|
||||
>
|
||||
<Icon.User className="w-4 h-auto mr-1" />
|
||||
<span className="font-normal">Mine</span>
|
||||
</button>
|
||||
{Array.from(sortedTagMap.keys()).map((tag) => (
|
||||
<button
|
||||
key={tag}
|
||||
className={classNames(
|
||||
"flex flex-row justify-center items-center px-2 leading-7 text-sm rounded-md hover:bg-gray-200",
|
||||
currentTab === `tag:${tag}` ? "!bg-gray-600 text-white shadow" : ""
|
||||
)}
|
||||
onClick={() => viewStore.setFilter({ tab: `tag:${tag}`, tag: undefined })}
|
||||
>
|
||||
<Icon.Hash className="w-4 h-auto mr-0.5" />
|
||||
<span className="max-w-[8rem] truncate font-normal">{tag}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const sortTags = (tags: string[]): Map<string, number> => {
|
||||
const map = new Map<string, number>();
|
||||
for (const tag of tags) {
|
||||
const count = map.get(tag) || 0;
|
||||
map.set(tag, count + 1);
|
||||
}
|
||||
const sortedMap = new Map([...map.entries()].sort((a, b) => b[1] - a[1]));
|
||||
return sortedMap;
|
||||
};
|
||||
|
||||
export default Navigator;
|
93
frontend/web/src/components/ShortcutActionsDropdown.tsx
Normal file
93
frontend/web/src/components/ShortcutActionsDropdown.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { shortcutService } from "../services";
|
||||
import useUserStore from "../stores/v1/user";
|
||||
import { showCommonDialog } from "./Alert";
|
||||
import CreateShortcutDialog from "./CreateShortcutDialog";
|
||||
import GenerateQRCodeDialog from "./GenerateQRCodeDialog";
|
||||
import Icon from "./Icon";
|
||||
import Dropdown from "./common/Dropdown";
|
||||
|
||||
interface Props {
|
||||
shortcut: Shortcut;
|
||||
}
|
||||
|
||||
const ShortcutActionsDropdown = (props: Props) => {
|
||||
const { shortcut } = props;
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const currentUser = useUserStore().getCurrentUser();
|
||||
const [showEditDialog, setShowEditDialog] = useState<boolean>(false);
|
||||
const [showQRCodeDialog, setShowQRCodeDialog] = useState<boolean>(false);
|
||||
const havePermission = currentUser.role === "ADMIN" || shortcut.creatorId === currentUser.id;
|
||||
|
||||
const handleDeleteShortcutButtonClick = (shortcut: Shortcut) => {
|
||||
showCommonDialog({
|
||||
title: "Delete Shortcut",
|
||||
content: `Are you sure to delete shortcut \`${shortcut.name}\`? You cannot undo this action.`,
|
||||
style: "danger",
|
||||
onConfirm: async () => {
|
||||
await shortcutService.deleteShortcutById(shortcut.id);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const gotoAnalytics = () => {
|
||||
navigate(`/shortcut/${shortcut.id}#analytics`);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dropdown
|
||||
actionsClassName="!w-32"
|
||||
actions={
|
||||
<>
|
||||
{havePermission && (
|
||||
<button
|
||||
className="w-full px-2 flex flex-row justify-start items-center text-left leading-8 cursor-pointer rounded hover:bg-gray-100 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
|
||||
onClick={() => setShowEditDialog(true)}
|
||||
>
|
||||
<Icon.Edit className="w-4 h-auto mr-2" /> {t("common.edit")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="w-full px-2 flex flex-row justify-start items-center text-left leading-8 cursor-pointer rounded hover:bg-gray-100 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
|
||||
onClick={() => setShowQRCodeDialog(true)}
|
||||
>
|
||||
<Icon.QrCode className="w-4 h-auto mr-2" /> QR Code
|
||||
</button>
|
||||
<button
|
||||
className="w-full px-2 flex flex-row justify-start items-center text-left leading-8 cursor-pointer rounded hover:bg-gray-100 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
|
||||
onClick={gotoAnalytics}
|
||||
>
|
||||
<Icon.BarChart2 className="w-4 h-auto mr-2" /> {t("analytics.self")}
|
||||
</button>
|
||||
{havePermission && (
|
||||
<button
|
||||
className="w-full px-2 flex flex-row justify-start items-center text-left leading-8 cursor-pointer rounded text-red-600 hover:bg-gray-100 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
|
||||
onClick={() => {
|
||||
handleDeleteShortcutButtonClick(shortcut);
|
||||
}}
|
||||
>
|
||||
<Icon.Trash className="w-4 h-auto mr-2" /> {t("common.delete")}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
></Dropdown>
|
||||
|
||||
{showEditDialog && (
|
||||
<CreateShortcutDialog
|
||||
shortcutId={shortcut.id}
|
||||
onClose={() => setShowEditDialog(false)}
|
||||
onConfirm={() => setShowEditDialog(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showQRCodeDialog && <GenerateQRCodeDialog shortcut={shortcut} onClose={() => setShowQRCodeDialog(false)} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShortcutActionsDropdown;
|
139
frontend/web/src/components/ShortcutCard.tsx
Normal file
139
frontend/web/src/components/ShortcutCard.tsx
Normal file
@ -0,0 +1,139 @@
|
||||
import { Tooltip } from "@mui/joy";
|
||||
import classNames from "classnames";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { absolutifyLink } from "../helpers/utils";
|
||||
import useFaviconStore from "../stores/v1/favicon";
|
||||
import useViewStore from "../stores/v1/view";
|
||||
import Icon from "./Icon";
|
||||
import ShortcutActionsDropdown from "./ShortcutActionsDropdown";
|
||||
import VisibilityIcon from "./VisibilityIcon";
|
||||
|
||||
interface Props {
|
||||
shortcut: Shortcut;
|
||||
}
|
||||
|
||||
const ShortcutView = (props: Props) => {
|
||||
const { shortcut } = props;
|
||||
const { t } = useTranslation();
|
||||
const viewStore = useViewStore();
|
||||
const faviconStore = useFaviconStore();
|
||||
const [favicon, setFavicon] = useState<string | undefined>(undefined);
|
||||
const shortcutLink = absolutifyLink(`/s/${shortcut.name}`);
|
||||
|
||||
useEffect(() => {
|
||||
faviconStore.getOrFetchUrlFavicon(shortcut.link).then((url) => {
|
||||
if (url) {
|
||||
setFavicon(url);
|
||||
}
|
||||
});
|
||||
}, [shortcut.link]);
|
||||
|
||||
const handleCopyButtonClick = () => {
|
||||
copy(shortcutLink);
|
||||
toast.success("Shortcut link copied to clipboard.");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={classNames("group px-4 py-3 w-full flex flex-col justify-start items-start border rounded-lg hover:shadow")}>
|
||||
<div className="w-full flex flex-row justify-between items-center">
|
||||
<div className="w-[calc(100%-16px)] flex flex-row justify-start items-center mr-1 shrink-0">
|
||||
<Link to={`/shortcut/${shortcut.id}`} className={classNames("w-8 h-8 flex justify-center items-center overflow-clip shrink-0")}>
|
||||
{favicon ? (
|
||||
<img className="w-full h-auto rounded-full" src={favicon} decoding="async" loading="lazy" />
|
||||
) : (
|
||||
<Icon.CircleSlash className="w-full h-auto text-gray-400" />
|
||||
)}
|
||||
</Link>
|
||||
<div className="ml-1 w-[calc(100%-24px)] flex flex-col justify-start items-start">
|
||||
<div className="w-full flex flex-row justify-start items-center">
|
||||
<a
|
||||
className={classNames(
|
||||
"max-w-[calc(100%-36px)] flex flex-row px-1 mr-1 justify-start items-center cursor-pointer rounded-md hover:bg-gray-100 hover:shadow"
|
||||
)}
|
||||
target="_blank"
|
||||
href={shortcutLink}
|
||||
>
|
||||
<div className="truncate">
|
||||
<span>{shortcut.title}</span>
|
||||
{shortcut.title ? (
|
||||
<span className="text-gray-400">(s/{shortcut.name})</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-gray-400">s/</span>
|
||||
<span className="truncate">{shortcut.name}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<span className="hidden group-hover:block ml-1 cursor-pointer shrink-0">
|
||||
<Icon.ExternalLink className="w-4 h-auto text-gray-600" />
|
||||
</span>
|
||||
</a>
|
||||
<Tooltip title="Copy" variant="solid" placement="top" arrow>
|
||||
<button
|
||||
className="hidden group-hover:block w-6 h-6 cursor-pointer rounded-md text-gray-500 hover:bg-gray-100 hover:shadow"
|
||||
onClick={() => handleCopyButtonClick()}
|
||||
>
|
||||
<Icon.Clipboard className="w-4 h-auto mx-auto" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<a className="pl-1 pr-4 w-full text-sm truncate text-gray-400 hover:underline" href={shortcut.link} target="_blank">
|
||||
{shortcut.link}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-full pt-2 flex flex-row justify-end items-start">
|
||||
<ShortcutActionsDropdown shortcut={shortcut} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 w-full flex flex-row justify-start items-start gap-2 truncate">
|
||||
{shortcut.tags.map((tag) => {
|
||||
return (
|
||||
<span
|
||||
key={tag}
|
||||
className="max-w-[8rem] truncate text-gray-400 text-sm font-mono leading-4 cursor-pointer hover:text-gray-600"
|
||||
onClick={() => viewStore.setFilter({ tag: tag })}
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{shortcut.tags.length === 0 && <span className="text-gray-400 text-sm font-mono leading-4 italic">No tags</span>}
|
||||
</div>
|
||||
<div className="w-full flex mt-2 gap-2">
|
||||
<Tooltip title="Creator" variant="solid" placement="top" arrow>
|
||||
<div className="w-auto px-2 leading-6 flex flex-row justify-start items-center border rounded-full text-gray-500 text-sm">
|
||||
<Icon.User className="w-4 h-auto mr-1" />
|
||||
<span className="max-w-[4rem] sm:max-w-[6rem] truncate">{shortcut.creator.nickname}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip title={t(`shortcut.visibility.${shortcut.visibility.toLowerCase()}.description`)} variant="solid" placement="top" arrow>
|
||||
<div
|
||||
className="w-auto px-2 leading-6 flex flex-row justify-start items-center border rounded-full cursor-pointer text-gray-500 text-sm"
|
||||
onClick={() => viewStore.setFilter({ visibility: shortcut.visibility })}
|
||||
>
|
||||
<VisibilityIcon className="w-4 h-auto mr-1" visibility={shortcut.visibility} />
|
||||
{t(`shortcut.visibility.${shortcut.visibility.toLowerCase()}.self`)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip title="View count" variant="solid" placement="top" arrow>
|
||||
<Link
|
||||
to={`/shortcut/${shortcut.id}#analytics`}
|
||||
className="w-auto px-2 leading-6 flex flex-row justify-start items-center border rounded-full cursor-pointer text-gray-500 text-sm"
|
||||
>
|
||||
<Icon.BarChart2 className="w-4 h-auto mr-1" />
|
||||
{shortcut.view} visits
|
||||
</Link>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShortcutView;
|
79
frontend/web/src/components/ShortcutView.tsx
Normal file
79
frontend/web/src/components/ShortcutView.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
import classNames from "classnames";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { absolutifyLink } from "../helpers/utils";
|
||||
import useFaviconStore from "../stores/v1/favicon";
|
||||
import Icon from "./Icon";
|
||||
import ShortcutActionsDropdown from "./ShortcutActionsDropdown";
|
||||
|
||||
interface Props {
|
||||
shortcut: Shortcut;
|
||||
}
|
||||
|
||||
const ShortcutView = (props: Props) => {
|
||||
const { shortcut } = props;
|
||||
const faviconStore = useFaviconStore();
|
||||
const [favicon, setFavicon] = useState<string | undefined>(undefined);
|
||||
const shortcutLink = absolutifyLink(`/s/${shortcut.name}`);
|
||||
|
||||
useEffect(() => {
|
||||
faviconStore.getOrFetchUrlFavicon(shortcut.link).then((url) => {
|
||||
if (url) {
|
||||
setFavicon(url);
|
||||
}
|
||||
});
|
||||
}, [shortcut.link]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={classNames(
|
||||
"group w-full px-3 py-2 flex flex-col justify-start items-start border rounded-lg hover:bg-gray-100 hover:shadow"
|
||||
)}
|
||||
>
|
||||
<div className="w-full flex flex-row justify-between items-center">
|
||||
<div className="w-[calc(100%-16px)] flex flex-row justify-start items-center mr-1 shrink-0">
|
||||
<Link to={`/shortcut/${shortcut.id}`} className={classNames("w-5 h-5 flex justify-center items-center overflow-clip shrink-0")}>
|
||||
{favicon ? (
|
||||
<img className="w-full h-auto rounded-full" src={favicon} decoding="async" loading="lazy" />
|
||||
) : (
|
||||
<Icon.CircleSlash className="w-full h-auto text-gray-400" />
|
||||
)}
|
||||
</Link>
|
||||
<div className="ml-1 w-[calc(100%-20px)] flex flex-col justify-start items-start">
|
||||
<div className="w-full flex flex-row justify-start items-center">
|
||||
<a
|
||||
className={classNames(
|
||||
"max-w-full flex flex-row px-1 mr-1 justify-start items-center cursor-pointer rounded-md hover:underline"
|
||||
)}
|
||||
href={shortcutLink}
|
||||
target="_blank"
|
||||
>
|
||||
<div className="truncate">
|
||||
<span>{shortcut.title}</span>
|
||||
{shortcut.title ? (
|
||||
<span className="text-gray-400">(s/{shortcut.name})</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-gray-400">s/</span>
|
||||
<span className="truncate">{shortcut.name}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<span className="hidden group-hover:block ml-1 cursor-pointer shrink-0">
|
||||
<Icon.ExternalLink className="w-4 h-auto text-gray-600" />
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row justify-end items-center">
|
||||
<ShortcutActionsDropdown shortcut={shortcut} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShortcutView;
|
30
frontend/web/src/components/ShortcutsContainer.tsx
Normal file
30
frontend/web/src/components/ShortcutsContainer.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import classNames from "classnames";
|
||||
import useViewStore from "../stores/v1/view";
|
||||
import ShortcutCard from "./ShortcutCard";
|
||||
import ShortcutView from "./ShortcutView";
|
||||
|
||||
interface Props {
|
||||
shortcutList: Shortcut[];
|
||||
}
|
||||
|
||||
const ShortcutsContainer: React.FC<Props> = (props: Props) => {
|
||||
const { shortcutList } = props;
|
||||
const viewStore = useViewStore();
|
||||
const displayStyle = viewStore.displayStyle || "full";
|
||||
const ShortcutItemView = viewStore.displayStyle === "compact" ? ShortcutView : ShortcutCard;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
"w-full grid grid-cols-1 gap-y-2 sm:gap-2",
|
||||
displayStyle === "full" ? "sm:grid-cols-2" : "grid-cols-2 sm:grid-cols-4 gap-2"
|
||||
)}
|
||||
>
|
||||
{shortcutList.map((shortcut) => {
|
||||
return <ShortcutItemView key={shortcut.id} shortcut={shortcut} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShortcutsContainer;
|
53
frontend/web/src/components/ViewSetting.tsx
Normal file
53
frontend/web/src/components/ViewSetting.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import { Divider, Option, Select, Switch } from "@mui/joy";
|
||||
import useViewStore from "../stores/v1/view";
|
||||
import Icon from "./Icon";
|
||||
import Dropdown from "./common/Dropdown";
|
||||
|
||||
const ViewSetting = () => {
|
||||
const viewStore = useViewStore();
|
||||
const order = viewStore.getOrder();
|
||||
const { field, direction } = order;
|
||||
const displayStyle = viewStore.displayStyle || "full";
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
trigger={
|
||||
<button>
|
||||
<Icon.Settings2 className="w-4 h-auto text-gray-500" />
|
||||
</button>
|
||||
}
|
||||
actionsClassName="!mt-3 !-right-2"
|
||||
actions={
|
||||
<div className="w-52 p-2 gap-2 flex flex-col justify-start items-start" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="w-full flex flex-row justify-between items-center">
|
||||
<span className="text-sm shrink-0 mr-2">Compact mode</span>
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={displayStyle === "compact"}
|
||||
onChange={(event) => viewStore.setDisplayStyle(event.target.checked ? "compact" : "full")}
|
||||
/>
|
||||
</div>
|
||||
<Divider className="!my-1" />
|
||||
<div className="w-full flex flex-row justify-between items-center">
|
||||
<span className="text-sm shrink-0 mr-2">Order by</span>
|
||||
<Select size="sm" value={field} onChange={(_, value) => viewStore.setOrder({ field: value as any })}>
|
||||
<Option value={"name"}>Name</Option>
|
||||
<Option value={"updatedTs"}>CreatedAt</Option>
|
||||
<Option value={"createdTs"}>UpdatedAt</Option>
|
||||
<Option value={"view"}>Visits</Option>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="w-full flex flex-row justify-between items-center">
|
||||
<span className="text-sm shrink-0 mr-2">Direction</span>
|
||||
<Select size="sm" value={direction} onChange={(_, value) => viewStore.setOrder({ direction: value as any })}>
|
||||
<Option value={"asc"}>ASC</Option>
|
||||
<Option value={"desc"}>DESC</Option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
></Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default ViewSetting;
|
20
frontend/web/src/components/VisibilityIcon.tsx
Normal file
20
frontend/web/src/components/VisibilityIcon.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import Icon from "./Icon";
|
||||
|
||||
interface Props {
|
||||
visibility: Visibility;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const VisibilityIcon = (props: Props) => {
|
||||
const { visibility, className } = props;
|
||||
if (visibility === "PRIVATE") {
|
||||
return <Icon.Lock className={className || ""} />;
|
||||
} else if (visibility === "WORKSPACE") {
|
||||
return <Icon.Building2 className={className || ""} />;
|
||||
} else if (visibility === "PUBLIC") {
|
||||
return <Icon.Globe2 className={className || ""} />;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export default VisibilityIcon;
|
65
frontend/web/src/components/common/Dropdown.tsx
Normal file
65
frontend/web/src/components/common/Dropdown.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
import { ReactNode, useEffect, useRef } from "react";
|
||||
import useToggle from "../../hooks/useToggle";
|
||||
import Icon from "../Icon";
|
||||
|
||||
interface Props {
|
||||
trigger?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
className?: string;
|
||||
actionsClassName?: string;
|
||||
}
|
||||
|
||||
const Dropdown: React.FC<Props> = (props: Props) => {
|
||||
const { trigger, actions, className, actionsClassName } = props;
|
||||
const [dropdownStatus, toggleDropdownStatus] = useToggle(false);
|
||||
const dropdownWrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (dropdownStatus) {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (!dropdownWrapperRef.current?.contains(event.target as Node)) {
|
||||
toggleDropdownStatus(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("click", handleClickOutside, {
|
||||
capture: true,
|
||||
});
|
||||
return () => {
|
||||
window.removeEventListener("click", handleClickOutside, {
|
||||
capture: true,
|
||||
});
|
||||
};
|
||||
}
|
||||
}, [dropdownStatus]);
|
||||
|
||||
const handleToggleDropdownStatus = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||
e.stopPropagation();
|
||||
toggleDropdownStatus();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dropdownWrapperRef}
|
||||
className={`relative flex flex-col justify-start items-start select-none ${className ?? ""}`}
|
||||
onClick={handleToggleDropdownStatus}
|
||||
>
|
||||
{trigger ? (
|
||||
trigger
|
||||
) : (
|
||||
<button className="flex flex-row justify-center items-center rounded text-gray-400 cursor-pointer hover:text-gray-500">
|
||||
<Icon.MoreVertical className="w-4 h-auto" />
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
className={`w-auto mt-1 absolute top-full right-0 flex flex-col justify-start items-start bg-white z-1 border p-1 rounded-md shadow ${
|
||||
actionsClassName ?? ""
|
||||
} ${dropdownStatus ? "" : "!hidden"}`}
|
||||
>
|
||||
{actions}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dropdown;
|
141
frontend/web/src/components/setting/AccessTokenSection.tsx
Normal file
141
frontend/web/src/components/setting/AccessTokenSection.tsx
Normal file
@ -0,0 +1,141 @@
|
||||
import { Button, IconButton } from "@mui/joy";
|
||||
import axios from "axios";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { ListUserAccessTokensResponse, UserAccessToken } from "../../../../types/proto/api/v2/user_service_pb";
|
||||
import useUserStore from "../../stores/v1/user";
|
||||
import { showCommonDialog } from "../Alert";
|
||||
import CreateAccessTokenDialog from "../CreateAccessTokenDialog";
|
||||
import Icon from "../Icon";
|
||||
|
||||
const listAccessTokens = async (userId: number) => {
|
||||
const { data } = await axios.get<ListUserAccessTokensResponse>(`/api/v2/users/${userId}/access_tokens`);
|
||||
return data.accessTokens;
|
||||
};
|
||||
|
||||
const AccessTokenSection = () => {
|
||||
const currentUser = useUserStore().getCurrentUser();
|
||||
const [userAccessTokens, setUserAccessTokens] = useState<UserAccessToken[]>([]);
|
||||
const [showCreateDialog, setShowCreateDialog] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
listAccessTokens(currentUser.id).then((accessTokens) => {
|
||||
setUserAccessTokens(accessTokens);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleCreateAccessTokenDialogConfirm = async () => {
|
||||
const accessTokens = await listAccessTokens(currentUser.id);
|
||||
setUserAccessTokens(accessTokens);
|
||||
};
|
||||
|
||||
const copyAccessToken = (accessToken: string) => {
|
||||
copy(accessToken);
|
||||
toast.success("Access token copied to clipboard");
|
||||
};
|
||||
|
||||
const handleDeleteAccessToken = async (accessToken: string) => {
|
||||
showCommonDialog({
|
||||
title: "Delete Access Token",
|
||||
content: `Are you sure to delete access token \`${getFormatedAccessToken(accessToken)}\`? You cannot undo this action.`,
|
||||
style: "danger",
|
||||
onConfirm: async () => {
|
||||
await axios.delete(`/api/v2/users/${currentUser.id}/access_tokens/${accessToken}`);
|
||||
setUserAccessTokens(userAccessTokens.filter((token) => token.accessToken !== accessToken));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const getFormatedAccessToken = (accessToken: string) => {
|
||||
return `${accessToken.slice(0, 4)}****${accessToken.slice(-4)}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full flex flex-col justify-start items-start space-y-4">
|
||||
<div className="w-full">
|
||||
<div className="sm:flex sm:items-center">
|
||||
<div className="sm:flex-auto">
|
||||
<p className="text-base font-semibold leading-6 text-gray-900">Access Tokens</p>
|
||||
<p className="mt-2 text-sm text-gray-700">A list of all access tokens for your account.</p>
|
||||
</div>
|
||||
<div className="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="neutral"
|
||||
onClick={() => {
|
||||
setShowCreateDialog(true);
|
||||
}}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flow-root">
|
||||
<div className="overflow-x-auto">
|
||||
<div className="inline-block min-w-full py-2 align-middle">
|
||||
<table className="min-w-full divide-y divide-gray-300">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">
|
||||
Token
|
||||
</th>
|
||||
<th scope="col" className="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900">
|
||||
Description
|
||||
</th>
|
||||
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">
|
||||
Created At
|
||||
</th>
|
||||
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">
|
||||
Expires At
|
||||
</th>
|
||||
<th scope="col" className="relative py-3.5 pl-3 pr-4">
|
||||
<span className="sr-only">Delete</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{userAccessTokens.map((userAccessToken) => (
|
||||
<tr key={userAccessToken.accessToken}>
|
||||
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-900 flex flex-row justify-start items-center gap-x-1">
|
||||
<span className="font-mono">{getFormatedAccessToken(userAccessToken.accessToken)}</span>
|
||||
<Button color="neutral" variant="plain" size="sm" onClick={() => copyAccessToken(userAccessToken.accessToken)}>
|
||||
<Icon.Clipboard className="w-4 h-auto text-gray-500" />
|
||||
</Button>
|
||||
</td>
|
||||
<td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm text-gray-900">{userAccessToken.description}</td>
|
||||
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{String(userAccessToken.issuedAt)}</td>
|
||||
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||
{String(userAccessToken.expiresAt ?? "Never")}
|
||||
</td>
|
||||
<td className="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm">
|
||||
<IconButton
|
||||
color="danger"
|
||||
variant="plain"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
handleDeleteAccessToken(userAccessToken.accessToken);
|
||||
}}
|
||||
>
|
||||
<Icon.Trash className="w-4 h-auto" />
|
||||
</IconButton>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showCreateDialog && (
|
||||
<CreateAccessTokenDialog onClose={() => setShowCreateDialog(false)} onConfirm={handleCreateAccessTokenDialogConfirm} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccessTokenSection;
|
42
frontend/web/src/components/setting/AccountSection.tsx
Normal file
42
frontend/web/src/components/setting/AccountSection.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import { Button } from "@mui/joy";
|
||||
import { useState } from "react";
|
||||
import useUserStore from "../../stores/v1/user";
|
||||
import ChangePasswordDialog from "../ChangePasswordDialog";
|
||||
import EditUserinfoDialog from "../EditUserinfoDialog";
|
||||
|
||||
const AccountSection: React.FC = () => {
|
||||
const currentUser = useUserStore().getCurrentUser();
|
||||
const [showEditUserinfoDialog, setShowEditUserinfoDialog] = useState<boolean>(false);
|
||||
const [showChangePasswordDialog, setShowChangePasswordDialog] = useState<boolean>(false);
|
||||
const isAdmin = currentUser.role === "ADMIN";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full flex flex-col justify-start items-start gap-y-2">
|
||||
<p className="text-base font-semibold leading-6 text-gray-900">Account</p>
|
||||
<p className="flex flex-row justify-start items-center mt-2">
|
||||
<span className="text-xl">{currentUser.nickname}</span>
|
||||
{isAdmin && <span className="ml-2 bg-blue-600 text-white px-2 leading-6 text-sm rounded-full">Admin</span>}
|
||||
</p>
|
||||
<p className="flex flex-row justify-start items-center">
|
||||
<span className="mr-3 text-gray-500">Email: </span>
|
||||
{currentUser.email}
|
||||
</p>
|
||||
<div className="flex flex-row justify-start items-center gap-2 mt-2">
|
||||
<Button variant="outlined" color="neutral" onClick={() => setShowEditUserinfoDialog(true)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button variant="outlined" color="neutral" onClick={() => setShowChangePasswordDialog(true)}>
|
||||
Change password
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showEditUserinfoDialog && <EditUserinfoDialog onClose={() => setShowEditUserinfoDialog(false)} />}
|
||||
|
||||
{showChangePasswordDialog && <ChangePasswordDialog onClose={() => setShowChangePasswordDialog(false)} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccountSection;
|
120
frontend/web/src/components/setting/MemberSection.tsx
Normal file
120
frontend/web/src/components/setting/MemberSection.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
import { Button, IconButton } from "@mui/joy";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import useUserStore from "../../stores/v1/user";
|
||||
import { showCommonDialog } from "../Alert";
|
||||
import CreateUserDialog from "../CreateUserDialog";
|
||||
import Icon from "../Icon";
|
||||
|
||||
const MemberSection = () => {
|
||||
const userStore = useUserStore();
|
||||
const [showCreateUserDialog, setShowCreateUserDialog] = useState<boolean>(false);
|
||||
const [currentEditingUser, setCurrentEditingUser] = useState<User | undefined>(undefined);
|
||||
const userList = Object.values(userStore.userMapById);
|
||||
|
||||
useEffect(() => {
|
||||
userStore.fetchUserList();
|
||||
}, []);
|
||||
|
||||
const handleCreateUserDialogClose = () => {
|
||||
setShowCreateUserDialog(false);
|
||||
setCurrentEditingUser(undefined);
|
||||
};
|
||||
|
||||
const handleDeleteUser = async (user: User) => {
|
||||
showCommonDialog({
|
||||
title: "Delete User",
|
||||
content: `Are you sure to delete user \`${user.nickname}\`? You cannot undo this action.`,
|
||||
style: "danger",
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await userStore.deleteUser(user.id);
|
||||
toast.success(`User \`${user.nickname}\` deleted successfully`);
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to delete user \`${user.nickname}\`: ${error.response.data.message}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full flex flex-col justify-start items-start space-y-4">
|
||||
<div className="w-full">
|
||||
<div className="sm:flex sm:items-center">
|
||||
<div className="sm:flex-auto">
|
||||
<p className="text-base font-semibold leading-6 text-gray-900">Users</p>
|
||||
<p className="mt-2 text-sm text-gray-700">
|
||||
A list of all the users in your workspace including their nickname, email and role.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="neutral"
|
||||
onClick={() => {
|
||||
setShowCreateUserDialog(true);
|
||||
setCurrentEditingUser(undefined);
|
||||
}}
|
||||
>
|
||||
Add user
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flow-root">
|
||||
<div className="overflow-x-auto">
|
||||
<div className="inline-block min-w-full py-2 align-middle">
|
||||
<table className="min-w-full divide-y divide-gray-300">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" className="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900">
|
||||
Nickname
|
||||
</th>
|
||||
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">
|
||||
Email
|
||||
</th>
|
||||
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">
|
||||
Role
|
||||
</th>
|
||||
<th scope="col" className="relative py-3.5 pl-3 pr-4">
|
||||
<span className="sr-only">Edit</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{userList.map((user) => (
|
||||
<tr key={user.email}>
|
||||
<td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm text-gray-900">{user.nickname}</td>
|
||||
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{user.email}</td>
|
||||
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{user.role}</td>
|
||||
<td className="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm">
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="plain"
|
||||
onClick={() => {
|
||||
setCurrentEditingUser(user);
|
||||
setShowCreateUserDialog(true);
|
||||
}}
|
||||
>
|
||||
<Icon.PenBox className="w-4 h-auto" />
|
||||
</IconButton>
|
||||
<IconButton size="sm" color="danger" variant="plain" onClick={() => handleDeleteUser(user)}>
|
||||
<Icon.Trash className="w-4 h-auto" />
|
||||
</IconButton>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showCreateUserDialog && <CreateUserDialog user={currentEditingUser} onClose={handleCreateUserDialogClose} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemberSection;
|
34
frontend/web/src/components/setting/WorkspaceSection.tsx
Normal file
34
frontend/web/src/components/setting/WorkspaceSection.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { Checkbox } from "@mui/joy";
|
||||
import { useEffect, useState } from "react";
|
||||
import { getWorkspaceProfile, upsertWorkspaceSetting } from "../../helpers/api";
|
||||
|
||||
const WorkspaceSection: React.FC = () => {
|
||||
const [disallowSignUp, setDisallowSignUp] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
getWorkspaceProfile().then(({ data }) => {
|
||||
setDisallowSignUp(data.disallowSignUp);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleDisallowSignUpChange = async (value: boolean) => {
|
||||
await upsertWorkspaceSetting("disallow-signup", JSON.stringify(value));
|
||||
setDisallowSignUp(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col justify-start items-start space-y-4">
|
||||
<p className="text-base font-semibold leading-6 text-gray-900">Workspace settings</p>
|
||||
<div className="w-full flex flex-col justify-start items-start">
|
||||
<Checkbox
|
||||
label="Disable user signup"
|
||||
checked={disallowSignUp}
|
||||
onChange={(event) => handleDisallowSignUpChange(event.target.checked)}
|
||||
/>
|
||||
<p className="mt-2 text-gray-500">Once disabled, other users cannot signup.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkspaceSection;
|
25
frontend/web/src/css/index.css
Normal file
25
frontend/web/src/css/index.css
Normal file
@ -0,0 +1,25 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body,
|
||||
html,
|
||||
#root {
|
||||
@apply text-base w-full h-full;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Noto Sans", "Noto Sans CJK SC", "Microsoft YaHei UI", "Microsoft YaHei",
|
||||
"WenQuanYi Micro Hei", sans-serif, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
|
||||
"Noto Color Emoji";
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Hide scrollbar for IE, Edge and Firefox */
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
}
|
87
frontend/web/src/helpers/api.ts
Normal file
87
frontend/web/src/helpers/api.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import axios from "axios";
|
||||
|
||||
export function getWorkspaceProfile() {
|
||||
return axios.get<WorkspaceProfile>("/api/v1/workspace/profile");
|
||||
}
|
||||
|
||||
export function signin(email: string, password: string) {
|
||||
return axios.post<User>("/api/v1/auth/signin", {
|
||||
email,
|
||||
password,
|
||||
});
|
||||
}
|
||||
|
||||
export function signup(email: string, nickname: string, password: string) {
|
||||
return axios.post<User>("/api/v1/auth/signup", {
|
||||
email,
|
||||
nickname,
|
||||
password,
|
||||
});
|
||||
}
|
||||
|
||||
export function signout() {
|
||||
return axios.post("/api/v1/auth/logout");
|
||||
}
|
||||
|
||||
export function getMyselfUser() {
|
||||
return axios.get<User>("/api/v1/user/me");
|
||||
}
|
||||
|
||||
export function getUserList() {
|
||||
return axios.get<User[]>("/api/v1/user");
|
||||
}
|
||||
|
||||
export function getUserById(id: number) {
|
||||
return axios.get<User>(`/api/v1/user/${id}`);
|
||||
}
|
||||
|
||||
export function createUser(userCreate: UserCreate) {
|
||||
return axios.post<User>("/api/v1/user", userCreate);
|
||||
}
|
||||
|
||||
export function patchUser(userPatch: UserPatch) {
|
||||
return axios.patch<User>(`/api/v1/user/${userPatch.id}`, userPatch);
|
||||
}
|
||||
|
||||
export function deleteUser(userId: UserId) {
|
||||
return axios.delete(`/api/v2/users/${userId}`);
|
||||
}
|
||||
|
||||
export function getShortcutList(shortcutFind?: ShortcutFind) {
|
||||
const queryList = [];
|
||||
if (shortcutFind?.tag) {
|
||||
queryList.push(`tag=${shortcutFind.tag}`);
|
||||
}
|
||||
return axios.get<Shortcut[]>(`/api/v1/shortcut?${queryList.join("&")}`);
|
||||
}
|
||||
|
||||
export function getShortcutById(id: number) {
|
||||
return axios.get<Shortcut>(`/api/v1/shortcut/${id}`);
|
||||
}
|
||||
|
||||
export function createShortcut(shortcutCreate: ShortcutCreate) {
|
||||
return axios.post<Shortcut>("/api/v1/shortcut", shortcutCreate);
|
||||
}
|
||||
|
||||
export function getShortcutAnalytics(shortcutId: ShortcutId) {
|
||||
return axios.get<AnalysisData>(`/api/v1/shortcut/${shortcutId}/analytics`);
|
||||
}
|
||||
|
||||
export function patchShortcut(shortcutPatch: ShortcutPatch) {
|
||||
return axios.patch<Shortcut>(`/api/v1/shortcut/${shortcutPatch.id}`, shortcutPatch);
|
||||
}
|
||||
|
||||
export function deleteShortcutById(shortcutId: ShortcutId) {
|
||||
return axios.delete(`/api/v1/shortcut/${shortcutId}`);
|
||||
}
|
||||
|
||||
export function upsertWorkspaceSetting(key: string, value: string) {
|
||||
return axios.post(`/api/v1/workspace/setting`, {
|
||||
key,
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
export function getUrlFavicon(url: string) {
|
||||
return axios.get<string>(`/api/v1/url/favicon?url=${url}`);
|
||||
}
|
11
frontend/web/src/helpers/utils.ts
Normal file
11
frontend/web/src/helpers/utils.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { isNull, isUndefined } from "lodash-es";
|
||||
|
||||
export const isNullorUndefined = (value: any) => {
|
||||
return isNull(value) || isUndefined(value);
|
||||
};
|
||||
|
||||
export function absolutifyLink(rel: string): string {
|
||||
const anchor = document.createElement("a");
|
||||
anchor.setAttribute("href", rel);
|
||||
return anchor.href;
|
||||
}
|
35
frontend/web/src/hooks/useLoading.ts
Normal file
35
frontend/web/src/hooks/useLoading.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { useState } from "react";
|
||||
|
||||
const useLoading = (initialState = true) => {
|
||||
const [state, setState] = useState({ isLoading: initialState, isFailed: false, isSucceed: false });
|
||||
|
||||
return {
|
||||
...state,
|
||||
setLoading: () => {
|
||||
setState({
|
||||
...state,
|
||||
isLoading: true,
|
||||
isFailed: false,
|
||||
isSucceed: false,
|
||||
});
|
||||
},
|
||||
setFinish: () => {
|
||||
setState({
|
||||
...state,
|
||||
isLoading: false,
|
||||
isFailed: false,
|
||||
isSucceed: true,
|
||||
});
|
||||
},
|
||||
setError: () => {
|
||||
setState({
|
||||
...state,
|
||||
isLoading: false,
|
||||
isFailed: true,
|
||||
isSucceed: false,
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default useLoading;
|
21
frontend/web/src/hooks/useToggle.ts
Normal file
21
frontend/web/src/hooks/useToggle.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
// Parameter is the boolean, with default "false" value
|
||||
const useToggle = (initialState = false): [boolean, (nextState?: boolean) => void] => {
|
||||
// Initialize the state
|
||||
const [state, setState] = useState(initialState);
|
||||
|
||||
// Define and memorize toggler function in case we pass down the comopnent,
|
||||
// This function change the boolean value to it's opposite value
|
||||
const toggle = useCallback((nextState?: boolean) => {
|
||||
if (nextState !== undefined) {
|
||||
setState(nextState);
|
||||
} else {
|
||||
setState((state) => !state);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return [state, toggle];
|
||||
};
|
||||
|
||||
export default useToggle;
|
15
frontend/web/src/i18n.ts
Normal file
15
frontend/web/src/i18n.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import i18n from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import en from "../../locales/en.json";
|
||||
|
||||
i18n.use(initReactI18next).init({
|
||||
resources: {
|
||||
en: {
|
||||
translation: en,
|
||||
},
|
||||
},
|
||||
lng: "en",
|
||||
fallbackLng: "en",
|
||||
});
|
||||
|
||||
export default i18n;
|
30
frontend/web/src/layouts/Root.tsx
Normal file
30
frontend/web/src/layouts/Root.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import { useEffect } from "react";
|
||||
import { Outlet, useNavigate } from "react-router-dom";
|
||||
import Header from "../components/Header";
|
||||
import useUserStore from "../stores/v1/user";
|
||||
|
||||
const Root: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const currentUser = useUserStore().getCurrentUser();
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentUser) {
|
||||
navigate("/auth", {
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{currentUser && (
|
||||
<div className="w-full h-auto flex flex-col justify-start items-start">
|
||||
<Header />
|
||||
<Outlet />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Root;
|
38
frontend/web/src/locales/en.json
Normal file
38
frontend/web/src/locales/en.json
Normal file
@ -0,0 +1,38 @@
|
||||
{
|
||||
"common": {
|
||||
"about": "About",
|
||||
"loading": "Loading",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"create": "Create",
|
||||
"download": "Download",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete"
|
||||
},
|
||||
"analytics": {
|
||||
"self": "Analytics",
|
||||
"top-sources": "Top sources",
|
||||
"source": "Source",
|
||||
"visitors": "Visitors",
|
||||
"devices": "Devices",
|
||||
"browser": "Browser",
|
||||
"browsers": "Browsers",
|
||||
"operating-system": "Operating System"
|
||||
},
|
||||
"shortcut": {
|
||||
"visibility": {
|
||||
"private": {
|
||||
"self": "Private",
|
||||
"description": "Only you can access"
|
||||
},
|
||||
"workspace": {
|
||||
"self": "Workspace",
|
||||
"description": "Workspace members can access"
|
||||
},
|
||||
"public": {
|
||||
"self": "Public",
|
||||
"description": "Visible to everyone on the internet"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
38
frontend/web/src/locales/zh.json
Normal file
38
frontend/web/src/locales/zh.json
Normal file
@ -0,0 +1,38 @@
|
||||
{
|
||||
"common": {
|
||||
"about": "关于",
|
||||
"loading": "加载中",
|
||||
"cancel": "取消",
|
||||
"save": "保存",
|
||||
"create": "创建",
|
||||
"download": "下载",
|
||||
"edit": "编辑",
|
||||
"delete": "删除"
|
||||
},
|
||||
"analytics": {
|
||||
"self": "分析",
|
||||
"top-sources": "热门来源",
|
||||
"source": "来源",
|
||||
"visitors": "访客数",
|
||||
"devices": "设备",
|
||||
"browser": "浏览器",
|
||||
"browsers": "浏览器",
|
||||
"operating-system": "操作系统"
|
||||
},
|
||||
"shortcut": {
|
||||
"visibility": {
|
||||
"private": {
|
||||
"self": "私有的",
|
||||
"description": "仅您可以访问"
|
||||
},
|
||||
"workspace": {
|
||||
"self": "工作区",
|
||||
"description": "工作区成员可以访问"
|
||||
},
|
||||
"public": {
|
||||
"self": "公开的",
|
||||
"description": "对任何人可见"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
21
frontend/web/src/main.tsx
Normal file
21
frontend/web/src/main.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import { CssVarsProvider } from "@mui/joy";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
import { Provider } from "react-redux";
|
||||
import { RouterProvider } from "react-router-dom";
|
||||
import "./css/index.css";
|
||||
import "./i18n";
|
||||
import router from "./routers";
|
||||
import store from "./stores";
|
||||
|
||||
const container = document.getElementById("root");
|
||||
const root = createRoot(container as HTMLElement);
|
||||
|
||||
root.render(
|
||||
<Provider store={store}>
|
||||
<CssVarsProvider>
|
||||
<RouterProvider router={router} />
|
||||
<Toaster position="top-center" />
|
||||
</CssVarsProvider>
|
||||
</Provider>
|
||||
);
|
91
frontend/web/src/pages/Home.tsx
Normal file
91
frontend/web/src/pages/Home.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
import { Button, Input } from "@mui/joy";
|
||||
import { useEffect, useState } from "react";
|
||||
import CreateShortcutDialog from "../components/CreateShortcutDialog";
|
||||
import FilterView from "../components/FilterView";
|
||||
import Icon from "../components/Icon";
|
||||
import Navigator from "../components/Navigator";
|
||||
import ShortcutsContainer from "../components/ShortcutsContainer";
|
||||
import ViewSetting from "../components/ViewSetting";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import { shortcutService } from "../services";
|
||||
import { useAppSelector } from "../stores";
|
||||
import useUserStore from "../stores/v1/user";
|
||||
import useViewStore, { getFilteredShortcutList, getOrderedShortcutList } from "../stores/v1/view";
|
||||
|
||||
interface State {
|
||||
showCreateShortcutDialog: boolean;
|
||||
}
|
||||
|
||||
const Home: React.FC = () => {
|
||||
const loadingState = useLoading();
|
||||
const currentUser = useUserStore().getCurrentUser();
|
||||
const viewStore = useViewStore();
|
||||
const { shortcutList } = useAppSelector((state) => state.shortcut);
|
||||
const [state, setState] = useState<State>({
|
||||
showCreateShortcutDialog: false,
|
||||
});
|
||||
const filter = viewStore.filter;
|
||||
const filteredShortcutList = getFilteredShortcutList(shortcutList, filter, currentUser);
|
||||
const orderedShortcutList = getOrderedShortcutList(filteredShortcutList, viewStore.order);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([shortcutService.getMyAllShortcuts()]).finally(() => {
|
||||
loadingState.setFinish();
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setShowCreateShortcutDialog = (show: boolean) => {
|
||||
setState({
|
||||
...state,
|
||||
showCreateShortcutDialog: show,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mx-auto max-w-6xl w-full px-3 md:px-12 pt-4 pb-6 flex flex-col justify-start items-start">
|
||||
<Navigator />
|
||||
<div className="w-full flex flex-row justify-between items-center mb-4">
|
||||
<div className="flex flex-row justify-start items-center">
|
||||
<Button className="hover:shadow" variant="soft" size="sm" onClick={() => setShowCreateShortcutDialog(true)}>
|
||||
<Icon.Plus className="w-5 h-auto" />
|
||||
<span className="hidden sm:block ml-0.5">Create</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-row justify-end items-center">
|
||||
<Input
|
||||
className="w-32 ml-2"
|
||||
type="text"
|
||||
size="sm"
|
||||
placeholder="Search"
|
||||
startDecorator={<Icon.Search className="w-4 h-auto" />}
|
||||
endDecorator={<ViewSetting />}
|
||||
value={filter.search}
|
||||
onChange={(e) => viewStore.setFilter({ search: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<FilterView />
|
||||
{loadingState.isLoading ? (
|
||||
<div className="py-12 w-full flex flex-row justify-center items-center opacity-80">
|
||||
<Icon.Loader className="mr-2 w-5 h-auto animate-spin" />
|
||||
loading
|
||||
</div>
|
||||
) : orderedShortcutList.length === 0 ? (
|
||||
<div className="py-16 w-full flex flex-col justify-center items-center">
|
||||
<Icon.PackageOpen className="w-16 h-auto text-gray-400" />
|
||||
<p className="mt-4">No shortcuts found.</p>
|
||||
</div>
|
||||
) : (
|
||||
<ShortcutsContainer shortcutList={orderedShortcutList} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{state.showCreateShortcutDialog && (
|
||||
<CreateShortcutDialog onClose={() => setShowCreateShortcutDialog(false)} onConfirm={() => setShowCreateShortcutDialog(false)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
25
frontend/web/src/pages/Setting.tsx
Normal file
25
frontend/web/src/pages/Setting.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import AccessTokenSection from "../components/setting/AccessTokenSection";
|
||||
import AccountSection from "../components/setting/AccountSection";
|
||||
import MemberSection from "../components/setting/MemberSection";
|
||||
import WorkspaceSection from "../components/setting/WorkspaceSection";
|
||||
import useUserStore from "../stores/v1/user";
|
||||
|
||||
const Setting: React.FC = () => {
|
||||
const currentUser = useUserStore().getCurrentUser();
|
||||
const isAdmin = currentUser.role === "ADMIN";
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl w-full px-3 md:px-12 py-6 flex flex-col justify-start items-start gap-y-12">
|
||||
<AccountSection />
|
||||
<AccessTokenSection />
|
||||
{isAdmin && (
|
||||
<>
|
||||
<MemberSection />
|
||||
<WorkspaceSection />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Setting;
|
203
frontend/web/src/pages/ShortcutDetail.tsx
Normal file
203
frontend/web/src/pages/ShortcutDetail.tsx
Normal file
@ -0,0 +1,203 @@
|
||||
import { Tooltip } from "@mui/joy";
|
||||
import classNames from "classnames";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLoaderData, useNavigate } from "react-router-dom";
|
||||
import { showCommonDialog } from "../components/Alert";
|
||||
import AnalyticsView from "../components/AnalyticsView";
|
||||
import CreateShortcutDialog from "../components/CreateShortcutDialog";
|
||||
import GenerateQRCodeDialog from "../components/GenerateQRCodeDialog";
|
||||
import Icon from "../components/Icon";
|
||||
import VisibilityIcon from "../components/VisibilityIcon";
|
||||
import Dropdown from "../components/common/Dropdown";
|
||||
import { absolutifyLink } from "../helpers/utils";
|
||||
import { shortcutService } from "../services";
|
||||
import useFaviconStore from "../stores/v1/favicon";
|
||||
import useUserStore from "../stores/v1/user";
|
||||
|
||||
interface State {
|
||||
showEditModal: boolean;
|
||||
}
|
||||
|
||||
const ShortcutDetail = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const shortcutId = (useLoaderData() as Shortcut).id;
|
||||
const shortcut = shortcutService.getShortcutById(shortcutId) as Shortcut;
|
||||
const currentUser = useUserStore().getCurrentUser();
|
||||
const faviconStore = useFaviconStore();
|
||||
const [state, setState] = useState<State>({
|
||||
showEditModal: false,
|
||||
});
|
||||
const [favicon, setFavicon] = useState<string | undefined>(undefined);
|
||||
const [showQRCodeDialog, setShowQRCodeDialog] = useState<boolean>(false);
|
||||
const havePermission = currentUser.role === "ADMIN" || shortcut.creatorId === currentUser.id;
|
||||
const shortcutLink = absolutifyLink(`/s/${shortcut.name}`);
|
||||
|
||||
useEffect(() => {
|
||||
faviconStore.getOrFetchUrlFavicon(shortcut.link).then((url) => {
|
||||
if (url) {
|
||||
setFavicon(url);
|
||||
}
|
||||
});
|
||||
}, [shortcut.link]);
|
||||
|
||||
const handleCopyButtonClick = () => {
|
||||
copy(shortcutLink);
|
||||
toast.success("Shortcut link copied to clipboard.");
|
||||
};
|
||||
|
||||
const handleDeleteShortcutButtonClick = (shortcut: Shortcut) => {
|
||||
showCommonDialog({
|
||||
title: "Delete Shortcut",
|
||||
content: `Are you sure to delete shortcut \`${shortcut.name}\`? You cannot undo this action.`,
|
||||
style: "danger",
|
||||
onConfirm: async () => {
|
||||
await shortcutService.deleteShortcutById(shortcut.id);
|
||||
navigate("/", {
|
||||
replace: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mx-auto max-w-6xl w-full px-3 md:px-12 pt-4 pb-6 flex flex-col justify-start items-start">
|
||||
<div className="mt-8 w-12 h-12 flex justify-center items-center overflow-clip">
|
||||
{favicon ? (
|
||||
<img className="w-full h-auto rounded-full" src={favicon} decoding="async" loading="lazy" />
|
||||
) : (
|
||||
<Icon.CircleSlash className="w-full h-auto text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
<a
|
||||
className={classNames(
|
||||
"group max-w-full flex flex-row px-1 mr-1 justify-start items-center cursor-pointer rounded-md hover:underline"
|
||||
)}
|
||||
href={shortcutLink}
|
||||
target="_blank"
|
||||
>
|
||||
<div className="truncate text-3xl">
|
||||
<span>{shortcut.title}</span>
|
||||
{shortcut.title ? (
|
||||
<span className="text-gray-400">(s/{shortcut.name})</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-gray-400">s/</span>
|
||||
<span className="truncate">{shortcut.name}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<span className="hidden group-hover:block ml-1 cursor-pointer shrink-0">
|
||||
<Icon.ExternalLink className="w-6 h-auto text-gray-600" />
|
||||
</span>
|
||||
</a>
|
||||
<div className="mt-2 w-full flex flex-row justify-normal items-center space-x-2">
|
||||
<Tooltip title="Copy" variant="solid" placement="top" arrow>
|
||||
<button
|
||||
className="w-8 h-8 cursor-pointer border rounded-full text-gray-500 hover:bg-gray-100 hover:shadow"
|
||||
onClick={() => handleCopyButtonClick()}
|
||||
>
|
||||
<Icon.Clipboard className="w-4 h-auto mx-auto" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip title="QR Code" variant="solid" placement="top" arrow>
|
||||
<button
|
||||
className="w-8 h-8 cursor-pointer border rounded-full text-gray-500 hover:bg-gray-100 hover:shadow"
|
||||
onClick={() => setShowQRCodeDialog(true)}
|
||||
>
|
||||
<Icon.QrCode className="w-4 h-auto mx-auto" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{havePermission && (
|
||||
<Dropdown
|
||||
className="w-8 h-8 flex justify-center items-center border cursor-pointer rounded-full hover:bg-gray-100 hover:shadow"
|
||||
actionsClassName="!w-32 !-right-24"
|
||||
actions={
|
||||
<>
|
||||
<button
|
||||
className="w-full px-2 flex flex-row justify-start items-center text-left leading-8 cursor-pointer rounded hover:bg-gray-100 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
|
||||
onClick={() => {
|
||||
setState({
|
||||
...state,
|
||||
showEditModal: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Icon.Edit className="w-4 h-auto mr-2" /> Edit
|
||||
</button>
|
||||
<button
|
||||
className="w-full px-2 flex flex-row justify-start items-center text-left leading-8 cursor-pointer rounded text-red-600 hover:bg-gray-100 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
|
||||
onClick={() => {
|
||||
handleDeleteShortcutButtonClick(shortcut);
|
||||
}}
|
||||
>
|
||||
<Icon.Trash className="w-4 h-auto mr-2" /> Delete
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
></Dropdown>
|
||||
)}
|
||||
</div>
|
||||
{shortcut.description && <p className="w-full break-all mt-2 text-gray-400 text-sm">{shortcut.description}</p>}
|
||||
<div className="mt-4 ml-1 flex flex-row justify-start items-start flex-wrap gap-2">
|
||||
{shortcut.tags.map((tag) => {
|
||||
return (
|
||||
<span key={tag} className="max-w-[8rem] truncate text-gray-400 text font-mono leading-4">
|
||||
#{tag}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{shortcut.tags.length === 0 && <span className="text-gray-400 text-sm font-mono leading-4 italic">No tags</span>}
|
||||
</div>
|
||||
<div className="w-full flex mt-4 gap-2">
|
||||
<Tooltip title="Creator" variant="solid" placement="top" arrow>
|
||||
<div className="w-auto px-2 leading-6 flex flex-row justify-start items-center border rounded-full text-gray-500 text-sm">
|
||||
<Icon.User className="w-4 h-auto mr-1" />
|
||||
<span className="max-w-[4rem] sm:max-w-[6rem] truncate">{shortcut.creator.nickname}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip title={t(`shortcut.visibility.${shortcut.visibility.toLowerCase()}.description`)} variant="solid" placement="top" arrow>
|
||||
<div className="w-auto px-2 leading-6 flex flex-row justify-start items-center border rounded-full text-gray-500 text-sm">
|
||||
<VisibilityIcon className="w-4 h-auto mr-1" visibility={shortcut.visibility} />
|
||||
{t(`shortcut.visibility.${shortcut.visibility.toLowerCase()}.self`)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip title="View count" variant="solid" placement="top" arrow>
|
||||
<div className="w-auto px-2 leading-6 flex flex-row justify-start items-center border rounded-full text-gray-500 text-sm">
|
||||
<Icon.BarChart2 className="w-4 h-auto mr-1" />
|
||||
{shortcut.view} visits
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex flex-col mt-8">
|
||||
<h3 id="analytics" className="pl-1 font-medium text-lg flex flex-row justify-start items-center">
|
||||
<Icon.BarChart2 className="w-6 h-auto mr-1" />
|
||||
Analytics
|
||||
</h3>
|
||||
<AnalyticsView className="mt-4 w-full grid grid-cols-1 sm:grid-cols-2 gap-2 sm:gap-4" shortcutId={shortcut.id} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showQRCodeDialog && <GenerateQRCodeDialog shortcut={shortcut} onClose={() => setShowQRCodeDialog(false)} />}
|
||||
|
||||
{state.showEditModal && (
|
||||
<CreateShortcutDialog
|
||||
shortcutId={shortcut.id}
|
||||
onClose={() =>
|
||||
setState({
|
||||
...state,
|
||||
showEditModal: false,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShortcutDetail;
|
123
frontend/web/src/pages/SignIn.tsx
Normal file
123
frontend/web/src/pages/SignIn.tsx
Normal file
@ -0,0 +1,123 @@
|
||||
import { Button, Input } from "@mui/joy";
|
||||
import React, { FormEvent, useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import * as api from "../helpers/api";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import { useAppSelector } from "../stores";
|
||||
import useUserStore from "../stores/v1/user";
|
||||
|
||||
const SignIn: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const userStore = useUserStore();
|
||||
const {
|
||||
workspaceProfile: {
|
||||
disallowSignUp,
|
||||
profile: { mode },
|
||||
},
|
||||
} = useAppSelector((state) => state.global);
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const actionBtnLoadingState = useLoading(false);
|
||||
const allowConfirm = email.length > 0 && password.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (userStore.getCurrentUser()) {
|
||||
return navigate("/", {
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (mode === "demo") {
|
||||
setEmail("steven@usememos.com");
|
||||
setPassword("secret");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleEmailInputChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const text = e.target.value as string;
|
||||
setEmail(text);
|
||||
};
|
||||
|
||||
const handlePasswordInputChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const text = e.target.value as string;
|
||||
setPassword(text);
|
||||
};
|
||||
|
||||
const handleSigninBtnClick = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (actionBtnLoadingState.isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
actionBtnLoadingState.setLoading();
|
||||
await api.signin(email, password);
|
||||
const user = await userStore.fetchCurrentUser();
|
||||
if (user) {
|
||||
navigate("/", {
|
||||
replace: true,
|
||||
});
|
||||
} else {
|
||||
toast.error("Signin failed");
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toast.error(error.response.data.message);
|
||||
}
|
||||
actionBtnLoadingState.setFinish();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-row justify-center items-center w-full h-auto mt-12 sm:mt-24 bg-white">
|
||||
<div className="w-80 max-w-full h-full py-4 flex flex-col justify-start items-center">
|
||||
<div className="w-full py-4 grow flex flex-col justify-center items-center">
|
||||
<div className="flex flex-row justify-start items-center w-auto mx-auto gap-y-2 mb-4">
|
||||
<img src="/logo.png" className="w-12 h-auto mr-2 -mt-1" alt="logo" />
|
||||
<span className="text-3xl opacity-80">Slash</span>
|
||||
</div>
|
||||
<form className="w-full mt-6" onSubmit={handleSigninBtnClick}>
|
||||
<div className={`flex flex-col justify-start items-start w-full ${actionBtnLoadingState.isLoading ? "opacity-80" : ""}`}>
|
||||
<div className="w-full flex flex-col mb-2">
|
||||
<span className="leading-8 mb-1 text-gray-600">Email</span>
|
||||
<Input
|
||||
className="w-full py-3"
|
||||
type="email"
|
||||
value={email}
|
||||
placeholder="steven@slash.com"
|
||||
onChange={handleEmailInputChanged}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full flex flex-col mb-2">
|
||||
<span className="leading-8 text-gray-600">Password</span>
|
||||
<Input className="w-full py-3" type="password" value={password} placeholder="····" onChange={handlePasswordInputChanged} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex flex-row justify-end items-center mt-4 space-x-2">
|
||||
<Button
|
||||
className="w-full"
|
||||
type="submit"
|
||||
color="primary"
|
||||
loading={actionBtnLoadingState.isLoading}
|
||||
disabled={actionBtnLoadingState.isLoading || !allowConfirm}
|
||||
onClick={handleSigninBtnClick}
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
{!disallowSignUp && (
|
||||
<p className="w-full mt-4 text-sm">
|
||||
<span>{"Don't have an account yet?"}</span>
|
||||
<Link to="/auth/signup" className="cursor-pointer ml-2 text-blue-600 hover:underline">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignIn;
|
130
frontend/web/src/pages/SignUp.tsx
Normal file
130
frontend/web/src/pages/SignUp.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
import { Button, Input } from "@mui/joy";
|
||||
import React, { FormEvent, useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import * as api from "../helpers/api";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import { globalService } from "../services";
|
||||
import useUserStore from "../stores/v1/user";
|
||||
|
||||
const SignUp: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const userStore = useUserStore();
|
||||
const {
|
||||
workspaceProfile: { disallowSignUp },
|
||||
} = globalService.getState();
|
||||
const [email, setEmail] = useState("");
|
||||
const [nickname, setNickname] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const actionBtnLoadingState = useLoading(false);
|
||||
const allowConfirm = email.length > 0 && nickname.length > 0 && password.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (userStore.getCurrentUser()) {
|
||||
return navigate("/", {
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (disallowSignUp) {
|
||||
return navigate("/auth", {
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleEmailInputChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const text = e.target.value as string;
|
||||
setEmail(text);
|
||||
};
|
||||
|
||||
const handleNicknameInputChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const text = e.target.value as string;
|
||||
setNickname(text);
|
||||
};
|
||||
|
||||
const handlePasswordInputChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const text = e.target.value as string;
|
||||
setPassword(text);
|
||||
};
|
||||
|
||||
const handleSignupBtnClick = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (actionBtnLoadingState.isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
actionBtnLoadingState.setLoading();
|
||||
await api.signup(email, nickname, password);
|
||||
const user = await userStore.fetchCurrentUser();
|
||||
if (user) {
|
||||
navigate("/", {
|
||||
replace: true,
|
||||
});
|
||||
} else {
|
||||
toast.error("Signup failed");
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toast.error(error.response.data.message);
|
||||
}
|
||||
actionBtnLoadingState.setFinish();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-row justify-center items-center w-full h-auto mt-12 sm:mt-24 bg-white">
|
||||
<div className="w-80 max-w-full h-full py-4 flex flex-col justify-start items-center">
|
||||
<div className="w-full py-4 grow flex flex-col justify-center items-center">
|
||||
<div className="flex flex-row justify-start items-center w-auto mx-auto gap-y-2 mb-4">
|
||||
<img src="/logo.png" className="w-12 h-auto mr-2 -mt-1" alt="logo" />
|
||||
<span className="text-3xl opacity-80">Slash</span>
|
||||
</div>
|
||||
<p className="w-full text-2xl mt-6">Create your account</p>
|
||||
<form className="w-full mt-4" onSubmit={handleSignupBtnClick}>
|
||||
<div className={`flex flex-col justify-start items-start w-full ${actionBtnLoadingState.isLoading ? "opacity-80" : ""}`}>
|
||||
<div className="w-full flex flex-col mb-2">
|
||||
<span className="leading-8 mb-1 text-gray-600">Email</span>
|
||||
<Input
|
||||
className="w-full py-3"
|
||||
type="email"
|
||||
value={email}
|
||||
placeholder="steven@slash.com"
|
||||
onChange={handleEmailInputChanged}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full flex flex-col mb-2">
|
||||
<span className="leading-8 text-gray-600">Nickname</span>
|
||||
<Input className="w-full py-3" type="text" value={nickname} placeholder="steven" onChange={handleNicknameInputChanged} />
|
||||
</div>
|
||||
<div className="w-full flex flex-col mb-2">
|
||||
<span className="leading-8 text-gray-600">Password</span>
|
||||
<Input className="w-full py-3" type="password" value={password} placeholder="····" onChange={handlePasswordInputChanged} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex flex-row justify-end items-center mt-4 space-x-2">
|
||||
<Button
|
||||
className="w-full"
|
||||
type="submit"
|
||||
color="primary"
|
||||
loading={actionBtnLoadingState.isLoading}
|
||||
disabled={actionBtnLoadingState.isLoading || !allowConfirm}
|
||||
onClick={handleSignupBtnClick}
|
||||
>
|
||||
Sign up
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<p className="w-full mt-4 text-sm">
|
||||
<span>{"Already has an account?"}</span>
|
||||
<Link to="/auth" className="cursor-pointer ml-2 text-blue-600 hover:underline">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignUp;
|
50
frontend/web/src/routers/index.tsx
Normal file
50
frontend/web/src/routers/index.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import { createBrowserRouter } from "react-router-dom";
|
||||
import App from "../App";
|
||||
import Root from "../layouts/Root";
|
||||
import Home from "../pages/Home";
|
||||
import Setting from "../pages/Setting";
|
||||
import ShortcutDetail from "../pages/ShortcutDetail";
|
||||
import SignIn from "../pages/SignIn";
|
||||
import SignUp from "../pages/SignUp";
|
||||
import { shortcutService } from "../services";
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: "/",
|
||||
element: <App />,
|
||||
children: [
|
||||
{
|
||||
path: "auth",
|
||||
element: <SignIn />,
|
||||
},
|
||||
{
|
||||
path: "auth/signup",
|
||||
element: <SignUp />,
|
||||
},
|
||||
{
|
||||
path: "",
|
||||
element: <Root />,
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
element: <Home />,
|
||||
},
|
||||
{
|
||||
path: "/shortcut/:shortcutId",
|
||||
element: <ShortcutDetail />,
|
||||
loader: async ({ params }) => {
|
||||
const shortcut = await shortcutService.getOrFetchShortcutById(Number(params.shortcutId));
|
||||
return shortcut;
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/setting",
|
||||
element: <Setting />,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
export default router;
|
20
frontend/web/src/services/globalService.ts
Normal file
20
frontend/web/src/services/globalService.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import * as api from "../helpers/api";
|
||||
import store from "../stores";
|
||||
import { setGlobalState } from "../stores/modules/global";
|
||||
|
||||
const globalService = {
|
||||
getState: () => {
|
||||
return store.getState().global;
|
||||
},
|
||||
|
||||
initialState: async () => {
|
||||
try {
|
||||
const workspaceProfile = (await api.getWorkspaceProfile()).data;
|
||||
store.dispatch(setGlobalState({ workspaceProfile }));
|
||||
} catch (error) {
|
||||
// do nth
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default globalService;
|
4
frontend/web/src/services/index.ts
Normal file
4
frontend/web/src/services/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import globalService from "./globalService";
|
||||
import shortcutService from "./shortcutService";
|
||||
|
||||
export { globalService, shortcutService };
|
71
frontend/web/src/services/shortcutService.ts
Normal file
71
frontend/web/src/services/shortcutService.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import * as api from "../helpers/api";
|
||||
import store from "../stores";
|
||||
import { createShortcut, deleteShortcut, patchShortcut, setShortcuts } from "../stores/modules/shortcut";
|
||||
|
||||
const convertResponseModelShortcut = (shortcut: Shortcut): Shortcut => {
|
||||
return {
|
||||
...shortcut,
|
||||
createdTs: shortcut.createdTs * 1000,
|
||||
updatedTs: shortcut.updatedTs * 1000,
|
||||
};
|
||||
};
|
||||
|
||||
const shortcutService = {
|
||||
getState: () => {
|
||||
return store.getState().shortcut;
|
||||
},
|
||||
|
||||
fetchWorkspaceShortcuts: async () => {
|
||||
const data = (await api.getShortcutList({})).data;
|
||||
const shortcuts = data.map((s) => convertResponseModelShortcut(s));
|
||||
store.dispatch(setShortcuts(shortcuts));
|
||||
return shortcuts;
|
||||
},
|
||||
|
||||
getMyAllShortcuts: async () => {
|
||||
const data = (await api.getShortcutList()).data;
|
||||
const shortcuts = data.map((s) => convertResponseModelShortcut(s));
|
||||
store.dispatch(setShortcuts(shortcuts));
|
||||
},
|
||||
|
||||
getShortcutById: (id: ShortcutId) => {
|
||||
for (const shortcut of shortcutService.getState().shortcutList) {
|
||||
if (shortcut.id === id) {
|
||||
return shortcut;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
getOrFetchShortcutById: async (id: ShortcutId) => {
|
||||
for (const shortcut of shortcutService.getState().shortcutList) {
|
||||
if (shortcut.id === id) {
|
||||
return shortcut;
|
||||
}
|
||||
}
|
||||
|
||||
const data = (await api.getShortcutById(id)).data;
|
||||
const shortcut = convertResponseModelShortcut(data);
|
||||
store.dispatch(createShortcut(shortcut));
|
||||
return shortcut;
|
||||
},
|
||||
|
||||
createShortcut: async (shortcutCreate: ShortcutCreate) => {
|
||||
const data = (await api.createShortcut(shortcutCreate)).data;
|
||||
const shortcut = convertResponseModelShortcut(data);
|
||||
store.dispatch(createShortcut(shortcut));
|
||||
},
|
||||
|
||||
patchShortcut: async (shortcutPatch: ShortcutPatch) => {
|
||||
const data = (await api.patchShortcut(shortcutPatch)).data;
|
||||
const shortcut = convertResponseModelShortcut(data);
|
||||
store.dispatch(patchShortcut(shortcut));
|
||||
},
|
||||
|
||||
deleteShortcutById: async (shortcutId: ShortcutId) => {
|
||||
await api.deleteShortcutById(shortcutId);
|
||||
store.dispatch(deleteShortcut(shortcutId));
|
||||
},
|
||||
};
|
||||
|
||||
export default shortcutService;
|
17
frontend/web/src/stores/index.ts
Normal file
17
frontend/web/src/stores/index.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { configureStore } from "@reduxjs/toolkit";
|
||||
import { TypedUseSelectorHook, useSelector } from "react-redux";
|
||||
import globalReducer from "./modules/global";
|
||||
import shortcutReducer from "./modules/shortcut";
|
||||
|
||||
const store = configureStore({
|
||||
reducer: {
|
||||
global: globalReducer,
|
||||
shortcut: shortcutReducer,
|
||||
},
|
||||
});
|
||||
|
||||
type AppState = ReturnType<typeof store.getState>;
|
||||
|
||||
export const useAppSelector: TypedUseSelectorHook<AppState> = useSelector;
|
||||
|
||||
export default store;
|
19
frontend/web/src/stores/modules/global.ts
Normal file
19
frontend/web/src/stores/modules/global.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||
|
||||
type State = {
|
||||
workspaceProfile: WorkspaceProfile;
|
||||
};
|
||||
|
||||
const globalSlice = createSlice({
|
||||
name: "global",
|
||||
initialState: {} as State,
|
||||
reducers: {
|
||||
setGlobalState: (_, action: PayloadAction<State>) => {
|
||||
return action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { setGlobalState } = globalSlice.actions;
|
||||
|
||||
export default globalSlice.reducer;
|
51
frontend/web/src/stores/modules/shortcut.ts
Normal file
51
frontend/web/src/stores/modules/shortcut.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||
|
||||
interface State {
|
||||
shortcutList: Shortcut[];
|
||||
}
|
||||
|
||||
const shortcutSlice = createSlice({
|
||||
name: "shortcut",
|
||||
initialState: {
|
||||
shortcutList: [],
|
||||
} as State,
|
||||
reducers: {
|
||||
setShortcuts: (state, action: PayloadAction<Shortcut[]>) => {
|
||||
return {
|
||||
...state,
|
||||
shortcutList: action.payload,
|
||||
};
|
||||
},
|
||||
createShortcut: (state, action: PayloadAction<Shortcut>) => {
|
||||
return {
|
||||
...state,
|
||||
shortcutList: state.shortcutList.concat(action.payload).sort((a, b) => b.createdTs - a.createdTs),
|
||||
};
|
||||
},
|
||||
patchShortcut: (state, action: PayloadAction<Partial<Shortcut>>) => {
|
||||
return {
|
||||
...state,
|
||||
shortcutList: state.shortcutList.map((s) => {
|
||||
if (s.id === action.payload.id) {
|
||||
return {
|
||||
...s,
|
||||
...action.payload,
|
||||
};
|
||||
} else {
|
||||
return s;
|
||||
}
|
||||
}),
|
||||
};
|
||||
},
|
||||
deleteShortcut: (state, action: PayloadAction<ShortcutId>) => {
|
||||
return {
|
||||
...state,
|
||||
shortcutList: [...state.shortcutList].filter((shortcut) => shortcut.id !== action.payload),
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { setShortcuts, createShortcut, patchShortcut, deleteShortcut } = shortcutSlice.actions;
|
||||
|
||||
export default shortcutSlice.reducer;
|
41
frontend/web/src/stores/v1/favicon.ts
Normal file
41
frontend/web/src/stores/v1/favicon.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import * as api from "../../helpers/api";
|
||||
|
||||
interface FaviconState {
|
||||
cache: {
|
||||
[key: string]: string;
|
||||
};
|
||||
getOrFetchUrlFavicon: (url: string) => Promise<string>;
|
||||
}
|
||||
|
||||
const useFaviconStore = create<FaviconState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
cache: {},
|
||||
getOrFetchUrlFavicon: async (url: string) => {
|
||||
const cache = get().cache;
|
||||
if (cache[url]) {
|
||||
return cache[url];
|
||||
}
|
||||
|
||||
try {
|
||||
const { data: favicon } = await api.getUrlFavicon(url);
|
||||
if (favicon) {
|
||||
cache[url] = favicon;
|
||||
set(cache);
|
||||
return favicon;
|
||||
}
|
||||
} catch (error) {
|
||||
// do nothing
|
||||
}
|
||||
return "";
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "favicon_cache",
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
export default useFaviconStore;
|
38
frontend/web/src/stores/v1/shortcut.ts
Normal file
38
frontend/web/src/stores/v1/shortcut.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { create } from "zustand";
|
||||
import * as api from "../../helpers/api";
|
||||
|
||||
const convertResponseModelShortcut = (shortcut: Shortcut): Shortcut => {
|
||||
return {
|
||||
...shortcut,
|
||||
createdTs: shortcut.createdTs * 1000,
|
||||
updatedTs: shortcut.updatedTs * 1000,
|
||||
};
|
||||
};
|
||||
|
||||
interface ShortcutState {
|
||||
shortcutMapById: Record<ShortcutId, Shortcut>;
|
||||
getOrFetchShortcutById: (id: ShortcutId) => Promise<Shortcut>;
|
||||
getShortcutById: (id: ShortcutId) => Shortcut;
|
||||
}
|
||||
|
||||
const useShortcutStore = create<ShortcutState>()((set, get) => ({
|
||||
shortcutMapById: {},
|
||||
getOrFetchShortcutById: async (id: ShortcutId) => {
|
||||
const shortcutMap = get().shortcutMapById;
|
||||
if (shortcutMap[id]) {
|
||||
return shortcutMap[id] as Shortcut;
|
||||
}
|
||||
|
||||
const { data } = await api.getShortcutById(id);
|
||||
const shortcut = convertResponseModelShortcut(data);
|
||||
shortcutMap[id] = shortcut;
|
||||
set(shortcutMap);
|
||||
return shortcut;
|
||||
},
|
||||
getShortcutById: (id: ShortcutId) => {
|
||||
const shortcutMap = get().shortcutMapById;
|
||||
return shortcutMap[id] as Shortcut;
|
||||
},
|
||||
}));
|
||||
|
||||
export default useShortcutStore;
|
88
frontend/web/src/stores/v1/user.ts
Normal file
88
frontend/web/src/stores/v1/user.ts
Normal file
@ -0,0 +1,88 @@
|
||||
import { create } from "zustand";
|
||||
import * as api from "../../helpers/api";
|
||||
|
||||
const convertResponseModelUser = (user: User): User => {
|
||||
return {
|
||||
...user,
|
||||
createdTs: user.createdTs * 1000,
|
||||
updatedTs: user.updatedTs * 1000,
|
||||
};
|
||||
};
|
||||
|
||||
interface UserState {
|
||||
userMapById: Record<UserId, User>;
|
||||
currentUserId?: UserId;
|
||||
fetchUserList: () => Promise<User[]>;
|
||||
fetchCurrentUser: () => Promise<User>;
|
||||
getOrFetchUserById: (id: UserId) => Promise<User>;
|
||||
getUserById: (id: UserId) => User;
|
||||
getCurrentUser: () => User;
|
||||
createUser: (userCreate: UserCreate) => Promise<User>;
|
||||
patchUser: (userPatch: UserPatch) => Promise<void>;
|
||||
deleteUser: (id: UserId) => Promise<void>;
|
||||
}
|
||||
|
||||
const useUserStore = create<UserState>()((set, get) => ({
|
||||
userMapById: {},
|
||||
fetchUserList: async () => {
|
||||
const { data: userList } = await api.getUserList();
|
||||
const userMap = get().userMapById;
|
||||
userList.forEach((user) => {
|
||||
userMap[user.id] = convertResponseModelUser(user);
|
||||
});
|
||||
set(userMap);
|
||||
return userList;
|
||||
},
|
||||
fetchCurrentUser: async () => {
|
||||
const { data } = await api.getMyselfUser();
|
||||
const user = convertResponseModelUser(data);
|
||||
const userMap = get().userMapById;
|
||||
userMap[user.id] = user;
|
||||
set({ userMapById: userMap, currentUserId: user.id });
|
||||
return user;
|
||||
},
|
||||
getOrFetchUserById: async (id: UserId) => {
|
||||
const userMap = get().userMapById;
|
||||
if (userMap[id]) {
|
||||
return userMap[id] as User;
|
||||
}
|
||||
|
||||
const { data } = await api.getUserById(id);
|
||||
const user = convertResponseModelUser(data);
|
||||
userMap[id] = user;
|
||||
set(userMap);
|
||||
return user;
|
||||
},
|
||||
createUser: async (userCreate: UserCreate) => {
|
||||
const { data } = await api.createUser(userCreate);
|
||||
const user = convertResponseModelUser(data);
|
||||
const userMap = get().userMapById;
|
||||
userMap[user.id] = user;
|
||||
set(userMap);
|
||||
return user;
|
||||
},
|
||||
patchUser: async (userPatch: UserPatch) => {
|
||||
const { data } = await api.patchUser(userPatch);
|
||||
const user = convertResponseModelUser(data);
|
||||
const userMap = get().userMapById;
|
||||
userMap[user.id] = user;
|
||||
set(userMap);
|
||||
},
|
||||
deleteUser: async (userId: UserId) => {
|
||||
await api.deleteUser(userId);
|
||||
const userMap = get().userMapById;
|
||||
delete userMap[userId];
|
||||
set(userMap);
|
||||
},
|
||||
getUserById: (id: UserId) => {
|
||||
const userMap = get().userMapById;
|
||||
return userMap[id] as User;
|
||||
},
|
||||
getCurrentUser: () => {
|
||||
const userMap = get().userMapById;
|
||||
const currentUserId = get().currentUserId;
|
||||
return userMap[currentUserId as UserId];
|
||||
},
|
||||
}));
|
||||
|
||||
export default useUserStore;
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user