first commit; dx 1.0 beta
This commit is contained in:
@@ -0,0 +1,654 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
PointerSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { BiCopy, BiDotsVertical, BiX } from "react-icons/bi";
|
||||
import {
|
||||
DengiButton,
|
||||
DengiYesOrNoDialog,
|
||||
LoadingSpinner,
|
||||
} from "./trash-components";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import {
|
||||
BiCheck,
|
||||
BiPlus,
|
||||
BiRedo,
|
||||
BiTime,
|
||||
BiTrash,
|
||||
BiUndo,
|
||||
} from "react-icons/bi";
|
||||
// import { ifstyle } from "../App";
|
||||
import {
|
||||
apiFormatDate,
|
||||
convertApiToEditor,
|
||||
convertEditorToApi,
|
||||
formatDate,
|
||||
} from "./dengi";
|
||||
import {
|
||||
EditorPrefillRequest,
|
||||
EditorPublishRequest,
|
||||
EditorRemoveRequest,
|
||||
} from "./editor_req_mgr";
|
||||
import { API_BASE_URL } from "../App";
|
||||
|
||||
// Configuration
|
||||
const COLUMNS = [
|
||||
[
|
||||
{
|
||||
key: "position",
|
||||
label: "номер",
|
||||
width: "w-9",
|
||||
placeholder: "№",
|
||||
useinput: true,
|
||||
},
|
||||
{
|
||||
key: "subject",
|
||||
label: "предмет",
|
||||
width: "w-40",
|
||||
placeholder: "предмет",
|
||||
useinput: false,
|
||||
},
|
||||
{
|
||||
key: "room",
|
||||
label: "кабинет",
|
||||
width: "w-14",
|
||||
placeholder: "123",
|
||||
useinput: false,
|
||||
},
|
||||
{
|
||||
key: "time1",
|
||||
label: "начало",
|
||||
width: "w-16",
|
||||
placeholder: "00:00",
|
||||
useinput: true,
|
||||
},
|
||||
{
|
||||
key: "time2",
|
||||
label: "конец",
|
||||
width: "w-16",
|
||||
placeholder: "00:00",
|
||||
useinput: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
key: "position",
|
||||
label: "номер",
|
||||
width: "w-9",
|
||||
placeholder: "№",
|
||||
useinput: true,
|
||||
},
|
||||
{
|
||||
key: "subject",
|
||||
label: "предмет",
|
||||
width: "w-40",
|
||||
placeholder: "предмет",
|
||||
useinput: false,
|
||||
},
|
||||
{
|
||||
key: "room",
|
||||
label: "кабинет",
|
||||
width: "w-14",
|
||||
placeholder: "123",
|
||||
useinput: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
key: "position",
|
||||
label: "номер",
|
||||
width: "w-9",
|
||||
placeholder: "№",
|
||||
useinput: true,
|
||||
},
|
||||
{
|
||||
key: "time1",
|
||||
label: "начало",
|
||||
width: "w-16",
|
||||
placeholder: "00:00",
|
||||
useinput: true,
|
||||
},
|
||||
{
|
||||
key: "time2",
|
||||
label: "конец",
|
||||
width: "w-16",
|
||||
placeholder: "00:00",
|
||||
useinput: true,
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
const INITIAL_DATA = [
|
||||
// {
|
||||
// id: "1",
|
||||
// position: "1",
|
||||
// subject: "Mathematics",
|
||||
// time: "09:00",
|
||||
// room: "A-1",
|
||||
// },
|
||||
// { id: "2", position: "2", subject: "Physics", time: "10:00", room: "B-2" },
|
||||
// { id: "3", position: "3", subject: "Chemistry", time: "11:00", room: "A-3" },
|
||||
// { id: "4", position: "4", subject: "Biology", time: "13:00", room: "C-1" },
|
||||
];
|
||||
|
||||
function TableInput(props) {
|
||||
return props.useinput ? <input {...props} /> : <textarea {...props} />;
|
||||
}
|
||||
|
||||
function SortableRow({
|
||||
item,
|
||||
columns,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
setFocusEdit,
|
||||
setHoverEdit,
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: item.id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className="bg-zinc-900 bg-grain rounded-lg mb-2"
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex">
|
||||
{columns.map((col) => (
|
||||
<TableInput
|
||||
key={col.key}
|
||||
useinput={col.useinput}
|
||||
type="text"
|
||||
value={item[col.key] || ""}
|
||||
wrap="hard"
|
||||
rows={1}
|
||||
onChange={(e) => onUpdate(item.id, col.key, e.target.value)}
|
||||
className={`px-1 py-3 text-center bg-transparent text-white border-r border-zinc-700 last:border-r-0 last:rounded-tr-lg focus:outline-none focus:bg-gray-700/50 placeholder-gray-500 transition-colors hover:bg-gray-700/20 first:rounded-tl-lg max-h-min resize-none block min-w-0 grow ${col.width}`}
|
||||
placeholder={col.placeholder}
|
||||
onFocus={() => setFocusEdit(col.key)}
|
||||
onBlur={() => setFocusEdit(null)}
|
||||
onMouseOver={() => setHoverEdit(col.key)}
|
||||
onMouseLeave={() => setHoverEdit(null)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="w-[98%] mx-auto h-[1px] bg-zinc-500" />
|
||||
<div className="flex justify-end w-full">
|
||||
<button
|
||||
onClick={() => onDuplicate(item.id)}
|
||||
className="min-w-12 min-h-12 transition-colors bg-zinc-600/20 text-zinc-400 hover:bg-zinc-600/40 hover:text-zinc-300 flex items-center justify-center rounded-l-md"
|
||||
>
|
||||
<BiCopy size={20} />
|
||||
</button>
|
||||
<div
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="min-w-12 min-h-12 transition-colors hover:bg-zinc-200 flex items-center justify-center cursor-move touch-none select-none hover:text-black font-black text-white bg-zinc-700/60"
|
||||
>
|
||||
<BiDotsVertical size={24} />
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onDelete(item.id)}
|
||||
className="min-w-12 min-h-12 transition-colors bg-red-600/20 text-red-400 hover:bg-red-600/40 hover:text-red-300 active:bg-red-600/60 flex items-center justify-center rounded-r-md"
|
||||
>
|
||||
<BiX size={24} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
async function getPrefilledRasp(tab, date, adminId) {
|
||||
let a = await EditorPrefillRequest(
|
||||
API_BASE_URL,
|
||||
tab,
|
||||
adminId,
|
||||
apiFormatDate(date)
|
||||
);
|
||||
if (a[0] == 1 && a[1]) {
|
||||
return convertApiToEditor(a[1]);
|
||||
}
|
||||
}
|
||||
|
||||
export function TableEditor({
|
||||
initialData = INITIAL_DATA,
|
||||
onChange = () => {},
|
||||
selectedTab,
|
||||
columns = COLUMNS[selectedTab],
|
||||
setChangesWereMade,
|
||||
adminId,
|
||||
date,
|
||||
dengiCash,
|
||||
}) {
|
||||
const [items, setItems] = useState(initialData);
|
||||
const [undoItems, setUndoItems] = useState([]);
|
||||
const [redoItems, setRedoItems] = useState([]);
|
||||
const [isLoading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let rejected = false;
|
||||
//console.log("cleanup");
|
||||
// cleanup on change
|
||||
setUndoItems([]);
|
||||
setRedoItems([]);
|
||||
setLoading(true);
|
||||
getPrefilledRasp(selectedTab, date, adminId).then((i) => {
|
||||
if (rejected) return;
|
||||
console.log(i || []);
|
||||
setItems(i || []);
|
||||
setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
rejected = true;
|
||||
};
|
||||
}, [
|
||||
selectedTab,
|
||||
formatDate(date) /* NOTE: we need to format to rely only on DD-MM-YY,
|
||||
and forget about time at all*/,
|
||||
]);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
}),
|
||||
useSensor(TouchSensor, {
|
||||
activationConstraint: {
|
||||
delay: 200,
|
||||
tolerance: 8,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const updateItems = (newItems) => {
|
||||
setItems(newItems);
|
||||
onChange?.(newItems);
|
||||
};
|
||||
|
||||
useEffect(() => setChangesWereMade(undoItems.length > 0), [undoItems]);
|
||||
const handleDragEnd = (event) => {
|
||||
const { active, over } = event;
|
||||
if (over && active.id !== over.id) {
|
||||
const oldIndex = items.findIndex((item) => item.id === active.id);
|
||||
const newIndex = items.findIndex((item) => item.id === over.id);
|
||||
const newItems = arrayMove(items, oldIndex, newIndex);
|
||||
|
||||
// Swap positions: assign old positions to new order
|
||||
const reorderedItems = newItems.map((item, idx) => ({
|
||||
...item,
|
||||
position: items[idx].position, // Use position from original array at this index
|
||||
}));
|
||||
|
||||
setUndoItems([items, ...undoItems]);
|
||||
updateItems(reorderedItems);
|
||||
}
|
||||
};
|
||||
const updateTimeoutRef = useRef({});
|
||||
const previousValueRef = useRef({});
|
||||
|
||||
const handleUpdate = (id, field, value) => {
|
||||
const key = `${id}-${field}`;
|
||||
if (!updateTimeoutRef.current[key]) {
|
||||
const currentItem = items.find((item) => item.id === id);
|
||||
previousValueRef.current[key] = currentItem[field];
|
||||
}
|
||||
if (updateTimeoutRef.current[key]) {
|
||||
clearTimeout(updateTimeoutRef.current[key]);
|
||||
}
|
||||
const newItems = items.map((item) =>
|
||||
item.id === id ? { ...item, [field]: value } : item
|
||||
);
|
||||
updateItems(newItems);
|
||||
updateTimeoutRef.current[key] = setTimeout(() => {
|
||||
// Create items array with the ORIGINAL value from before editing started
|
||||
const itemsWithOriginalValue = items.map((item) =>
|
||||
item.id === id
|
||||
? { ...item, [field]: previousValueRef.current[key] }
|
||||
: item
|
||||
);
|
||||
setUndoItems([itemsWithOriginalValue, ...undoItems]);
|
||||
|
||||
// Clean up
|
||||
delete updateTimeoutRef.current[key];
|
||||
delete previousValueRef.current[key];
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const handleUndo = () => {
|
||||
if (isLoading) return;
|
||||
setRedoItems([items, ...redoItems]);
|
||||
updateItems(undoItems[0]);
|
||||
setUndoItems(undoItems.slice(1));
|
||||
};
|
||||
const handleRedo = () => {
|
||||
if (isLoading) return;
|
||||
setUndoItems([items, ...undoItems]);
|
||||
updateItems(redoItems[0]);
|
||||
setRedoItems(redoItems.slice(1));
|
||||
};
|
||||
|
||||
const handleDelete = (id) => {
|
||||
if (isLoading) return;
|
||||
const newItems = items.filter((item) => item.id !== id);
|
||||
setUndoItems([items, ...undoItems]);
|
||||
updateItems(newItems);
|
||||
};
|
||||
|
||||
const handleDuplicate = (id) => {
|
||||
const index = items.findIndex((item) => item.id === id);
|
||||
if (index === -1) return;
|
||||
|
||||
const itemToDuplicate = items[index];
|
||||
|
||||
// Create new item with unique id
|
||||
const newItem = {
|
||||
...itemToDuplicate,
|
||||
id: Date.now(), // or use uuid() or any unique id generator
|
||||
};
|
||||
|
||||
// Insert the new item right after the duplicated item
|
||||
const newItems = [
|
||||
...items.slice(0, index + 1),
|
||||
newItem,
|
||||
...items.slice(index + 1),
|
||||
];
|
||||
|
||||
// Reassign positions based on the original order
|
||||
// The duplicate takes the next position, and everything shifts
|
||||
const reorderedItems = newItems.map((item, idx) => ({
|
||||
...item,
|
||||
position: String(idx + 1), // or just (idx + 1) if position is a number
|
||||
}));
|
||||
|
||||
setUndoItems([items, ...undoItems]);
|
||||
updateItems(reorderedItems);
|
||||
};
|
||||
|
||||
const handleAddRow = () => {
|
||||
if (isLoading) return;
|
||||
const newId = String(Date.now());
|
||||
const sortedItems = [...items];
|
||||
sortedItems.sort((a, b) => a.position - b.position);
|
||||
const lastPosition =
|
||||
items.length > 0
|
||||
? String(
|
||||
parseInt(sortedItems[sortedItems.length - 1].position || "0") + 1
|
||||
)
|
||||
: "1";
|
||||
|
||||
const newItem = { id: newId, position: lastPosition };
|
||||
columns.forEach((col) => {
|
||||
if (col.key !== "position") {
|
||||
newItem[col.key] = "";
|
||||
}
|
||||
});
|
||||
setUndoItems([items, ...undoItems]);
|
||||
updateItems([...items, newItem]);
|
||||
setTimeout(
|
||||
() =>
|
||||
window.scrollTo({
|
||||
top: document.documentElement.scrollHeight - window.innerHeight,
|
||||
left: window.scrollX, // preserve current X
|
||||
behavior: "smooth",
|
||||
}),
|
||||
100
|
||||
);
|
||||
};
|
||||
|
||||
const [focusEdit, setFocusEdit] = useState(null);
|
||||
const [hoverEdit, setHoverEdit] = useState(null);
|
||||
|
||||
const [deleteDiag, setDeleteDiag] = useState(false);
|
||||
|
||||
const handleClear = () => {
|
||||
if (isLoading) return;
|
||||
setUndoItems([items, ...undoItems]);
|
||||
updateItems([]);
|
||||
};
|
||||
|
||||
const handleChangesDefault = () => {
|
||||
if (selectedTab != 0 || isLoading) return;
|
||||
setLoading(true);
|
||||
getPrefilledRasp(1, date, adminId).then((i) => {
|
||||
setUndoItems([items, ...undoItems]);
|
||||
setItems(i);
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
const handlePublish = () => {
|
||||
if (isLoading) return;
|
||||
setUndoItems([]);
|
||||
setRedoItems([]);
|
||||
console.log(convertEditorToApi(items));
|
||||
dengiCash.fullClear(); // keep it clean
|
||||
setLoading(true);
|
||||
EditorPublishRequest(
|
||||
API_BASE_URL,
|
||||
selectedTab,
|
||||
adminId,
|
||||
apiFormatDate(date),
|
||||
JSON.stringify(convertEditorToApi(items))
|
||||
).then(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
const handleDeleteAll = () => {
|
||||
// if (isLoading) return; // WARN!
|
||||
setDeleteDiag(true);
|
||||
};
|
||||
|
||||
let deleteDialogBusy = false; // NOTE: we don't need a state
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<DengiYesOrNoDialog
|
||||
content={{
|
||||
message: "ВСЁ БУДЕТ УДАЛЕНО! продолжить?",
|
||||
yesBtn: "да",
|
||||
noBtn: "нет",
|
||||
yesIcon: <BiTrash />,
|
||||
noIcon: <BiX size={20} />,
|
||||
bgDissmis: true,
|
||||
yesBtnStyle: "bg-red-500! text-white! md:hover:bg-red-600!",
|
||||
}}
|
||||
onAnswer={(answ) => {
|
||||
if (deleteDialogBusy) return;
|
||||
if (!answ) setDeleteDiag(false);
|
||||
else {
|
||||
dengiCash.fullClear();
|
||||
// scary
|
||||
deleteDialogBusy = true;
|
||||
EditorRemoveRequest(
|
||||
API_BASE_URL,
|
||||
selectedTab,
|
||||
adminId,
|
||||
apiFormatDate(date)
|
||||
).then(() => {
|
||||
deleteDialogBusy = false; // for 100% sure
|
||||
setDeleteDiag(false);
|
||||
location.hash = "/app";
|
||||
});
|
||||
}
|
||||
}}
|
||||
show={deleteDiag}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full overflow-hidden my-1 flex flex-col gap-3">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<DengiButton onClick={handleUndo} disabled={undoItems.length == 0}>
|
||||
<BiUndo />
|
||||
</DengiButton>
|
||||
<DengiButton onClick={handleRedo} disabled={redoItems.length == 0}>
|
||||
<BiRedo />
|
||||
</DengiButton>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{selectedTab == 0 && (
|
||||
<motion.div exit={{ opacity: 0 }}>
|
||||
<DengiButton
|
||||
className="border-red-600! md:not-disabled:not-hover:border-red-400!"
|
||||
onClick={handleChangesDefault}
|
||||
>
|
||||
<BiTime />
|
||||
убрать зам.
|
||||
</DengiButton>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<motion.div layout="position" className="bg-black">
|
||||
<DengiButton
|
||||
onClick={handleClear}
|
||||
className="border-red-600! md:not-disabled:not-hover:border-red-400!"
|
||||
>
|
||||
<BiTrash />
|
||||
очистить
|
||||
</DengiButton>
|
||||
</motion.div>
|
||||
</div>
|
||||
{!isLoading && (
|
||||
<div>
|
||||
<div className="flex w-full justify-around items-center mb-2">
|
||||
{columns.map((col, i) => (
|
||||
<>
|
||||
<div
|
||||
key={col.key + i}
|
||||
className={
|
||||
`max-${col.width} transition-colors text-` +
|
||||
((focusEdit == null && hoverEdit == null) ||
|
||||
focusEdit == col.key ||
|
||||
hoverEdit == col.key
|
||||
? "white"
|
||||
: "zinc-600")
|
||||
}
|
||||
// onMouseOver={() => setHoverEdit(col.key)}
|
||||
// onMouseLeave={() => setHoverEdit(null)}
|
||||
>
|
||||
{col.label}
|
||||
</div>
|
||||
|
||||
<span
|
||||
key={col.key + i + "divider"}
|
||||
className="w-4 h-[1px] bg-zinc-600 -rotate-45 last:hidden"
|
||||
/>
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={items.map((item) => item.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div>
|
||||
<AnimatePresence mode={items.length > 0 ? "sync" : "wait"}>
|
||||
{items.map((item) => (
|
||||
<motion.div
|
||||
initial={{ x: -15, filter: "blur(5px)", opacity: 0 }}
|
||||
animate={{ x: 0, filter: "blur(0px)", opacity: 1 }}
|
||||
exit={{ x: 15, filter: "blur(5px)", opacity: 0 }}
|
||||
key={item.id}
|
||||
>
|
||||
<SortableRow
|
||||
item={item}
|
||||
columns={columns}
|
||||
onUpdate={handleUpdate}
|
||||
onDelete={handleDelete}
|
||||
onDuplicate={handleDuplicate}
|
||||
setFocusEdit={setFocusEdit}
|
||||
setHoverEdit={setHoverEdit}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<motion.div
|
||||
initial={{
|
||||
filter: "blur(5px)",
|
||||
opacity: 0,
|
||||
scale: 0.8,
|
||||
}}
|
||||
animate={{
|
||||
filter: "blur(0px)",
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
}}
|
||||
className="border-2 border-dashed border-gray-700 rounded-lg p-12 text-center text-gray-500"
|
||||
>
|
||||
пусто
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
{/* <div className="mt-6 flex gap-3">
|
||||
<button
|
||||
onClick={handleAddRow}
|
||||
className="flex-1 bg-blue-600 text-white px-6 py-3 rounded-lg font-semibold hover:bg-blue-700 active:bg-blue-800"
|
||||
>
|
||||
+ Add Row
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="px-6 py-3 rounded-lg border-2 border-gray-700 text-gray-300 font-semibold hover:bg-gray-800 active:bg-gray-700"
|
||||
>
|
||||
Clear All
|
||||
</button>
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isLoading && (
|
||||
<div className="w-full flex items-center justify-center py-3">
|
||||
<LoadingSpinner size={35} />
|
||||
</div>
|
||||
)}
|
||||
<motion.div layout="position" className="flex w-full justify-end gap-2">
|
||||
<DengiButton
|
||||
onClick={handleDeleteAll}
|
||||
className="border-red-600! md:not-disabled:not-hover:border-red-400!"
|
||||
>
|
||||
<BiTrash />
|
||||
удалить всё
|
||||
</DengiButton>
|
||||
<DengiButton onClick={handleAddRow}>
|
||||
<BiPlus />
|
||||
</DengiButton>
|
||||
<DengiButton onClick={handlePublish}>
|
||||
<BiCheck />
|
||||
сохранить
|
||||
</DengiButton>
|
||||
</motion.div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user