mirror of https://github.com/usememos/memos
feat(web): replace EditableTimestamp with inline editor timestamp popover
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;
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
Loading…
Reference in New Issue