mirror of https://github.com/usememos/memos.git
feat: add EditableTimestamp component for inline date editing in MemoDetailSidebar
This commit is contained in:
parent
a7b0d71f6e
commit
6731eccded
|
|
@ -0,0 +1,104 @@
|
|||
import { Timestamp, timestampDate } from "@bufbuild/protobuf/wkt";
|
||||
import { PencilIcon } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Props {
|
||||
timestamp: Timestamp | undefined;
|
||||
onChange: (date: Date) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const EditableTimestamp = ({ timestamp, onChange, className }: Props) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const date = timestamp ? timestampDate(timestamp) : new Date();
|
||||
const displayValue = date.toLocaleString();
|
||||
|
||||
// Format date for datetime-local input (YYYY-MM-DDTHH:mm)
|
||||
const formatForInput = (d: Date): string => {
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
const hours = String(d.getHours()).padStart(2, "0");
|
||||
const minutes = String(d.getMinutes()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.showPicker?.(); // Open datetime picker if available
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
const handleEdit = () => {
|
||||
setInputValue(formatForInput(date));
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!inputValue) {
|
||||
setIsEditing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const newDate = new Date(inputValue);
|
||||
if (isNaN(newDate.getTime())) {
|
||||
toast.error("Invalid date format");
|
||||
return;
|
||||
}
|
||||
|
||||
onChange(newDate);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsEditing(false);
|
||||
setInputValue("");
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
handleSave();
|
||||
} else if (e.key === "Escape") {
|
||||
handleCancel();
|
||||
}
|
||||
};
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="datetime-local"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onBlur={handleSave}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
"w-full px-2 py-1.5 text-sm text-foreground bg-background rounded-md border border-border outline-none transition-all focus:border-ring focus:ring-1 focus:ring-ring/20",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleEdit}
|
||||
className={cn(
|
||||
"group w-full text-left px-2 py-1.5 text-sm text-foreground/80 rounded-md transition-all flex items-center justify-between hover:bg-accent/50 hover:text-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="font-normal">{displayValue}</span>
|
||||
<PencilIcon className="w-3.5 h-3.5 opacity-0 group-hover:opacity-40 transition-opacity shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditableTimestamp;
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
import { create } from "@bufbuild/protobuf";
|
||||
import { timestampDate } from "@bufbuild/protobuf/wkt";
|
||||
import { timestampFromDate } from "@bufbuild/protobuf/wkt";
|
||||
import { isEqual } from "lodash-es";
|
||||
import { CheckCircleIcon, Code2Icon, HashIcon, LinkIcon } from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
import EditableTimestamp from "@/components/EditableTimestamp";
|
||||
import { useUpdateMemo } from "@/hooks/useMemoQueries";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Memo, Memo_PropertySchema, MemoRelation_Type } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
|
@ -18,84 +21,92 @@ const MemoDetailSidebar = ({ memo, className, parentPage }: Props) => {
|
|||
const property = create(Memo_PropertySchema, memo.property || {});
|
||||
const hasSpecialProperty = property.hasLink || property.hasTaskList || property.hasCode || property.hasIncompleteTasks;
|
||||
const shouldShowRelationGraph = memo.relations.filter((r) => r.type === MemoRelation_Type.REFERENCE).length > 0;
|
||||
const { mutate: updateMemo } = useUpdateMemo();
|
||||
|
||||
const handleUpdateTimestamp = (field: "createTime" | "updateTime", date: Date) => {
|
||||
const timestamp = timestampFromDate(date);
|
||||
updateMemo(
|
||||
{
|
||||
update: {
|
||||
name: memo.name,
|
||||
[field]: timestamp,
|
||||
},
|
||||
updateMask: [field === "createTime" ? "create_time" : "update_time"],
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("Updated successfully");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn("relative w-full h-auto max-h-screen overflow-auto hide-scrollbar flex flex-col justify-start items-start", className)}
|
||||
>
|
||||
<div className="flex flex-col justify-start items-start w-full px-1 gap-2 h-auto shrink-0 flex-nowrap hide-scrollbar">
|
||||
<div className="flex flex-col justify-start items-start w-full gap-4 h-auto shrink-0 flex-nowrap hide-scrollbar">
|
||||
{shouldShowRelationGraph && (
|
||||
<div className="relative w-full h-36 border border-border rounded-lg bg-muted">
|
||||
<div className="relative w-full h-36 border border-border rounded-lg bg-muted overflow-hidden">
|
||||
<MemoRelationForceGraph className="w-full h-full" memo={memo} parentPage={parentPage} />
|
||||
<div className="absolute top-1 left-2 text-xs opacity-60 font-mono gap-1 flex flex-row items-center">
|
||||
<div className="absolute top-2 left-2 text-xs text-muted-foreground/60 font-medium gap-1 flex flex-row items-center">
|
||||
<span>{t("common.relations")}</span>
|
||||
<span className="text-xs opacity-60">(Beta)</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full flex flex-col">
|
||||
<p className="flex flex-row justify-start items-center w-full gap-1 mb-1 text-sm leading-6 text-muted-foreground select-none">
|
||||
<span>{t("common.created-at")}</span>
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{memo.createTime && timestampDate(memo.createTime).toLocaleString()}</p>
|
||||
<div className="w-full space-y-1">
|
||||
<p className="text-xs font-medium text-muted-foreground/60 uppercase tracking-wide px-1">{t("common.created-at")}</p>
|
||||
<EditableTimestamp timestamp={memo.createTime} onChange={(date) => handleUpdateTimestamp("createTime", date)} />
|
||||
</div>
|
||||
{!isEqual(memo.createTime, memo.updateTime) && (
|
||||
<div className="w-full flex flex-col">
|
||||
<p className="flex flex-row justify-start items-center w-full gap-1 mb-1 text-sm leading-6 text-muted-foreground select-none">
|
||||
<span>{t("common.last-updated-at")}</span>
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{memo.updateTime && timestampDate(memo.updateTime).toLocaleString()}</p>
|
||||
<div className="w-full space-y-1">
|
||||
<p className="text-xs font-medium text-muted-foreground/60 uppercase tracking-wide px-1">{t("common.last-updated-at")}</p>
|
||||
<EditableTimestamp timestamp={memo.updateTime} onChange={(date) => handleUpdateTimestamp("updateTime", date)} />
|
||||
</div>
|
||||
)}
|
||||
{hasSpecialProperty && (
|
||||
<div className="w-full flex flex-col">
|
||||
<p className="flex flex-row justify-start items-center w-full gap-1 mb-1 text-sm leading-6 text-muted-foreground select-none">
|
||||
<span>{t("common.properties")}</span>
|
||||
</p>
|
||||
<div className="w-full flex flex-row justify-start items-center gap-x-2 gap-y-1 flex-wrap text-muted-foreground">
|
||||
<div className="w-full space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground/60 uppercase tracking-wide px-1">{t("common.properties")}</p>
|
||||
<div className="w-full flex flex-row justify-start items-center gap-2 flex-wrap px-1">
|
||||
{property.hasLink && (
|
||||
<div className="w-auto border border-border pl-1 pr-1.5 rounded-md flex justify-between items-center">
|
||||
<div className="w-auto flex justify-start items-center mr-1">
|
||||
<LinkIcon className="w-4 h-auto mr-1" />
|
||||
<span className="block text-sm">{t("memo.links")}</span>
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-1.5 px-2 py-1 bg-muted/50 border border-border/50 rounded-md text-xs text-muted-foreground">
|
||||
<LinkIcon className="w-3.5 h-3.5" />
|
||||
<span>{t("memo.links")}</span>
|
||||
</div>
|
||||
)}
|
||||
{property.hasTaskList && (
|
||||
<div className="w-auto border border-border pl-1 pr-1.5 rounded-md flex justify-between items-center">
|
||||
<div className="w-auto flex justify-start items-center mr-1">
|
||||
<CheckCircleIcon className="w-4 h-auto mr-1" />
|
||||
<span className="block text-sm">{t("memo.to-do")}</span>
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-1.5 px-2 py-1 bg-muted/50 border border-border/50 rounded-md text-xs text-muted-foreground">
|
||||
<CheckCircleIcon className="w-3.5 h-3.5" />
|
||||
<span>{t("memo.to-do")}</span>
|
||||
</div>
|
||||
)}
|
||||
{property.hasCode && (
|
||||
<div className="w-auto border border-border pl-1 pr-1.5 rounded-md flex justify-between items-center">
|
||||
<div className="w-auto flex justify-start items-center mr-1">
|
||||
<Code2Icon className="w-4 h-auto mr-1" />
|
||||
<span className="block text-sm">{t("memo.code")}</span>
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-1.5 px-2 py-1 bg-muted/50 border border-border/50 rounded-md text-xs text-muted-foreground">
|
||||
<Code2Icon className="w-3.5 h-3.5" />
|
||||
<span>{t("memo.code")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{memo.tags.length > 0 && (
|
||||
<div className="w-full">
|
||||
<div className="flex flex-row justify-start items-center w-full gap-1 mb-1 text-sm leading-6 text-muted-foreground select-none">
|
||||
<span>{t("common.tags")}</span>
|
||||
<span className="shrink-0">({memo.tags.length})</span>
|
||||
<div className="w-full space-y-2">
|
||||
<div className="flex flex-row justify-start items-center gap-1.5 px-1">
|
||||
<p className="text-xs font-medium text-muted-foreground/60 uppercase tracking-wide">{t("common.tags")}</p>
|
||||
<span className="text-xs text-muted-foreground/40">({memo.tags.length})</span>
|
||||
</div>
|
||||
<div className="w-full flex flex-row justify-start items-center relative flex-wrap gap-x-2 gap-y-1">
|
||||
<div className="w-full flex flex-row justify-start items-center flex-wrap gap-1.5 px-1">
|
||||
{memo.tags.map((tag) => (
|
||||
<div
|
||||
key={tag}
|
||||
className="shrink-0 w-auto max-w-full text-sm rounded-md leading-6 flex flex-row justify-start items-center select-none hover:opacity-80 text-muted-foreground"
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 bg-muted/50 border border-border/50 rounded-md text-xs text-muted-foreground hover:bg-muted transition-colors cursor-pointer group"
|
||||
>
|
||||
<HashIcon className="group-hover:hidden w-4 h-auto shrink-0 opacity-40" />
|
||||
<div className={cn("inline-flex flex-nowrap ml-0.5 gap-0.5 cursor-pointer max-w-[calc(100%-16px)]")}>
|
||||
<span className="truncate opacity-80">{tag}</span>
|
||||
</div>
|
||||
<HashIcon className="w-3 h-3 opacity-40 group-hover:opacity-60 transition-opacity" />
|
||||
<span className="opacity-80 group-hover:opacity-100 transition-opacity">{tag}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue