Skip to content

Commit 6731ecc

Browse files
committed
feat: add EditableTimestamp component for inline date editing in MemoDetailSidebar
1 parent a7b0d71 commit 6731ecc

File tree

2 files changed

+159
-44
lines changed

2 files changed

+159
-44
lines changed
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { Timestamp, timestampDate } from "@bufbuild/protobuf/wkt";
2+
import { PencilIcon } from "lucide-react";
3+
import { useEffect, useRef, useState } from "react";
4+
import toast from "react-hot-toast";
5+
import { cn } from "@/lib/utils";
6+
7+
interface Props {
8+
timestamp: Timestamp | undefined;
9+
onChange: (date: Date) => void;
10+
className?: string;
11+
}
12+
13+
const EditableTimestamp = ({ timestamp, onChange, className }: Props) => {
14+
const [isEditing, setIsEditing] = useState(false);
15+
const [inputValue, setInputValue] = useState("");
16+
const inputRef = useRef<HTMLInputElement>(null);
17+
18+
const date = timestamp ? timestampDate(timestamp) : new Date();
19+
const displayValue = date.toLocaleString();
20+
21+
// Format date for datetime-local input (YYYY-MM-DDTHH:mm)
22+
const formatForInput = (d: Date): string => {
23+
const year = d.getFullYear();
24+
const month = String(d.getMonth() + 1).padStart(2, "0");
25+
const day = String(d.getDate()).padStart(2, "0");
26+
const hours = String(d.getHours()).padStart(2, "0");
27+
const minutes = String(d.getMinutes()).padStart(2, "0");
28+
return `${year}-${month}-${day}T${hours}:${minutes}`;
29+
};
30+
31+
useEffect(() => {
32+
if (isEditing && inputRef.current) {
33+
inputRef.current.focus();
34+
inputRef.current.showPicker?.(); // Open datetime picker if available
35+
}
36+
}, [isEditing]);
37+
38+
const handleEdit = () => {
39+
setInputValue(formatForInput(date));
40+
setIsEditing(true);
41+
};
42+
43+
const handleSave = () => {
44+
if (!inputValue) {
45+
setIsEditing(false);
46+
return;
47+
}
48+
49+
const newDate = new Date(inputValue);
50+
if (isNaN(newDate.getTime())) {
51+
toast.error("Invalid date format");
52+
return;
53+
}
54+
55+
onChange(newDate);
56+
setIsEditing(false);
57+
};
58+
59+
const handleCancel = () => {
60+
setIsEditing(false);
61+
setInputValue("");
62+
};
63+
64+
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
65+
if (e.key === "Enter") {
66+
handleSave();
67+
} else if (e.key === "Escape") {
68+
handleCancel();
69+
}
70+
};
71+
72+
if (isEditing) {
73+
return (
74+
<input
75+
ref={inputRef}
76+
type="datetime-local"
77+
value={inputValue}
78+
onChange={(e) => setInputValue(e.target.value)}
79+
onBlur={handleSave}
80+
onKeyDown={handleKeyDown}
81+
className={cn(
82+
"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",
83+
className,
84+
)}
85+
/>
86+
);
87+
}
88+
89+
return (
90+
<button
91+
type="button"
92+
onClick={handleEdit}
93+
className={cn(
94+
"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",
95+
className,
96+
)}
97+
>
98+
<span className="font-normal">{displayValue}</span>
99+
<PencilIcon className="w-3.5 h-3.5 opacity-0 group-hover:opacity-40 transition-opacity shrink-0 text-muted-foreground" />
100+
</button>
101+
);
102+
};
103+
104+
export default EditableTimestamp;

web/src/components/MemoDetailSidebar/MemoDetailSidebar.tsx

Lines changed: 55 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { create } from "@bufbuild/protobuf";
2-
import { timestampDate } from "@bufbuild/protobuf/wkt";
2+
import { timestampFromDate } from "@bufbuild/protobuf/wkt";
33
import { isEqual } from "lodash-es";
44
import { CheckCircleIcon, Code2Icon, HashIcon, LinkIcon } from "lucide-react";
5+
import toast from "react-hot-toast";
6+
import EditableTimestamp from "@/components/EditableTimestamp";
7+
import { useUpdateMemo } from "@/hooks/useMemoQueries";
58
import { cn } from "@/lib/utils";
69
import { Memo, Memo_PropertySchema, MemoRelation_Type } from "@/types/proto/api/v1/memo_service_pb";
710
import { useTranslate } from "@/utils/i18n";
@@ -18,84 +21,92 @@ const MemoDetailSidebar = ({ memo, className, parentPage }: Props) => {
1821
const property = create(Memo_PropertySchema, memo.property || {});
1922
const hasSpecialProperty = property.hasLink || property.hasTaskList || property.hasCode || property.hasIncompleteTasks;
2023
const shouldShowRelationGraph = memo.relations.filter((r) => r.type === MemoRelation_Type.REFERENCE).length > 0;
24+
const { mutate: updateMemo } = useUpdateMemo();
25+
26+
const handleUpdateTimestamp = (field: "createTime" | "updateTime", date: Date) => {
27+
const timestamp = timestampFromDate(date);
28+
updateMemo(
29+
{
30+
update: {
31+
name: memo.name,
32+
[field]: timestamp,
33+
},
34+
updateMask: [field === "createTime" ? "create_time" : "update_time"],
35+
},
36+
{
37+
onSuccess: () => {
38+
toast.success("Updated successfully");
39+
},
40+
onError: (error) => {
41+
toast.error(error.message);
42+
},
43+
},
44+
);
45+
};
2146

2247
return (
2348
<aside
2449
className={cn("relative w-full h-auto max-h-screen overflow-auto hide-scrollbar flex flex-col justify-start items-start", className)}
2550
>
26-
<div className="flex flex-col justify-start items-start w-full px-1 gap-2 h-auto shrink-0 flex-nowrap hide-scrollbar">
51+
<div className="flex flex-col justify-start items-start w-full gap-4 h-auto shrink-0 flex-nowrap hide-scrollbar">
2752
{shouldShowRelationGraph && (
28-
<div className="relative w-full h-36 border border-border rounded-lg bg-muted">
53+
<div className="relative w-full h-36 border border-border rounded-lg bg-muted overflow-hidden">
2954
<MemoRelationForceGraph className="w-full h-full" memo={memo} parentPage={parentPage} />
30-
<div className="absolute top-1 left-2 text-xs opacity-60 font-mono gap-1 flex flex-row items-center">
55+
<div className="absolute top-2 left-2 text-xs text-muted-foreground/60 font-medium gap-1 flex flex-row items-center">
3156
<span>{t("common.relations")}</span>
3257
<span className="text-xs opacity-60">(Beta)</span>
3358
</div>
3459
</div>
3560
)}
36-
<div className="w-full flex flex-col">
37-
<p className="flex flex-row justify-start items-center w-full gap-1 mb-1 text-sm leading-6 text-muted-foreground select-none">
38-
<span>{t("common.created-at")}</span>
39-
</p>
40-
<p className="text-sm text-muted-foreground">{memo.createTime && timestampDate(memo.createTime).toLocaleString()}</p>
61+
<div className="w-full space-y-1">
62+
<p className="text-xs font-medium text-muted-foreground/60 uppercase tracking-wide px-1">{t("common.created-at")}</p>
63+
<EditableTimestamp timestamp={memo.createTime} onChange={(date) => handleUpdateTimestamp("createTime", date)} />
4164
</div>
4265
{!isEqual(memo.createTime, memo.updateTime) && (
43-
<div className="w-full flex flex-col">
44-
<p className="flex flex-row justify-start items-center w-full gap-1 mb-1 text-sm leading-6 text-muted-foreground select-none">
45-
<span>{t("common.last-updated-at")}</span>
46-
</p>
47-
<p className="text-sm text-muted-foreground">{memo.updateTime && timestampDate(memo.updateTime).toLocaleString()}</p>
66+
<div className="w-full space-y-1">
67+
<p className="text-xs font-medium text-muted-foreground/60 uppercase tracking-wide px-1">{t("common.last-updated-at")}</p>
68+
<EditableTimestamp timestamp={memo.updateTime} onChange={(date) => handleUpdateTimestamp("updateTime", date)} />
4869
</div>
4970
)}
5071
{hasSpecialProperty && (
51-
<div className="w-full flex flex-col">
52-
<p className="flex flex-row justify-start items-center w-full gap-1 mb-1 text-sm leading-6 text-muted-foreground select-none">
53-
<span>{t("common.properties")}</span>
54-
</p>
55-
<div className="w-full flex flex-row justify-start items-center gap-x-2 gap-y-1 flex-wrap text-muted-foreground">
72+
<div className="w-full space-y-2">
73+
<p className="text-xs font-medium text-muted-foreground/60 uppercase tracking-wide px-1">{t("common.properties")}</p>
74+
<div className="w-full flex flex-row justify-start items-center gap-2 flex-wrap px-1">
5675
{property.hasLink && (
57-
<div className="w-auto border border-border pl-1 pr-1.5 rounded-md flex justify-between items-center">
58-
<div className="w-auto flex justify-start items-center mr-1">
59-
<LinkIcon className="w-4 h-auto mr-1" />
60-
<span className="block text-sm">{t("memo.links")}</span>
61-
</div>
76+
<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">
77+
<LinkIcon className="w-3.5 h-3.5" />
78+
<span>{t("memo.links")}</span>
6279
</div>
6380
)}
6481
{property.hasTaskList && (
65-
<div className="w-auto border border-border pl-1 pr-1.5 rounded-md flex justify-between items-center">
66-
<div className="w-auto flex justify-start items-center mr-1">
67-
<CheckCircleIcon className="w-4 h-auto mr-1" />
68-
<span className="block text-sm">{t("memo.to-do")}</span>
69-
</div>
82+
<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">
83+
<CheckCircleIcon className="w-3.5 h-3.5" />
84+
<span>{t("memo.to-do")}</span>
7085
</div>
7186
)}
7287
{property.hasCode && (
73-
<div className="w-auto border border-border pl-1 pr-1.5 rounded-md flex justify-between items-center">
74-
<div className="w-auto flex justify-start items-center mr-1">
75-
<Code2Icon className="w-4 h-auto mr-1" />
76-
<span className="block text-sm">{t("memo.code")}</span>
77-
</div>
88+
<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">
89+
<Code2Icon className="w-3.5 h-3.5" />
90+
<span>{t("memo.code")}</span>
7891
</div>
7992
)}
8093
</div>
8194
</div>
8295
)}
8396
{memo.tags.length > 0 && (
84-
<div className="w-full">
85-
<div className="flex flex-row justify-start items-center w-full gap-1 mb-1 text-sm leading-6 text-muted-foreground select-none">
86-
<span>{t("common.tags")}</span>
87-
<span className="shrink-0">({memo.tags.length})</span>
97+
<div className="w-full space-y-2">
98+
<div className="flex flex-row justify-start items-center gap-1.5 px-1">
99+
<p className="text-xs font-medium text-muted-foreground/60 uppercase tracking-wide">{t("common.tags")}</p>
100+
<span className="text-xs text-muted-foreground/40">({memo.tags.length})</span>
88101
</div>
89-
<div className="w-full flex flex-row justify-start items-center relative flex-wrap gap-x-2 gap-y-1">
102+
<div className="w-full flex flex-row justify-start items-center flex-wrap gap-1.5 px-1">
90103
{memo.tags.map((tag) => (
91104
<div
92105
key={tag}
93-
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"
106+
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"
94107
>
95-
<HashIcon className="group-hover:hidden w-4 h-auto shrink-0 opacity-40" />
96-
<div className={cn("inline-flex flex-nowrap ml-0.5 gap-0.5 cursor-pointer max-w-[calc(100%-16px)]")}>
97-
<span className="truncate opacity-80">{tag}</span>
98-
</div>
108+
<HashIcon className="w-3 h-3 opacity-40 group-hover:opacity-60 transition-opacity" />
109+
<span className="opacity-80 group-hover:opacity-100 transition-opacity">{tag}</span>
99110
</div>
100111
))}
101112
</div>

0 commit comments

Comments
 (0)