feat: migrate dialogs

This commit is contained in:
Johnny 2025-07-06 22:01:55 +08:00
parent f70138535c
commit 240d89fbca
33 changed files with 1201 additions and 1205 deletions

View file

@ -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) {

View file

@ -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" +

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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;
}

View file

@ -1 +0,0 @@
export { generateDialog } from "./BaseDialog";

View file

@ -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>
);
});

View file

@ -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>
);
});

View file

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

View file

@ -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>
);
});

View file

@ -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>
);
}

View file

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

View file

@ -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>
);
};

View file

@ -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>
);
});

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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>
);
});

View file

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

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

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

View file

@ -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" />

View file

@ -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" />

View file

@ -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">

View file

@ -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,