first commit; dx 1.0 beta

This commit is contained in:
2025-11-09 22:35:16 +03:00
commit 4d01725955
153 changed files with 6504 additions and 0 deletions
+654
View File
@@ -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 />
убрать&nbsp;зам.
</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>
</>
);
}