feat(web): replace EditableTimestamp with inline editor timestamp popover

This commit is contained in:
Johnny 2026-02-11 23:45:53 +08:00
parent 566fdccae6
commit 6402618c26
8 changed files with 111 additions and 130 deletions

View File

@ -1,104 +0,0 @@
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,10 +1,7 @@
import { create } from "@bufbuild/protobuf";
import { timestampFromDate } from "@bufbuild/protobuf/wkt";
import { timestampDate } 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,29 +15,10 @@ interface Props {
const MemoDetailSidebar = ({ memo, className, parentPage }: Props) => {
const t = useTranslate();
const { mutate: updateMemo } = useUpdateMemo();
const property = create(Memo_PropertySchema, memo.property || {});
const hasSpecialProperty = property.hasLink || property.hasTaskList || property.hasCode;
const hasReferenceRelations = memo.relations.some((r) => r.type === MemoRelation_Type.REFERENCE);
const handleUpdateTimestamp = (field: "createTime" | "updateTime", date: Date) => {
const currentTimestamp = memo[field];
const newTimestamp = timestampFromDate(date);
if (isEqual(currentTimestamp, newTimestamp)) {
return;
}
updateMemo(
{
update: { name: memo.name, [field]: newTimestamp },
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 flex flex-col justify-start items-start", className)}>
<div className="flex flex-col justify-start items-start w-full gap-4 h-auto shrink-0 flex-nowrap">
@ -56,13 +34,13 @@ const MemoDetailSidebar = ({ memo, className, parentPage }: Props) => {
<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)} />
<p className="text-sm text-muted-foreground px-1">{memo.createTime ? timestampDate(memo.createTime).toLocaleString() : "-"}</p>
</div>
{!isEqual(memo.createTime, memo.updateTime) && (
<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)} />
<p className="text-sm text-muted-foreground px-1">{memo.updateTime ? timestampDate(memo.updateTime).toLocaleString() : "-"}</p>
</div>
)}

View File

@ -0,0 +1,89 @@
import { type FC, useRef, useState } from "react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { useTranslate } from "@/utils/i18n";
import { useEditorContext } from "../state";
const DATETIME_FORMAT = "YYYY-MM-DD HH:mm:ss";
function formatDate(date: Date): string {
const pad = (n: number) => String(n).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
function parseDate(value: string): Date | undefined {
const match = value.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/);
if (!match) return undefined;
const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]), Number(match[4]), Number(match[5]), Number(match[6]));
return Number.isNaN(date.getTime()) ? undefined : date;
}
const TimestampInput: FC<{
label: string;
date: Date | undefined;
onChange: (date: Date) => void;
}> = ({ label, date, onChange }) => {
const initialValue = useRef(date ? formatDate(date) : "");
const [value, setValue] = useState(initialValue.current);
const [invalid, setInvalid] = useState(false);
const handleBlur = () => {
const parsed = parseDate(value);
if (parsed) {
setInvalid(false);
onChange(parsed);
} else {
setInvalid(true);
}
};
return (
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">
{label}
{value !== initialValue.current && <span className="text-primary ml-0.5">*</span>}
</label>
<input
type="text"
className="block w-full rounded-md border border-border bg-background px-2 py-1 text-sm font-mono data-[invalid=true]:border-destructive"
data-invalid={invalid}
placeholder={DATETIME_FORMAT}
value={value}
onChange={(e) => setValue(e.target.value)}
onBlur={handleBlur}
/>
</div>
);
};
export const TimestampPopover: FC = () => {
const t = useTranslate();
const { state, actions, dispatch } = useEditorContext();
const { createTime, updateTime } = state.timestamps;
if (!createTime) return null;
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="w-full text-sm text-muted-foreground -mb-1 text-left hover:text-foreground transition-colors cursor-pointer"
>
{formatDate(createTime)}
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-auto p-2 pt-1 space-y-1">
<TimestampInput
label={t("common.created-at")}
date={createTime}
onChange={(d) => dispatch(actions.setTimestamps({ createTime: d }))}
/>
<TimestampInput
label={t("common.last-updated-at")}
date={updateTime}
onChange={(d) => dispatch(actions.setTimestamps({ updateTime: d }))}
/>
</PopoverContent>
</Popover>
);
};

View File

@ -9,3 +9,4 @@ export { LinkMemoDialog } from "./LinkMemoDialog";
export { LocationDialog } from "./LocationDialog";
export { default as LocationDisplay } from "./LocationDisplay";
export { default as RelationList } from "./RelationList";
export { TimestampPopover } from "./TimestampPopover";

View File

@ -9,7 +9,7 @@ import { handleError } from "@/lib/error";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
import { convertVisibilityFromString } from "@/utils/memo";
import { EditorContent, EditorMetadata, EditorToolbar, FocusModeExitButton, FocusModeOverlay } from "./components";
import { EditorContent, EditorMetadata, EditorToolbar, FocusModeExitButton, FocusModeOverlay, TimestampPopover } from "./components";
import { FOCUS_MODE_STYLES } from "./constants";
import type { EditorRefActions } from "./Editor";
import { useAutoSave, useFocusMode, useKeyboard, useMemoInit } from "./hooks";
@ -141,6 +141,8 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
{/* Exit button is absolutely positioned in top-right corner when active */}
<FocusModeExitButton isActive={state.ui.isFocusMode} onToggle={handleToggleFocusMode} title={t("editor.exit-focus-mode")} />
{memoName && <TimestampPopover />}
{/* Editor content grows to fill available space in focus mode */}
<EditorContent ref={editorRef} placeholder={placeholder} autoFocus={autoFocus} />

View File

@ -72,6 +72,11 @@ export const editorActions = {
payload: value,
}),
setTimestamps: (timestamps: Partial<EditorState["timestamps"]>): EditorAction => ({
type: "SET_TIMESTAMPS",
payload: timestamps,
}),
reset: (): EditorAction => ({
type: "RESET",
}),

View File

@ -119,6 +119,15 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
},
};
case "SET_TIMESTAMPS":
return {
...state,
timestamps: {
...state.timestamps,
...action.payload,
},
};
case "RESET":
return {
...initialState,

View File

@ -45,6 +45,7 @@ export type EditorAction =
| { type: "SET_LOADING"; payload: { key: LoadingKey; value: boolean } }
| { type: "SET_DRAGGING"; payload: boolean }
| { type: "SET_COMPOSING"; payload: boolean }
| { type: "SET_TIMESTAMPS"; payload: Partial<EditorState["timestamps"]> }
| { type: "RESET" };
export const initialState: EditorState = {