mirror of
https://github.com/usememos/memos.git
synced 2025-10-26 22:36:16 +08:00
feat: migrate dialogs
This commit is contained in:
parent
f70138535c
commit
240d89fbca
33 changed files with 1201 additions and 1205 deletions
|
|
@ -34,7 +34,7 @@ service AttachmentService {
|
|||
// GetAttachmentBinary returns a attachment binary by name.
|
||||
rpc GetAttachmentBinary(GetAttachmentBinaryRequest) returns (google.api.HttpBody) {
|
||||
option (google.api.http) = {get: "/file/{name=attachments/*}/{filename}"};
|
||||
option (google.api.method_signature) = "name,filename";
|
||||
option (google.api.method_signature) = "name,filename,thumbnail";
|
||||
}
|
||||
// UpdateAttachment updates a attachment.
|
||||
rpc UpdateAttachment(UpdateAttachmentRequest) returns (Attachment) {
|
||||
|
|
|
|||
|
|
@ -596,14 +596,14 @@ const file_api_v1_attachment_service_proto_rawDesc = "" +
|
|||
"updateMask\"N\n" +
|
||||
"\x17DeleteAttachmentRequest\x123\n" +
|
||||
"\x04name\x18\x01 \x01(\tB\x1f\xe0A\x02\xfaA\x19\n" +
|
||||
"\x17memos.api.v1/AttachmentR\x04name2\xdb\x06\n" +
|
||||
"\x17memos.api.v1/AttachmentR\x04name2\xe5\x06\n" +
|
||||
"\x11AttachmentService\x12\x89\x01\n" +
|
||||
"\x10CreateAttachment\x12%.memos.api.v1.CreateAttachmentRequest\x1a\x18.memos.api.v1.Attachment\"4\xdaA\n" +
|
||||
"attachment\x82\xd3\xe4\x93\x02!:\n" +
|
||||
"attachment\"\x13/api/v1/attachments\x12{\n" +
|
||||
"\x0fListAttachments\x12$.memos.api.v1.ListAttachmentsRequest\x1a%.memos.api.v1.ListAttachmentsResponse\"\x1b\x82\xd3\xe4\x93\x02\x15\x12\x13/api/v1/attachments\x12z\n" +
|
||||
"\rGetAttachment\x12\".memos.api.v1.GetAttachmentRequest\x1a\x18.memos.api.v1.Attachment\"+\xdaA\x04name\x82\xd3\xe4\x93\x02\x1e\x12\x1c/api/v1/{name=attachments/*}\x12\x94\x01\n" +
|
||||
"\x13GetAttachmentBinary\x12(.memos.api.v1.GetAttachmentBinaryRequest\x1a\x14.google.api.HttpBody\"=\xdaA\rname,filename\x82\xd3\xe4\x93\x02'\x12%/file/{name=attachments/*}/{filename}\x12\xa9\x01\n" +
|
||||
"\rGetAttachment\x12\".memos.api.v1.GetAttachmentRequest\x1a\x18.memos.api.v1.Attachment\"+\xdaA\x04name\x82\xd3\xe4\x93\x02\x1e\x12\x1c/api/v1/{name=attachments/*}\x12\x9e\x01\n" +
|
||||
"\x13GetAttachmentBinary\x12(.memos.api.v1.GetAttachmentBinaryRequest\x1a\x14.google.api.HttpBody\"G\xdaA\x17name,filename,thumbnail\x82\xd3\xe4\x93\x02'\x12%/file/{name=attachments/*}/{filename}\x12\xa9\x01\n" +
|
||||
"\x10UpdateAttachment\x12%.memos.api.v1.UpdateAttachmentRequest\x1a\x18.memos.api.v1.Attachment\"T\xdaA\x16attachment,update_mask\x82\xd3\xe4\x93\x025:\n" +
|
||||
"attachment2'/api/v1/{attachment.name=attachments/*}\x12~\n" +
|
||||
"\x10DeleteAttachment\x12%.memos.api.v1.DeleteAttachmentRequest\x1a\x16.google.protobuf.Empty\"+\xdaA\x04name\x82\xd3\xe4\x93\x02\x1e*\x1c/api/v1/{name=attachments/*}B\xae\x01\n" +
|
||||
|
|
|
|||
|
|
@ -9,11 +9,11 @@ import {
|
|||
FileVideo2Icon,
|
||||
SheetIcon,
|
||||
} from "lucide-react";
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Attachment } from "@/types/proto/api/v1/attachment_service";
|
||||
import { getAttachmentType, getAttachmentUrl } from "@/utils/attachment";
|
||||
import showPreviewImageDialog from "./PreviewImageDialog";
|
||||
import { PreviewImageDialog } from "./PreviewImageDialog";
|
||||
import SquareDiv from "./kit/SquareDiv";
|
||||
|
||||
interface Props {
|
||||
|
|
@ -24,26 +24,52 @@ interface Props {
|
|||
|
||||
const AttachmentIcon = (props: Props) => {
|
||||
const { attachment } = props;
|
||||
const [previewImage, setPreviewImage] = useState<{ open: boolean; urls: string[]; index: number }>({
|
||||
open: false,
|
||||
urls: [],
|
||||
index: 0,
|
||||
});
|
||||
const resourceType = getAttachmentType(attachment);
|
||||
const resourceUrl = getAttachmentUrl(attachment);
|
||||
const attachmentUrl = getAttachmentUrl(attachment);
|
||||
const className = cn("w-full h-auto", props.className);
|
||||
const strokeWidth = props.strokeWidth;
|
||||
|
||||
const previewResource = () => {
|
||||
window.open(resourceUrl);
|
||||
window.open(attachmentUrl);
|
||||
};
|
||||
|
||||
const handleImageClick = () => {
|
||||
setPreviewImage({ open: true, urls: [attachmentUrl], index: 0 });
|
||||
};
|
||||
|
||||
if (resourceType === "image/*") {
|
||||
return (
|
||||
<SquareDiv className={cn(className, "flex items-center justify-center overflow-clip")}>
|
||||
<img
|
||||
className="min-w-full min-h-full object-cover"
|
||||
src={attachment.externalLink ? resourceUrl : resourceUrl + "?thumbnail=true"}
|
||||
onClick={() => showPreviewImageDialog(resourceUrl)}
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
<>
|
||||
<SquareDiv className={cn(className, "flex items-center justify-center overflow-clip")}>
|
||||
<img
|
||||
className="min-w-full min-h-full object-cover"
|
||||
src={attachment.externalLink ? attachmentUrl : attachmentUrl + "?thumbnail=true"}
|
||||
onClick={handleImageClick}
|
||||
onError={(e) => {
|
||||
// Fallback to original image if thumbnail fails
|
||||
const target = e.target as HTMLImageElement;
|
||||
if (target.src.includes("?thumbnail=true")) {
|
||||
console.warn("Thumbnail failed, falling back to original image:", attachmentUrl);
|
||||
target.src = attachmentUrl;
|
||||
}
|
||||
}}
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
/>
|
||||
</SquareDiv>
|
||||
|
||||
<PreviewImageDialog
|
||||
open={previewImage.open}
|
||||
onOpenChange={(open) => setPreviewImage((prev) => ({ ...prev, open }))}
|
||||
imgUrls={previewImage.urls}
|
||||
initialIndex={previewImage.index}
|
||||
/>
|
||||
</SquareDiv>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,19 +1,21 @@
|
|||
import { XIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { userStore } from "@/store/v2";
|
||||
import { User } from "@/types/proto/api/v1/user_service";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { generateDialog } from "./Dialog";
|
||||
|
||||
interface Props extends DialogProps {
|
||||
user: User;
|
||||
interface ChangeMemberPasswordDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
user?: User;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
const ChangeMemberPasswordDialog: React.FC<Props> = (props: Props) => {
|
||||
const { user, destroy } = props;
|
||||
export function ChangeMemberPasswordDialog({ open, onOpenChange, user, onSuccess }: ChangeMemberPasswordDialogProps) {
|
||||
const t = useTranslate();
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [newPasswordAgain, setNewPasswordAgain] = useState("");
|
||||
|
|
@ -23,7 +25,7 @@ const ChangeMemberPasswordDialog: React.FC<Props> = (props: Props) => {
|
|||
}, []);
|
||||
|
||||
const handleCloseBtnClick = () => {
|
||||
destroy();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleNewPasswordChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
|
|
@ -37,6 +39,8 @@ const ChangeMemberPasswordDialog: React.FC<Props> = (props: Props) => {
|
|||
};
|
||||
|
||||
const handleSaveBtnClick = async () => {
|
||||
if (!user) return;
|
||||
|
||||
if (newPassword === "" || newPasswordAgain === "") {
|
||||
toast.error(t("message.fill-all"));
|
||||
return;
|
||||
|
|
@ -57,62 +61,55 @@ const ChangeMemberPasswordDialog: React.FC<Props> = (props: Props) => {
|
|||
["password"],
|
||||
);
|
||||
toast(t("message.password-changed"));
|
||||
handleCloseBtnClick();
|
||||
onSuccess?.();
|
||||
onOpenChange(false);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toast.error(error.details);
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div className="max-w-full shadow flex flex-col justify-start items-start bg-card text-card-foreground p-4 rounded-lg">
|
||||
<div className="flex flex-row justify-between items-center mb-4 gap-2 w-full">
|
||||
<p>
|
||||
{t("setting.account-section.change-password")} ({user.displayName})
|
||||
</p>
|
||||
<Button variant="ghost" onClick={handleCloseBtnClick}>
|
||||
<XIcon className="w-5 h-auto" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col justify-start items-start w-80!">
|
||||
<p className="text-sm mb-1">{t("auth.new-password")}</p>
|
||||
<Input
|
||||
className="w-full"
|
||||
type="password"
|
||||
placeholder={t("auth.new-password")}
|
||||
value={newPassword}
|
||||
onChange={handleNewPasswordChanged}
|
||||
/>
|
||||
<p className="text-sm mb-1 mt-2">{t("auth.repeat-new-password")}</p>
|
||||
<Input
|
||||
className="w-full"
|
||||
type="password"
|
||||
placeholder={t("auth.repeat-new-password")}
|
||||
value={newPasswordAgain}
|
||||
onChange={handleNewPasswordAgainChanged}
|
||||
/>
|
||||
<div className="flex flex-row justify-end items-center mt-4 w-full gap-x-2">
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("setting.account-section.change-password")} ({user.displayName})
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="newPassword">{t("auth.new-password")}</Label>
|
||||
<Input
|
||||
id="newPassword"
|
||||
type="password"
|
||||
placeholder={t("auth.new-password")}
|
||||
value={newPassword}
|
||||
onChange={handleNewPasswordChanged}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="newPasswordAgain">{t("auth.repeat-new-password")}</Label>
|
||||
<Input
|
||||
id="newPasswordAgain"
|
||||
type="password"
|
||||
placeholder={t("auth.repeat-new-password")}
|
||||
value={newPasswordAgain}
|
||||
onChange={handleNewPasswordAgainChanged}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={handleCloseBtnClick}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button color="primary" onClick={handleSaveBtnClick}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function showChangeMemberPasswordDialog(user: User) {
|
||||
generateDialog(
|
||||
{
|
||||
className: "change-member-password-dialog",
|
||||
dialogName: "change-member-password-dialog",
|
||||
},
|
||||
ChangeMemberPasswordDialog,
|
||||
{ user },
|
||||
<Button onClick={handleSaveBtnClick}>{t("common.save")}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default showChangeMemberPasswordDialog;
|
||||
export default ChangeMemberPasswordDialog;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { XIcon } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
|
|
@ -9,10 +9,11 @@ import { userServiceClient } from "@/grpcweb";
|
|||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import useLoading from "@/hooks/useLoading";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { generateDialog } from "./Dialog";
|
||||
|
||||
interface Props extends DialogProps {
|
||||
onConfirm: () => void;
|
||||
interface CreateAccessTokenDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
|
|
@ -20,8 +21,7 @@ interface State {
|
|||
expiration: number;
|
||||
}
|
||||
|
||||
const CreateAccessTokenDialog: React.FC<Props> = (props: Props) => {
|
||||
const { destroy, onConfirm } = props;
|
||||
export function CreateAccessTokenDialog({ open, onOpenChange, onSuccess }: CreateAccessTokenDialogProps) {
|
||||
const t = useTranslate();
|
||||
const currentUser = useCurrentUser();
|
||||
const [state, setState] = useState({
|
||||
|
|
@ -71,6 +71,7 @@ const CreateAccessTokenDialog: React.FC<Props> = (props: Props) => {
|
|||
}
|
||||
|
||||
try {
|
||||
requestState.setLoading();
|
||||
await userServiceClient.createUserAccessToken({
|
||||
parent: currentUser.name,
|
||||
accessToken: {
|
||||
|
|
@ -79,42 +80,39 @@ const CreateAccessTokenDialog: React.FC<Props> = (props: Props) => {
|
|||
},
|
||||
});
|
||||
|
||||
onConfirm();
|
||||
destroy();
|
||||
requestState.setFinish();
|
||||
onSuccess();
|
||||
onOpenChange(false);
|
||||
} catch (error: any) {
|
||||
toast.error(error.details);
|
||||
console.error(error);
|
||||
requestState.setError();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-full shadow flex flex-col justify-start items-start bg-card text-card-foreground p-4 rounded-lg">
|
||||
<div className="flex flex-row justify-between items-center w-full mb-4 gap-2">
|
||||
<p>{t("setting.access-token-section.create-dialog.create-access-token")}</p>
|
||||
<Button variant="ghost" onClick={() => destroy()}>
|
||||
<XIcon className="w-5 h-auto" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col justify-start items-start w-80!">
|
||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||
<span className="mb-2">
|
||||
{t("setting.access-token-section.create-dialog.description")} <span className="text-destructive">*</span>
|
||||
</span>
|
||||
<div className="relative w-full">
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("setting.access-token-section.create-dialog.create-access-token")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="description">
|
||||
{t("setting.access-token-section.create-dialog.description")} <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
className="w-full"
|
||||
id="description"
|
||||
type="text"
|
||||
placeholder={t("setting.access-token-section.create-dialog.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">
|
||||
{t("setting.access-token-section.create-dialog.expiration")} <span className="text-destructive">*</span>
|
||||
</span>
|
||||
<div className="w-full flex flex-row justify-start items-center text-base">
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
{t("setting.access-token-section.create-dialog.expiration")} <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<RadioGroup value={state.expiration.toString()} onValueChange={handleRoleInputChange} className="flex flex-row gap-4">
|
||||
{expirationOptions.map((option) => (
|
||||
<div key={option.value} className="flex items-center space-x-2">
|
||||
|
|
@ -125,30 +123,17 @@ const CreateAccessTokenDialog: React.FC<Props> = (props: Props) => {
|
|||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex flex-row justify-end items-center mt-4 space-x-2">
|
||||
<Button variant="ghost" disabled={requestState.isLoading} onClick={destroy}>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" disabled={requestState.isLoading} onClick={() => onOpenChange(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button disabled={requestState.isLoading} onClick={handleSaveBtnClick}>
|
||||
{t("common.create")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function showCreateAccessTokenDialog(onConfirm: () => void) {
|
||||
generateDialog(
|
||||
{
|
||||
className: "create-access-token-dialog",
|
||||
dialogName: "create-access-token-dialog",
|
||||
},
|
||||
CreateAccessTokenDialog,
|
||||
{
|
||||
onConfirm,
|
||||
},
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default showCreateAccessTokenDialog;
|
||||
export default CreateAccessTokenDialog;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { XIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
|
@ -9,7 +9,6 @@ import { identityProviderServiceClient } from "@/grpcweb";
|
|||
import { absolutifyLink } from "@/helpers/utils";
|
||||
import { FieldMapping, IdentityProvider, IdentityProvider_Type, OAuth2Config } from "@/types/proto/api/v1/idp_service";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { generateDialog } from "./Dialog";
|
||||
|
||||
const templateList: IdentityProvider[] = [
|
||||
{
|
||||
|
|
@ -98,15 +97,16 @@ const templateList: IdentityProvider[] = [
|
|||
},
|
||||
];
|
||||
|
||||
interface Props extends DialogProps {
|
||||
interface CreateIdentityProviderDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
identityProvider?: IdentityProvider;
|
||||
confirmCallback?: () => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
const CreateIdentityProviderDialog: React.FC<Props> = (props: Props) => {
|
||||
export function CreateIdentityProviderDialog({ open, onOpenChange, identityProvider, onSuccess }: CreateIdentityProviderDialogProps) {
|
||||
const t = useTranslate();
|
||||
const identityProviderTypes = [...new Set(templateList.map((t) => t.type))];
|
||||
const { confirmCallback, destroy, identityProvider } = props;
|
||||
const [basicInfo, setBasicInfo] = useState({
|
||||
title: "",
|
||||
identifierFilter: "",
|
||||
|
|
@ -165,7 +165,7 @@ const CreateIdentityProviderDialog: React.FC<Props> = (props: Props) => {
|
|||
}, [selectedTemplate]);
|
||||
|
||||
const handleCloseBtnClick = () => {
|
||||
destroy();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const allowConfirmAction = () => {
|
||||
|
|
@ -230,10 +230,8 @@ const CreateIdentityProviderDialog: React.FC<Props> = (props: Props) => {
|
|||
toast.error(error.details);
|
||||
console.error(error);
|
||||
}
|
||||
if (confirmCallback) {
|
||||
confirmCallback();
|
||||
}
|
||||
destroy();
|
||||
onSuccess?.();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const setPartialOAuth2Config = (state: Partial<OAuth2Config>) => {
|
||||
|
|
@ -244,204 +242,192 @@ const CreateIdentityProviderDialog: React.FC<Props> = (props: Props) => {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-full shadow flex flex-col justify-start items-start bg-card text-card-foreground p-4 rounded-lg">
|
||||
<div className="flex flex-row justify-between items-center mb-4 gap-2 w-full">
|
||||
<p>{t(isCreating ? "setting.sso-section.create-sso" : "setting.sso-section.update-sso")}</p>
|
||||
<Button variant="ghost" onClick={handleCloseBtnClick}>
|
||||
<XIcon className="w-5 h-auto" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col justify-start items-start w-80">
|
||||
{isCreating && (
|
||||
<>
|
||||
<p className="mb-1!">{t("common.type")}</p>
|
||||
<Select value={String(type)} onValueChange={(value) => setType(parseInt(value) as unknown as IdentityProvider_Type)}>
|
||||
<SelectTrigger className="w-full mb-4">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{identityProviderTypes.map((kind) => (
|
||||
<SelectItem key={kind} value={String(kind)}>
|
||||
{IdentityProvider_Type[kind] || kind}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="mb-2 text-sm font-medium">{t("setting.sso-section.template")}</p>
|
||||
<Select value={selectedTemplate} onValueChange={(value) => setSelectedTemplate(value)}>
|
||||
<SelectTrigger className="mb-1 h-auto w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{templateList.map((template) => (
|
||||
<SelectItem key={template.title} value={template.title}>
|
||||
{template.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Separator className="my-2" />
|
||||
</>
|
||||
)}
|
||||
<p className="mb-1 text-sm font-medium">
|
||||
{t("common.name")}
|
||||
<span className="text-destructive">*</span>
|
||||
</p>
|
||||
<Input
|
||||
className="mb-2 w-full"
|
||||
placeholder={t("common.name")}
|
||||
value={basicInfo.title}
|
||||
onChange={(e) =>
|
||||
setBasicInfo({
|
||||
...basicInfo,
|
||||
title: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="mb-1 text-sm font-medium">{t("setting.sso-section.identifier-filter")}</p>
|
||||
<Input
|
||||
className="mb-2 w-full"
|
||||
placeholder={t("setting.sso-section.identifier-filter")}
|
||||
value={basicInfo.identifierFilter}
|
||||
onChange={(e) =>
|
||||
setBasicInfo({
|
||||
...basicInfo,
|
||||
identifierFilter: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Separator className="my-2" />
|
||||
{type === "OAUTH2" && (
|
||||
<>
|
||||
{isCreating && (
|
||||
<p className="border border-border rounded-md p-2 text-sm w-full mb-2 break-all">
|
||||
{t("setting.sso-section.redirect-url")}: {absolutifyLink("/auth/callback")}
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t(isCreating ? "setting.sso-section.create-sso" : "setting.sso-section.update-sso")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col justify-start items-start w-full space-y-4">
|
||||
{isCreating && (
|
||||
<>
|
||||
<p className="mb-1!">{t("common.type")}</p>
|
||||
<Select value={String(type)} onValueChange={(value) => setType(parseInt(value) as unknown as IdentityProvider_Type)}>
|
||||
<SelectTrigger className="w-full mb-4">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{identityProviderTypes.map((kind) => (
|
||||
<SelectItem key={kind} value={String(kind)}>
|
||||
{IdentityProvider_Type[kind] || kind}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="mb-2 text-sm font-medium">{t("setting.sso-section.template")}</p>
|
||||
<Select value={selectedTemplate} onValueChange={(value) => setSelectedTemplate(value)}>
|
||||
<SelectTrigger className="mb-1 h-auto w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{templateList.map((template) => (
|
||||
<SelectItem key={template.title} value={template.title}>
|
||||
{template.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Separator className="my-2" />
|
||||
</>
|
||||
)}
|
||||
<p className="mb-1 text-sm font-medium">
|
||||
{t("common.name")}
|
||||
<span className="text-destructive">*</span>
|
||||
</p>
|
||||
<Input
|
||||
className="mb-2 w-full"
|
||||
placeholder={t("common.name")}
|
||||
value={basicInfo.title}
|
||||
onChange={(e) =>
|
||||
setBasicInfo({
|
||||
...basicInfo,
|
||||
title: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="mb-1 text-sm font-medium">{t("setting.sso-section.identifier-filter")}</p>
|
||||
<Input
|
||||
className="mb-2 w-full"
|
||||
placeholder={t("setting.sso-section.identifier-filter")}
|
||||
value={basicInfo.identifierFilter}
|
||||
onChange={(e) =>
|
||||
setBasicInfo({
|
||||
...basicInfo,
|
||||
identifierFilter: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Separator className="my-2" />
|
||||
{type === "OAUTH2" && (
|
||||
<>
|
||||
{isCreating && (
|
||||
<p className="border border-border rounded-md p-2 text-sm w-full mb-2 break-all">
|
||||
{t("setting.sso-section.redirect-url")}: {absolutifyLink("/auth/callback")}
|
||||
</p>
|
||||
)}
|
||||
<p className="mb-1 text-sm font-medium">
|
||||
{t("setting.sso-section.client-id")}
|
||||
<span className="text-destructive">*</span>
|
||||
</p>
|
||||
)}
|
||||
<p className="mb-1 text-sm font-medium">
|
||||
{t("setting.sso-section.client-id")}
|
||||
<span className="text-destructive">*</span>
|
||||
</p>
|
||||
<Input
|
||||
className="mb-2 w-full"
|
||||
placeholder={t("setting.sso-section.client-id")}
|
||||
value={oauth2Config.clientId}
|
||||
onChange={(e) => setPartialOAuth2Config({ clientId: e.target.value })}
|
||||
/>
|
||||
<p className="mb-1 text-sm font-medium">
|
||||
{t("setting.sso-section.client-secret")}
|
||||
<span className="text-destructive">*</span>
|
||||
</p>
|
||||
<Input
|
||||
className="mb-2 w-full"
|
||||
placeholder={t("setting.sso-section.client-secret")}
|
||||
value={oauth2Config.clientSecret}
|
||||
onChange={(e) => setPartialOAuth2Config({ clientSecret: e.target.value })}
|
||||
/>
|
||||
<p className="mb-1 text-sm font-medium">
|
||||
{t("setting.sso-section.authorization-endpoint")}
|
||||
<span className="text-destructive">*</span>
|
||||
</p>
|
||||
<Input
|
||||
className="mb-2 w-full"
|
||||
placeholder={t("setting.sso-section.authorization-endpoint")}
|
||||
value={oauth2Config.authUrl}
|
||||
onChange={(e) => setPartialOAuth2Config({ authUrl: e.target.value })}
|
||||
/>
|
||||
<p className="mb-1 text-sm font-medium">
|
||||
{t("setting.sso-section.token-endpoint")}
|
||||
<span className="text-destructive">*</span>
|
||||
</p>
|
||||
<Input
|
||||
className="mb-2 w-full"
|
||||
placeholder={t("setting.sso-section.token-endpoint")}
|
||||
value={oauth2Config.tokenUrl}
|
||||
onChange={(e) => setPartialOAuth2Config({ tokenUrl: e.target.value })}
|
||||
/>
|
||||
<p className="mb-1 text-sm font-medium">
|
||||
{t("setting.sso-section.user-endpoint")}
|
||||
<span className="text-destructive">*</span>
|
||||
</p>
|
||||
<Input
|
||||
className="mb-2 w-full"
|
||||
placeholder={t("setting.sso-section.user-endpoint")}
|
||||
value={oauth2Config.userInfoUrl}
|
||||
onChange={(e) => setPartialOAuth2Config({ userInfoUrl: e.target.value })}
|
||||
/>
|
||||
<p className="mb-1 text-sm font-medium">
|
||||
{t("setting.sso-section.scopes")}
|
||||
<span className="text-destructive">*</span>
|
||||
</p>
|
||||
<Input
|
||||
className="mb-2 w-full"
|
||||
placeholder={t("setting.sso-section.scopes")}
|
||||
value={oauth2Scopes}
|
||||
onChange={(e) => setOAuth2Scopes(e.target.value)}
|
||||
/>
|
||||
<Separator className="my-2" />
|
||||
<p className="mb-1 text-sm font-medium">
|
||||
{t("setting.sso-section.identifier")}
|
||||
<span className="text-destructive">*</span>
|
||||
</p>
|
||||
<Input
|
||||
className="mb-2 w-full"
|
||||
placeholder={t("setting.sso-section.identifier")}
|
||||
value={oauth2Config.fieldMapping!.identifier}
|
||||
onChange={(e) =>
|
||||
setPartialOAuth2Config({ fieldMapping: { ...oauth2Config.fieldMapping, identifier: e.target.value } as FieldMapping })
|
||||
}
|
||||
/>
|
||||
<p className="mb-1 text-sm font-medium">{t("setting.sso-section.display-name")}</p>
|
||||
<Input
|
||||
className="mb-2 w-full"
|
||||
placeholder={t("setting.sso-section.display-name")}
|
||||
value={oauth2Config.fieldMapping!.displayName}
|
||||
onChange={(e) =>
|
||||
setPartialOAuth2Config({ fieldMapping: { ...oauth2Config.fieldMapping, displayName: e.target.value } as FieldMapping })
|
||||
}
|
||||
/>
|
||||
<p className="mb-1 text-sm font-medium">{t("common.email")}</p>
|
||||
<Input
|
||||
className="mb-2 w-full"
|
||||
placeholder={t("common.email")}
|
||||
value={oauth2Config.fieldMapping!.email}
|
||||
onChange={(e) =>
|
||||
setPartialOAuth2Config({ fieldMapping: { ...oauth2Config.fieldMapping, email: e.target.value } as FieldMapping })
|
||||
}
|
||||
/>
|
||||
<p className="mb-1 text-sm font-medium">Avatar URL</p>
|
||||
<Input
|
||||
className="mb-2 w-full"
|
||||
placeholder={"Avatar URL"}
|
||||
value={oauth2Config.fieldMapping!.avatarUrl}
|
||||
onChange={(e) =>
|
||||
setPartialOAuth2Config({ fieldMapping: { ...oauth2Config.fieldMapping, avatarUrl: e.target.value } as FieldMapping })
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className="mt-2 w-full flex flex-row justify-end items-center space-x-1">
|
||||
<Input
|
||||
className="mb-2 w-full"
|
||||
placeholder={t("setting.sso-section.client-id")}
|
||||
value={oauth2Config.clientId}
|
||||
onChange={(e) => setPartialOAuth2Config({ clientId: e.target.value })}
|
||||
/>
|
||||
<p className="mb-1 text-sm font-medium">
|
||||
{t("setting.sso-section.client-secret")}
|
||||
<span className="text-destructive">*</span>
|
||||
</p>
|
||||
<Input
|
||||
className="mb-2 w-full"
|
||||
placeholder={t("setting.sso-section.client-secret")}
|
||||
value={oauth2Config.clientSecret}
|
||||
onChange={(e) => setPartialOAuth2Config({ clientSecret: e.target.value })}
|
||||
/>
|
||||
<p className="mb-1 text-sm font-medium">
|
||||
{t("setting.sso-section.authorization-endpoint")}
|
||||
<span className="text-destructive">*</span>
|
||||
</p>
|
||||
<Input
|
||||
className="mb-2 w-full"
|
||||
placeholder={t("setting.sso-section.authorization-endpoint")}
|
||||
value={oauth2Config.authUrl}
|
||||
onChange={(e) => setPartialOAuth2Config({ authUrl: e.target.value })}
|
||||
/>
|
||||
<p className="mb-1 text-sm font-medium">
|
||||
{t("setting.sso-section.token-endpoint")}
|
||||
<span className="text-destructive">*</span>
|
||||
</p>
|
||||
<Input
|
||||
className="mb-2 w-full"
|
||||
placeholder={t("setting.sso-section.token-endpoint")}
|
||||
value={oauth2Config.tokenUrl}
|
||||
onChange={(e) => setPartialOAuth2Config({ tokenUrl: e.target.value })}
|
||||
/>
|
||||
<p className="mb-1 text-sm font-medium">
|
||||
{t("setting.sso-section.user-endpoint")}
|
||||
<span className="text-destructive">*</span>
|
||||
</p>
|
||||
<Input
|
||||
className="mb-2 w-full"
|
||||
placeholder={t("setting.sso-section.user-endpoint")}
|
||||
value={oauth2Config.userInfoUrl}
|
||||
onChange={(e) => setPartialOAuth2Config({ userInfoUrl: e.target.value })}
|
||||
/>
|
||||
<p className="mb-1 text-sm font-medium">
|
||||
{t("setting.sso-section.scopes")}
|
||||
<span className="text-destructive">*</span>
|
||||
</p>
|
||||
<Input
|
||||
className="mb-2 w-full"
|
||||
placeholder={t("setting.sso-section.scopes")}
|
||||
value={oauth2Scopes}
|
||||
onChange={(e) => setOAuth2Scopes(e.target.value)}
|
||||
/>
|
||||
<Separator className="my-2" />
|
||||
<p className="mb-1 text-sm font-medium">
|
||||
{t("setting.sso-section.identifier")}
|
||||
<span className="text-destructive">*</span>
|
||||
</p>
|
||||
<Input
|
||||
className="mb-2 w-full"
|
||||
placeholder={t("setting.sso-section.identifier")}
|
||||
value={oauth2Config.fieldMapping!.identifier}
|
||||
onChange={(e) =>
|
||||
setPartialOAuth2Config({ fieldMapping: { ...oauth2Config.fieldMapping, identifier: e.target.value } as FieldMapping })
|
||||
}
|
||||
/>
|
||||
<p className="mb-1 text-sm font-medium">{t("setting.sso-section.display-name")}</p>
|
||||
<Input
|
||||
className="mb-2 w-full"
|
||||
placeholder={t("setting.sso-section.display-name")}
|
||||
value={oauth2Config.fieldMapping!.displayName}
|
||||
onChange={(e) =>
|
||||
setPartialOAuth2Config({ fieldMapping: { ...oauth2Config.fieldMapping, displayName: e.target.value } as FieldMapping })
|
||||
}
|
||||
/>
|
||||
<p className="mb-1 text-sm font-medium">{t("common.email")}</p>
|
||||
<Input
|
||||
className="mb-2 w-full"
|
||||
placeholder={t("common.email")}
|
||||
value={oauth2Config.fieldMapping!.email}
|
||||
onChange={(e) =>
|
||||
setPartialOAuth2Config({ fieldMapping: { ...oauth2Config.fieldMapping, email: e.target.value } as FieldMapping })
|
||||
}
|
||||
/>
|
||||
<p className="mb-1 text-sm font-medium">Avatar URL</p>
|
||||
<Input
|
||||
className="mb-2 w-full"
|
||||
placeholder={"Avatar URL"}
|
||||
value={oauth2Config.fieldMapping!.avatarUrl}
|
||||
onChange={(e) =>
|
||||
setPartialOAuth2Config({ fieldMapping: { ...oauth2Config.fieldMapping, avatarUrl: e.target.value } as FieldMapping })
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={handleCloseBtnClick}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleConfirmBtnClick} disabled={!allowConfirmAction()}>
|
||||
{t(isCreating ? "common.create" : "common.update")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function showCreateIdentityProviderDialog(identityProvider?: IdentityProvider, confirmCallback?: () => void) {
|
||||
generateDialog(
|
||||
{
|
||||
className: "create-identity-provider-dialog",
|
||||
dialogName: "create-identity-provider-dialog",
|
||||
},
|
||||
CreateIdentityProviderDialog,
|
||||
{ identityProvider, confirmCallback },
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default showCreateIdentityProviderDialog;
|
||||
export default CreateIdentityProviderDialog;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { XIcon } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { shortcutServiceClient } from "@/grpcweb";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
|
|
@ -10,23 +11,24 @@ import useLoading from "@/hooks/useLoading";
|
|||
import { userStore } from "@/store/v2";
|
||||
import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { generateDialog } from "./Dialog";
|
||||
|
||||
interface Props extends DialogProps {
|
||||
interface CreateShortcutDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
shortcut?: Shortcut;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
||||
const { destroy } = props;
|
||||
export function CreateShortcutDialog({ open, onOpenChange, shortcut: initialShortcut, onSuccess }: CreateShortcutDialogProps) {
|
||||
const t = useTranslate();
|
||||
const user = useCurrentUser();
|
||||
const [shortcut, setShortcut] = useState<Shortcut>({
|
||||
name: props.shortcut?.name || "",
|
||||
title: props.shortcut?.title || "",
|
||||
filter: props.shortcut?.filter || "",
|
||||
name: initialShortcut?.name || "",
|
||||
title: initialShortcut?.title || "",
|
||||
filter: initialShortcut?.filter || "",
|
||||
});
|
||||
const requestState = useLoading(false);
|
||||
const isCreating = !props.shortcut;
|
||||
const isCreating = !initialShortcut;
|
||||
|
||||
const onShortcutTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setShortcut({ ...shortcut, title: e.target.value });
|
||||
|
|
@ -43,6 +45,7 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
|||
}
|
||||
|
||||
try {
|
||||
requestState.setLoading();
|
||||
if (isCreating) {
|
||||
await shortcutServiceClient.createShortcut({
|
||||
parent: user.name,
|
||||
|
|
@ -57,7 +60,7 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
|||
await shortcutServiceClient.updateShortcut({
|
||||
shortcut: {
|
||||
...shortcut,
|
||||
name: props.shortcut!.name, // Keep the original resource name
|
||||
name: initialShortcut!.name, // Keep the original resource name
|
||||
},
|
||||
updateMask: ["title", "filter"],
|
||||
});
|
||||
|
|
@ -65,79 +68,74 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
|||
}
|
||||
// Refresh shortcuts.
|
||||
await userStore.fetchShortcuts();
|
||||
destroy();
|
||||
requestState.setFinish();
|
||||
onSuccess?.();
|
||||
onOpenChange(false);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toast.error(error.details);
|
||||
requestState.setError();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-full shadow flex flex-col justify-start items-start bg-card text-card-foreground p-4 rounded-lg">
|
||||
<div className="flex flex-row justify-between items-center mb-4 gap-2 w-full">
|
||||
<p className="title-text">{`${isCreating ? t("common.create") : t("common.edit")} ${t("common.shortcuts")}`}</p>
|
||||
<Button variant="ghost" onClick={() => destroy()}>
|
||||
<XIcon className="w-5 h-auto" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col justify-start items-start max-w-md min-w-72">
|
||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||
<span className="text-sm whitespace-nowrap mb-1">{t("common.title")}</span>
|
||||
<Input className="w-full" type="text" placeholder="" value={shortcut.title} onChange={onShortcutTitleChange} />
|
||||
<span className="text-sm whitespace-nowrap mt-3 mb-1">{t("common.filter")}</span>
|
||||
<Textarea
|
||||
className="w-full"
|
||||
rows={3}
|
||||
placeholder={t("common.shortcut-filter")}
|
||||
value={shortcut.filter}
|
||||
onChange={onShortcutFilterChange}
|
||||
/>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{`${isCreating ? t("common.create") : t("common.edit")} ${t("common.shortcuts")}`}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="title">{t("common.title")}</Label>
|
||||
<Input id="title" type="text" placeholder="" value={shortcut.title} onChange={onShortcutTitleChange} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="filter">{t("common.filter")}</Label>
|
||||
<Textarea
|
||||
id="filter"
|
||||
rows={3}
|
||||
placeholder={t("common.shortcut-filter")}
|
||||
value={shortcut.filter}
|
||||
onChange={onShortcutFilterChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<p className="mb-2">{t("common.learn-more")}:</p>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>
|
||||
<a
|
||||
className="text-primary hover:underline"
|
||||
href="https://www.usememos.com/docs/getting-started/shortcuts"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Docs - Shortcuts
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
className="text-primary hover:underline"
|
||||
href="https://www.usememos.com/docs/getting-started/shortcuts#how-to-write-a-filter"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
How to Write a Filter?
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full opacity-70">
|
||||
<p className="text-sm">{t("common.learn-more")}:</p>
|
||||
<ul className="list-disc list-inside text-sm pl-2 mt-1">
|
||||
<li>
|
||||
<a
|
||||
className="text-sm text-primary hover:underline"
|
||||
href="https://www.usememos.com/docs/getting-started/shortcuts"
|
||||
target="_blank"
|
||||
>
|
||||
Docs - Shortcuts
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
className="text-sm text-primary hover:underline"
|
||||
href="https://www.usememos.com/docs/getting-started/shortcuts#how-to-write-a-filter"
|
||||
target="_blank"
|
||||
>
|
||||
How to Write a Filter?
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="w-full flex flex-row justify-end items-center space-x-2 mt-2">
|
||||
<Button variant="ghost" disabled={requestState.isLoading} onClick={destroy}>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" disabled={requestState.isLoading} onClick={() => onOpenChange(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button color="primary" disabled={requestState.isLoading} onClick={handleConfirm}>
|
||||
<Button disabled={requestState.isLoading} onClick={handleConfirm}>
|
||||
{t("common.confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function showCreateShortcutDialog(props: Pick<Props, "shortcut">) {
|
||||
generateDialog(
|
||||
{
|
||||
className: "create-shortcut-dialog",
|
||||
dialogName: "create-shortcut-dialog",
|
||||
},
|
||||
CreateShortcutDialog,
|
||||
props,
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default showCreateShortcutDialog;
|
||||
export default CreateShortcutDialog;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { XIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
|
|
@ -9,19 +9,19 @@ import { userServiceClient } from "@/grpcweb";
|
|||
import useLoading from "@/hooks/useLoading";
|
||||
import { User, User_Role } from "@/types/proto/api/v1/user_service";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { generateDialog } from "./Dialog";
|
||||
|
||||
interface Props extends DialogProps {
|
||||
interface CreateUserDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
user?: User;
|
||||
confirmCallback?: () => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
const CreateUserDialog: React.FC<Props> = (props: Props) => {
|
||||
const { confirmCallback, destroy } = props;
|
||||
export function CreateUserDialog({ open, onOpenChange, user: initialUser, onSuccess }: CreateUserDialogProps) {
|
||||
const t = useTranslate();
|
||||
const [user, setUser] = useState(User.fromPartial({ ...props.user }));
|
||||
const [user, setUser] = useState(User.fromPartial({ ...initialUser }));
|
||||
const requestState = useLoading(false);
|
||||
const isCreating = !props.user;
|
||||
const isCreating = !initialUser;
|
||||
|
||||
const setPartialUser = (state: Partial<User>) => {
|
||||
setUser({
|
||||
|
|
@ -37,106 +37,99 @@ const CreateUserDialog: React.FC<Props> = (props: Props) => {
|
|||
}
|
||||
|
||||
try {
|
||||
requestState.setLoading();
|
||||
if (isCreating) {
|
||||
await userServiceClient.createUser({ user });
|
||||
toast.success("Create user successfully");
|
||||
} else {
|
||||
const updateMask = [];
|
||||
if (user.username !== props.user?.username) {
|
||||
if (user.username !== initialUser?.username) {
|
||||
updateMask.push("username");
|
||||
}
|
||||
if (user.password) {
|
||||
updateMask.push("password");
|
||||
}
|
||||
if (user.role !== props.user?.role) {
|
||||
if (user.role !== initialUser?.role) {
|
||||
updateMask.push("role");
|
||||
}
|
||||
await userServiceClient.updateUser({ user, updateMask });
|
||||
toast.success("Update user successfully");
|
||||
}
|
||||
requestState.setFinish();
|
||||
onSuccess?.();
|
||||
onOpenChange(false);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toast.error(error.details);
|
||||
requestState.setError();
|
||||
}
|
||||
if (confirmCallback) {
|
||||
confirmCallback();
|
||||
}
|
||||
destroy();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-full shadow flex flex-col justify-start items-start bg-card text-card-foreground p-4 rounded-lg">
|
||||
<div className="flex flex-row justify-between items-center mb-4 gap-2 w-full">
|
||||
<p className="title-text">{`${isCreating ? t("common.create") : t("common.edit")} ${t("common.user")}`}</p>
|
||||
<Button variant="ghost" onClick={() => destroy()}>
|
||||
<XIcon className="w-5 h-auto" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col justify-start items-start max-w-md min-w-72">
|
||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||
<span className="text-sm whitespace-nowrap mb-1">{t("common.username")}</span>
|
||||
<Input
|
||||
className="w-full"
|
||||
type="text"
|
||||
placeholder={t("common.username")}
|
||||
value={user.username}
|
||||
onChange={(e) =>
|
||||
setPartialUser({
|
||||
username: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="text-sm whitespace-nowrap mt-3 mb-1">{t("common.password")}</span>
|
||||
<Input
|
||||
className="w-full"
|
||||
type="password"
|
||||
placeholder={t("common.password")}
|
||||
autoComplete="off"
|
||||
value={user.password}
|
||||
onChange={(e) =>
|
||||
setPartialUser({
|
||||
password: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="text-sm whitespace-nowrap mt-3 mb-1">{t("common.role")}</span>
|
||||
<RadioGroup
|
||||
value={user.role}
|
||||
onValueChange={(value) => setPartialUser({ role: value as User_Role })}
|
||||
className="flex flex-row gap-4"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={User_Role.USER} id="user" />
|
||||
<Label htmlFor="user">{t("setting.member-section.user")}</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={User_Role.ADMIN} id="admin" />
|
||||
<Label htmlFor="admin">{t("setting.member-section.admin")}</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{`${isCreating ? t("common.create") : t("common.edit")} ${t("common.user")}`}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="username">{t("common.username")}</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
placeholder={t("common.username")}
|
||||
value={user.username}
|
||||
onChange={(e) =>
|
||||
setPartialUser({
|
||||
username: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password">{t("common.password")}</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder={t("common.password")}
|
||||
autoComplete="off"
|
||||
value={user.password}
|
||||
onChange={(e) =>
|
||||
setPartialUser({
|
||||
password: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("common.role")}</Label>
|
||||
<RadioGroup
|
||||
value={user.role}
|
||||
onValueChange={(value) => setPartialUser({ role: value as User_Role })}
|
||||
className="flex flex-row gap-4"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={User_Role.USER} id="user" />
|
||||
<Label htmlFor="user">{t("setting.member-section.user")}</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={User_Role.ADMIN} id="admin" />
|
||||
<Label htmlFor="admin">{t("setting.member-section.admin")}</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex flex-row justify-end items-center space-x-2 mt-2">
|
||||
<Button variant="ghost" disabled={requestState.isLoading} onClick={destroy}>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" disabled={requestState.isLoading} onClick={() => onOpenChange(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button color="primary" disabled={requestState.isLoading} onClick={handleConfirm}>
|
||||
<Button disabled={requestState.isLoading} onClick={handleConfirm}>
|
||||
{t("common.confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function showCreateUserDialog(user?: User, confirmCallback?: () => void) {
|
||||
generateDialog(
|
||||
{
|
||||
className: "create-user-dialog",
|
||||
dialogName: "create-user-dialog",
|
||||
},
|
||||
CreateUserDialog,
|
||||
{ user, confirmCallback },
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default showCreateUserDialog;
|
||||
export default CreateUserDialog;
|
||||
|
|
|
|||
|
|
@ -1,17 +1,19 @@
|
|||
import { XIcon } from "lucide-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { webhookServiceClient } from "@/grpcweb";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import useLoading from "@/hooks/useLoading";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { generateDialog } from "./Dialog";
|
||||
|
||||
interface Props extends DialogProps {
|
||||
interface CreateWebhookDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
webhookName?: string;
|
||||
onConfirm: () => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
|
|
@ -19,11 +21,10 @@ interface State {
|
|||
url: string;
|
||||
}
|
||||
|
||||
const CreateWebhookDialog: React.FC<Props> = (props: Props) => {
|
||||
const { webhookName, destroy, onConfirm } = props;
|
||||
export function CreateWebhookDialog({ open, onOpenChange, webhookName, onSuccess }: CreateWebhookDialogProps) {
|
||||
const t = useTranslate();
|
||||
const currentUser = useCurrentUser();
|
||||
const [state, setState] = useState({
|
||||
const [state, setState] = useState<State>({
|
||||
displayName: "",
|
||||
url: "",
|
||||
});
|
||||
|
|
@ -43,7 +44,7 @@ const CreateWebhookDialog: React.FC<Props> = (props: Props) => {
|
|||
});
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
}, [webhookName]);
|
||||
|
||||
const setPartialState = (partialState: Partial<State>) => {
|
||||
setState({
|
||||
|
|
@ -76,6 +77,7 @@ const CreateWebhookDialog: React.FC<Props> = (props: Props) => {
|
|||
}
|
||||
|
||||
try {
|
||||
requestState.setLoading();
|
||||
if (isCreating) {
|
||||
await webhookServiceClient.createWebhook({
|
||||
parent: currentUser.name,
|
||||
|
|
@ -95,46 +97,45 @@ const CreateWebhookDialog: React.FC<Props> = (props: Props) => {
|
|||
});
|
||||
}
|
||||
|
||||
onConfirm();
|
||||
destroy();
|
||||
onSuccess?.();
|
||||
onOpenChange(false);
|
||||
requestState.setFinish();
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toast.error(error.details);
|
||||
requestState.setError();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-full shadow flex flex-col justify-start items-start bg-card text-card-foreground p-4 rounded-lg">
|
||||
<div className="flex flex-row justify-between items-center mb-4 gap-2 w-full">
|
||||
<p className="title-text">
|
||||
{isCreating ? t("setting.webhook-section.create-dialog.create-webhook") : t("setting.webhook-section.create-dialog.edit-webhook")}
|
||||
</p>
|
||||
<Button variant="ghost" onClick={() => destroy()}>
|
||||
<XIcon className="w-5 h-auto" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col justify-start items-start w-80!">
|
||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||
<span className="mb-2">
|
||||
{t("setting.webhook-section.create-dialog.title")} <span className="text-destructive">*</span>
|
||||
</span>
|
||||
<div className="relative w-full">
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isCreating
|
||||
? t("setting.webhook-section.create-dialog.create-webhook")
|
||||
: t("setting.webhook-section.create-dialog.edit-webhook")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="displayName">
|
||||
{t("setting.webhook-section.create-dialog.title")} <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
className="w-full"
|
||||
id="displayName"
|
||||
type="text"
|
||||
placeholder={t("setting.webhook-section.create-dialog.an-easy-to-remember-name")}
|
||||
value={state.displayName}
|
||||
onChange={handleTitleInputChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||
<span className="mb-2">
|
||||
{t("setting.webhook-section.create-dialog.payload-url")} <span className="text-destructive">*</span>
|
||||
</span>
|
||||
<div className="relative w-full">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="url">
|
||||
{t("setting.webhook-section.create-dialog.payload-url")} <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
className="w-full"
|
||||
id="url"
|
||||
type="text"
|
||||
placeholder={t("setting.webhook-section.create-dialog.url-example-post-receive")}
|
||||
value={state.url}
|
||||
|
|
@ -142,30 +143,17 @@ const CreateWebhookDialog: React.FC<Props> = (props: Props) => {
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex flex-row justify-end items-center mt-2 space-x-2">
|
||||
<Button variant="ghost" disabled={requestState.isLoading} onClick={destroy}>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" disabled={requestState.isLoading} onClick={() => onOpenChange(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button color="primary" disabled={requestState.isLoading} onClick={handleSaveBtnClick}>
|
||||
<Button disabled={requestState.isLoading} onClick={handleSaveBtnClick}>
|
||||
{t("common.create")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function showCreateWebhookDialog(onConfirm: () => void) {
|
||||
generateDialog(
|
||||
{
|
||||
className: "create-webhook-dialog",
|
||||
dialogName: "create-webhook-dialog",
|
||||
},
|
||||
CreateWebhookDialog,
|
||||
{
|
||||
onConfirm,
|
||||
},
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default showCreateWebhookDialog;
|
||||
export default CreateWebhookDialog;
|
||||
|
|
|
|||
|
|
@ -1,99 +0,0 @@
|
|||
import { observer } from "mobx-react-lite";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import dialogStore from "@/store/v2/dialog";
|
||||
|
||||
interface DialogConfig {
|
||||
dialogName: string;
|
||||
className?: string;
|
||||
clickSpaceDestroy?: boolean;
|
||||
}
|
||||
|
||||
interface Props extends DialogConfig, DialogProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const BaseDialog = observer((props: Props) => {
|
||||
const { children, className, clickSpaceDestroy, dialogName, destroy } = props;
|
||||
const dialogContainerRef = useRef<HTMLDivElement>(null);
|
||||
const dialogIndex = dialogStore.state.stack.findIndex((item) => item === dialogName);
|
||||
|
||||
useEffect(() => {
|
||||
dialogStore.pushDialog(dialogName);
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.code === "Escape") {
|
||||
if (dialogName === dialogStore.topDialog) {
|
||||
destroy();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.body.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.body.removeEventListener("keydown", handleKeyDown);
|
||||
dialogStore.removeDialog(dialogName);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (dialogIndex > 0 && dialogContainerRef.current) {
|
||||
dialogContainerRef.current.style.marginTop = `${dialogIndex * 16}px`;
|
||||
}
|
||||
}, [dialogIndex]);
|
||||
|
||||
const handleSpaceClicked = () => {
|
||||
if (clickSpaceDestroy) {
|
||||
destroy();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"fixed top-0 left-0 flex flex-col justify-start items-center w-full h-full pt-16 pb-8 px-4 z-50 overflow-x-hidden overflow-y-scroll transition-all hide-scrollbar bg-foreground/60",
|
||||
className,
|
||||
)}
|
||||
onMouseDown={handleSpaceClicked}
|
||||
>
|
||||
<div ref={dialogContainerRef} onMouseDown={(e) => e.stopPropagation()}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export function generateDialog<T extends DialogProps>(
|
||||
config: DialogConfig,
|
||||
DialogComponent: React.FC<T>,
|
||||
props?: Omit<T, "destroy">,
|
||||
): DialogCallback {
|
||||
const tempDiv = document.createElement("div");
|
||||
const dialog = createRoot(tempDiv);
|
||||
document.body.append(tempDiv);
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
const cbs: DialogCallback = {
|
||||
destroy: () => {
|
||||
document.body.style.removeProperty("overflow");
|
||||
dialog.unmount();
|
||||
tempDiv.remove();
|
||||
},
|
||||
};
|
||||
|
||||
const dialogProps = {
|
||||
...props,
|
||||
destroy: cbs.destroy,
|
||||
} as T;
|
||||
|
||||
const Fragment = observer(() => (
|
||||
<BaseDialog destroy={cbs.destroy} clickSpaceDestroy={true} {...config}>
|
||||
<DialogComponent {...dialogProps} />
|
||||
</BaseDialog>
|
||||
));
|
||||
|
||||
dialog.render(<Fragment />);
|
||||
|
||||
return cbs;
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { generateDialog } from "./BaseDialog";
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { Edit3Icon, MoreVerticalIcon, TrashIcon, PlusIcon } from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useState } from "react";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { shortcutServiceClient } from "@/grpcweb";
|
||||
import useAsyncEffect from "@/hooks/useAsyncEffect";
|
||||
|
|
@ -8,7 +9,7 @@ import { userStore } from "@/store/v2";
|
|||
import memoFilterStore from "@/store/v2/memoFilter";
|
||||
import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import showCreateShortcutDialog from "../CreateShortcutDialog";
|
||||
import CreateShortcutDialog from "../CreateShortcutDialog";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/dropdown-menu";
|
||||
|
||||
const emojiRegex = /^(\p{Emoji_Presentation}|\p{Emoji}\uFE0F)$/u;
|
||||
|
|
@ -23,6 +24,8 @@ const getShortcutId = (name: string): string => {
|
|||
const ShortcutsSection = observer(() => {
|
||||
const t = useTranslate();
|
||||
const shortcuts = userStore.state.shortcuts;
|
||||
const [isCreateShortcutDialogOpen, setIsCreateShortcutDialogOpen] = useState(false);
|
||||
const [editingShortcut, setEditingShortcut] = useState<Shortcut | undefined>();
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
await userStore.fetchShortcuts();
|
||||
|
|
@ -36,6 +39,21 @@ const ShortcutsSection = observer(() => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleCreateShortcut = () => {
|
||||
setEditingShortcut(undefined);
|
||||
setIsCreateShortcutDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleEditShortcut = (shortcut: Shortcut) => {
|
||||
setEditingShortcut(shortcut);
|
||||
setIsCreateShortcutDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleShortcutDialogSuccess = () => {
|
||||
setIsCreateShortcutDialogOpen(false);
|
||||
setEditingShortcut(undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col justify-start items-start mt-3 px-1 h-auto shrink-0 flex-nowrap hide-scrollbar">
|
||||
<div className="flex flex-row justify-between items-center w-full gap-1 mb-1 text-sm leading-6 text-muted-foreground select-none">
|
||||
|
|
@ -43,7 +61,7 @@ const ShortcutsSection = observer(() => {
|
|||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PlusIcon className="w-4 h-auto cursor-pointer" onClick={() => showCreateShortcutDialog({})} />
|
||||
<PlusIcon className="w-4 h-auto cursor-pointer" onClick={handleCreateShortcut} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("common.create")}</p>
|
||||
|
|
@ -75,7 +93,7 @@ const ShortcutsSection = observer(() => {
|
|||
<MoreVerticalIcon className="w-4 h-auto shrink-0 text-muted-foreground cursor-pointer hover:text-foreground" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" alignOffset={-12}>
|
||||
<DropdownMenuItem onClick={() => showCreateShortcutDialog({ shortcut })}>
|
||||
<DropdownMenuItem onClick={() => handleEditShortcut(shortcut)}>
|
||||
<Edit3Icon className="w-4 h-auto" />
|
||||
{t("common.edit")}
|
||||
</DropdownMenuItem>
|
||||
|
|
@ -89,6 +107,12 @@ const ShortcutsSection = observer(() => {
|
|||
);
|
||||
})}
|
||||
</div>
|
||||
<CreateShortcutDialog
|
||||
open={isCreateShortcutDialogOpen}
|
||||
onOpenChange={setIsCreateShortcutDialogOpen}
|
||||
shortcut={editingShortcut}
|
||||
onSuccess={handleShortcutDialogSuccess}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
import { Edit3Icon, HashIcon, MoreVerticalIcon, TagsIcon, TrashIcon } from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import useLocalStorage from "react-use/lib/useLocalStorage";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { memoServiceClient } from "@/grpcweb";
|
||||
import { useDialog } from "@/hooks/useDialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { userStore } from "@/store/v2";
|
||||
import memoFilterStore, { MemoFilter } from "@/store/v2/memoFilter";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import showRenameTagDialog from "../RenameTagDialog";
|
||||
import RenameTagDialog from "../RenameTagDialog";
|
||||
import TagTree from "../TagTree";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/dropdown-menu";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||
|
|
@ -20,6 +22,8 @@ interface Props {
|
|||
const TagsSection = observer((props: Props) => {
|
||||
const t = useTranslate();
|
||||
const [treeMode, setTreeMode] = useLocalStorage<boolean>("tag-view-as-tree", false);
|
||||
const renameTagDialog = useDialog();
|
||||
const [selectedTag, setSelectedTag] = useState<string>("");
|
||||
const tags = Object.entries(userStore.state.tagCount)
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.sort((a, b) => b[1] - a[1]);
|
||||
|
|
@ -36,6 +40,16 @@ const TagsSection = observer((props: Props) => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleRenameTag = (tag: string) => {
|
||||
setSelectedTag(tag);
|
||||
renameTagDialog.open();
|
||||
};
|
||||
|
||||
const handleRenameSuccess = () => {
|
||||
// Refresh tags after rename
|
||||
userStore.fetchUsers();
|
||||
};
|
||||
|
||||
const handleDeleteTag = async (tag: string) => {
|
||||
const confirmed = window.confirm(t("tag.delete-confirm"));
|
||||
if (confirmed) {
|
||||
|
|
@ -83,7 +97,7 @@ const TagsSection = observer((props: Props) => {
|
|||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" sideOffset={2}>
|
||||
<DropdownMenuItem onClick={() => showRenameTagDialog({ tag: tag })}>
|
||||
<DropdownMenuItem onClick={() => handleRenameTag(tag)}>
|
||||
<Edit3Icon className="w-4 h-auto" />
|
||||
{t("common.rename")}
|
||||
</DropdownMenuItem>
|
||||
|
|
@ -112,6 +126,14 @@ const TagsSection = observer((props: Props) => {
|
|||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Rename Tag Dialog */}
|
||||
<RenameTagDialog
|
||||
open={renameTagDialog.isOpen}
|
||||
onOpenChange={renameTagDialog.setOpen}
|
||||
tag={selectedTag}
|
||||
onSuccess={handleRenameSuccess}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
import { memo } from "react";
|
||||
import { memo, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Attachment } from "@/types/proto/api/v1/attachment_service";
|
||||
import { getAttachmentType, getAttachmentUrl } from "@/utils/attachment";
|
||||
import MemoAttachment from "./MemoAttachment";
|
||||
import showPreviewImageDialog from "./PreviewImageDialog";
|
||||
import { PreviewImageDialog } from "./PreviewImageDialog";
|
||||
|
||||
const MemoAttachmentListView = ({ attachments = [] }: { attachments: Attachment[] }) => {
|
||||
const [previewImage, setPreviewImage] = useState<{ open: boolean; urls: string[]; index: number }>({
|
||||
open: false,
|
||||
urls: [],
|
||||
index: 0,
|
||||
});
|
||||
const mediaAttachments: Attachment[] = [];
|
||||
const otherAttachments: Attachment[] = [];
|
||||
|
||||
|
|
@ -24,7 +29,7 @@ const MemoAttachmentListView = ({ attachments = [] }: { attachments: Attachment[
|
|||
.filter((attachment) => getAttachmentType(attachment) === "image/*")
|
||||
.map((attachment) => getAttachmentUrl(attachment));
|
||||
const index = imgUrls.findIndex((url) => url === imgUrl);
|
||||
showPreviewImageDialog(imgUrls, index);
|
||||
setPreviewImage({ open: true, urls: imgUrls, index });
|
||||
};
|
||||
|
||||
const MediaCard = ({ attachment, className }: { attachment: Attachment; className?: string }) => {
|
||||
|
|
@ -39,6 +44,14 @@ const MemoAttachmentListView = ({ attachments = [] }: { attachments: Attachment[
|
|||
className,
|
||||
)}
|
||||
src={attachment.externalLink ? attachmentUrl : attachmentUrl + "?thumbnail=true"}
|
||||
onError={(e) => {
|
||||
// Fallback to original image if thumbnail fails
|
||||
const target = e.target as HTMLImageElement;
|
||||
if (target.src.includes("?thumbnail=true")) {
|
||||
console.warn("Thumbnail failed, falling back to original image:", attachmentUrl);
|
||||
target.src = attachmentUrl;
|
||||
}
|
||||
}}
|
||||
onClick={() => handleImageClick(attachmentUrl)}
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
|
|
@ -88,6 +101,13 @@ const MemoAttachmentListView = ({ attachments = [] }: { attachments: Attachment[
|
|||
<>
|
||||
{mediaAttachments.length > 0 && <MediaList attachments={mediaAttachments} />}
|
||||
<OtherList attachments={otherAttachments} />
|
||||
|
||||
<PreviewImageDialog
|
||||
open={previewImage.open}
|
||||
onOpenChange={(open) => setPreviewImage((prev) => ({ ...prev, open }))}
|
||||
imgUrls={previewImage.urls}
|
||||
initialIndex={previewImage.index}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import MemoEditor from "./MemoEditor";
|
|||
import MemoLocationView from "./MemoLocationView";
|
||||
import MemoReactionistView from "./MemoReactionListView";
|
||||
import MemoRelationListView from "./MemoRelationListView";
|
||||
import showPreviewImageDialog from "./PreviewImageDialog";
|
||||
import { PreviewImageDialog } from "./PreviewImageDialog";
|
||||
import ReactionSelector from "./ReactionSelector";
|
||||
import UserAvatar from "./UserAvatar";
|
||||
import VisibilityIcon from "./VisibilityIcon";
|
||||
|
|
@ -46,6 +46,11 @@ const MemoView: React.FC<Props> = observer((props: Props) => {
|
|||
const [showEditor, setShowEditor] = useState<boolean>(false);
|
||||
const [creator, setCreator] = useState(userStore.getUserByName(memo.creator));
|
||||
const [showNSFWContent, setShowNSFWContent] = useState(props.showNsfwContent);
|
||||
const [previewImage, setPreviewImage] = useState<{ open: boolean; urls: string[]; index: number }>({
|
||||
open: false,
|
||||
urls: [],
|
||||
index: 0,
|
||||
});
|
||||
const workspaceMemoRelatedSetting = workspaceStore.state.memoRelatedSetting;
|
||||
const referencedMemos = memo.relations.filter((relation) => relation.type === MemoRelation_Type.REFERENCE);
|
||||
const commentAmount = memo.relations.filter(
|
||||
|
|
@ -80,7 +85,7 @@ const MemoView: React.FC<Props> = observer((props: Props) => {
|
|||
if (targetEl.tagName === "IMG") {
|
||||
const imgUrl = targetEl.getAttribute("src");
|
||||
if (imgUrl) {
|
||||
showPreviewImageDialog([imgUrl], 0);
|
||||
setPreviewImage({ open: true, urls: [imgUrl], index: 0 });
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
|
@ -256,6 +261,13 @@ const MemoView: React.FC<Props> = observer((props: Props) => {
|
|||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<PreviewImageDialog
|
||||
open={previewImage.open}
|
||||
onOpenChange={(open) => setPreviewImage((prev) => ({ ...prev, open }))}
|
||||
imgUrls={previewImage.urls}
|
||||
initialIndex={previewImage.index}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,203 +1,93 @@
|
|||
import { XIcon } from "lucide-react";
|
||||
import { X } from "lucide-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { generateDialog } from "./Dialog";
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
|
||||
const MIN_SCALE = 0.5;
|
||||
const MAX_SCALE = 5;
|
||||
const SCALE_UNIT = 0.2;
|
||||
|
||||
interface Props extends DialogProps {
|
||||
interface PreviewImageDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
imgUrls: string[];
|
||||
initialIndex: number;
|
||||
initialIndex?: number;
|
||||
}
|
||||
|
||||
interface State {
|
||||
scale: number;
|
||||
originX: number;
|
||||
originY: number;
|
||||
}
|
||||
|
||||
const defaultState: State = {
|
||||
scale: 1,
|
||||
originX: -1,
|
||||
originY: -1,
|
||||
};
|
||||
|
||||
const PreviewImageDialog: React.FC<Props> = ({ destroy, imgUrls, initialIndex }: Props) => {
|
||||
export function PreviewImageDialog({ open, onOpenChange, imgUrls, initialIndex = 0 }: PreviewImageDialogProps) {
|
||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||
const [state, setState] = useState<State>(defaultState);
|
||||
let startX = -1;
|
||||
let endX = -1;
|
||||
|
||||
const handleCloseBtnClick = () => {
|
||||
destroyAndResetViewport();
|
||||
};
|
||||
|
||||
const handleTouchStart = (event: React.TouchEvent) => {
|
||||
if (event.touches.length > 1) {
|
||||
// two or more fingers, ignore
|
||||
return;
|
||||
}
|
||||
startX = event.touches[0].clientX;
|
||||
};
|
||||
|
||||
const handleTouchMove = (event: React.TouchEvent) => {
|
||||
if (event.touches.length > 1) {
|
||||
// two or more fingers, ignore
|
||||
return;
|
||||
}
|
||||
endX = event.touches[0].clientX;
|
||||
};
|
||||
|
||||
const handleTouchEnd = (event: React.TouchEvent) => {
|
||||
if (event.touches.length > 1) {
|
||||
// two or more fingers, ignore
|
||||
return;
|
||||
}
|
||||
if (startX > -1 && endX > -1) {
|
||||
const distance = startX - endX;
|
||||
if (distance > 50) {
|
||||
showNextImg();
|
||||
} else if (distance < -50) {
|
||||
showPrevImg();
|
||||
}
|
||||
}
|
||||
|
||||
endX = -1;
|
||||
startX = -1;
|
||||
};
|
||||
|
||||
const showPrevImg = () => {
|
||||
if (currentIndex > 0) {
|
||||
setState(defaultState);
|
||||
setCurrentIndex(currentIndex - 1);
|
||||
} else {
|
||||
destroyAndResetViewport();
|
||||
}
|
||||
};
|
||||
|
||||
const showNextImg = () => {
|
||||
if (currentIndex < imgUrls.length - 1) {
|
||||
setState(defaultState);
|
||||
setCurrentIndex(currentIndex + 1);
|
||||
} else {
|
||||
destroyAndResetViewport();
|
||||
}
|
||||
};
|
||||
|
||||
const handleImgContainerClick = (event: React.MouseEvent) => {
|
||||
if (event.clientX < window.innerWidth / 2) {
|
||||
showPrevImg();
|
||||
} else {
|
||||
showNextImg();
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageContainerKeyDown = (event: KeyboardEvent) => {
|
||||
switch (event.key) {
|
||||
case "ArrowLeft":
|
||||
showPrevImg();
|
||||
break;
|
||||
case "ArrowRight":
|
||||
showNextImg();
|
||||
break;
|
||||
case "Escape":
|
||||
destroyAndResetViewport();
|
||||
break;
|
||||
default:
|
||||
}
|
||||
};
|
||||
|
||||
const handleImgContainerScroll = (event: React.WheelEvent) => {
|
||||
event.stopPropagation();
|
||||
const offsetX = event.nativeEvent.offsetX;
|
||||
const offsetY = event.nativeEvent.offsetY;
|
||||
const sign = event.deltaY < 0 ? 1 : -1;
|
||||
const scale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, state.scale + sign * SCALE_UNIT));
|
||||
setState({
|
||||
...state,
|
||||
originX: offsetX,
|
||||
originY: offsetY,
|
||||
scale: scale,
|
||||
});
|
||||
};
|
||||
|
||||
const setViewportScalable = () => {
|
||||
const viewport = document.querySelector("meta[name=viewport]");
|
||||
if (viewport) {
|
||||
const contentAttrs = viewport.getAttribute("content");
|
||||
if (contentAttrs) {
|
||||
viewport.setAttribute("content", contentAttrs.replace("user-scalable=no", "user-scalable=yes"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const destroyAndResetViewport = () => {
|
||||
const viewport = document.querySelector("meta[name=viewport]");
|
||||
if (viewport) {
|
||||
const contentAttrs = viewport.getAttribute("content");
|
||||
if (contentAttrs) {
|
||||
viewport.setAttribute("content", contentAttrs.replace("user-scalable=yes", "user-scalable=no"));
|
||||
}
|
||||
}
|
||||
destroy();
|
||||
};
|
||||
|
||||
const imageComputedStyle = {
|
||||
transform: `scale(${state.scale})`,
|
||||
transformOrigin: `${state.originX === -1 ? "center" : `${state.originX}px`} ${state.originY === -1 ? "center" : `${state.originY}px`}`,
|
||||
};
|
||||
|
||||
// Update current index when initialIndex prop changes
|
||||
useEffect(() => {
|
||||
setViewportScalable();
|
||||
}, []);
|
||||
setCurrentIndex(initialIndex);
|
||||
}, [initialIndex]);
|
||||
|
||||
// Handle keyboard navigation
|
||||
useEffect(() => {
|
||||
document.addEventListener("keydown", handleImageContainerKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleImageContainerKeyDown);
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (!open) return;
|
||||
|
||||
switch (event.key) {
|
||||
case "Escape":
|
||||
onOpenChange(false);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
}, [currentIndex]);
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [open, onOpenChange]);
|
||||
|
||||
const handleClose = () => {
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
// Prevent closing when clicking on the image
|
||||
const handleImageClick = (event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
// Return early if no images provided
|
||||
if (!imgUrls.length) return null;
|
||||
|
||||
// Ensure currentIndex is within bounds
|
||||
const safeIndex = Math.max(0, Math.min(currentIndex, imgUrls.length - 1));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed top-8 right-8 z-1 flex flex-col justify-start items-center">
|
||||
<Button onClick={handleCloseBtnClick}>
|
||||
<XIcon className="w-6 h-auto" />
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className="w-full h-screen p-4 sm:p-8 flex flex-col justify-center items-center hide-scrollbar"
|
||||
onClick={handleImgContainerClick}
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="!w-[100vw] !h-[100vh] !max-w-[100vw] !max-h-[100vw] p-0 border-0 shadow-none bg-transparent [&>button]:hidden"
|
||||
aria-describedby="image-preview-description"
|
||||
>
|
||||
<img
|
||||
className="object-contain max-h-full max-w-full"
|
||||
style={imageComputedStyle}
|
||||
src={imgUrls[currentIndex]}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onWheel={handleImgContainerScroll}
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
{/* Close button */}
|
||||
<div className="fixed top-4 right-4 z-50">
|
||||
<Button
|
||||
onClick={handleClose}
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="rounded-full bg-popover/20 hover:bg-popover/30 border-border/20 backdrop-blur-sm"
|
||||
aria-label="Close image preview"
|
||||
>
|
||||
<X className="h-4 w-4 text-popover-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
export default function showPreviewImageDialog(imgUrls: string[] | string, initialIndex?: number): void {
|
||||
generateDialog(
|
||||
{
|
||||
className: "preview-image-dialog p-0 z-1001",
|
||||
dialogName: "preview-image-dialog",
|
||||
},
|
||||
PreviewImageDialog,
|
||||
{
|
||||
imgUrls: Array.isArray(imgUrls) ? imgUrls : [imgUrls],
|
||||
initialIndex: initialIndex || 0,
|
||||
},
|
||||
{/* Image container */}
|
||||
<div className="w-full h-full flex items-center justify-center p-4 sm:p-8 overflow-auto">
|
||||
<img
|
||||
src={imgUrls[safeIndex]}
|
||||
alt={`Preview image ${safeIndex + 1} of ${imgUrls.length}`}
|
||||
className="max-w-full max-h-full object-contain select-none"
|
||||
onClick={handleImageClick}
|
||||
draggable={false}
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Screen reader description */}
|
||||
<div id="image-preview-description" className="sr-only">
|
||||
Image preview dialog. Press Escape to close or click outside the image.
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,21 @@
|
|||
import { XIcon } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { memoServiceClient } from "@/grpcweb";
|
||||
import useLoading from "@/hooks/useLoading";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { generateDialog } from "./Dialog";
|
||||
|
||||
interface Props extends DialogProps {
|
||||
interface RenameTagDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
tag: string;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
const RenameTagDialog: React.FC<Props> = (props: Props) => {
|
||||
const { tag, destroy } = props;
|
||||
export function RenameTagDialog({ open, onOpenChange, tag, onSuccess }: RenameTagDialogProps) {
|
||||
const t = useTranslate();
|
||||
const [newName, setNewName] = useState(tag);
|
||||
const requestState = useLoading(false);
|
||||
|
|
@ -33,65 +35,55 @@ const RenameTagDialog: React.FC<Props> = (props: Props) => {
|
|||
}
|
||||
|
||||
try {
|
||||
requestState.setLoading();
|
||||
await memoServiceClient.renameMemoTag({
|
||||
parent: "memos/-",
|
||||
oldTag: tag,
|
||||
newTag: newName,
|
||||
});
|
||||
toast.success(t("tag.rename-success"));
|
||||
requestState.setFinish();
|
||||
onSuccess?.();
|
||||
onOpenChange(false);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toast.error(error.details);
|
||||
requestState.setError();
|
||||
}
|
||||
destroy();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-full shadow flex flex-col justify-start items-start bg-card text-card-foreground p-4 rounded-lg">
|
||||
<div className="flex flex-row justify-between items-center mb-4 gap-2 w-full">
|
||||
<p className="title-text">{t("tag.rename-tag")}</p>
|
||||
<Button variant="ghost" onClick={() => destroy()}>
|
||||
<XIcon className="w-5 h-auto" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col justify-start items-start max-w-xs">
|
||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||
<div className="relative w-full mb-2 flex flex-row justify-start items-center space-x-2">
|
||||
<span className="w-20 text-sm whitespace-nowrap shrink-0 text-right">{t("tag.old-name")}</span>
|
||||
<Input className="w-full" readOnly disabled type="text" placeholder="A new tag name" value={tag} />
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("tag.rename-tag")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="oldName">{t("tag.old-name")}</Label>
|
||||
<Input id="oldName" readOnly disabled type="text" value={tag} />
|
||||
</div>
|
||||
<div className="relative w-full mb-2 flex flex-row justify-start items-center space-x-2">
|
||||
<span className="w-20 text-sm whitespace-nowrap shrink-0 text-right">{t("tag.new-name")}</span>
|
||||
<Input className="w-full" type="text" placeholder="A new tag name" value={newName} onChange={handleTagNameInputChange} />
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="newName">{t("tag.new-name")}</Label>
|
||||
<Input id="newName" type="text" placeholder="A new tag name" value={newName} onChange={handleTagNameInputChange} />
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<ul className="list-disc list-inside">
|
||||
<li>{t("tag.rename-tip")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<ul className="list-disc list-inside text-sm ml-4">
|
||||
<li>
|
||||
<p className="leading-5">{t("tag.rename-tip")}</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="w-full flex flex-row justify-end items-center space-x-2">
|
||||
<Button variant="ghost" disabled={requestState.isLoading} onClick={destroy}>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" disabled={requestState.isLoading} onClick={() => onOpenChange(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button disabled={requestState.isLoading} onClick={handleConfirm}>
|
||||
{t("common.confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function showRenameTagDialog(props: Pick<Props, "tag">) {
|
||||
generateDialog(
|
||||
{
|
||||
className: "rename-tag-dialog",
|
||||
dialogName: "rename-tag-dialog",
|
||||
},
|
||||
RenameTagDialog,
|
||||
props,
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default showRenameTagDialog;
|
||||
export default RenameTagDialog;
|
||||
|
|
|
|||
|
|
@ -5,9 +5,10 @@ import { toast } from "react-hot-toast";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { userServiceClient } from "@/grpcweb";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { useDialog } from "@/hooks/useDialog";
|
||||
import { UserAccessToken } from "@/types/proto/api/v1/user_service";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import showCreateAccessTokenDialog from "../CreateAccessTokenDialog";
|
||||
import CreateAccessTokenDialog from "../CreateAccessTokenDialog";
|
||||
import LearnMore from "../LearnMore";
|
||||
|
||||
const listAccessTokens = async (parent: string) => {
|
||||
|
|
@ -19,6 +20,7 @@ const AccessTokenSection = () => {
|
|||
const t = useTranslate();
|
||||
const currentUser = useCurrentUser();
|
||||
const [userAccessTokens, setUserAccessTokens] = useState<UserAccessToken[]>([]);
|
||||
const createTokenDialog = useDialog();
|
||||
|
||||
useEffect(() => {
|
||||
listAccessTokens(currentUser.name).then((accessTokens) => {
|
||||
|
|
@ -31,6 +33,10 @@ const AccessTokenSection = () => {
|
|||
setUserAccessTokens(accessTokens);
|
||||
};
|
||||
|
||||
const handleCreateToken = () => {
|
||||
createTokenDialog.open();
|
||||
};
|
||||
|
||||
const copyAccessToken = (accessToken: string) => {
|
||||
copy(accessToken);
|
||||
toast.success(t("setting.access-token-section.access-token-copied-to-clipboard"));
|
||||
|
|
@ -61,12 +67,7 @@ const AccessTokenSection = () => {
|
|||
<p className="text-sm text-muted-foreground">{t("setting.access-token-section.description")}</p>
|
||||
</div>
|
||||
<div className="mt-4 sm:mt-0">
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
showCreateAccessTokenDialog(handleCreateAccessTokenDialogConfirm);
|
||||
}}
|
||||
>
|
||||
<Button color="primary" onClick={handleCreateToken}>
|
||||
{t("common.create")}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -128,6 +129,13 @@ const AccessTokenSection = () => {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create Access Token Dialog */}
|
||||
<CreateAccessTokenDialog
|
||||
open={createTokenDialog.isOpen}
|
||||
onOpenChange={createTokenDialog.setOpen}
|
||||
onSuccess={handleCreateAccessTokenDialogConfirm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,36 +1,25 @@
|
|||
import { sortBy } from "lodash-es";
|
||||
import { MoreVerticalIcon } from "lucide-react";
|
||||
import { MoreVerticalIcon, PlusIcon } from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { userServiceClient } from "@/grpcweb";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { useDialog } from "@/hooks/useDialog";
|
||||
import { userStore } from "@/store/v2";
|
||||
import { State } from "@/types/proto/api/v1/common";
|
||||
import { User, User_Role } from "@/types/proto/api/v1/user_service";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import showCreateUserDialog from "../CreateUserDialog";
|
||||
import CreateUserDialog from "../CreateUserDialog";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/dropdown-menu";
|
||||
|
||||
interface LocalState {
|
||||
creatingUser: User;
|
||||
}
|
||||
|
||||
const MemberSection = observer(() => {
|
||||
const t = useTranslate();
|
||||
const currentUser = useCurrentUser();
|
||||
const [state, setState] = useState<LocalState>({
|
||||
creatingUser: User.fromPartial({
|
||||
username: "",
|
||||
password: "",
|
||||
role: User_Role.USER,
|
||||
}),
|
||||
});
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const createDialog = useDialog();
|
||||
const editDialog = useDialog();
|
||||
const [editingUser, setEditingUser] = useState<User | undefined>();
|
||||
const sortedUsers = sortBy(users, "id");
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -52,62 +41,14 @@ const MemberSection = observer(() => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleUsernameInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setState({
|
||||
...state,
|
||||
creatingUser: {
|
||||
...state.creatingUser,
|
||||
username: event.target.value,
|
||||
},
|
||||
});
|
||||
const handleCreateUser = () => {
|
||||
setEditingUser(undefined);
|
||||
createDialog.open();
|
||||
};
|
||||
|
||||
const handlePasswordInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setState({
|
||||
...state,
|
||||
creatingUser: {
|
||||
...state.creatingUser,
|
||||
password: event.target.value,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleUserRoleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setState({
|
||||
...state,
|
||||
creatingUser: {
|
||||
...state.creatingUser,
|
||||
role: event.target.value as User_Role,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreateUserBtnClick = async () => {
|
||||
if (state.creatingUser.username === "" || state.creatingUser.password === "") {
|
||||
toast.error(t("message.fill-all"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await userServiceClient.createUser({
|
||||
user: {
|
||||
username: state.creatingUser.username,
|
||||
password: state.creatingUser.password,
|
||||
role: state.creatingUser.role,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
toast.error(error.details);
|
||||
}
|
||||
await fetchUsers();
|
||||
setState({
|
||||
...state,
|
||||
creatingUser: User.fromPartial({
|
||||
username: "",
|
||||
password: "",
|
||||
role: User_Role.USER,
|
||||
}),
|
||||
});
|
||||
const handleEditUser = (user: User) => {
|
||||
setEditingUser(user);
|
||||
editDialog.open();
|
||||
};
|
||||
|
||||
const handleArchiveUserClick = async (user: User) => {
|
||||
|
|
@ -145,48 +86,12 @@ const MemberSection = observer(() => {
|
|||
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-2 pt-2 pb-4">
|
||||
<p className="font-medium text-muted-foreground">{t("setting.member-section.create-a-member")}</p>
|
||||
<div className="w-auto flex flex-col justify-start items-start gap-2 border border-border rounded-md py-2 px-3">
|
||||
<div className="flex flex-col justify-start items-start gap-1">
|
||||
<span>{t("common.username")}</span>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t("common.username")}
|
||||
autoComplete="off"
|
||||
value={state.creatingUser.username}
|
||||
onChange={handleUsernameInputChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col justify-start items-start gap-1">
|
||||
<span>{t("common.password")}</span>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={t("common.password")}
|
||||
autoComplete="off"
|
||||
value={state.creatingUser.password}
|
||||
onChange={handlePasswordInputChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col justify-start items-start gap-1">
|
||||
<span>{t("common.role")}</span>
|
||||
<RadioGroup
|
||||
defaultValue={User_Role.USER}
|
||||
onValueChange={(value) => handleUserRoleInputChange({ target: { value } } as React.ChangeEvent<HTMLInputElement>)}
|
||||
className="flex flex-row gap-4"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={User_Role.USER} id="user-role" />
|
||||
<Label htmlFor="user-role">{t("setting.member-section.user")}</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={User_Role.ADMIN} id="admin-role" />
|
||||
<Label htmlFor="admin-role">{t("setting.member-section.admin")}</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<Button onClick={handleCreateUserBtnClick}>{t("common.create")}</Button>
|
||||
</div>
|
||||
<div className="w-full flex flex-row justify-between items-center">
|
||||
<p className="font-medium text-muted-foreground">{t("setting.member-section.create-a-member")}</p>
|
||||
<Button onClick={handleCreateUser}>
|
||||
<PlusIcon className="w-4 h-4 mr-2" />
|
||||
{t("common.create")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="w-full flex flex-row justify-between items-center mt-6">
|
||||
<div className="title-text">{t("setting.member-list")}</div>
|
||||
|
|
@ -232,9 +137,7 @@ const MemberSection = observer(() => {
|
|||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={2}>
|
||||
<DropdownMenuItem onClick={() => showCreateUserDialog(user, () => fetchUsers())}>
|
||||
{t("common.update")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleEditUser(user)}>{t("common.update")}</DropdownMenuItem>
|
||||
{user.state === State.NORMAL ? (
|
||||
<DropdownMenuItem onClick={() => handleArchiveUserClick(user)}>
|
||||
{t("setting.member-section.archive-member")}
|
||||
|
|
@ -260,6 +163,12 @@ const MemberSection = observer(() => {
|
|||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create User Dialog */}
|
||||
<CreateUserDialog open={createDialog.isOpen} onOpenChange={createDialog.setOpen} onSuccess={fetchUsers} />
|
||||
|
||||
{/* Edit User Dialog */}
|
||||
<CreateUserDialog open={editDialog.isOpen} onOpenChange={editDialog.setOpen} user={editingUser} onSuccess={fetchUsers} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { MoreVerticalIcon, PenLineIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { useDialog } from "@/hooks/useDialog";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import showChangeMemberPasswordDialog from "../ChangeMemberPasswordDialog";
|
||||
import showUpdateAccountDialog from "../UpdateAccountDialog";
|
||||
import ChangeMemberPasswordDialog from "../ChangeMemberPasswordDialog";
|
||||
import UpdateAccountDialog from "../UpdateAccountDialog";
|
||||
import UserAvatar from "../UserAvatar";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/dropdown-menu";
|
||||
import AccessTokenSection from "./AccessTokenSection";
|
||||
|
|
@ -12,6 +13,16 @@ import UserSessionsSection from "./UserSessionsSection";
|
|||
const MyAccountSection = () => {
|
||||
const t = useTranslate();
|
||||
const user = useCurrentUser();
|
||||
const accountDialog = useDialog();
|
||||
const passwordDialog = useDialog();
|
||||
|
||||
const handleEditAccount = () => {
|
||||
accountDialog.open();
|
||||
};
|
||||
|
||||
const handleChangePassword = () => {
|
||||
passwordDialog.open();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full gap-2 pt-2 pb-4">
|
||||
|
|
@ -27,7 +38,7 @@ const MyAccountSection = () => {
|
|||
</div>
|
||||
</div>
|
||||
<div className="w-full flex flex-row justify-start items-center mt-2 space-x-2">
|
||||
<Button variant="outline" onClick={showUpdateAccountDialog}>
|
||||
<Button variant="outline" onClick={handleEditAccount}>
|
||||
<PenLineIcon className="w-4 h-4 mx-auto mr-1" />
|
||||
{t("common.edit")}
|
||||
</Button>
|
||||
|
|
@ -38,15 +49,19 @@ const MyAccountSection = () => {
|
|||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem onClick={() => showChangeMemberPasswordDialog(user)}>
|
||||
{t("setting.account-section.change-password")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleChangePassword}>{t("setting.account-section.change-password")}</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<UserSessionsSection />
|
||||
<AccessTokenSection />
|
||||
|
||||
{/* Update Account Dialog */}
|
||||
<UpdateAccountDialog open={accountDialog.isOpen} onOpenChange={accountDialog.setOpen} />
|
||||
|
||||
{/* Change Password Dialog */}
|
||||
<ChangeMemberPasswordDialog open={passwordDialog.isOpen} onOpenChange={passwordDialog.setOpen} user={user} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,12 +8,14 @@ import { Separator } from "@/components/ui/separator";
|
|||
import { identityProviderServiceClient } from "@/grpcweb";
|
||||
import { IdentityProvider } from "@/types/proto/api/v1/idp_service";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import showCreateIdentityProviderDialog from "../CreateIdentityProviderDialog";
|
||||
import CreateIdentityProviderDialog from "../CreateIdentityProviderDialog";
|
||||
import LearnMore from "../LearnMore";
|
||||
|
||||
const SSOSection = () => {
|
||||
const t = useTranslate();
|
||||
const [identityProviderList, setIdentityProviderList] = useState<IdentityProvider[]>([]);
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
const [editingIdentityProvider, setEditingIdentityProvider] = useState<IdentityProvider | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
fetchIdentityProviderList();
|
||||
|
|
@ -37,6 +39,22 @@ const SSOSection = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleCreateIdentityProvider = () => {
|
||||
setEditingIdentityProvider(undefined);
|
||||
setIsCreateDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleEditIdentityProvider = (identityProvider: IdentityProvider) => {
|
||||
setEditingIdentityProvider(identityProvider);
|
||||
setIsCreateDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDialogSuccess = async () => {
|
||||
await fetchIdentityProviderList();
|
||||
setIsCreateDialogOpen(false);
|
||||
setEditingIdentityProvider(undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-2 pt-2 pb-4">
|
||||
<div className="w-full flex flex-row justify-between items-center gap-1">
|
||||
|
|
@ -44,7 +62,7 @@ const SSOSection = () => {
|
|||
<span className="font-mono text-muted-foreground">{t("setting.sso-section.sso-list")}</span>
|
||||
<LearnMore url="https://www.usememos.com/docs/advanced-settings/sso" />
|
||||
</div>
|
||||
<Button color="primary" onClick={() => showCreateIdentityProviderDialog(undefined, fetchIdentityProviderList)}>
|
||||
<Button color="primary" onClick={handleCreateIdentityProvider}>
|
||||
{t("common.create")}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -68,9 +86,7 @@ const SSOSection = () => {
|
|||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={2}>
|
||||
<DropdownMenuItem onClick={() => showCreateIdentityProviderDialog(identityProvider, fetchIdentityProviderList)}>
|
||||
{t("common.edit")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleEditIdentityProvider(identityProvider)}>{t("common.edit")}</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleDeleteIdentityProvider(identityProvider)}>{t("common.delete")}</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
|
@ -93,6 +109,12 @@ const SSOSection = () => {
|
|||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<CreateIdentityProviderDialog
|
||||
open={isCreateDialogOpen}
|
||||
onOpenChange={setIsCreateDialogOpen}
|
||||
identityProvider={editingIdentityProvider}
|
||||
onSuccess={handleDialogSuccess}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,12 +6,13 @@ import { webhookServiceClient } from "@/grpcweb";
|
|||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { Webhook } from "@/types/proto/api/v1/webhook_service";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import showCreateWebhookDialog from "../CreateWebhookDialog";
|
||||
import CreateWebhookDialog from "../CreateWebhookDialog";
|
||||
|
||||
const WebhookSection = () => {
|
||||
const t = useTranslate();
|
||||
const currentUser = useCurrentUser();
|
||||
const [webhooks, setWebhooks] = useState<Webhook[]>([]);
|
||||
const [isCreateWebhookDialogOpen, setIsCreateWebhookDialogOpen] = useState(false);
|
||||
|
||||
const listWebhooks = async () => {
|
||||
if (!currentUser) return [];
|
||||
|
|
@ -30,6 +31,7 @@ const WebhookSection = () => {
|
|||
const handleCreateWebhookDialogConfirm = async () => {
|
||||
const webhooks = await listWebhooks();
|
||||
setWebhooks(webhooks);
|
||||
setIsCreateWebhookDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleDeleteWebhook = async (webhook: Webhook) => {
|
||||
|
|
@ -47,12 +49,7 @@ const WebhookSection = () => {
|
|||
<p className="flex flex-row justify-start items-center font-medium text-muted-foreground">{t("setting.webhook-section.title")}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
showCreateWebhookDialog(handleCreateWebhookDialogConfirm);
|
||||
}}
|
||||
>
|
||||
<Button color="primary" onClick={() => setIsCreateWebhookDialogOpen(true)}>
|
||||
{t("common.create")}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -116,6 +113,11 @@ const WebhookSection = () => {
|
|||
<ExternalLinkIcon className="inline w-4 h-auto ml-1" />
|
||||
</Link>
|
||||
</div>
|
||||
<CreateWebhookDialog
|
||||
open={isCreateWebhookDialogOpen}
|
||||
onOpenChange={setIsCreateWebhookDialogOpen}
|
||||
onSuccess={handleCreateWebhookDialogConfirm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,16 +10,18 @@ import { Separator } from "@/components/ui/separator";
|
|||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { identityProviderServiceClient } from "@/grpcweb";
|
||||
import useDialog from "@/hooks/useDialog";
|
||||
import { workspaceSettingNamePrefix } from "@/store/common";
|
||||
import { workspaceStore } from "@/store/v2";
|
||||
import { WorkspaceSettingKey } from "@/store/v2/workspace";
|
||||
import { IdentityProvider } from "@/types/proto/api/v1/idp_service";
|
||||
import { WorkspaceGeneralSetting } from "@/types/proto/api/v1/workspace_service";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import showUpdateCustomizedProfileDialog from "../UpdateCustomizedProfileDialog";
|
||||
import UpdateCustomizedProfileDialog from "../UpdateCustomizedProfileDialog";
|
||||
|
||||
const WorkspaceSection = observer(() => {
|
||||
const t = useTranslate();
|
||||
const customizeDialog = useDialog();
|
||||
const originalSetting = WorkspaceGeneralSetting.fromPartial(
|
||||
workspaceStore.getWorkspaceSettingByKey(WorkspaceSettingKey.GENERAL)?.generalSetting || {},
|
||||
);
|
||||
|
|
@ -31,7 +33,7 @@ const WorkspaceSection = observer(() => {
|
|||
}, [workspaceStore.getWorkspaceSettingByKey(WorkspaceSettingKey.GENERAL)]);
|
||||
|
||||
const handleUpdateCustomizedProfileButtonClick = () => {
|
||||
showUpdateCustomizedProfileDialog();
|
||||
customizeDialog.open();
|
||||
};
|
||||
|
||||
const updatePartialSetting = (partial: Partial<WorkspaceGeneralSetting>) => {
|
||||
|
|
@ -166,6 +168,15 @@ const WorkspaceSection = observer(() => {
|
|||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<UpdateCustomizedProfileDialog
|
||||
open={customizeDialog.isOpen}
|
||||
onOpenChange={customizeDialog.setOpen}
|
||||
onSuccess={() => {
|
||||
// Refresh workspace settings if needed
|
||||
toast.success("Profile updated successfully!");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,17 +3,22 @@ import { XIcon } from "lucide-react";
|
|||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { convertFileToBase64 } from "@/helpers/utils";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { userStore, workspaceStore } from "@/store/v2";
|
||||
import { User as UserPb } from "@/types/proto/api/v1/user_service";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { generateDialog } from "./Dialog";
|
||||
import UserAvatar from "./UserAvatar";
|
||||
|
||||
type Props = DialogProps;
|
||||
interface UpdateAccountDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
avatarUrl: string;
|
||||
|
|
@ -23,7 +28,7 @@ interface State {
|
|||
description: string;
|
||||
}
|
||||
|
||||
const UpdateAccountDialog = ({ destroy }: Props) => {
|
||||
export function UpdateAccountDialog({ open, onOpenChange, onSuccess }: UpdateAccountDialogProps) {
|
||||
const t = useTranslate();
|
||||
const currentUser = useCurrentUser();
|
||||
const [state, setState] = useState<State>({
|
||||
|
|
@ -36,7 +41,7 @@ const UpdateAccountDialog = ({ destroy }: Props) => {
|
|||
const workspaceGeneralSetting = workspaceStore.state.generalSetting;
|
||||
|
||||
const handleCloseBtnClick = () => {
|
||||
destroy();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const setPartialState = (partialState: Partial<State>) => {
|
||||
|
|
@ -133,7 +138,8 @@ const UpdateAccountDialog = ({ destroy }: Props) => {
|
|||
updateMask,
|
||||
);
|
||||
toast.success(t("message.update-succeed"));
|
||||
handleCloseBtnClick();
|
||||
onSuccess?.();
|
||||
onOpenChange(false);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toast.error(error.details);
|
||||
|
|
@ -141,77 +147,74 @@ const UpdateAccountDialog = ({ destroy }: Props) => {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-full shadow flex flex-col justify-start items-start bg-card text-card-foreground p-4 rounded-lg">
|
||||
<div className="flex flex-row justify-between items-center mb-4 gap-2 w-full">
|
||||
<p className="title-text">{t("setting.account-section.update-information")}</p>
|
||||
<Button variant="ghost" onClick={handleCloseBtnClick}>
|
||||
<XIcon className="w-5 h-auto" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col justify-start items-start w-64! space-y-2">
|
||||
<div className="w-full flex flex-row justify-start items-center">
|
||||
<span className="text-sm mr-2">{t("common.avatar")}</span>
|
||||
<label className="relative cursor-pointer hover:opacity-80">
|
||||
<UserAvatar className="w-10! h-10!" avatarUrl={state.avatarUrl} />
|
||||
<input type="file" accept="image/*" className="absolute invisible w-full h-full inset-0" onChange={handleAvatarChanged} />
|
||||
</label>
|
||||
{state.avatarUrl && (
|
||||
<XIcon
|
||||
className="w-4 h-auto ml-1 cursor-pointer opacity-60 hover:opacity-80"
|
||||
onClick={() =>
|
||||
setPartialState({
|
||||
avatarUrl: "",
|
||||
})
|
||||
}
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("setting.account-section.update-information")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Label>{t("common.avatar")}</Label>
|
||||
<label className="relative cursor-pointer hover:opacity-80">
|
||||
<UserAvatar className="w-10 h-10" avatarUrl={state.avatarUrl} />
|
||||
<input type="file" accept="image/*" className="absolute invisible w-full h-full inset-0" onChange={handleAvatarChanged} />
|
||||
</label>
|
||||
{state.avatarUrl && (
|
||||
<XIcon
|
||||
className="w-4 h-auto cursor-pointer opacity-60 hover:opacity-80"
|
||||
onClick={() =>
|
||||
setPartialState({
|
||||
avatarUrl: "",
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="username">
|
||||
{t("common.username")}
|
||||
<span className="text-sm text-muted-foreground ml-1">({t("setting.account-section.username-note")})</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="username"
|
||||
value={state.username}
|
||||
onChange={handleUsernameChanged}
|
||||
disabled={workspaceGeneralSetting.disallowChangeUsername}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="displayName">
|
||||
{t("common.nickname")}
|
||||
<span className="text-sm text-muted-foreground ml-1">({t("setting.account-section.nickname-note")})</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="displayName"
|
||||
value={state.displayName}
|
||||
onChange={handleDisplayNameChanged}
|
||||
disabled={workspaceGeneralSetting.disallowChangeNickname}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">
|
||||
{t("common.email")}
|
||||
<span className="text-sm text-muted-foreground ml-1">({t("setting.account-section.email-note")})</span>
|
||||
</Label>
|
||||
<Input id="email" type="email" value={state.email} onChange={handleEmailChanged} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="description">{t("common.description")}</Label>
|
||||
<Textarea id="description" rows={2} value={state.description} onChange={handleDescriptionChanged} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm">
|
||||
{t("common.username")}
|
||||
<span className="text-sm text-muted-foreground ml-1">({t("setting.account-section.username-note")})</span>
|
||||
</p>
|
||||
<Input
|
||||
className="w-full"
|
||||
value={state.username}
|
||||
onChange={handleUsernameChanged}
|
||||
disabled={workspaceGeneralSetting.disallowChangeUsername}
|
||||
/>
|
||||
<p className="text-sm">
|
||||
{t("common.nickname")}
|
||||
<span className="text-sm text-muted-foreground ml-1">({t("setting.account-section.nickname-note")})</span>
|
||||
</p>
|
||||
<Input
|
||||
className="w-full"
|
||||
value={state.displayName}
|
||||
onChange={handleDisplayNameChanged}
|
||||
disabled={workspaceGeneralSetting.disallowChangeNickname}
|
||||
/>
|
||||
<p className="text-sm">
|
||||
{t("common.email")}
|
||||
<span className="text-sm text-muted-foreground ml-1">({t("setting.account-section.email-note")})</span>
|
||||
</p>
|
||||
<Input className="w-full" type="email" value={state.email} onChange={handleEmailChanged} />
|
||||
<p className="text-sm">{t("common.description")}</p>
|
||||
<Textarea className="w-full" rows={2} value={state.description} onChange={handleDescriptionChanged} />
|
||||
<div className="w-full flex flex-row justify-end items-center pt-4 space-x-2">
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={handleCloseBtnClick}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSaveBtnClick}>{t("common.save")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function showUpdateAccountDialog() {
|
||||
generateDialog(
|
||||
{
|
||||
className: "update-account-dialog",
|
||||
dialogName: "update-account-dialog",
|
||||
},
|
||||
UpdateAccountDialog,
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default showUpdateAccountDialog;
|
||||
export default UpdateAccountDialog;
|
||||
|
|
|
|||
0
web/src/components/UpdateCustomizedProfileDialog.new.tsx
Normal file
0
web/src/components/UpdateCustomizedProfileDialog.new.tsx
Normal file
|
|
@ -1,8 +1,9 @@
|
|||
import { XIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { workspaceSettingNamePrefix } from "@/store/common";
|
||||
import { workspaceStore } from "@/store/v2";
|
||||
|
|
@ -10,29 +11,28 @@ import { WorkspaceSettingKey } from "@/store/v2/workspace";
|
|||
import { WorkspaceCustomProfile } from "@/types/proto/api/v1/workspace_service";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import AppearanceSelect from "./AppearanceSelect";
|
||||
import { generateDialog } from "./Dialog";
|
||||
import LocaleSelect from "./LocaleSelect";
|
||||
|
||||
type Props = DialogProps;
|
||||
interface UpdateCustomizedProfileDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
const UpdateCustomizedProfileDialog = ({ destroy }: Props) => {
|
||||
export function UpdateCustomizedProfileDialog({ open, onOpenChange, onSuccess }: UpdateCustomizedProfileDialogProps) {
|
||||
const t = useTranslate();
|
||||
const workspaceGeneralSetting = workspaceStore.state.generalSetting;
|
||||
const [customProfile, setCustomProfile] = useState<WorkspaceCustomProfile>(
|
||||
WorkspaceCustomProfile.fromPartial(workspaceGeneralSetting.customProfile || {}),
|
||||
);
|
||||
|
||||
const handleCloseButtonClick = () => {
|
||||
destroy();
|
||||
};
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const setPartialState = (partialState: Partial<WorkspaceCustomProfile>) => {
|
||||
setCustomProfile((state) => {
|
||||
return {
|
||||
...state,
|
||||
...partialState,
|
||||
};
|
||||
});
|
||||
setCustomProfile((state) => ({
|
||||
...state,
|
||||
...partialState,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleNameChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
|
|
@ -75,12 +75,17 @@ const UpdateCustomizedProfileDialog = ({ destroy }: Props) => {
|
|||
});
|
||||
};
|
||||
|
||||
const handleCloseButtonClick = () => {
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleSaveButtonClick = async () => {
|
||||
if (customProfile.title === "") {
|
||||
toast.error("Title cannot be empty.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await workspaceStore.upsertWorkspaceSetting({
|
||||
name: `${workspaceSettingNamePrefix}${WorkspaceSettingKey.GENERAL}`,
|
||||
|
|
@ -89,61 +94,75 @@ const UpdateCustomizedProfileDialog = ({ destroy }: Props) => {
|
|||
customProfile: customProfile,
|
||||
},
|
||||
});
|
||||
toast.success(t("message.update-succeed"));
|
||||
onSuccess?.();
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return;
|
||||
toast.error("Failed to update profile");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
toast.success(t("message.update-succeed"));
|
||||
destroy();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-full shadow flex flex-col justify-start items-start bg-card text-card-foreground p-4 rounded-lg">
|
||||
<div className="flex flex-row justify-between items-center mb-4 gap-2 w-full">
|
||||
<p className="title-text">{t("setting.system-section.customize-server.title")}</p>
|
||||
<Button variant="ghost" onClick={handleCloseButtonClick}>
|
||||
<XIcon className="w-5 h-auto" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col justify-start items-start min-w-[16rem]">
|
||||
<p className="text-sm mb-1">{t("setting.system-section.server-name")}</p>
|
||||
<Input className="w-full" type="text" value={customProfile.title} onChange={handleNameChanged} />
|
||||
<p className="text-sm mb-1 mt-2">{t("setting.system-section.customize-server.icon-url")}</p>
|
||||
<Input className="w-full" type="text" value={customProfile.logoUrl} onChange={handleLogoUrlChanged} />
|
||||
<p className="text-sm mb-1 mt-2">{t("setting.system-section.customize-server.description")}</p>
|
||||
<Textarea rows={3} value={customProfile.description} onChange={handleDescriptionChanged} />
|
||||
<p className="text-sm mb-1 mt-2">{t("setting.system-section.customize-server.locale")}</p>
|
||||
<LocaleSelect className="w-full!" value={customProfile.locale} onChange={handleLocaleSelectChange} />
|
||||
<p className="text-sm mb-1 mt-2">{t("setting.system-section.customize-server.appearance")}</p>
|
||||
<AppearanceSelect className="w-full!" value={customProfile.appearance as Appearance} onChange={handleAppearanceSelectChange} />
|
||||
<div className="mt-4 w-full flex flex-row justify-between items-center space-x-2">
|
||||
<div className="flex flex-row justify-start items-center">
|
||||
<Button variant="outline" onClick={handleRestoreButtonClick}>
|
||||
{t("common.restore")}
|
||||
</Button>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("setting.system-section.customize-server.title")}</DialogTitle>
|
||||
<DialogDescription>Customize your workspace appearance and settings.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="server-name">{t("setting.system-section.server-name")}</Label>
|
||||
<Input id="server-name" type="text" value={customProfile.title} onChange={handleNameChanged} placeholder="Enter server name" />
|
||||
</div>
|
||||
<div className="flex flex-row justify-end items-center gap-2">
|
||||
<Button variant="ghost" onClick={handleCloseButtonClick}>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="icon-url">{t("setting.system-section.customize-server.icon-url")}</Label>
|
||||
<Input id="icon-url" type="text" value={customProfile.logoUrl} onChange={handleLogoUrlChanged} placeholder="Enter icon URL" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="description">{t("setting.system-section.customize-server.description")}</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
rows={3}
|
||||
value={customProfile.description}
|
||||
onChange={handleDescriptionChanged}
|
||||
placeholder="Enter description"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("setting.system-section.customize-server.locale")}</Label>
|
||||
<LocaleSelect className="w-full" value={customProfile.locale} onChange={handleLocaleSelectChange} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("setting.system-section.customize-server.appearance")}</Label>
|
||||
<AppearanceSelect className="w-full" value={customProfile.appearance as Appearance} onChange={handleAppearanceSelectChange} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-4">
|
||||
<Button variant="outline" onClick={handleRestoreButtonClick} disabled={isLoading}>
|
||||
{t("common.restore")}
|
||||
</Button>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={handleCloseButtonClick} disabled={isLoading}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button color="primary" onClick={handleSaveButtonClick}>
|
||||
{t("common.save")}
|
||||
<Button onClick={handleSaveButtonClick} disabled={isLoading}>
|
||||
{isLoading ? "Saving..." : t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function showUpdateCustomizedProfileDialog() {
|
||||
generateDialog(
|
||||
{
|
||||
className: "update-customized-profile-dialog",
|
||||
dialogName: "update-customized-profile-dialog",
|
||||
},
|
||||
UpdateCustomizedProfileDialog,
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default showUpdateCustomizedProfileDialog;
|
||||
export default UpdateCustomizedProfileDialog;
|
||||
|
|
|
|||
0
web/src/components/examples/WorkspaceSection.example.tsx
Normal file
0
web/src/components/examples/WorkspaceSection.example.tsx
Normal file
|
|
@ -1,88 +1,117 @@
|
|||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { XIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
|
||||
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-foreground/50",
|
||||
className,
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-foreground/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
/**
|
||||
* Dialog content variants with improved mobile responsiveness.
|
||||
*
|
||||
* Mobile behavior:
|
||||
* - Mobile phones (< 640px): Uses calc(100% - 2rem) width with better 1rem margin on each side
|
||||
* - Small tablets (≥ 640px): Uses calc(100% - 3rem) width with 1.5rem margin on each side
|
||||
* - Medium screens and up (≥ 768px): Uses fixed max-widths based on size variant
|
||||
*
|
||||
* Size variants:
|
||||
* - sm: max-w-sm (384px) for compact dialogs
|
||||
* - default: max-w-md (448px) for standard dialogs
|
||||
* - lg: max-w-lg (512px) for larger forms
|
||||
* - xl: max-w-xl (576px) for detailed content
|
||||
* - 2xl: max-w-2xl (672px) for wide layouts
|
||||
* - full: Takes available width with margins
|
||||
*/
|
||||
const dialogContentVariants = cva(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border shadow-lg duration-200",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
sm: "w-[calc(100%-2rem)] max-w-[calc(100%-2rem)] p-4 sm:w-[calc(100%-3rem)] sm:max-w-[calc(100%-3rem)] sm:p-6 md:w-full md:max-w-sm",
|
||||
default:
|
||||
"w-[calc(100%-2rem)] max-w-[calc(100%-2rem)] p-4 sm:w-[calc(100%-3rem)] sm:max-w-[calc(100%-3rem)] sm:p-6 md:w-full md:max-w-md",
|
||||
lg: "w-[calc(100%-2rem)] max-w-[calc(100%-2rem)] p-4 sm:w-[calc(100%-3rem)] sm:max-w-[calc(100%-3rem)] sm:p-6 md:w-full md:max-w-lg",
|
||||
xl: "w-[calc(100%-2rem)] max-w-[calc(100%-2rem)] p-4 sm:w-[calc(100%-3rem)] sm:max-w-[calc(100%-3rem)] sm:p-6 md:w-full md:max-w-xl",
|
||||
"2xl":
|
||||
"w-[calc(100%-2rem)] max-w-[calc(100%-2rem)] p-4 sm:w-[calc(100%-3rem)] sm:max-w-[calc(100%-3rem)] sm:p-6 md:w-full md:max-w-2xl",
|
||||
full: "w-[calc(100%-2rem)] max-w-[calc(100%-2rem)] p-4 sm:w-[calc(100%-3rem)] sm:max-w-[calc(100%-3rem)] sm:p-6 md:w-[calc(100%-2rem)] md:max-w-none",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> &
|
||||
VariantProps<typeof dialogContentVariants> & {
|
||||
showCloseButton?: boolean;
|
||||
}
|
||||
>(({ className, children, showCloseButton = true, size, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content ref={ref} className={cn(dialogContentVariants({ size }), className)} {...props}>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
const DialogHeader = React.forwardRef<React.ElementRef<"div">, React.ComponentPropsWithoutRef<"div">>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex flex-col gap-2 text-center sm:text-left", className)} {...props} />
|
||||
));
|
||||
DialogHeader.displayName = "DialogHeader";
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="dialog-header" className={cn("flex flex-col gap-2 text-center sm:text-left", className)} {...props} />;
|
||||
}
|
||||
const DialogFooter = React.forwardRef<React.ElementRef<"div">, React.ComponentPropsWithoutRef<"div">>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)} {...props} />
|
||||
));
|
||||
DialogFooter.displayName = "DialogFooter";
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="dialog-footer" className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)} {...props} />;
|
||||
}
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title ref={ref} className={cn("text-lg leading-none font-semibold", className)} {...props} />
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return <DialogPrimitive.Title data-slot="dialog-title" className={cn("text-lg leading-none font-semibold", className)} {...props} />;
|
||||
}
|
||||
|
||||
function DialogDescription({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description data-slot="dialog-description" className={cn("text-muted-foreground text-sm", className)} {...props} />
|
||||
);
|
||||
}
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description ref={ref} className={cn("text-muted-foreground text-sm", className)} {...props} />
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
|
|
|
|||
118
web/src/hooks/useDialog.ts
Normal file
118
web/src/hooks/useDialog.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import { useState, useCallback } from "react";
|
||||
|
||||
/**
|
||||
* Hook for managing dialog state with a clean API
|
||||
*
|
||||
* @returns Object with dialog state and handlers
|
||||
*
|
||||
* @example
|
||||
* const dialog = useDialog();
|
||||
*
|
||||
* return (
|
||||
* <>
|
||||
* <Button onClick={dialog.open}>Open Dialog</Button>
|
||||
* <SomeDialog
|
||||
* open={dialog.isOpen}
|
||||
* onOpenChange={dialog.setOpen}
|
||||
* onSuccess={dialog.close}
|
||||
* />
|
||||
* </>
|
||||
* );
|
||||
*/
|
||||
export function useDialog(defaultOpen = false) {
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
|
||||
const open = useCallback(() => setIsOpen(true), []);
|
||||
const close = useCallback(() => setIsOpen(false), []);
|
||||
const toggle = useCallback(() => setIsOpen((prev) => !prev), []);
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
open,
|
||||
close,
|
||||
toggle,
|
||||
setOpen: setIsOpen,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing multiple dialogs with named keys
|
||||
*
|
||||
* @returns Object with dialog management functions
|
||||
*
|
||||
* @example
|
||||
* const dialogs = useDialogs();
|
||||
*
|
||||
* return (
|
||||
* <>
|
||||
* <Button onClick={() => dialogs.open('create')}>Create User</Button>
|
||||
* <Button onClick={() => dialogs.open('edit')}>Edit User</Button>
|
||||
*
|
||||
* <CreateUserDialog
|
||||
* open={dialogs.isOpen('create')}
|
||||
* onOpenChange={(open) => dialogs.setOpen('create', open)}
|
||||
* />
|
||||
* <EditUserDialog
|
||||
* open={dialogs.isOpen('edit')}
|
||||
* onOpenChange={(open) => dialogs.setOpen('edit', open)}
|
||||
* />
|
||||
* </>
|
||||
* );
|
||||
*/
|
||||
export function useDialogs() {
|
||||
const [openDialogs, setOpenDialogs] = useState<Set<string>>(new Set());
|
||||
|
||||
const isOpen = useCallback((key: string) => openDialogs.has(key), [openDialogs]);
|
||||
|
||||
const open = useCallback((key: string) => {
|
||||
setOpenDialogs((prev) => new Set([...prev, key]));
|
||||
}, []);
|
||||
|
||||
const close = useCallback((key: string) => {
|
||||
setOpenDialogs((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(key);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggle = useCallback((key: string) => {
|
||||
setOpenDialogs((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.add(key);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setOpen = useCallback((key: string, open: boolean) => {
|
||||
if (open) {
|
||||
setOpenDialogs((prev) => new Set([...prev, key]));
|
||||
} else {
|
||||
setOpenDialogs((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(key);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const closeAll = useCallback(() => {
|
||||
setOpenDialogs(new Set());
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
open,
|
||||
close,
|
||||
toggle,
|
||||
setOpen,
|
||||
closeAll,
|
||||
openDialogs: Array.from(openDialogs),
|
||||
};
|
||||
}
|
||||
|
||||
export default useDialog;
|
||||
|
|
@ -70,7 +70,7 @@ const Attachments = observer(() => {
|
|||
<section className="@container w-full max-w-5xl min-h-full flex flex-col justify-start items-center sm:pt-3 md:pt-6 pb-8">
|
||||
{!md && <MobileHeader />}
|
||||
<div className="w-full px-4 sm:px-6">
|
||||
<div className="w-full shadow flex flex-col justify-start items-start px-4 py-3 rounded-xl bg-background text-foreground">
|
||||
<div className="w-full border border-border flex flex-col justify-start items-start px-4 py-3 rounded-xl bg-background text-foreground">
|
||||
<div className="relative w-full flex flex-row justify-between items-center">
|
||||
<p className="py-1 flex flex-row justify-start items-center select-none opacity-80">
|
||||
<PaperclipIcon className="w-6 h-auto mr-1 opacity-80" />
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ const Inboxes = observer(() => {
|
|||
<section className="@container w-full max-w-5xl min-h-full flex flex-col justify-start items-center sm:pt-3 md:pt-6 pb-8">
|
||||
{!md && <MobileHeader />}
|
||||
<div className="w-full px-4 sm:px-6">
|
||||
<div className="w-full shadow flex flex-col justify-start items-start px-4 py-3 rounded-xl bg-background text-foreground">
|
||||
<div className="w-full border border-border flex flex-col justify-start items-start px-4 py-3 rounded-xl bg-background text-foreground">
|
||||
<div className="relative w-full flex flex-row justify-between items-center">
|
||||
<p className="py-1 flex flex-row justify-start items-center select-none opacity-80">
|
||||
<BellIcon className="w-6 h-auto mr-1 opacity-80" />
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ const Setting = observer(() => {
|
|||
<section className="@container w-full max-w-5xl min-h-full flex flex-col justify-start items-start sm:pt-3 md:pt-6 pb-8">
|
||||
{!md && <MobileHeader />}
|
||||
<div className="w-full px-4 sm:px-6">
|
||||
<div className="w-full shadow border border-border flex flex-row justify-start items-start px-4 py-3 rounded-xl bg-background text-muted-foreground">
|
||||
<div className="w-full border border-border flex flex-row justify-start items-start px-4 py-3 rounded-xl bg-background text-muted-foreground">
|
||||
<div className="hidden sm:flex flex-col justify-start items-start w-40 h-auto shrink-0 py-2">
|
||||
<span className="text-sm mt-0.5 pl-3 font-mono select-none text-muted-foreground">{t("common.basic")}</span>
|
||||
<div className="w-full flex flex-col justify-start items-start mt-1">
|
||||
|
|
|
|||
|
|
@ -852,7 +852,34 @@ export const AttachmentServiceDefinition = {
|
|||
responseStream: false,
|
||||
options: {
|
||||
_unknownFields: {
|
||||
8410: [new Uint8Array([13, 110, 97, 109, 101, 44, 102, 105, 108, 101, 110, 97, 109, 101])],
|
||||
8410: [
|
||||
new Uint8Array([
|
||||
23,
|
||||
110,
|
||||
97,
|
||||
109,
|
||||
101,
|
||||
44,
|
||||
102,
|
||||
105,
|
||||
108,
|
||||
101,
|
||||
110,
|
||||
97,
|
||||
109,
|
||||
101,
|
||||
44,
|
||||
116,
|
||||
104,
|
||||
117,
|
||||
109,
|
||||
98,
|
||||
110,
|
||||
97,
|
||||
105,
|
||||
108,
|
||||
]),
|
||||
],
|
||||
578365826: [
|
||||
new Uint8Array([
|
||||
39,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue