7.9 KB
raw
import React, { useContext, useRef, useEffect, useCallback, useMemo } from "react";
import { useForm, Controller } from "react-hook-form";
import PropTypes from "prop-types";
import { timeString } from "../utils/time";
import { Context } from "../components/context";
import TagNoteInput from "./tagNoteInput";
import styles from "../styles/components/entry.module.css";
const extractTags = (text) =>
text
.split(/\s+/)
.filter((word) => word.startsWith("#") && word.length > 1)
.map((word) => word.toLowerCase());
const toDatetimeLocal = (date) => {
const d = date instanceof Date ? date : new Date(date);
const pad = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
};
const Entry = React.forwardRef(
({ entry, removeEntry, isSelected, style }, forwardedRef) => {
const { state, dispatch } = useContext(Context);
const { register, handleSubmit, reset, control } = useForm({
defaultValues: {
note: entry.note,
start: toDatetimeLocal(entry.start),
end: toDatetimeLocal(entry.end),
},
});
const allTags = useMemo(() => {
const s = new Set();
for (const e of state.log) for (const t of e.tags || []) s.add(t);
return [...s].sort();
}, [state.log]);
const focusedEntry = useRef(null);
const isEditing = state.edit && isSelected == entry.id;
useEffect(() => {
if (isEditing) {
reset({
note: entry.note,
start: toDatetimeLocal(entry.start),
end: toDatetimeLocal(entry.end),
});
}
}, [isEditing, entry.id, entry.note, entry.start, entry.end, reset]);
const setRefs = useCallback(
(node) => {
focusedEntry.current = node;
if (typeof forwardedRef === "function") {
forwardedRef(node);
} else if (forwardedRef && "current" in forwardedRef) {
forwardedRef.current = node;
}
},
[forwardedRef]
);
const onSubmit = (data) => {
const start = new Date(data.start);
const end = new Date(data.end);
if (isNaN(start) || isNaN(end) || end < start) {
dispatch({ type: "TOGGLE_EDITION", edit: false });
return;
}
dispatch({
type: "EDIT_LOG",
entry: {
...entry,
start,
end,
note: data.note,
tags: extractTags(data.note),
},
});
dispatch({ type: "TOGGLE_EDITION", edit: false });
};
useEffect(() => {
if (isSelected == entry.id && focusedEntry.current) {
focusedEntry.current.focus();
focusedEntry.current.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
}, [isSelected, entry.id]);
const containerClasses = [styles.entryContainer];
if (isSelected == entry.id) containerClasses.push(styles.selected);
if (isEditing) {
containerClasses.push(styles.zoom);
containerClasses.push(styles.editing);
}
return (
<div
style={style}
className={containerClasses.join(" ")}
ref={setRefs}
tabIndex={-1}
>
{isEditing ? (
<form className={styles.entryForm} onSubmit={handleSubmit(onSubmit)}>
<div className={`${styles.entryTime} ${styles.entryTimeEditing}`}>
<div className={styles.entryDuration}>
{timeString(entry.end - entry.start)}
</div>
<label className={styles.entryTimeRow}>
<span className={styles.entryTimeRowLabel}>From</span>
<input
type="datetime-local"
step="1"
aria-label="Start time"
className={styles.entryTimeInput}
{...register("start")}
/>
</label>
<label className={styles.entryTimeRow}>
<span className={styles.entryTimeRowLabel}>To</span>
<input
type="datetime-local"
step="1"
aria-label="End time"
className={styles.entryTimeInput}
{...register("end")}
/>
</label>
</div>
<div className={styles.entryNote}>
<Controller
name="note"
control={control}
render={({ field }) => (
<TagNoteInput
className={styles.entryNoteInput}
aria-label="Note"
placeholder="Note with #tags"
autoFocus
value={field.value}
onChange={field.onChange}
allTags={allTags}
/>
)}
/>
</div>
<button
className={`${styles.entryButton} ${styles.entrySubmit}`}
type="submit"
aria-label="Save"
title="Save (Enter)"
>
<IconCheck />
</button>
<button
className={`${styles.entryButton} ${styles.entryRemove}`}
type="button"
aria-label="Cancel"
title="Cancel"
onClick={() => dispatch({ type: "TOGGLE_EDITION", edit: false })}
>
<IconX />
</button>
</form>
) : (
<>
<div className={styles.entryTime}>
{timeString(entry.end - entry.start)}
<span>{entry.start.toLocaleTimeString()}</span>
</div>
<div
className={`${styles.entryNote} ${entry.note.length === 0 ? styles.entryNoteEmpty : ""}`.trim()}
>
{entry.note}
{entry.tags.length > 0 && (
<small>{entry.tags.join(", ")}</small>
)}
</div>
<button
className={`${styles.entryButton} ${styles.entryEdit}`}
aria-label="Edit"
title="Edit (⎇+E)"
onClick={() => {
dispatch({ type: "SELECT_LOG_ITEM", id: entry.id });
dispatch({ type: "TOGGLE_EDITION", edit: true });
}}
>
<IconPencil />
</button>
<button
className={`${styles.entryButton} ${styles.entryRemove}`}
aria-label="Delete"
title="Delete (⎇+D)"
onClick={() => {
dispatch({ type: "SELECT_LOG_ITEM", id: "" });
removeEntry(entry.id);
}}
>
<IconTrash />
</button>
</>
)}
</div>
);
}
);
const IconPencil = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/>
</svg>
);
const IconTrash = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M6 19a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/>
</svg>
);
const IconCheck = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M9 16.2L4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4z"/>
</svg>
);
const IconX = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
</svg>
);
Entry.propTypes = {
entry: PropTypes.object,
removeEntry: PropTypes.func,
isSelected: PropTypes.string,
style: PropTypes.object,
};
Entry.displayName = "Entry";
export default Entry;