Commit 240d89fb authored by Johnny's avatar Johnny

feat: migrate dialogs

parent f7013853
...@@ -34,7 +34,7 @@ service AttachmentService { ...@@ -34,7 +34,7 @@ service AttachmentService {
// GetAttachmentBinary returns a attachment binary by name. // GetAttachmentBinary returns a attachment binary by name.
rpc GetAttachmentBinary(GetAttachmentBinaryRequest) returns (google.api.HttpBody) { rpc GetAttachmentBinary(GetAttachmentBinaryRequest) returns (google.api.HttpBody) {
option (google.api.http) = {get: "/file/{name=attachments/*}/{filename}"}; 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. // UpdateAttachment updates a attachment.
rpc UpdateAttachment(UpdateAttachmentRequest) returns (Attachment) { rpc UpdateAttachment(UpdateAttachmentRequest) returns (Attachment) {
......
...@@ -596,14 +596,14 @@ const file_api_v1_attachment_service_proto_rawDesc = "" + ...@@ -596,14 +596,14 @@ const file_api_v1_attachment_service_proto_rawDesc = "" +
"updateMask\"N\n" + "updateMask\"N\n" +
"\x17DeleteAttachmentRequest\x123\n" + "\x17DeleteAttachmentRequest\x123\n" +
"\x04name\x18\x01 \x01(\tB\x1f\xe0A\x02\xfaA\x19\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" + "\x11AttachmentService\x12\x89\x01\n" +
"\x10CreateAttachment\x12%.memos.api.v1.CreateAttachmentRequest\x1a\x18.memos.api.v1.Attachment\"4\xdaA\n" + "\x10CreateAttachment\x12%.memos.api.v1.CreateAttachmentRequest\x1a\x18.memos.api.v1.Attachment\"4\xdaA\n" +
"attachment\x82\xd3\xe4\x93\x02!:\n" + "attachment\x82\xd3\xe4\x93\x02!:\n" +
"attachment\"\x13/api/v1/attachments\x12{\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" + "\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" + "\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\"=\xdaA\rname,filename\x82\xd3\xe4\x93\x02'\x12%/file/{name=attachments/*}/{filename}\x12\xa9\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" + "\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" + "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" + "\x10DeleteAttachment\x12%.memos.api.v1.DeleteAttachmentRequest\x1a\x16.google.protobuf.Empty\"+\xdaA\x04name\x82\xd3\xe4\x93\x02\x1e*\x1c/api/v1/{name=attachments/*}B\xae\x01\n" +
......
...@@ -9,11 +9,11 @@ import { ...@@ -9,11 +9,11 @@ import {
FileVideo2Icon, FileVideo2Icon,
SheetIcon, SheetIcon,
} from "lucide-react"; } from "lucide-react";
import React from "react"; import React, { useState } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Attachment } from "@/types/proto/api/v1/attachment_service"; import { Attachment } from "@/types/proto/api/v1/attachment_service";
import { getAttachmentType, getAttachmentUrl } from "@/utils/attachment"; import { getAttachmentType, getAttachmentUrl } from "@/utils/attachment";
import showPreviewImageDialog from "./PreviewImageDialog"; import { PreviewImageDialog } from "./PreviewImageDialog";
import SquareDiv from "./kit/SquareDiv"; import SquareDiv from "./kit/SquareDiv";
interface Props { interface Props {
...@@ -24,26 +24,52 @@ interface Props { ...@@ -24,26 +24,52 @@ interface Props {
const AttachmentIcon = (props: Props) => { const AttachmentIcon = (props: Props) => {
const { attachment } = props; const { attachment } = props;
const [previewImage, setPreviewImage] = useState<{ open: boolean; urls: string[]; index: number }>({
open: false,
urls: [],
index: 0,
});
const resourceType = getAttachmentType(attachment); const resourceType = getAttachmentType(attachment);
const resourceUrl = getAttachmentUrl(attachment); const attachmentUrl = getAttachmentUrl(attachment);
const className = cn("w-full h-auto", props.className); const className = cn("w-full h-auto", props.className);
const strokeWidth = props.strokeWidth; const strokeWidth = props.strokeWidth;
const previewResource = () => { const previewResource = () => {
window.open(resourceUrl); window.open(attachmentUrl);
};
const handleImageClick = () => {
setPreviewImage({ open: true, urls: [attachmentUrl], index: 0 });
}; };
if (resourceType === "image/*") { if (resourceType === "image/*") {
return ( return (
<SquareDiv className={cn(className, "flex items-center justify-center overflow-clip")}> <>
<img <SquareDiv className={cn(className, "flex items-center justify-center overflow-clip")}>
className="min-w-full min-h-full object-cover" <img
src={attachment.externalLink ? resourceUrl : resourceUrl + "?thumbnail=true"} className="min-w-full min-h-full object-cover"
onClick={() => showPreviewImageDialog(resourceUrl)} src={attachment.externalLink ? attachmentUrl : attachmentUrl + "?thumbnail=true"}
decoding="async" onClick={handleImageClick}
loading="lazy" 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> </>
); );
} }
......
import { XIcon } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { userStore } from "@/store/v2"; import { userStore } from "@/store/v2";
import { User } from "@/types/proto/api/v1/user_service"; import { User } from "@/types/proto/api/v1/user_service";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import { generateDialog } from "./Dialog";
interface Props extends DialogProps { interface ChangeMemberPasswordDialogProps {
user: User; open: boolean;
onOpenChange: (open: boolean) => void;
user?: User;
onSuccess?: () => void;
} }
const ChangeMemberPasswordDialog: React.FC<Props> = (props: Props) => { export function ChangeMemberPasswordDialog({ open, onOpenChange, user, onSuccess }: ChangeMemberPasswordDialogProps) {
const { user, destroy } = props;
const t = useTranslate(); const t = useTranslate();
const [newPassword, setNewPassword] = useState(""); const [newPassword, setNewPassword] = useState("");
const [newPasswordAgain, setNewPasswordAgain] = useState(""); const [newPasswordAgain, setNewPasswordAgain] = useState("");
...@@ -23,7 +25,7 @@ const ChangeMemberPasswordDialog: React.FC<Props> = (props: Props) => { ...@@ -23,7 +25,7 @@ const ChangeMemberPasswordDialog: React.FC<Props> = (props: Props) => {
}, []); }, []);
const handleCloseBtnClick = () => { const handleCloseBtnClick = () => {
destroy(); onOpenChange(false);
}; };
const handleNewPasswordChanged = (e: React.ChangeEvent<HTMLInputElement>) => { const handleNewPasswordChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
...@@ -37,6 +39,8 @@ const ChangeMemberPasswordDialog: React.FC<Props> = (props: Props) => { ...@@ -37,6 +39,8 @@ const ChangeMemberPasswordDialog: React.FC<Props> = (props: Props) => {
}; };
const handleSaveBtnClick = async () => { const handleSaveBtnClick = async () => {
if (!user) return;
if (newPassword === "" || newPasswordAgain === "") { if (newPassword === "" || newPasswordAgain === "") {
toast.error(t("message.fill-all")); toast.error(t("message.fill-all"));
return; return;
...@@ -57,62 +61,55 @@ const ChangeMemberPasswordDialog: React.FC<Props> = (props: Props) => { ...@@ -57,62 +61,55 @@ const ChangeMemberPasswordDialog: React.FC<Props> = (props: Props) => {
["password"], ["password"],
); );
toast(t("message.password-changed")); toast(t("message.password-changed"));
handleCloseBtnClick(); onSuccess?.();
onOpenChange(false);
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
toast.error(error.details); toast.error(error.details);
} }
}; };
if (!user) return null;
return ( return (
<div className="max-w-full shadow flex flex-col justify-start items-start bg-card text-card-foreground p-4 rounded-lg"> <Dialog open={open} onOpenChange={onOpenChange}>
<div className="flex flex-row justify-between items-center mb-4 gap-2 w-full"> <DialogContent className="max-w-md">
<p> <DialogHeader>
{t("setting.account-section.change-password")} ({user.displayName}) <DialogTitle>
</p> {t("setting.account-section.change-password")} ({user.displayName})
<Button variant="ghost" onClick={handleCloseBtnClick}> </DialogTitle>
<XIcon className="w-5 h-auto" /> </DialogHeader>
</Button> <div className="flex flex-col gap-4">
</div> <div className="grid gap-2">
<div className="flex flex-col justify-start items-start w-80!"> <Label htmlFor="newPassword">{t("auth.new-password")}</Label>
<p className="text-sm mb-1">{t("auth.new-password")}</p> <Input
<Input id="newPassword"
className="w-full" type="password"
type="password" placeholder={t("auth.new-password")}
placeholder={t("auth.new-password")} value={newPassword}
value={newPassword} onChange={handleNewPasswordChanged}
onChange={handleNewPasswordChanged} />
/> </div>
<p className="text-sm mb-1 mt-2">{t("auth.repeat-new-password")}</p> <div className="grid gap-2">
<Input <Label htmlFor="newPasswordAgain">{t("auth.repeat-new-password")}</Label>
className="w-full" <Input
type="password" id="newPasswordAgain"
placeholder={t("auth.repeat-new-password")} type="password"
value={newPasswordAgain} placeholder={t("auth.repeat-new-password")}
onChange={handleNewPasswordAgainChanged} value={newPasswordAgain}
/> onChange={handleNewPasswordAgainChanged}
<div className="flex flex-row justify-end items-center mt-4 w-full gap-x-2"> />
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={handleCloseBtnClick}> <Button variant="ghost" onClick={handleCloseBtnClick}>
{t("common.cancel")} {t("common.cancel")}
</Button> </Button>
<Button color="primary" onClick={handleSaveBtnClick}> <Button onClick={handleSaveBtnClick}>{t("common.save")}</Button>
{t("common.save")} </DialogFooter>
</Button> </DialogContent>
</div> </Dialog>
</div>
</div>
);
};
function showChangeMemberPasswordDialog(user: User) {
generateDialog(
{
className: "change-member-password-dialog",
dialogName: "change-member-password-dialog",
},
ChangeMemberPasswordDialog,
{ user },
); );
} }
export default showChangeMemberPasswordDialog; export default ChangeMemberPasswordDialog;
import { XIcon } from "lucide-react";
import React, { useState } from "react"; import React, { useState } from "react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
...@@ -9,10 +9,11 @@ import { userServiceClient } from "@/grpcweb"; ...@@ -9,10 +9,11 @@ import { userServiceClient } from "@/grpcweb";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
import useLoading from "@/hooks/useLoading"; import useLoading from "@/hooks/useLoading";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import { generateDialog } from "./Dialog";
interface Props extends DialogProps { interface CreateAccessTokenDialogProps {
onConfirm: () => void; open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
} }
interface State { interface State {
...@@ -20,8 +21,7 @@ interface State { ...@@ -20,8 +21,7 @@ interface State {
expiration: number; expiration: number;
} }
const CreateAccessTokenDialog: React.FC<Props> = (props: Props) => { export function CreateAccessTokenDialog({ open, onOpenChange, onSuccess }: CreateAccessTokenDialogProps) {
const { destroy, onConfirm } = props;
const t = useTranslate(); const t = useTranslate();
const currentUser = useCurrentUser(); const currentUser = useCurrentUser();
const [state, setState] = useState({ const [state, setState] = useState({
...@@ -71,6 +71,7 @@ const CreateAccessTokenDialog: React.FC<Props> = (props: Props) => { ...@@ -71,6 +71,7 @@ const CreateAccessTokenDialog: React.FC<Props> = (props: Props) => {
} }
try { try {
requestState.setLoading();
await userServiceClient.createUserAccessToken({ await userServiceClient.createUserAccessToken({
parent: currentUser.name, parent: currentUser.name,
accessToken: { accessToken: {
...@@ -79,42 +80,39 @@ const CreateAccessTokenDialog: React.FC<Props> = (props: Props) => { ...@@ -79,42 +80,39 @@ const CreateAccessTokenDialog: React.FC<Props> = (props: Props) => {
}, },
}); });
onConfirm(); requestState.setFinish();
destroy(); onSuccess();
onOpenChange(false);
} catch (error: any) { } catch (error: any) {
toast.error(error.details); toast.error(error.details);
console.error(error); console.error(error);
requestState.setError();
} }
}; };
return ( return (
<div className="max-w-full shadow flex flex-col justify-start items-start bg-card text-card-foreground p-4 rounded-lg"> <Dialog open={open} onOpenChange={onOpenChange}>
<div className="flex flex-row justify-between items-center w-full mb-4 gap-2"> <DialogContent className="max-w-md">
<p>{t("setting.access-token-section.create-dialog.create-access-token")}</p> <DialogHeader>
<Button variant="ghost" onClick={() => destroy()}> <DialogTitle>{t("setting.access-token-section.create-dialog.create-access-token")}</DialogTitle>
<XIcon className="w-5 h-auto" /> </DialogHeader>
</Button> <div className="flex flex-col gap-4">
</div> <div className="grid gap-2">
<div className="flex flex-col justify-start items-start w-80!"> <Label htmlFor="description">
<div className="w-full flex flex-col justify-start items-start mb-3"> {t("setting.access-token-section.create-dialog.description")} <span className="text-destructive">*</span>
<span className="mb-2"> </Label>
{t("setting.access-token-section.create-dialog.description")} <span className="text-destructive">*</span>
</span>
<div className="relative w-full">
<Input <Input
className="w-full" id="description"
type="text" type="text"
placeholder={t("setting.access-token-section.create-dialog.some-description")} placeholder={t("setting.access-token-section.create-dialog.some-description")}
value={state.description} value={state.description}
onChange={handleDescriptionInputChange} onChange={handleDescriptionInputChange}
/> />
</div> </div>
</div> <div className="grid gap-2">
<div className="w-full flex flex-col justify-start items-start mb-3"> <Label>
<span className="mb-2"> {t("setting.access-token-section.create-dialog.expiration")} <span className="text-destructive">*</span>
{t("setting.access-token-section.create-dialog.expiration")} <span className="text-destructive">*</span> </Label>
</span>
<div className="w-full flex flex-row justify-start items-center text-base">
<RadioGroup value={state.expiration.toString()} onValueChange={handleRoleInputChange} className="flex flex-row gap-4"> <RadioGroup value={state.expiration.toString()} onValueChange={handleRoleInputChange} className="flex flex-row gap-4">
{expirationOptions.map((option) => ( {expirationOptions.map((option) => (
<div key={option.value} className="flex items-center space-x-2"> <div key={option.value} className="flex items-center space-x-2">
...@@ -125,30 +123,17 @@ const CreateAccessTokenDialog: React.FC<Props> = (props: Props) => { ...@@ -125,30 +123,17 @@ const CreateAccessTokenDialog: React.FC<Props> = (props: Props) => {
</RadioGroup> </RadioGroup>
</div> </div>
</div> </div>
<div className="w-full flex flex-row justify-end items-center mt-4 space-x-2"> <DialogFooter>
<Button variant="ghost" disabled={requestState.isLoading} onClick={destroy}> <Button variant="ghost" disabled={requestState.isLoading} onClick={() => onOpenChange(false)}>
{t("common.cancel")} {t("common.cancel")}
</Button> </Button>
<Button disabled={requestState.isLoading} onClick={handleSaveBtnClick}> <Button disabled={requestState.isLoading} onClick={handleSaveBtnClick}>
{t("common.create")} {t("common.create")}
</Button> </Button>
</div> </DialogFooter>
</div> </DialogContent>
</div> </Dialog>
);
};
function showCreateAccessTokenDialog(onConfirm: () => void) {
generateDialog(
{
className: "create-access-token-dialog",
dialogName: "create-access-token-dialog",
},
CreateAccessTokenDialog,
{
onConfirm,
},
); );
} }
export default showCreateAccessTokenDialog; export default CreateAccessTokenDialog;
import { XIcon } from "lucide-react";
import React, { useState } from "react"; import React, { useState } from "react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { shortcutServiceClient } from "@/grpcweb"; import { shortcutServiceClient } from "@/grpcweb";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
...@@ -10,23 +11,24 @@ import useLoading from "@/hooks/useLoading"; ...@@ -10,23 +11,24 @@ import useLoading from "@/hooks/useLoading";
import { userStore } from "@/store/v2"; import { userStore } from "@/store/v2";
import { Shortcut } from "@/types/proto/api/v1/shortcut_service"; import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import { generateDialog } from "./Dialog";
interface Props extends DialogProps { interface CreateShortcutDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
shortcut?: Shortcut; shortcut?: Shortcut;
onSuccess?: () => void;
} }
const CreateShortcutDialog: React.FC<Props> = (props: Props) => { export function CreateShortcutDialog({ open, onOpenChange, shortcut: initialShortcut, onSuccess }: CreateShortcutDialogProps) {
const { destroy } = props;
const t = useTranslate(); const t = useTranslate();
const user = useCurrentUser(); const user = useCurrentUser();
const [shortcut, setShortcut] = useState<Shortcut>({ const [shortcut, setShortcut] = useState<Shortcut>({
name: props.shortcut?.name || "", name: initialShortcut?.name || "",
title: props.shortcut?.title || "", title: initialShortcut?.title || "",
filter: props.shortcut?.filter || "", filter: initialShortcut?.filter || "",
}); });
const requestState = useLoading(false); const requestState = useLoading(false);
const isCreating = !props.shortcut; const isCreating = !initialShortcut;
const onShortcutTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => { const onShortcutTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setShortcut({ ...shortcut, title: e.target.value }); setShortcut({ ...shortcut, title: e.target.value });
...@@ -43,6 +45,7 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => { ...@@ -43,6 +45,7 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
} }
try { try {
requestState.setLoading();
if (isCreating) { if (isCreating) {
await shortcutServiceClient.createShortcut({ await shortcutServiceClient.createShortcut({
parent: user.name, parent: user.name,
...@@ -57,7 +60,7 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => { ...@@ -57,7 +60,7 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
await shortcutServiceClient.updateShortcut({ await shortcutServiceClient.updateShortcut({
shortcut: { shortcut: {
...shortcut, ...shortcut,
name: props.shortcut!.name, // Keep the original resource name name: initialShortcut!.name, // Keep the original resource name
}, },
updateMask: ["title", "filter"], updateMask: ["title", "filter"],
}); });
...@@ -65,79 +68,74 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => { ...@@ -65,79 +68,74 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
} }
// Refresh shortcuts. // Refresh shortcuts.
await userStore.fetchShortcuts(); await userStore.fetchShortcuts();
destroy(); requestState.setFinish();
onSuccess?.();
onOpenChange(false);
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
toast.error(error.details); toast.error(error.details);
requestState.setError();
} }
}; };
return ( return (
<div className="max-w-full shadow flex flex-col justify-start items-start bg-card text-card-foreground p-4 rounded-lg"> <Dialog open={open} onOpenChange={onOpenChange}>
<div className="flex flex-row justify-between items-center mb-4 gap-2 w-full"> <DialogContent className="max-w-md">
<p className="title-text">{`${isCreating ? t("common.create") : t("common.edit")} ${t("common.shortcuts")}`}</p> <DialogHeader>
<Button variant="ghost" onClick={() => destroy()}> <DialogTitle>{`${isCreating ? t("common.create") : t("common.edit")} ${t("common.shortcuts")}`}</DialogTitle>
<XIcon className="w-5 h-auto" /> </DialogHeader>
</Button> <div className="flex flex-col gap-4">
</div> <div className="grid gap-2">
<div className="flex flex-col justify-start items-start max-w-md min-w-72"> <Label htmlFor="title">{t("common.title")}</Label>
<div className="w-full flex flex-col justify-start items-start mb-3"> <Input id="title" type="text" placeholder="" value={shortcut.title} onChange={onShortcutTitleChange} />
<span className="text-sm whitespace-nowrap mb-1">{t("common.title")}</span> </div>
<Input className="w-full" type="text" placeholder="" value={shortcut.title} onChange={onShortcutTitleChange} /> <div className="grid gap-2">
<span className="text-sm whitespace-nowrap mt-3 mb-1">{t("common.filter")}</span> <Label htmlFor="filter">{t("common.filter")}</Label>
<Textarea <Textarea
className="w-full" id="filter"
rows={3} rows={3}
placeholder={t("common.shortcut-filter")} placeholder={t("common.shortcut-filter")}
value={shortcut.filter} value={shortcut.filter}
onChange={onShortcutFilterChange} 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>
<div className="w-full opacity-70"> <DialogFooter>
<p className="text-sm">{t("common.learn-more")}:</p> <Button variant="ghost" disabled={requestState.isLoading} onClick={() => onOpenChange(false)}>
<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}>
{t("common.cancel")} {t("common.cancel")}
</Button> </Button>
<Button color="primary" disabled={requestState.isLoading} onClick={handleConfirm}> <Button disabled={requestState.isLoading} onClick={handleConfirm}>
{t("common.confirm")} {t("common.confirm")}
</Button> </Button>
</div> </DialogFooter>
</div> </DialogContent>
</div> </Dialog>
);
};
function showCreateShortcutDialog(props: Pick<Props, "shortcut">) {
generateDialog(
{
className: "create-shortcut-dialog",
dialogName: "create-shortcut-dialog",
},
CreateShortcutDialog,
props,
); );
} }
export default showCreateShortcutDialog; export default CreateShortcutDialog;
import { XIcon } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
...@@ -9,19 +9,19 @@ import { userServiceClient } from "@/grpcweb"; ...@@ -9,19 +9,19 @@ import { userServiceClient } from "@/grpcweb";
import useLoading from "@/hooks/useLoading"; import useLoading from "@/hooks/useLoading";
import { User, User_Role } from "@/types/proto/api/v1/user_service"; import { User, User_Role } from "@/types/proto/api/v1/user_service";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import { generateDialog } from "./Dialog";
interface Props extends DialogProps { interface CreateUserDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
user?: User; user?: User;
confirmCallback?: () => void; onSuccess?: () => void;
} }
const CreateUserDialog: React.FC<Props> = (props: Props) => { export function CreateUserDialog({ open, onOpenChange, user: initialUser, onSuccess }: CreateUserDialogProps) {
const { confirmCallback, destroy } = props;
const t = useTranslate(); const t = useTranslate();
const [user, setUser] = useState(User.fromPartial({ ...props.user })); const [user, setUser] = useState(User.fromPartial({ ...initialUser }));
const requestState = useLoading(false); const requestState = useLoading(false);
const isCreating = !props.user; const isCreating = !initialUser;
const setPartialUser = (state: Partial<User>) => { const setPartialUser = (state: Partial<User>) => {
setUser({ setUser({
...@@ -37,106 +37,99 @@ const CreateUserDialog: React.FC<Props> = (props: Props) => { ...@@ -37,106 +37,99 @@ const CreateUserDialog: React.FC<Props> = (props: Props) => {
} }
try { try {
requestState.setLoading();
if (isCreating) { if (isCreating) {
await userServiceClient.createUser({ user }); await userServiceClient.createUser({ user });
toast.success("Create user successfully"); toast.success("Create user successfully");
} else { } else {
const updateMask = []; const updateMask = [];
if (user.username !== props.user?.username) { if (user.username !== initialUser?.username) {
updateMask.push("username"); updateMask.push("username");
} }
if (user.password) { if (user.password) {
updateMask.push("password"); updateMask.push("password");
} }
if (user.role !== props.user?.role) { if (user.role !== initialUser?.role) {
updateMask.push("role"); updateMask.push("role");
} }
await userServiceClient.updateUser({ user, updateMask }); await userServiceClient.updateUser({ user, updateMask });
toast.success("Update user successfully"); toast.success("Update user successfully");
} }
requestState.setFinish();
onSuccess?.();
onOpenChange(false);
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
toast.error(error.details); toast.error(error.details);
requestState.setError();
} }
if (confirmCallback) {
confirmCallback();
}
destroy();
}; };
return ( return (
<div className="max-w-full shadow flex flex-col justify-start items-start bg-card text-card-foreground p-4 rounded-lg"> <Dialog open={open} onOpenChange={onOpenChange}>
<div className="flex flex-row justify-between items-center mb-4 gap-2 w-full"> <DialogContent className="max-w-md">
<p className="title-text">{`${isCreating ? t("common.create") : t("common.edit")} ${t("common.user")}`}</p> <DialogHeader>
<Button variant="ghost" onClick={() => destroy()}> <DialogTitle>{`${isCreating ? t("common.create") : t("common.edit")} ${t("common.user")}`}</DialogTitle>
<XIcon className="w-5 h-auto" /> </DialogHeader>
</Button> <div className="flex flex-col gap-4">
</div> <div className="grid gap-2">
<div className="flex flex-col justify-start items-start max-w-md min-w-72"> <Label htmlFor="username">{t("common.username")}</Label>
<div className="w-full flex flex-col justify-start items-start mb-3"> <Input
<span className="text-sm whitespace-nowrap mb-1">{t("common.username")}</span> id="username"
<Input type="text"
className="w-full" placeholder={t("common.username")}
type="text" value={user.username}
placeholder={t("common.username")} onChange={(e) =>
value={user.username} setPartialUser({
onChange={(e) => username: e.target.value,
setPartialUser({ })
username: e.target.value, }
}) />
} </div>
/> <div className="grid gap-2">
<span className="text-sm whitespace-nowrap mt-3 mb-1">{t("common.password")}</span> <Label htmlFor="password">{t("common.password")}</Label>
<Input <Input
className="w-full" id="password"
type="password" type="password"
placeholder={t("common.password")} placeholder={t("common.password")}
autoComplete="off" autoComplete="off"
value={user.password} value={user.password}
onChange={(e) => onChange={(e) =>
setPartialUser({ setPartialUser({
password: e.target.value, password: e.target.value,
}) })
} }
/> />
<span className="text-sm whitespace-nowrap mt-3 mb-1">{t("common.role")}</span> </div>
<RadioGroup <div className="grid gap-2">
value={user.role} <Label>{t("common.role")}</Label>
onValueChange={(value) => setPartialUser({ role: value as User_Role })} <RadioGroup
className="flex flex-row gap-4" value={user.role}
> onValueChange={(value) => setPartialUser({ role: value as User_Role })}
<div className="flex items-center space-x-2"> className="flex flex-row gap-4"
<RadioGroupItem value={User_Role.USER} id="user" /> >
<Label htmlFor="user">{t("setting.member-section.user")}</Label> <div className="flex items-center space-x-2">
</div> <RadioGroupItem value={User_Role.USER} id="user" />
<div className="flex items-center space-x-2"> <Label htmlFor="user">{t("setting.member-section.user")}</Label>
<RadioGroupItem value={User_Role.ADMIN} id="admin" /> </div>
<Label htmlFor="admin">{t("setting.member-section.admin")}</Label> <div className="flex items-center space-x-2">
</div> <RadioGroupItem value={User_Role.ADMIN} id="admin" />
</RadioGroup> <Label htmlFor="admin">{t("setting.member-section.admin")}</Label>
</div>
</RadioGroup>
</div>
</div> </div>
<div className="w-full flex flex-row justify-end items-center space-x-2 mt-2"> <DialogFooter>
<Button variant="ghost" disabled={requestState.isLoading} onClick={destroy}> <Button variant="ghost" disabled={requestState.isLoading} onClick={() => onOpenChange(false)}>
{t("common.cancel")} {t("common.cancel")}
</Button> </Button>
<Button color="primary" disabled={requestState.isLoading} onClick={handleConfirm}> <Button disabled={requestState.isLoading} onClick={handleConfirm}>
{t("common.confirm")} {t("common.confirm")}
</Button> </Button>
</div> </DialogFooter>
</div> </DialogContent>
</div> </Dialog>
);
};
function showCreateUserDialog(user?: User, confirmCallback?: () => void) {
generateDialog(
{
className: "create-user-dialog",
dialogName: "create-user-dialog",
},
CreateUserDialog,
{ user, confirmCallback },
); );
} }
export default showCreateUserDialog; export default CreateUserDialog;
import { XIcon } from "lucide-react";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { webhookServiceClient } from "@/grpcweb"; import { webhookServiceClient } from "@/grpcweb";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
import useLoading from "@/hooks/useLoading"; import useLoading from "@/hooks/useLoading";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import { generateDialog } from "./Dialog";
interface Props extends DialogProps { interface CreateWebhookDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
webhookName?: string; webhookName?: string;
onConfirm: () => void; onSuccess?: () => void;
} }
interface State { interface State {
...@@ -19,11 +21,10 @@ interface State { ...@@ -19,11 +21,10 @@ interface State {
url: string; url: string;
} }
const CreateWebhookDialog: React.FC<Props> = (props: Props) => { export function CreateWebhookDialog({ open, onOpenChange, webhookName, onSuccess }: CreateWebhookDialogProps) {
const { webhookName, destroy, onConfirm } = props;
const t = useTranslate(); const t = useTranslate();
const currentUser = useCurrentUser(); const currentUser = useCurrentUser();
const [state, setState] = useState({ const [state, setState] = useState<State>({
displayName: "", displayName: "",
url: "", url: "",
}); });
...@@ -43,7 +44,7 @@ const CreateWebhookDialog: React.FC<Props> = (props: Props) => { ...@@ -43,7 +44,7 @@ const CreateWebhookDialog: React.FC<Props> = (props: Props) => {
}); });
}); });
} }
}, []); }, [webhookName]);
const setPartialState = (partialState: Partial<State>) => { const setPartialState = (partialState: Partial<State>) => {
setState({ setState({
...@@ -76,6 +77,7 @@ const CreateWebhookDialog: React.FC<Props> = (props: Props) => { ...@@ -76,6 +77,7 @@ const CreateWebhookDialog: React.FC<Props> = (props: Props) => {
} }
try { try {
requestState.setLoading();
if (isCreating) { if (isCreating) {
await webhookServiceClient.createWebhook({ await webhookServiceClient.createWebhook({
parent: currentUser.name, parent: currentUser.name,
...@@ -95,46 +97,45 @@ const CreateWebhookDialog: React.FC<Props> = (props: Props) => { ...@@ -95,46 +97,45 @@ const CreateWebhookDialog: React.FC<Props> = (props: Props) => {
}); });
} }
onConfirm(); onSuccess?.();
destroy(); onOpenChange(false);
requestState.setFinish();
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
toast.error(error.details); toast.error(error.details);
requestState.setError();
} }
}; };
return ( return (
<div className="max-w-full shadow flex flex-col justify-start items-start bg-card text-card-foreground p-4 rounded-lg"> <Dialog open={open} onOpenChange={onOpenChange}>
<div className="flex flex-row justify-between items-center mb-4 gap-2 w-full"> <DialogContent className="max-w-md">
<p className="title-text"> <DialogHeader>
{isCreating ? t("setting.webhook-section.create-dialog.create-webhook") : t("setting.webhook-section.create-dialog.edit-webhook")} <DialogTitle>
</p> {isCreating
<Button variant="ghost" onClick={() => destroy()}> ? t("setting.webhook-section.create-dialog.create-webhook")
<XIcon className="w-5 h-auto" /> : t("setting.webhook-section.create-dialog.edit-webhook")}
</Button> </DialogTitle>
</div> </DialogHeader>
<div className="flex flex-col justify-start items-start w-80!"> <div className="flex flex-col gap-4">
<div className="w-full flex flex-col justify-start items-start mb-3"> <div className="grid gap-2">
<span className="mb-2"> <Label htmlFor="displayName">
{t("setting.webhook-section.create-dialog.title")} <span className="text-destructive">*</span> {t("setting.webhook-section.create-dialog.title")} <span className="text-destructive">*</span>
</span> </Label>
<div className="relative w-full">
<Input <Input
className="w-full" id="displayName"
type="text" type="text"
placeholder={t("setting.webhook-section.create-dialog.an-easy-to-remember-name")} placeholder={t("setting.webhook-section.create-dialog.an-easy-to-remember-name")}
value={state.displayName} value={state.displayName}
onChange={handleTitleInputChange} onChange={handleTitleInputChange}
/> />
</div> </div>
</div> <div className="grid gap-2">
<div className="w-full flex flex-col justify-start items-start mb-3"> <Label htmlFor="url">
<span className="mb-2"> {t("setting.webhook-section.create-dialog.payload-url")} <span className="text-destructive">*</span>
{t("setting.webhook-section.create-dialog.payload-url")} <span className="text-destructive">*</span> </Label>
</span>
<div className="relative w-full">
<Input <Input
className="w-full" id="url"
type="text" type="text"
placeholder={t("setting.webhook-section.create-dialog.url-example-post-receive")} placeholder={t("setting.webhook-section.create-dialog.url-example-post-receive")}
value={state.url} value={state.url}
...@@ -142,30 +143,17 @@ const CreateWebhookDialog: React.FC<Props> = (props: Props) => { ...@@ -142,30 +143,17 @@ const CreateWebhookDialog: React.FC<Props> = (props: Props) => {
/> />
</div> </div>
</div> </div>
<div className="w-full flex flex-row justify-end items-center mt-2 space-x-2"> <DialogFooter>
<Button variant="ghost" disabled={requestState.isLoading} onClick={destroy}> <Button variant="ghost" disabled={requestState.isLoading} onClick={() => onOpenChange(false)}>
{t("common.cancel")} {t("common.cancel")}
</Button> </Button>
<Button color="primary" disabled={requestState.isLoading} onClick={handleSaveBtnClick}> <Button disabled={requestState.isLoading} onClick={handleSaveBtnClick}>
{t("common.create")} {t("common.create")}
</Button> </Button>
</div> </DialogFooter>
</div> </DialogContent>
</div> </Dialog>
);
};
function showCreateWebhookDialog(onConfirm: () => void) {
generateDialog(
{
className: "create-webhook-dialog",
dialogName: "create-webhook-dialog",
},
CreateWebhookDialog,
{
onConfirm,
},
); );
} }
export default showCreateWebhookDialog; export default CreateWebhookDialog;
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;
}
export { generateDialog } from "./BaseDialog";
import { Edit3Icon, MoreVerticalIcon, TrashIcon, PlusIcon } from "lucide-react"; import { Edit3Icon, MoreVerticalIcon, TrashIcon, PlusIcon } from "lucide-react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useState } from "react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { shortcutServiceClient } from "@/grpcweb"; import { shortcutServiceClient } from "@/grpcweb";
import useAsyncEffect from "@/hooks/useAsyncEffect"; import useAsyncEffect from "@/hooks/useAsyncEffect";
...@@ -8,7 +9,7 @@ import { userStore } from "@/store/v2"; ...@@ -8,7 +9,7 @@ import { userStore } from "@/store/v2";
import memoFilterStore from "@/store/v2/memoFilter"; import memoFilterStore from "@/store/v2/memoFilter";
import { Shortcut } from "@/types/proto/api/v1/shortcut_service"; import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import showCreateShortcutDialog from "../CreateShortcutDialog"; import CreateShortcutDialog from "../CreateShortcutDialog";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/dropdown-menu"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/dropdown-menu";
const emojiRegex = /^(\p{Emoji_Presentation}|\p{Emoji}\uFE0F)$/u; const emojiRegex = /^(\p{Emoji_Presentation}|\p{Emoji}\uFE0F)$/u;
...@@ -23,6 +24,8 @@ const getShortcutId = (name: string): string => { ...@@ -23,6 +24,8 @@ const getShortcutId = (name: string): string => {
const ShortcutsSection = observer(() => { const ShortcutsSection = observer(() => {
const t = useTranslate(); const t = useTranslate();
const shortcuts = userStore.state.shortcuts; const shortcuts = userStore.state.shortcuts;
const [isCreateShortcutDialogOpen, setIsCreateShortcutDialogOpen] = useState(false);
const [editingShortcut, setEditingShortcut] = useState<Shortcut | undefined>();
useAsyncEffect(async () => { useAsyncEffect(async () => {
await userStore.fetchShortcuts(); await userStore.fetchShortcuts();
...@@ -36,6 +39,21 @@ const ShortcutsSection = observer(() => { ...@@ -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 ( 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="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"> <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(() => { ...@@ -43,7 +61,7 @@ const ShortcutsSection = observer(() => {
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<PlusIcon className="w-4 h-auto cursor-pointer" onClick={() => showCreateShortcutDialog({})} /> <PlusIcon className="w-4 h-auto cursor-pointer" onClick={handleCreateShortcut} />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>{t("common.create")}</p> <p>{t("common.create")}</p>
...@@ -75,7 +93,7 @@ const ShortcutsSection = observer(() => { ...@@ -75,7 +93,7 @@ const ShortcutsSection = observer(() => {
<MoreVerticalIcon className="w-4 h-auto shrink-0 text-muted-foreground cursor-pointer hover:text-foreground" /> <MoreVerticalIcon className="w-4 h-auto shrink-0 text-muted-foreground cursor-pointer hover:text-foreground" />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" alignOffset={-12}> <DropdownMenuContent align="end" alignOffset={-12}>
<DropdownMenuItem onClick={() => showCreateShortcutDialog({ shortcut })}> <DropdownMenuItem onClick={() => handleEditShortcut(shortcut)}>
<Edit3Icon className="w-4 h-auto" /> <Edit3Icon className="w-4 h-auto" />
{t("common.edit")} {t("common.edit")}
</DropdownMenuItem> </DropdownMenuItem>
...@@ -89,6 +107,12 @@ const ShortcutsSection = observer(() => { ...@@ -89,6 +107,12 @@ const ShortcutsSection = observer(() => {
); );
})} })}
</div> </div>
<CreateShortcutDialog
open={isCreateShortcutDialogOpen}
onOpenChange={setIsCreateShortcutDialogOpen}
shortcut={editingShortcut}
onSuccess={handleShortcutDialogSuccess}
/>
</div> </div>
); );
}); });
......
import { Edit3Icon, HashIcon, MoreVerticalIcon, TagsIcon, TrashIcon } from "lucide-react"; import { Edit3Icon, HashIcon, MoreVerticalIcon, TagsIcon, TrashIcon } from "lucide-react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import useLocalStorage from "react-use/lib/useLocalStorage"; import useLocalStorage from "react-use/lib/useLocalStorage";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { memoServiceClient } from "@/grpcweb"; import { memoServiceClient } from "@/grpcweb";
import { useDialog } from "@/hooks/useDialog";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { userStore } from "@/store/v2"; import { userStore } from "@/store/v2";
import memoFilterStore, { MemoFilter } from "@/store/v2/memoFilter"; import memoFilterStore, { MemoFilter } from "@/store/v2/memoFilter";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import showRenameTagDialog from "../RenameTagDialog"; import RenameTagDialog from "../RenameTagDialog";
import TagTree from "../TagTree"; import TagTree from "../TagTree";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/dropdown-menu"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/dropdown-menu";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
...@@ -20,6 +22,8 @@ interface Props { ...@@ -20,6 +22,8 @@ interface Props {
const TagsSection = observer((props: Props) => { const TagsSection = observer((props: Props) => {
const t = useTranslate(); const t = useTranslate();
const [treeMode, setTreeMode] = useLocalStorage<boolean>("tag-view-as-tree", false); 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) const tags = Object.entries(userStore.state.tagCount)
.sort((a, b) => a[0].localeCompare(b[0])) .sort((a, b) => a[0].localeCompare(b[0]))
.sort((a, b) => b[1] - a[1]); .sort((a, b) => b[1] - a[1]);
...@@ -36,6 +40,16 @@ const TagsSection = observer((props: Props) => { ...@@ -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 handleDeleteTag = async (tag: string) => {
const confirmed = window.confirm(t("tag.delete-confirm")); const confirmed = window.confirm(t("tag.delete-confirm"));
if (confirmed) { if (confirmed) {
...@@ -83,7 +97,7 @@ const TagsSection = observer((props: Props) => { ...@@ -83,7 +97,7 @@ const TagsSection = observer((props: Props) => {
</div> </div>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start" sideOffset={2}> <DropdownMenuContent align="start" sideOffset={2}>
<DropdownMenuItem onClick={() => showRenameTagDialog({ tag: tag })}> <DropdownMenuItem onClick={() => handleRenameTag(tag)}>
<Edit3Icon className="w-4 h-auto" /> <Edit3Icon className="w-4 h-auto" />
{t("common.rename")} {t("common.rename")}
</DropdownMenuItem> </DropdownMenuItem>
...@@ -112,6 +126,14 @@ const TagsSection = observer((props: Props) => { ...@@ -112,6 +126,14 @@ const TagsSection = observer((props: Props) => {
</div> </div>
) )
)} )}
{/* Rename Tag Dialog */}
<RenameTagDialog
open={renameTagDialog.isOpen}
onOpenChange={renameTagDialog.setOpen}
tag={selectedTag}
onSuccess={handleRenameSuccess}
/>
</div> </div>
); );
}); });
......
import { memo } from "react"; import { memo, useState } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Attachment } from "@/types/proto/api/v1/attachment_service"; import { Attachment } from "@/types/proto/api/v1/attachment_service";
import { getAttachmentType, getAttachmentUrl } from "@/utils/attachment"; import { getAttachmentType, getAttachmentUrl } from "@/utils/attachment";
import MemoAttachment from "./MemoAttachment"; import MemoAttachment from "./MemoAttachment";
import showPreviewImageDialog from "./PreviewImageDialog"; import { PreviewImageDialog } from "./PreviewImageDialog";
const MemoAttachmentListView = ({ attachments = [] }: { attachments: Attachment[] }) => { const MemoAttachmentListView = ({ attachments = [] }: { attachments: Attachment[] }) => {
const [previewImage, setPreviewImage] = useState<{ open: boolean; urls: string[]; index: number }>({
open: false,
urls: [],
index: 0,
});
const mediaAttachments: Attachment[] = []; const mediaAttachments: Attachment[] = [];
const otherAttachments: Attachment[] = []; const otherAttachments: Attachment[] = [];
...@@ -24,7 +29,7 @@ const MemoAttachmentListView = ({ attachments = [] }: { attachments: Attachment[ ...@@ -24,7 +29,7 @@ const MemoAttachmentListView = ({ attachments = [] }: { attachments: Attachment[
.filter((attachment) => getAttachmentType(attachment) === "image/*") .filter((attachment) => getAttachmentType(attachment) === "image/*")
.map((attachment) => getAttachmentUrl(attachment)); .map((attachment) => getAttachmentUrl(attachment));
const index = imgUrls.findIndex((url) => url === imgUrl); const index = imgUrls.findIndex((url) => url === imgUrl);
showPreviewImageDialog(imgUrls, index); setPreviewImage({ open: true, urls: imgUrls, index });
}; };
const MediaCard = ({ attachment, className }: { attachment: Attachment; className?: string }) => { const MediaCard = ({ attachment, className }: { attachment: Attachment; className?: string }) => {
...@@ -39,6 +44,14 @@ const MemoAttachmentListView = ({ attachments = [] }: { attachments: Attachment[ ...@@ -39,6 +44,14 @@ const MemoAttachmentListView = ({ attachments = [] }: { attachments: Attachment[
className, className,
)} )}
src={attachment.externalLink ? attachmentUrl : attachmentUrl + "?thumbnail=true"} 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)} onClick={() => handleImageClick(attachmentUrl)}
decoding="async" decoding="async"
loading="lazy" loading="lazy"
...@@ -88,6 +101,13 @@ const MemoAttachmentListView = ({ attachments = [] }: { attachments: Attachment[ ...@@ -88,6 +101,13 @@ const MemoAttachmentListView = ({ attachments = [] }: { attachments: Attachment[
<> <>
{mediaAttachments.length > 0 && <MediaList attachments={mediaAttachments} />} {mediaAttachments.length > 0 && <MediaList attachments={mediaAttachments} />}
<OtherList attachments={otherAttachments} /> <OtherList attachments={otherAttachments} />
<PreviewImageDialog
open={previewImage.open}
onOpenChange={(open) => setPreviewImage((prev) => ({ ...prev, open }))}
imgUrls={previewImage.urls}
initialIndex={previewImage.index}
/>
</> </>
); );
}; };
......
...@@ -20,7 +20,7 @@ import MemoEditor from "./MemoEditor"; ...@@ -20,7 +20,7 @@ import MemoEditor from "./MemoEditor";
import MemoLocationView from "./MemoLocationView"; import MemoLocationView from "./MemoLocationView";
import MemoReactionistView from "./MemoReactionListView"; import MemoReactionistView from "./MemoReactionListView";
import MemoRelationListView from "./MemoRelationListView"; import MemoRelationListView from "./MemoRelationListView";
import showPreviewImageDialog from "./PreviewImageDialog"; import { PreviewImageDialog } from "./PreviewImageDialog";
import ReactionSelector from "./ReactionSelector"; import ReactionSelector from "./ReactionSelector";
import UserAvatar from "./UserAvatar"; import UserAvatar from "./UserAvatar";
import VisibilityIcon from "./VisibilityIcon"; import VisibilityIcon from "./VisibilityIcon";
...@@ -46,6 +46,11 @@ const MemoView: React.FC<Props> = observer((props: Props) => { ...@@ -46,6 +46,11 @@ const MemoView: React.FC<Props> = observer((props: Props) => {
const [showEditor, setShowEditor] = useState<boolean>(false); const [showEditor, setShowEditor] = useState<boolean>(false);
const [creator, setCreator] = useState(userStore.getUserByName(memo.creator)); const [creator, setCreator] = useState(userStore.getUserByName(memo.creator));
const [showNSFWContent, setShowNSFWContent] = useState(props.showNsfwContent); 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 workspaceMemoRelatedSetting = workspaceStore.state.memoRelatedSetting;
const referencedMemos = memo.relations.filter((relation) => relation.type === MemoRelation_Type.REFERENCE); const referencedMemos = memo.relations.filter((relation) => relation.type === MemoRelation_Type.REFERENCE);
const commentAmount = memo.relations.filter( const commentAmount = memo.relations.filter(
...@@ -80,7 +85,7 @@ const MemoView: React.FC<Props> = observer((props: Props) => { ...@@ -80,7 +85,7 @@ const MemoView: React.FC<Props> = observer((props: Props) => {
if (targetEl.tagName === "IMG") { if (targetEl.tagName === "IMG") {
const imgUrl = targetEl.getAttribute("src"); const imgUrl = targetEl.getAttribute("src");
if (imgUrl) { if (imgUrl) {
showPreviewImageDialog([imgUrl], 0); setPreviewImage({ open: true, urls: [imgUrl], index: 0 });
} }
} }
}, []); }, []);
...@@ -256,6 +261,13 @@ const MemoView: React.FC<Props> = observer((props: Props) => { ...@@ -256,6 +261,13 @@ const MemoView: React.FC<Props> = observer((props: Props) => {
</button> </button>
</> </>
)} )}
<PreviewImageDialog
open={previewImage.open}
onOpenChange={(open) => setPreviewImage((prev) => ({ ...prev, open }))}
imgUrls={previewImage.urls}
initialIndex={previewImage.index}
/>
</div> </div>
); );
}); });
......
import { XIcon } from "lucide-react"; import { X } from "lucide-react";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { generateDialog } from "./Dialog"; import { Dialog, DialogContent } from "@/components/ui/dialog";
const MIN_SCALE = 0.5; interface PreviewImageDialogProps {
const MAX_SCALE = 5; open: boolean;
const SCALE_UNIT = 0.2; onOpenChange: (open: boolean) => void;
interface Props extends DialogProps {
imgUrls: string[]; imgUrls: string[];
initialIndex: number; initialIndex?: number;
}
interface State {
scale: number;
originX: number;
originY: number;
} }
const defaultState: State = { export function PreviewImageDialog({ open, onOpenChange, imgUrls, initialIndex = 0 }: PreviewImageDialogProps) {
scale: 1,
originX: -1,
originY: -1,
};
const PreviewImageDialog: React.FC<Props> = ({ destroy, imgUrls, initialIndex }: Props) => {
const [currentIndex, setCurrentIndex] = useState(initialIndex); 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) => { // Update current index when initialIndex prop changes
if (event.touches.length > 1) { useEffect(() => {
// two or more fingers, ignore setCurrentIndex(initialIndex);
return; }, [initialIndex]);
}
endX = event.touches[0].clientX;
};
const handleTouchEnd = (event: React.TouchEvent) => { // Handle keyboard navigation
if (event.touches.length > 1) { useEffect(() => {
// two or more fingers, ignore const handleKeyDown = (event: KeyboardEvent) => {
return; if (!open) return;
}
if (startX > -1 && endX > -1) { switch (event.key) {
const distance = startX - endX; case "Escape":
if (distance > 50) { onOpenChange(false);
showNextImg(); break;
} else if (distance < -50) { default:
showPrevImg(); break;
} }
} };
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) => { document.addEventListener("keydown", handleKeyDown);
if (event.clientX < window.innerWidth / 2) { return () => document.removeEventListener("keydown", handleKeyDown);
showPrevImg(); }, [open, onOpenChange]);
} else {
showNextImg();
}
};
const handleImageContainerKeyDown = (event: KeyboardEvent) => { const handleClose = () => {
switch (event.key) { onOpenChange(false);
case "ArrowLeft":
showPrevImg();
break;
case "ArrowRight":
showNextImg();
break;
case "Escape":
destroyAndResetViewport();
break;
default:
}
}; };
const handleImgContainerScroll = (event: React.WheelEvent) => { // Prevent closing when clicking on the image
const handleImageClick = (event: React.MouseEvent) => {
event.stopPropagation(); 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`}`,
}; };
useEffect(() => { // Return early if no images provided
setViewportScalable(); if (!imgUrls.length) return null;
}, []);
useEffect(() => { // Ensure currentIndex is within bounds
document.addEventListener("keydown", handleImageContainerKeyDown); const safeIndex = Math.max(0, Math.min(currentIndex, imgUrls.length - 1));
return () => {
document.removeEventListener("keydown", handleImageContainerKeyDown);
};
}, [currentIndex]);
return ( return (
<> <Dialog open={open} onOpenChange={onOpenChange}>
<div className="fixed top-8 right-8 z-1 flex flex-col justify-start items-center"> <DialogContent
<Button onClick={handleCloseBtnClick}> className="!w-[100vw] !h-[100vh] !max-w-[100vw] !max-h-[100vw] p-0 border-0 shadow-none bg-transparent [&>button]:hidden"
<XIcon className="w-6 h-auto" /> aria-describedby="image-preview-description"
</Button>
</div>
<div
className="w-full h-screen p-4 sm:p-8 flex flex-col justify-center items-center hide-scrollbar"
onClick={handleImgContainerClick}
> >
<img {/* Close button */}
className="object-contain max-h-full max-w-full" <div className="fixed top-4 right-4 z-50">
style={imageComputedStyle} <Button
src={imgUrls[currentIndex]} onClick={handleClose}
onClick={(e) => e.stopPropagation()} variant="secondary"
onTouchStart={handleTouchStart} size="icon"
onTouchMove={handleTouchMove} className="rounded-full bg-popover/20 hover:bg-popover/30 border-border/20 backdrop-blur-sm"
onTouchEnd={handleTouchEnd} aria-label="Close image preview"
onWheel={handleImgContainerScroll} >
decoding="async" <X className="h-4 w-4 text-popover-foreground" />
loading="lazy" </Button>
/> </div>
</div>
</> {/* Image container */}
); <div className="w-full h-full flex items-center justify-center p-4 sm:p-8 overflow-auto">
}; <img
src={imgUrls[safeIndex]}
export default function showPreviewImageDialog(imgUrls: string[] | string, initialIndex?: number): void { alt={`Preview image ${safeIndex + 1} of ${imgUrls.length}`}
generateDialog( className="max-w-full max-h-full object-contain select-none"
{ onClick={handleImageClick}
className: "preview-image-dialog p-0 z-1001", draggable={false}
dialogName: "preview-image-dialog", loading="eager"
}, decoding="async"
PreviewImageDialog, />
{ </div>
imgUrls: Array.isArray(imgUrls) ? imgUrls : [imgUrls],
initialIndex: initialIndex || 0, {/* 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>
); );
} }
import { XIcon } from "lucide-react";
import React, { useState } from "react"; import React, { useState } from "react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { memoServiceClient } from "@/grpcweb"; import { memoServiceClient } from "@/grpcweb";
import useLoading from "@/hooks/useLoading"; import useLoading from "@/hooks/useLoading";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import { generateDialog } from "./Dialog";
interface Props extends DialogProps { interface RenameTagDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
tag: string; tag: string;
onSuccess?: () => void;
} }
const RenameTagDialog: React.FC<Props> = (props: Props) => { export function RenameTagDialog({ open, onOpenChange, tag, onSuccess }: RenameTagDialogProps) {
const { tag, destroy } = props;
const t = useTranslate(); const t = useTranslate();
const [newName, setNewName] = useState(tag); const [newName, setNewName] = useState(tag);
const requestState = useLoading(false); const requestState = useLoading(false);
...@@ -33,65 +35,55 @@ const RenameTagDialog: React.FC<Props> = (props: Props) => { ...@@ -33,65 +35,55 @@ const RenameTagDialog: React.FC<Props> = (props: Props) => {
} }
try { try {
requestState.setLoading();
await memoServiceClient.renameMemoTag({ await memoServiceClient.renameMemoTag({
parent: "memos/-", parent: "memos/-",
oldTag: tag, oldTag: tag,
newTag: newName, newTag: newName,
}); });
toast.success(t("tag.rename-success")); toast.success(t("tag.rename-success"));
requestState.setFinish();
onSuccess?.();
onOpenChange(false);
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
toast.error(error.details); toast.error(error.details);
requestState.setError();
} }
destroy();
}; };
return ( return (
<div className="max-w-full shadow flex flex-col justify-start items-start bg-card text-card-foreground p-4 rounded-lg"> <Dialog open={open} onOpenChange={onOpenChange}>
<div className="flex flex-row justify-between items-center mb-4 gap-2 w-full"> <DialogContent className="max-w-md">
<p className="title-text">{t("tag.rename-tag")}</p> <DialogHeader>
<Button variant="ghost" onClick={() => destroy()}> <DialogTitle>{t("tag.rename-tag")}</DialogTitle>
<XIcon className="w-5 h-auto" /> </DialogHeader>
</Button> <div className="flex flex-col gap-4">
</div> <div className="grid gap-2">
<div className="flex flex-col justify-start items-start max-w-xs"> <Label htmlFor="oldName">{t("tag.old-name")}</Label>
<div className="w-full flex flex-col justify-start items-start mb-3"> <Input id="oldName" readOnly disabled type="text" value={tag} />
<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} />
</div> </div>
<div className="relative w-full mb-2 flex flex-row justify-start items-center space-x-2"> <div className="grid gap-2">
<span className="w-20 text-sm whitespace-nowrap shrink-0 text-right">{t("tag.new-name")}</span> <Label htmlFor="newName">{t("tag.new-name")}</Label>
<Input className="w-full" type="text" placeholder="A new tag name" value={newName} onChange={handleTagNameInputChange} /> <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> </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>
<div className="w-full flex flex-row justify-end items-center space-x-2"> <DialogFooter>
<Button variant="ghost" disabled={requestState.isLoading} onClick={destroy}> <Button variant="ghost" disabled={requestState.isLoading} onClick={() => onOpenChange(false)}>
{t("common.cancel")} {t("common.cancel")}
</Button> </Button>
<Button disabled={requestState.isLoading} onClick={handleConfirm}> <Button disabled={requestState.isLoading} onClick={handleConfirm}>
{t("common.confirm")} {t("common.confirm")}
</Button> </Button>
</div> </DialogFooter>
</div> </DialogContent>
</div> </Dialog>
);
};
function showRenameTagDialog(props: Pick<Props, "tag">) {
generateDialog(
{
className: "rename-tag-dialog",
dialogName: "rename-tag-dialog",
},
RenameTagDialog,
props,
); );
} }
export default showRenameTagDialog; export default RenameTagDialog;
...@@ -5,9 +5,10 @@ import { toast } from "react-hot-toast"; ...@@ -5,9 +5,10 @@ import { toast } from "react-hot-toast";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { userServiceClient } from "@/grpcweb"; import { userServiceClient } from "@/grpcweb";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
import { useDialog } from "@/hooks/useDialog";
import { UserAccessToken } from "@/types/proto/api/v1/user_service"; import { UserAccessToken } from "@/types/proto/api/v1/user_service";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import showCreateAccessTokenDialog from "../CreateAccessTokenDialog"; import CreateAccessTokenDialog from "../CreateAccessTokenDialog";
import LearnMore from "../LearnMore"; import LearnMore from "../LearnMore";
const listAccessTokens = async (parent: string) => { const listAccessTokens = async (parent: string) => {
...@@ -19,6 +20,7 @@ const AccessTokenSection = () => { ...@@ -19,6 +20,7 @@ const AccessTokenSection = () => {
const t = useTranslate(); const t = useTranslate();
const currentUser = useCurrentUser(); const currentUser = useCurrentUser();
const [userAccessTokens, setUserAccessTokens] = useState<UserAccessToken[]>([]); const [userAccessTokens, setUserAccessTokens] = useState<UserAccessToken[]>([]);
const createTokenDialog = useDialog();
useEffect(() => { useEffect(() => {
listAccessTokens(currentUser.name).then((accessTokens) => { listAccessTokens(currentUser.name).then((accessTokens) => {
...@@ -31,6 +33,10 @@ const AccessTokenSection = () => { ...@@ -31,6 +33,10 @@ const AccessTokenSection = () => {
setUserAccessTokens(accessTokens); setUserAccessTokens(accessTokens);
}; };
const handleCreateToken = () => {
createTokenDialog.open();
};
const copyAccessToken = (accessToken: string) => { const copyAccessToken = (accessToken: string) => {
copy(accessToken); copy(accessToken);
toast.success(t("setting.access-token-section.access-token-copied-to-clipboard")); toast.success(t("setting.access-token-section.access-token-copied-to-clipboard"));
...@@ -61,12 +67,7 @@ const AccessTokenSection = () => { ...@@ -61,12 +67,7 @@ const AccessTokenSection = () => {
<p className="text-sm text-muted-foreground">{t("setting.access-token-section.description")}</p> <p className="text-sm text-muted-foreground">{t("setting.access-token-section.description")}</p>
</div> </div>
<div className="mt-4 sm:mt-0"> <div className="mt-4 sm:mt-0">
<Button <Button color="primary" onClick={handleCreateToken}>
color="primary"
onClick={() => {
showCreateAccessTokenDialog(handleCreateAccessTokenDialogConfirm);
}}
>
{t("common.create")} {t("common.create")}
</Button> </Button>
</div> </div>
...@@ -128,6 +129,13 @@ const AccessTokenSection = () => { ...@@ -128,6 +129,13 @@ const AccessTokenSection = () => {
</div> </div>
</div> </div>
</div> </div>
{/* Create Access Token Dialog */}
<CreateAccessTokenDialog
open={createTokenDialog.isOpen}
onOpenChange={createTokenDialog.setOpen}
onSuccess={handleCreateAccessTokenDialogConfirm}
/>
</div> </div>
); );
}; };
......
import { sortBy } from "lodash-es"; import { sortBy } from "lodash-es";
import { MoreVerticalIcon } from "lucide-react"; import { MoreVerticalIcon, PlusIcon } from "lucide-react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { Button } from "@/components/ui/button"; 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 { userServiceClient } from "@/grpcweb";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
import { useDialog } from "@/hooks/useDialog";
import { userStore } from "@/store/v2"; import { userStore } from "@/store/v2";
import { State } from "@/types/proto/api/v1/common"; import { State } from "@/types/proto/api/v1/common";
import { User, User_Role } from "@/types/proto/api/v1/user_service"; import { User, User_Role } from "@/types/proto/api/v1/user_service";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import showCreateUserDialog from "../CreateUserDialog"; import CreateUserDialog from "../CreateUserDialog";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/dropdown-menu"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/dropdown-menu";
interface LocalState {
creatingUser: User;
}
const MemberSection = observer(() => { const MemberSection = observer(() => {
const t = useTranslate(); const t = useTranslate();
const currentUser = useCurrentUser(); const currentUser = useCurrentUser();
const [state, setState] = useState<LocalState>({
creatingUser: User.fromPartial({
username: "",
password: "",
role: User_Role.USER,
}),
});
const [users, setUsers] = useState<User[]>([]); const [users, setUsers] = useState<User[]>([]);
const createDialog = useDialog();
const editDialog = useDialog();
const [editingUser, setEditingUser] = useState<User | undefined>();
const sortedUsers = sortBy(users, "id"); const sortedUsers = sortBy(users, "id");
useEffect(() => { useEffect(() => {
...@@ -52,62 +41,14 @@ const MemberSection = observer(() => { ...@@ -52,62 +41,14 @@ const MemberSection = observer(() => {
} }
}; };
const handleUsernameInputChange = (event: React.ChangeEvent<HTMLInputElement>) => { const handleCreateUser = () => {
setState({ setEditingUser(undefined);
...state, createDialog.open();
creatingUser: {
...state.creatingUser,
username: event.target.value,
},
});
};
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 () => { const handleEditUser = (user: User) => {
if (state.creatingUser.username === "" || state.creatingUser.password === "") { setEditingUser(user);
toast.error(t("message.fill-all")); editDialog.open();
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 handleArchiveUserClick = async (user: User) => { const handleArchiveUserClick = async (user: User) => {
...@@ -145,48 +86,12 @@ const MemberSection = observer(() => { ...@@ -145,48 +86,12 @@ const MemberSection = observer(() => {
return ( return (
<div className="w-full flex flex-col gap-2 pt-2 pb-4"> <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-full flex flex-row justify-between items-center">
<div className="w-auto flex flex-col justify-start items-start gap-2 border border-border rounded-md py-2 px-3"> <p className="font-medium text-muted-foreground">{t("setting.member-section.create-a-member")}</p>
<div className="flex flex-col justify-start items-start gap-1"> <Button onClick={handleCreateUser}>
<span>{t("common.username")}</span> <PlusIcon className="w-4 h-4 mr-2" />
<Input {t("common.create")}
type="text" </Button>
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> </div>
<div className="w-full flex flex-row justify-between items-center mt-6"> <div className="w-full flex flex-row justify-between items-center mt-6">
<div className="title-text">{t("setting.member-list")}</div> <div className="title-text">{t("setting.member-list")}</div>
...@@ -232,9 +137,7 @@ const MemberSection = observer(() => { ...@@ -232,9 +137,7 @@ const MemberSection = observer(() => {
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={2}> <DropdownMenuContent align="end" sideOffset={2}>
<DropdownMenuItem onClick={() => showCreateUserDialog(user, () => fetchUsers())}> <DropdownMenuItem onClick={() => handleEditUser(user)}>{t("common.update")}</DropdownMenuItem>
{t("common.update")}
</DropdownMenuItem>
{user.state === State.NORMAL ? ( {user.state === State.NORMAL ? (
<DropdownMenuItem onClick={() => handleArchiveUserClick(user)}> <DropdownMenuItem onClick={() => handleArchiveUserClick(user)}>
{t("setting.member-section.archive-member")} {t("setting.member-section.archive-member")}
...@@ -260,6 +163,12 @@ const MemberSection = observer(() => { ...@@ -260,6 +163,12 @@ const MemberSection = observer(() => {
</table> </table>
</div> </div>
</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> </div>
); );
}); });
......
import { MoreVerticalIcon, PenLineIcon } from "lucide-react"; import { MoreVerticalIcon, PenLineIcon } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
import { useDialog } from "@/hooks/useDialog";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import showChangeMemberPasswordDialog from "../ChangeMemberPasswordDialog"; import ChangeMemberPasswordDialog from "../ChangeMemberPasswordDialog";
import showUpdateAccountDialog from "../UpdateAccountDialog"; import UpdateAccountDialog from "../UpdateAccountDialog";
import UserAvatar from "../UserAvatar"; import UserAvatar from "../UserAvatar";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/dropdown-menu"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/dropdown-menu";
import AccessTokenSection from "./AccessTokenSection"; import AccessTokenSection from "./AccessTokenSection";
...@@ -12,6 +13,16 @@ import UserSessionsSection from "./UserSessionsSection"; ...@@ -12,6 +13,16 @@ import UserSessionsSection from "./UserSessionsSection";
const MyAccountSection = () => { const MyAccountSection = () => {
const t = useTranslate(); const t = useTranslate();
const user = useCurrentUser(); const user = useCurrentUser();
const accountDialog = useDialog();
const passwordDialog = useDialog();
const handleEditAccount = () => {
accountDialog.open();
};
const handleChangePassword = () => {
passwordDialog.open();
};
return ( return (
<div className="w-full gap-2 pt-2 pb-4"> <div className="w-full gap-2 pt-2 pb-4">
...@@ -27,7 +38,7 @@ const MyAccountSection = () => { ...@@ -27,7 +38,7 @@ const MyAccountSection = () => {
</div> </div>
</div> </div>
<div className="w-full flex flex-row justify-start items-center mt-2 space-x-2"> <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" /> <PenLineIcon className="w-4 h-4 mx-auto mr-1" />
{t("common.edit")} {t("common.edit")}
</Button> </Button>
...@@ -38,15 +49,19 @@ const MyAccountSection = () => { ...@@ -38,15 +49,19 @@ const MyAccountSection = () => {
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start"> <DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => showChangeMemberPasswordDialog(user)}> <DropdownMenuItem onClick={handleChangePassword}>{t("setting.account-section.change-password")}</DropdownMenuItem>
{t("setting.account-section.change-password")}
</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
<UserSessionsSection /> <UserSessionsSection />
<AccessTokenSection /> <AccessTokenSection />
{/* Update Account Dialog */}
<UpdateAccountDialog open={accountDialog.isOpen} onOpenChange={accountDialog.setOpen} />
{/* Change Password Dialog */}
<ChangeMemberPasswordDialog open={passwordDialog.isOpen} onOpenChange={passwordDialog.setOpen} user={user} />
</div> </div>
); );
}; };
......
...@@ -8,12 +8,14 @@ import { Separator } from "@/components/ui/separator"; ...@@ -8,12 +8,14 @@ import { Separator } from "@/components/ui/separator";
import { identityProviderServiceClient } from "@/grpcweb"; import { identityProviderServiceClient } from "@/grpcweb";
import { IdentityProvider } from "@/types/proto/api/v1/idp_service"; import { IdentityProvider } from "@/types/proto/api/v1/idp_service";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import showCreateIdentityProviderDialog from "../CreateIdentityProviderDialog"; import CreateIdentityProviderDialog from "../CreateIdentityProviderDialog";
import LearnMore from "../LearnMore"; import LearnMore from "../LearnMore";
const SSOSection = () => { const SSOSection = () => {
const t = useTranslate(); const t = useTranslate();
const [identityProviderList, setIdentityProviderList] = useState<IdentityProvider[]>([]); const [identityProviderList, setIdentityProviderList] = useState<IdentityProvider[]>([]);
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [editingIdentityProvider, setEditingIdentityProvider] = useState<IdentityProvider | undefined>();
useEffect(() => { useEffect(() => {
fetchIdentityProviderList(); fetchIdentityProviderList();
...@@ -37,6 +39,22 @@ const SSOSection = () => { ...@@ -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 ( return (
<div className="w-full flex flex-col gap-2 pt-2 pb-4"> <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"> <div className="w-full flex flex-row justify-between items-center gap-1">
...@@ -44,7 +62,7 @@ const SSOSection = () => { ...@@ -44,7 +62,7 @@ const SSOSection = () => {
<span className="font-mono text-muted-foreground">{t("setting.sso-section.sso-list")}</span> <span className="font-mono text-muted-foreground">{t("setting.sso-section.sso-list")}</span>
<LearnMore url="https://www.usememos.com/docs/advanced-settings/sso" /> <LearnMore url="https://www.usememos.com/docs/advanced-settings/sso" />
</div> </div>
<Button color="primary" onClick={() => showCreateIdentityProviderDialog(undefined, fetchIdentityProviderList)}> <Button color="primary" onClick={handleCreateIdentityProvider}>
{t("common.create")} {t("common.create")}
</Button> </Button>
</div> </div>
...@@ -68,9 +86,7 @@ const SSOSection = () => { ...@@ -68,9 +86,7 @@ const SSOSection = () => {
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={2}> <DropdownMenuContent align="end" sideOffset={2}>
<DropdownMenuItem onClick={() => showCreateIdentityProviderDialog(identityProvider, fetchIdentityProviderList)}> <DropdownMenuItem onClick={() => handleEditIdentityProvider(identityProvider)}>{t("common.edit")}</DropdownMenuItem>
{t("common.edit")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDeleteIdentityProvider(identityProvider)}>{t("common.delete")}</DropdownMenuItem> <DropdownMenuItem onClick={() => handleDeleteIdentityProvider(identityProvider)}>{t("common.delete")}</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
...@@ -93,6 +109,12 @@ const SSOSection = () => { ...@@ -93,6 +109,12 @@ const SSOSection = () => {
</li> </li>
</ul> </ul>
</div> </div>
<CreateIdentityProviderDialog
open={isCreateDialogOpen}
onOpenChange={setIsCreateDialogOpen}
identityProvider={editingIdentityProvider}
onSuccess={handleDialogSuccess}
/>
</div> </div>
); );
}; };
......
...@@ -6,12 +6,13 @@ import { webhookServiceClient } from "@/grpcweb"; ...@@ -6,12 +6,13 @@ import { webhookServiceClient } from "@/grpcweb";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
import { Webhook } from "@/types/proto/api/v1/webhook_service"; import { Webhook } from "@/types/proto/api/v1/webhook_service";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import showCreateWebhookDialog from "../CreateWebhookDialog"; import CreateWebhookDialog from "../CreateWebhookDialog";
const WebhookSection = () => { const WebhookSection = () => {
const t = useTranslate(); const t = useTranslate();
const currentUser = useCurrentUser(); const currentUser = useCurrentUser();
const [webhooks, setWebhooks] = useState<Webhook[]>([]); const [webhooks, setWebhooks] = useState<Webhook[]>([]);
const [isCreateWebhookDialogOpen, setIsCreateWebhookDialogOpen] = useState(false);
const listWebhooks = async () => { const listWebhooks = async () => {
if (!currentUser) return []; if (!currentUser) return [];
...@@ -30,6 +31,7 @@ const WebhookSection = () => { ...@@ -30,6 +31,7 @@ const WebhookSection = () => {
const handleCreateWebhookDialogConfirm = async () => { const handleCreateWebhookDialogConfirm = async () => {
const webhooks = await listWebhooks(); const webhooks = await listWebhooks();
setWebhooks(webhooks); setWebhooks(webhooks);
setIsCreateWebhookDialogOpen(false);
}; };
const handleDeleteWebhook = async (webhook: Webhook) => { const handleDeleteWebhook = async (webhook: Webhook) => {
...@@ -47,12 +49,7 @@ const WebhookSection = () => { ...@@ -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> <p className="flex flex-row justify-start items-center font-medium text-muted-foreground">{t("setting.webhook-section.title")}</p>
</div> </div>
<div> <div>
<Button <Button color="primary" onClick={() => setIsCreateWebhookDialogOpen(true)}>
color="primary"
onClick={() => {
showCreateWebhookDialog(handleCreateWebhookDialogConfirm);
}}
>
{t("common.create")} {t("common.create")}
</Button> </Button>
</div> </div>
...@@ -116,6 +113,11 @@ const WebhookSection = () => { ...@@ -116,6 +113,11 @@ const WebhookSection = () => {
<ExternalLinkIcon className="inline w-4 h-auto ml-1" /> <ExternalLinkIcon className="inline w-4 h-auto ml-1" />
</Link> </Link>
</div> </div>
<CreateWebhookDialog
open={isCreateWebhookDialogOpen}
onOpenChange={setIsCreateWebhookDialogOpen}
onSuccess={handleCreateWebhookDialogConfirm}
/>
</div> </div>
); );
}; };
......
...@@ -10,16 +10,18 @@ import { Separator } from "@/components/ui/separator"; ...@@ -10,16 +10,18 @@ import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { identityProviderServiceClient } from "@/grpcweb"; import { identityProviderServiceClient } from "@/grpcweb";
import useDialog from "@/hooks/useDialog";
import { workspaceSettingNamePrefix } from "@/store/common"; import { workspaceSettingNamePrefix } from "@/store/common";
import { workspaceStore } from "@/store/v2"; import { workspaceStore } from "@/store/v2";
import { WorkspaceSettingKey } from "@/store/v2/workspace"; import { WorkspaceSettingKey } from "@/store/v2/workspace";
import { IdentityProvider } from "@/types/proto/api/v1/idp_service"; import { IdentityProvider } from "@/types/proto/api/v1/idp_service";
import { WorkspaceGeneralSetting } from "@/types/proto/api/v1/workspace_service"; import { WorkspaceGeneralSetting } from "@/types/proto/api/v1/workspace_service";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import showUpdateCustomizedProfileDialog from "../UpdateCustomizedProfileDialog"; import UpdateCustomizedProfileDialog from "../UpdateCustomizedProfileDialog";
const WorkspaceSection = observer(() => { const WorkspaceSection = observer(() => {
const t = useTranslate(); const t = useTranslate();
const customizeDialog = useDialog();
const originalSetting = WorkspaceGeneralSetting.fromPartial( const originalSetting = WorkspaceGeneralSetting.fromPartial(
workspaceStore.getWorkspaceSettingByKey(WorkspaceSettingKey.GENERAL)?.generalSetting || {}, workspaceStore.getWorkspaceSettingByKey(WorkspaceSettingKey.GENERAL)?.generalSetting || {},
); );
...@@ -31,7 +33,7 @@ const WorkspaceSection = observer(() => { ...@@ -31,7 +33,7 @@ const WorkspaceSection = observer(() => {
}, [workspaceStore.getWorkspaceSettingByKey(WorkspaceSettingKey.GENERAL)]); }, [workspaceStore.getWorkspaceSettingByKey(WorkspaceSettingKey.GENERAL)]);
const handleUpdateCustomizedProfileButtonClick = () => { const handleUpdateCustomizedProfileButtonClick = () => {
showUpdateCustomizedProfileDialog(); customizeDialog.open();
}; };
const updatePartialSetting = (partial: Partial<WorkspaceGeneralSetting>) => { const updatePartialSetting = (partial: Partial<WorkspaceGeneralSetting>) => {
...@@ -166,6 +168,15 @@ const WorkspaceSection = observer(() => { ...@@ -166,6 +168,15 @@ const WorkspaceSection = observer(() => {
{t("common.save")} {t("common.save")}
</Button> </Button>
</div> </div>
<UpdateCustomizedProfileDialog
open={customizeDialog.isOpen}
onOpenChange={customizeDialog.setOpen}
onSuccess={() => {
// Refresh workspace settings if needed
toast.success("Profile updated successfully!");
}}
/>
</div> </div>
); );
}); });
......
...@@ -3,17 +3,22 @@ import { XIcon } from "lucide-react"; ...@@ -3,17 +3,22 @@ import { XIcon } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { convertFileToBase64 } from "@/helpers/utils"; import { convertFileToBase64 } from "@/helpers/utils";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
import { userStore, workspaceStore } from "@/store/v2"; import { userStore, workspaceStore } from "@/store/v2";
import { User as UserPb } from "@/types/proto/api/v1/user_service"; import { User as UserPb } from "@/types/proto/api/v1/user_service";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import { generateDialog } from "./Dialog";
import UserAvatar from "./UserAvatar"; import UserAvatar from "./UserAvatar";
type Props = DialogProps; interface UpdateAccountDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
}
interface State { interface State {
avatarUrl: string; avatarUrl: string;
...@@ -23,7 +28,7 @@ interface State { ...@@ -23,7 +28,7 @@ interface State {
description: string; description: string;
} }
const UpdateAccountDialog = ({ destroy }: Props) => { export function UpdateAccountDialog({ open, onOpenChange, onSuccess }: UpdateAccountDialogProps) {
const t = useTranslate(); const t = useTranslate();
const currentUser = useCurrentUser(); const currentUser = useCurrentUser();
const [state, setState] = useState<State>({ const [state, setState] = useState<State>({
...@@ -36,7 +41,7 @@ const UpdateAccountDialog = ({ destroy }: Props) => { ...@@ -36,7 +41,7 @@ const UpdateAccountDialog = ({ destroy }: Props) => {
const workspaceGeneralSetting = workspaceStore.state.generalSetting; const workspaceGeneralSetting = workspaceStore.state.generalSetting;
const handleCloseBtnClick = () => { const handleCloseBtnClick = () => {
destroy(); onOpenChange(false);
}; };
const setPartialState = (partialState: Partial<State>) => { const setPartialState = (partialState: Partial<State>) => {
...@@ -133,7 +138,8 @@ const UpdateAccountDialog = ({ destroy }: Props) => { ...@@ -133,7 +138,8 @@ const UpdateAccountDialog = ({ destroy }: Props) => {
updateMask, updateMask,
); );
toast.success(t("message.update-succeed")); toast.success(t("message.update-succeed"));
handleCloseBtnClick(); onSuccess?.();
onOpenChange(false);
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
toast.error(error.details); toast.error(error.details);
...@@ -141,77 +147,74 @@ const UpdateAccountDialog = ({ destroy }: Props) => { ...@@ -141,77 +147,74 @@ const UpdateAccountDialog = ({ destroy }: Props) => {
}; };
return ( return (
<div className="max-w-full shadow flex flex-col justify-start items-start bg-card text-card-foreground p-4 rounded-lg"> <Dialog open={open} onOpenChange={onOpenChange}>
<div className="flex flex-row justify-between items-center mb-4 gap-2 w-full"> <DialogContent className="max-w-md">
<p className="title-text">{t("setting.account-section.update-information")}</p> <DialogHeader>
<Button variant="ghost" onClick={handleCloseBtnClick}> <DialogTitle>{t("setting.account-section.update-information")}</DialogTitle>
<XIcon className="w-5 h-auto" /> </DialogHeader>
</Button> <div className="flex flex-col gap-4">
</div> <div className="flex flex-row items-center gap-2">
<div className="flex flex-col justify-start items-start w-64! space-y-2"> <Label>{t("common.avatar")}</Label>
<div className="w-full flex flex-row justify-start items-center"> <label className="relative cursor-pointer hover:opacity-80">
<span className="text-sm mr-2">{t("common.avatar")}</span> <UserAvatar className="w-10 h-10" avatarUrl={state.avatarUrl} />
<label className="relative cursor-pointer hover:opacity-80"> <input type="file" accept="image/*" className="absolute invisible w-full h-full inset-0" onChange={handleAvatarChanged} />
<UserAvatar className="w-10! h-10!" avatarUrl={state.avatarUrl} /> </label>
<input type="file" accept="image/*" className="absolute invisible w-full h-full inset-0" onChange={handleAvatarChanged} /> {state.avatarUrl && (
</label> <XIcon
{state.avatarUrl && ( className="w-4 h-auto cursor-pointer opacity-60 hover:opacity-80"
<XIcon onClick={() =>
className="w-4 h-auto ml-1 cursor-pointer opacity-60 hover:opacity-80" setPartialState({
onClick={() => avatarUrl: "",
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> </div>
<p className="text-sm"> <DialogFooter>
{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">
<Button variant="ghost" onClick={handleCloseBtnClick}> <Button variant="ghost" onClick={handleCloseBtnClick}>
{t("common.cancel")} {t("common.cancel")}
</Button> </Button>
<Button onClick={handleSaveBtnClick}>{t("common.save")}</Button> <Button onClick={handleSaveBtnClick}>{t("common.save")}</Button>
</div> </DialogFooter>
</div> </DialogContent>
</div> </Dialog>
);
};
function showUpdateAccountDialog() {
generateDialog(
{
className: "update-account-dialog",
dialogName: "update-account-dialog",
},
UpdateAccountDialog,
); );
} }
export default showUpdateAccountDialog; export default UpdateAccountDialog;
import { XIcon } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { workspaceSettingNamePrefix } from "@/store/common"; import { workspaceSettingNamePrefix } from "@/store/common";
import { workspaceStore } from "@/store/v2"; import { workspaceStore } from "@/store/v2";
...@@ -10,29 +11,28 @@ import { WorkspaceSettingKey } from "@/store/v2/workspace"; ...@@ -10,29 +11,28 @@ import { WorkspaceSettingKey } from "@/store/v2/workspace";
import { WorkspaceCustomProfile } from "@/types/proto/api/v1/workspace_service"; import { WorkspaceCustomProfile } from "@/types/proto/api/v1/workspace_service";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import AppearanceSelect from "./AppearanceSelect"; import AppearanceSelect from "./AppearanceSelect";
import { generateDialog } from "./Dialog";
import LocaleSelect from "./LocaleSelect"; 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 t = useTranslate();
const workspaceGeneralSetting = workspaceStore.state.generalSetting; const workspaceGeneralSetting = workspaceStore.state.generalSetting;
const [customProfile, setCustomProfile] = useState<WorkspaceCustomProfile>( const [customProfile, setCustomProfile] = useState<WorkspaceCustomProfile>(
WorkspaceCustomProfile.fromPartial(workspaceGeneralSetting.customProfile || {}), WorkspaceCustomProfile.fromPartial(workspaceGeneralSetting.customProfile || {}),
); );
const handleCloseButtonClick = () => { const [isLoading, setIsLoading] = useState(false);
destroy();
};
const setPartialState = (partialState: Partial<WorkspaceCustomProfile>) => { const setPartialState = (partialState: Partial<WorkspaceCustomProfile>) => {
setCustomProfile((state) => { setCustomProfile((state) => ({
return { ...state,
...state, ...partialState,
...partialState, }));
};
});
}; };
const handleNameChanged = (e: React.ChangeEvent<HTMLInputElement>) => { const handleNameChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
...@@ -75,12 +75,17 @@ const UpdateCustomizedProfileDialog = ({ destroy }: Props) => { ...@@ -75,12 +75,17 @@ const UpdateCustomizedProfileDialog = ({ destroy }: Props) => {
}); });
}; };
const handleCloseButtonClick = () => {
onOpenChange(false);
};
const handleSaveButtonClick = async () => { const handleSaveButtonClick = async () => {
if (customProfile.title === "") { if (customProfile.title === "") {
toast.error("Title cannot be empty."); toast.error("Title cannot be empty.");
return; return;
} }
setIsLoading(true);
try { try {
await workspaceStore.upsertWorkspaceSetting({ await workspaceStore.upsertWorkspaceSetting({
name: `${workspaceSettingNamePrefix}${WorkspaceSettingKey.GENERAL}`, name: `${workspaceSettingNamePrefix}${WorkspaceSettingKey.GENERAL}`,
...@@ -89,61 +94,75 @@ const UpdateCustomizedProfileDialog = ({ destroy }: Props) => { ...@@ -89,61 +94,75 @@ const UpdateCustomizedProfileDialog = ({ destroy }: Props) => {
customProfile: customProfile, customProfile: customProfile,
}, },
}); });
toast.success(t("message.update-succeed"));
onSuccess?.();
onOpenChange(false);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
return; toast.error("Failed to update profile");
} finally {
setIsLoading(false);
} }
toast.success(t("message.update-succeed"));
destroy();
}; };
return ( return (
<div className="max-w-full shadow flex flex-col justify-start items-start bg-card text-card-foreground p-4 rounded-lg"> <Dialog open={open} onOpenChange={onOpenChange}>
<div className="flex flex-row justify-between items-center mb-4 gap-2 w-full"> <DialogContent className="max-w-2xl">
<p className="title-text">{t("setting.system-section.customize-server.title")}</p> <DialogHeader>
<Button variant="ghost" onClick={handleCloseButtonClick}> <DialogTitle>{t("setting.system-section.customize-server.title")}</DialogTitle>
<XIcon className="w-5 h-auto" /> <DialogDescription>Customize your workspace appearance and settings.</DialogDescription>
</Button> </DialogHeader>
</div>
<div className="flex flex-col justify-start items-start min-w-[16rem]"> <div className="grid gap-4 py-4">
<p className="text-sm mb-1">{t("setting.system-section.server-name")}</p> <div className="grid gap-2">
<Input className="w-full" type="text" value={customProfile.title} onChange={handleNameChanged} /> <Label htmlFor="server-name">{t("setting.system-section.server-name")}</Label>
<p className="text-sm mb-1 mt-2">{t("setting.system-section.customize-server.icon-url")}</p> <Input id="server-name" type="text" value={customProfile.title} onChange={handleNameChanged} placeholder="Enter server name" />
<Input className="w-full" type="text" value={customProfile.logoUrl} onChange={handleLogoUrlChanged} /> </div>
<p className="text-sm mb-1 mt-2">{t("setting.system-section.customize-server.description")}</p>
<Textarea rows={3} value={customProfile.description} onChange={handleDescriptionChanged} /> <div className="grid gap-2">
<p className="text-sm mb-1 mt-2">{t("setting.system-section.customize-server.locale")}</p> <Label htmlFor="icon-url">{t("setting.system-section.customize-server.icon-url")}</Label>
<LocaleSelect className="w-full!" value={customProfile.locale} onChange={handleLocaleSelectChange} /> <Input id="icon-url" type="text" value={customProfile.logoUrl} onChange={handleLogoUrlChanged} placeholder="Enter icon URL" />
<p className="text-sm mb-1 mt-2">{t("setting.system-section.customize-server.appearance")}</p> </div>
<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="grid gap-2">
<div className="flex flex-row justify-start items-center"> <Label htmlFor="description">{t("setting.system-section.customize-server.description")}</Label>
<Button variant="outline" onClick={handleRestoreButtonClick}> <Textarea
{t("common.restore")} id="description"
</Button> rows={3}
value={customProfile.description}
onChange={handleDescriptionChanged}
placeholder="Enter description"
/>
</div> </div>
<div className="flex flex-row justify-end items-center gap-2">
<Button variant="ghost" onClick={handleCloseButtonClick}> <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")} {t("common.cancel")}
</Button> </Button>
<Button color="primary" onClick={handleSaveButtonClick}> <Button onClick={handleSaveButtonClick} disabled={isLoading}>
{t("common.save")} {isLoading ? "Saving..." : t("common.save")}
</Button> </Button>
</div> </div>
</div> </div>
</div> </DialogContent>
</div> </Dialog>
);
};
function showUpdateCustomizedProfileDialog() {
generateDialog(
{
className: "update-customized-profile-dialog",
dialogName: "update-customized-profile-dialog",
},
UpdateCustomizedProfileDialog,
); );
} }
export default showUpdateCustomizedProfileDialog; export default UpdateCustomizedProfileDialog;
import * as DialogPrimitive from "@radix-ui/react-dialog"; import * as DialogPrimitive from "@radix-ui/react-dialog";
import { cva, type VariantProps } from "class-variance-authority";
import { XIcon } from "lucide-react"; import { XIcon } from "lucide-react";
import * as React from "react"; import * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) { const Dialog = DialogPrimitive.Root;
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) { const DialogTrigger = DialogPrimitive.Trigger;
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) { const DialogPortal = DialogPrimitive.Portal;
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) { const DialogClose = DialogPrimitive.Close;
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) { const DialogOverlay = React.forwardRef<
return ( React.ElementRef<typeof DialogPrimitive.Overlay>,
<DialogPrimitive.Overlay React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
data-slot="dialog-overlay" >(({ className, ...props }, ref) => (
className={cn( <DialogPrimitive.Overlay
"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", ref={ref}
className, data-slot="dialog-overlay"
)} className={cn(
{...props} "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",
},
},
);
function DialogContent({ const DialogContent = React.forwardRef<
className, React.ElementRef<typeof DialogPrimitive.Content>,
children, React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> &
showCloseButton = true, VariantProps<typeof dialogContentVariants> & {
...props showCloseButton?: boolean;
}: React.ComponentProps<typeof DialogPrimitive.Content> & { }
showCloseButton?: boolean; >(({ className, children, showCloseButton = true, size, ...props }, ref) => (
}) { <DialogPortal>
return ( <DialogOverlay />
<DialogPortal data-slot="dialog-portal"> <DialogPrimitive.Content ref={ref} className={cn(dialogContentVariants({ size }), className)} {...props}>
<DialogOverlay /> {children}
<DialogPrimitive.Content {showCloseButton && (
data-slot="dialog-content" <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">
className={cn( <XIcon />
"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", <span className="sr-only">Close</span>
className, </DialogPrimitive.Close>
)} )}
{...props} </DialogPrimitive.Content>
> </DialogPortal>
{children} ));
{showCloseButton && ( DialogContent.displayName = DialogPrimitive.Content.displayName;
<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>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { const DialogHeader = React.forwardRef<React.ElementRef<"div">, React.ComponentPropsWithoutRef<"div">>(({ className, ...props }, ref) => (
return <div data-slot="dialog-header" className={cn("flex flex-col gap-2 text-center sm:text-left", className)} {...props} />; <div ref={ref} className={cn("flex flex-col gap-2 text-center sm:text-left", className)} {...props} />
} ));
DialogHeader.displayName = "DialogHeader";
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { const DialogFooter = React.forwardRef<React.ElementRef<"div">, React.ComponentPropsWithoutRef<"div">>(({ className, ...props }, ref) => (
return <div data-slot="dialog-footer" className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)} {...props} />; <div ref={ref} className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)} {...props} />
} ));
DialogFooter.displayName = "DialogFooter";
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) { const DialogTitle = React.forwardRef<
return <DialogPrimitive.Title data-slot="dialog-title" className={cn("text-lg leading-none font-semibold", className)} {...props} />; 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 DialogDescription({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Description>) { const DialogDescription = React.forwardRef<
return ( React.ElementRef<typeof DialogPrimitive.Description>,
<DialogPrimitive.Description data-slot="dialog-description" className={cn("text-muted-foreground text-sm", className)} {...props} /> 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 { export {
Dialog, Dialog,
......
import { useState, useCallback } from "react";
/**
* Hook for managing dialog state with a clean API
*
* @returns Object with dialog state and handlers
*
* @example
* const dialog = useDialog();
*
* return (
* <>
* <Button onClick={dialog.open}>Open Dialog</Button>
* <SomeDialog
* open={dialog.isOpen}
* onOpenChange={dialog.setOpen}
* onSuccess={dialog.close}
* />
* </>
* );
*/
export function useDialog(defaultOpen = false) {
const [isOpen, setIsOpen] = useState(defaultOpen);
const open = useCallback(() => setIsOpen(true), []);
const close = useCallback(() => setIsOpen(false), []);
const toggle = useCallback(() => setIsOpen((prev) => !prev), []);
return {
isOpen,
open,
close,
toggle,
setOpen: setIsOpen,
};
}
/**
* Hook for managing multiple dialogs with named keys
*
* @returns Object with dialog management functions
*
* @example
* const dialogs = useDialogs();
*
* return (
* <>
* <Button onClick={() => dialogs.open('create')}>Create User</Button>
* <Button onClick={() => dialogs.open('edit')}>Edit User</Button>
*
* <CreateUserDialog
* open={dialogs.isOpen('create')}
* onOpenChange={(open) => dialogs.setOpen('create', open)}
* />
* <EditUserDialog
* open={dialogs.isOpen('edit')}
* onOpenChange={(open) => dialogs.setOpen('edit', open)}
* />
* </>
* );
*/
export function useDialogs() {
const [openDialogs, setOpenDialogs] = useState<Set<string>>(new Set());
const isOpen = useCallback((key: string) => openDialogs.has(key), [openDialogs]);
const open = useCallback((key: string) => {
setOpenDialogs((prev) => new Set([...prev, key]));
}, []);
const close = useCallback((key: string) => {
setOpenDialogs((prev) => {
const next = new Set(prev);
next.delete(key);
return next;
});
}, []);
const toggle = useCallback((key: string) => {
setOpenDialogs((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
}, []);
const setOpen = useCallback((key: string, open: boolean) => {
if (open) {
setOpenDialogs((prev) => new Set([...prev, key]));
} else {
setOpenDialogs((prev) => {
const next = new Set(prev);
next.delete(key);
return next;
});
}
}, []);
const closeAll = useCallback(() => {
setOpenDialogs(new Set());
}, []);
return {
isOpen,
open,
close,
toggle,
setOpen,
closeAll,
openDialogs: Array.from(openDialogs),
};
}
export default useDialog;
...@@ -70,7 +70,7 @@ const Attachments = observer(() => { ...@@ -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"> <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 />} {!md && <MobileHeader />}
<div className="w-full px-4 sm:px-6"> <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"> <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"> <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" /> <PaperclipIcon className="w-6 h-auto mr-1 opacity-80" />
......
...@@ -36,7 +36,7 @@ const Inboxes = observer(() => { ...@@ -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"> <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 />} {!md && <MobileHeader />}
<div className="w-full px-4 sm:px-6"> <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"> <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"> <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" /> <BellIcon className="w-6 h-auto mr-1 opacity-80" />
......
...@@ -87,7 +87,7 @@ const Setting = observer(() => { ...@@ -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"> <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 />} {!md && <MobileHeader />}
<div className="w-full px-4 sm:px-6"> <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"> <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> <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"> <div className="w-full flex flex-col justify-start items-start mt-1">
......
...@@ -852,7 +852,34 @@ export const AttachmentServiceDefinition = { ...@@ -852,7 +852,34 @@ export const AttachmentServiceDefinition = {
responseStream: false, responseStream: false,
options: { options: {
_unknownFields: { _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: [ 578365826: [
new Uint8Array([ new Uint8Array([
39, 39,
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment