chore: update frontend folder

This commit is contained in:
Steven
2023-08-23 09:13:42 +08:00
parent 40814a801a
commit f5817c575c
129 changed files with 104 additions and 1648 deletions

View 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;

View 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} />);
};

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View File

@ -0,0 +1,3 @@
import * as Icon from "lucide-react";
export default Icon;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;