mirror of https://github.com/usememos/memos.git
feat(web): replace EditableTimestamp with inline editor timestamp popover
This commit is contained in:
parent
566fdccae6
commit
6402618c26
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
||||
|
|
|
|||
|
|
@ -72,6 +72,11 @@ export const editorActions = {
|
|||
payload: value,
|
||||
}),
|
||||
|
||||
setTimestamps: (timestamps: Partial<EditorState["timestamps"]>): EditorAction => ({
|
||||
type: "SET_TIMESTAMPS",
|
||||
payload: timestamps,
|
||||
}),
|
||||
|
||||
reset: (): EditorAction => ({
|
||||
type: "RESET",
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Reference in New Issue