mirror of
https://github.com/aykhans/slash-e.git
synced 2025-07-03 20:21:40 +00:00
chore: update frontend folder
This commit is contained in:
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;
|
Reference in New Issue
Block a user