feat: add EditableTimestamp component for inline date editing in MemoDetailSidebar

This commit is contained in:
Johnny 2026-01-26 23:23:14 +08:00
parent a7b0d71f6e
commit 6731eccded
2 changed files with 159 additions and 44 deletions

View File

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

View File

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