dx4 v2
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
import { useAtom } from "jotai";
|
||||
import { motion } from "motion/react";
|
||||
import { BiCopy, BiError, BiLeftArrowAlt, BiLink, BiX } from "react-icons/bi";
|
||||
import Logo from "../assets/logo.svg";
|
||||
import { AdminReq, AdminUpdateRequest } from "../trash/req_mgr";
|
||||
import { API_BASE_URL } from "../App";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
DengiButton,
|
||||
DengiEditable,
|
||||
DengiYesOrNoDialog,
|
||||
TextPreloader,
|
||||
} from "../trash/trash-components";
|
||||
import { copyDXLink } from "../trash/helpers";
|
||||
|
||||
export default function AdminProfilePage({
|
||||
adminAtom,
|
||||
setsetRoute,
|
||||
routeAtom,
|
||||
pickmeModeAtom,
|
||||
dengi_cash,
|
||||
classIDAtom,
|
||||
}) {
|
||||
const [pickmeMode, _] = useAtom(pickmeModeAtom);
|
||||
const [_route, setRoute] = useAtom(routeAtom);
|
||||
const [adminId, _setAdminId] = useAtom(adminAtom);
|
||||
const [classId, _setClassId] = useAtom(classIDAtom);
|
||||
const [adminFrId, setAdminFrId] = useState(false);
|
||||
const [adminData, setAdminData] = useState(false);
|
||||
if (classId === "") {
|
||||
setsetRoute(setRoute, "/");
|
||||
}
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
const a = (await AdminReq(API_BASE_URL, adminId)) || false;
|
||||
if (!cancelled && a[0] === 1) {
|
||||
setAdminFrId(a[1].adminName);
|
||||
setAdminData({ class: a[1].adminClass, adminPerms: a[1].adminPerms });
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [adminId]);
|
||||
return (
|
||||
<>
|
||||
<motion.div className="w-full h-screen flex flex-col items-center justify-center gap-5 overflow-x-hidden z-2">
|
||||
<motion.div
|
||||
className="w-screen h-screen flex flex-col itemsц-center sm:max-w-[500px] sm:max-h-[90vh] gap-3"
|
||||
initial={{ scale: 0.99, opacity: 0, y: 5 }}
|
||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||
>
|
||||
<Header {...{ setsetRoute, setRoute, pickmeMode }} />
|
||||
<span className="text-xl font-bold">профиль админа</span>
|
||||
<AdminProfileEditor
|
||||
{...{
|
||||
adminId,
|
||||
classId: adminData.class ? adminData.class : false,
|
||||
adminFrId,
|
||||
pickmeMode,
|
||||
setAdminFrId: (v) => {
|
||||
setAdminFrId(v);
|
||||
dengi_cash.delete(`adminfr-${adminId}`);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
<CoolBg {...{ pickmeMode }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminProfileEditor({
|
||||
adminFrId,
|
||||
setAdminFrId,
|
||||
adminId,
|
||||
classId,
|
||||
pickmeMode = false,
|
||||
}) {
|
||||
return (
|
||||
<div className=" bg-grain bg-zinc-800/50 p-5 rounded-2xl not-md:w-[90%]">
|
||||
<table className="">
|
||||
<tbody className="*:*:w-10 *:*:h-10">
|
||||
<tr>
|
||||
<td className="">имя</td>
|
||||
<td className="w-50!">
|
||||
<DengiEditable
|
||||
value={adminFrId}
|
||||
pink={pickmeMode}
|
||||
onSubmit={async (e) => {
|
||||
e.setIdle(true);
|
||||
const r = await AdminUpdateRequest(API_BASE_URL, adminId, {
|
||||
action: "change_name",
|
||||
value: e.value.trim(),
|
||||
});
|
||||
if (r[1] == true) setAdminFrId(e.value);
|
||||
e.setIdle(false);
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>id</td>
|
||||
<td>
|
||||
<DengiEditable
|
||||
value={adminId}
|
||||
password={true}
|
||||
pink={pickmeMode}
|
||||
disabled
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>class</td>
|
||||
<td className="font-mono text-center select-all selectable">
|
||||
{classId ? classId : <TextPreloader />}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<CopyLink {...{ adminId, classId }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const CopyLink = ({ classId, adminId }) => {
|
||||
const [showDiag, setShowDiag] = useState(false);
|
||||
const [seenDiag, setSeenDiag] = useState(false);
|
||||
const [linkCopied, setLinkCopied] = useState(false);
|
||||
const copy = () => {
|
||||
copyDXLink(classId, adminId).then((a) => {
|
||||
if (a) {
|
||||
setLinkCopied(true);
|
||||
setTimeout(() => {
|
||||
setLinkCopied(false);
|
||||
}, 2000);
|
||||
}
|
||||
});
|
||||
};
|
||||
return (
|
||||
<div className="flex justify-end mt-2">
|
||||
<DengiYesOrNoDialog
|
||||
show={showDiag}
|
||||
content={{
|
||||
message: (
|
||||
<p>
|
||||
<b>любой</b>, кто получит ссылку,{" "}
|
||||
<b>будет иметь доступ к админке</b>.<br /> вы желаете продолжить?
|
||||
</p>
|
||||
),
|
||||
yesBtn: "да",
|
||||
noBtn: "нет",
|
||||
yesIcon: <BiCopy />,
|
||||
noIcon: <BiX size={20} />,
|
||||
bgDissmis: true,
|
||||
yesBtnStyle: "bg-orange-500! text-white! md:hover:bg-orange-600!",
|
||||
}}
|
||||
onAnswer={(answ) => {
|
||||
if (answ) {
|
||||
setSeenDiag(true);
|
||||
copy();
|
||||
}
|
||||
setShowDiag(false);
|
||||
}}
|
||||
/>
|
||||
<DengiButton onClick={() => (seenDiag ? copy() : setShowDiag(true))}>
|
||||
<BiLink /> <BiError color={"orange"} />
|
||||
<span>{linkCopied ? "скопировано" : "коп ссылку на админку"}</span>
|
||||
</DengiButton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CoolBg = ({ pickmeMode }) => {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`fixed top-0 left-0 w-full h-full -z-5 overflow-hidden bg-gradient-to-tl to-black to-50% bg-fixed
|
||||
${pickmeMode ? "from-pink-400/10" : "from-white/2"}`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Header = ({ setsetRoute, setRoute, pickmeMode }) => {
|
||||
return (
|
||||
<div className={"flex items-center w-full justify-between px-5"}>
|
||||
<span className="flex gap-2 items-center">
|
||||
<BiLeftArrowAlt
|
||||
size={35}
|
||||
onClick={() => {
|
||||
setsetRoute(setRoute, "/app");
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
className={
|
||||
"rounded-full " +
|
||||
(pickmeMode
|
||||
? "lg:hover:bg-pink-400"
|
||||
: "lg:hover:bg-white lg:hover:fill-black") +
|
||||
" lg:hover:scale-105 transition cursor-pointer"
|
||||
}
|
||||
/>
|
||||
<img src={Logo} alt="logo" width={100} draggable={false} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,521 @@
|
||||
import { useAtom } from "jotai";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import {
|
||||
BiCheck,
|
||||
BiCheckCircle,
|
||||
BiCopy,
|
||||
BiLeftArrowAlt,
|
||||
BiLink,
|
||||
BiPlus,
|
||||
BiPlusCircle,
|
||||
BiTrash,
|
||||
BiX,
|
||||
BiXCircle,
|
||||
} from "react-icons/bi";
|
||||
import Logo from "../assets/logo.svg";
|
||||
import {
|
||||
AdminReq,
|
||||
AdminUpdateRequest,
|
||||
ClassUpdateRequest,
|
||||
getClassProfile,
|
||||
} from "../trash/req_mgr";
|
||||
import { API_BASE_URL } from "../App";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
DengiButton,
|
||||
DengiEditable,
|
||||
DengiYesOrNoDialog,
|
||||
LoadingSpinner,
|
||||
TextPreloader,
|
||||
} from "../trash/trash-components";
|
||||
import { b64e, copyDXLink, randomString } from "../trash/helpers";
|
||||
import { AvailableAdminSymbols } from "../trash/dengi";
|
||||
|
||||
export default function AdminProfilePage({
|
||||
classIDAtom,
|
||||
adminAtom,
|
||||
setsetRoute,
|
||||
routeAtom,
|
||||
pickmeModeAtom,
|
||||
}) {
|
||||
const [pickmeMode, _] = useAtom(pickmeModeAtom);
|
||||
const [_route, setRoute] = useAtom(routeAtom);
|
||||
const [adminId, _setAdminId] = useAtom(adminAtom);
|
||||
const [classId, _setClassId] = useAtom(classIDAtom);
|
||||
const [error, setError] = useState("");
|
||||
const [classData, setClassData] = useState(false);
|
||||
const [adminsData, setAdminsData] = useState(false);
|
||||
const [dataId, setDataId] = useState(0);
|
||||
if (classId === "") {
|
||||
setsetRoute(setRoute, "/");
|
||||
}
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
const a = (await getClassProfile(API_BASE_URL, adminId)) || false;
|
||||
if (!cancelled && a[0] === 1) {
|
||||
if (a[1]["class"]) {
|
||||
setClassData(a[1]["class"]);
|
||||
setAdminsData(a[1]["admins"]);
|
||||
} else {
|
||||
setError(a[1]);
|
||||
}
|
||||
} else {
|
||||
setError(a[1]);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [adminId, dataId]);
|
||||
return (
|
||||
<>
|
||||
<motion.div className="w-full h-screen flex flex-col items-center justify-center gap-5 overflow-x-hidden z-2">
|
||||
<motion.div
|
||||
className="w-screen h-screen flex flex-col items-center sm:max-w-[500px] sm:max-h-[90vh] gap-3"
|
||||
initial={{ scale: 0.99, opacity: 0, y: 5 }}
|
||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||
>
|
||||
<Header {...{ setsetRoute, setRoute, pickmeMode }} />
|
||||
<span className="text-xl font-bold text-center">
|
||||
управление классом
|
||||
</span>
|
||||
{error && typeof error == "string" && (
|
||||
<span className="text-red-500 text-2xl">{error}</span>
|
||||
)}
|
||||
<ClassProfileEditor
|
||||
{...{
|
||||
classId,
|
||||
loaded: classData !== false,
|
||||
classFr: classData.userclassname,
|
||||
pickmeMode,
|
||||
adminId,
|
||||
adminsData,
|
||||
update: () => {
|
||||
setDataId(dataId + 1);
|
||||
},
|
||||
setAdminsData,
|
||||
setClassFr: (v) => {
|
||||
setClassData((prev) => ({ ...prev, userclassname: v }));
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
<CoolBg {...{ pickmeMode }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClassProfileEditor({
|
||||
classId,
|
||||
classFr,
|
||||
pickmeMode,
|
||||
loaded,
|
||||
adminId,
|
||||
adminsData,
|
||||
setClassFr,
|
||||
update,
|
||||
setAdminsData,
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center bg-grain gap-2 bg-zinc-800/50 p-5 rounded-2xl not-md:w-[90%] relative md:min-w-[520px]">
|
||||
<table className="">
|
||||
<tbody className="*:*:min-w-20 *:*:h-10">
|
||||
<tr>
|
||||
<td className="">classId</td>
|
||||
<td className="font-mono text-center select-all selectable">
|
||||
{classId}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="">название</td>
|
||||
<td className="w-40">
|
||||
<DengiEditable
|
||||
value={loaded ? classFr : false}
|
||||
pink={pickmeMode}
|
||||
onSubmit={async (e) => {
|
||||
e.setIdle(true);
|
||||
let r = await ClassUpdateRequest(API_BASE_URL, adminId, {
|
||||
action: "update_name",
|
||||
value: e.value.trim(),
|
||||
});
|
||||
if (r[1] == true) setClassFr(e.value.trim());
|
||||
e.setIdle(false);
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="w-50 mt-5">
|
||||
<CopyClassLink {...{ classId }} />
|
||||
</div>
|
||||
<span className="h-[1px] w-[80%] bg-zinc-700 block my-5 "></span>
|
||||
<span className="text-xl font-bold block">админы</span>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="*:px-2">
|
||||
<th>имя</th>
|
||||
<th>id</th>
|
||||
<th>уров.</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{adminsData &&
|
||||
adminsData.map((i) => (
|
||||
<AdminProfileRow
|
||||
key={i.adminId}
|
||||
{...{
|
||||
adminId: i.adminId,
|
||||
editorAdminId: adminId,
|
||||
adminName: i.adminName,
|
||||
adminLevel: i.adminPrems,
|
||||
classId,
|
||||
update,
|
||||
pickmeMode,
|
||||
setAdminFrId: (v) => {
|
||||
setAdminsData((prev) =>
|
||||
prev.map((a) =>
|
||||
a.adminId === i.adminId ? { ...a, adminName: v } : a
|
||||
)
|
||||
);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{!loaded && (
|
||||
<tr>
|
||||
<td>
|
||||
<span className="flex w-full justify-center">
|
||||
<TextPreloader />
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className="flex w-full justify-center">
|
||||
<TextPreloader />
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className="flex w-full justify-center">
|
||||
<TextPreloader />
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<LoadingSpinner size={20} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
{loaded && <CreateAdmin {...{ editorAdminId: adminId, update }} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateAdmin({ editorAdminId, update }) {
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [adminId, setAdminId] = useState("");
|
||||
const [adminName, setAdminName] = useState("");
|
||||
const [locked, setLocked] = useState(false);
|
||||
const form = useRef(null);
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full gap-1">
|
||||
<span className="w-full" />
|
||||
<AnimatePresence>
|
||||
{creating && (
|
||||
<motion.form
|
||||
className="flex gap-2"
|
||||
variants={{
|
||||
off: { opacity: 0, x: 20 },
|
||||
on: { opacity: 1, x: 0 },
|
||||
}}
|
||||
initial="off"
|
||||
animate="on"
|
||||
exit="off"
|
||||
ref={form}
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
if (locked || adminId.length < 5) return;
|
||||
setLocked(true);
|
||||
const c = await ClassUpdateRequest(
|
||||
API_BASE_URL,
|
||||
editorAdminId,
|
||||
{
|
||||
action: "create_admin",
|
||||
value: `${adminId};${b64e(adminName)}`,
|
||||
}
|
||||
);
|
||||
if (c[1] == true) {
|
||||
update();
|
||||
setCreating(false);
|
||||
}
|
||||
setLocked(false);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className="bg-zinc-900 rounded-2xl border border-zinc-500/50 px-2"
|
||||
placeholder="имя"
|
||||
required
|
||||
value={adminName}
|
||||
disabled={locked}
|
||||
maxLength={50}
|
||||
onInput={(e) => setAdminName(e.target.value.trimStart())}
|
||||
/>
|
||||
<input
|
||||
className="bg-zinc-900 rounded-2xl border border-zinc-500/50 px-2"
|
||||
placeholder="adminId"
|
||||
value={adminId}
|
||||
required
|
||||
disabled={locked}
|
||||
maxLength={20}
|
||||
minLength={5}
|
||||
min={5}
|
||||
onInput={(e) => {
|
||||
let val = e.target.value;
|
||||
let nval = "";
|
||||
val.split("").forEach((i) => {
|
||||
if (AvailableAdminSymbols.includes(i)) {
|
||||
nval = nval + i;
|
||||
}
|
||||
});
|
||||
e.target.value = nval;
|
||||
setAdminId(nval);
|
||||
}}
|
||||
/>
|
||||
<button>
|
||||
<DengiButton>
|
||||
{locked ? <LoadingSpinner /> : <BiPlus />}
|
||||
</DengiButton>
|
||||
</button>
|
||||
</motion.form>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<DengiButton
|
||||
disabled={locked}
|
||||
onClick={() => {
|
||||
setCreating(!creating);
|
||||
setAdminName("");
|
||||
setAdminId(randomString(8, AvailableAdminSymbols));
|
||||
}}
|
||||
>
|
||||
<span className={`${creating ? "rotate-45" : ""} transition`}>
|
||||
<BiPlusCircle />
|
||||
</span>
|
||||
</DengiButton>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminProfileRow({
|
||||
adminId,
|
||||
editorAdminId,
|
||||
adminName,
|
||||
classId,
|
||||
pickmeMode,
|
||||
setAdminFrId,
|
||||
adminLevel,
|
||||
update,
|
||||
}) {
|
||||
return (
|
||||
<tr className="h-12">
|
||||
<td className="px-2">
|
||||
<DengiEditable
|
||||
value={adminName}
|
||||
pink={pickmeMode}
|
||||
onSubmit={async (e) => {
|
||||
e.setIdle(true);
|
||||
const r = await AdminUpdateRequest(API_BASE_URL, editorAdminId, {
|
||||
action: "change_name",
|
||||
value: e.value.trim(),
|
||||
person: adminId,
|
||||
});
|
||||
if (r[1] == true) setAdminFrId(e.value);
|
||||
e.setIdle(false);
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2">
|
||||
<DengiEditable
|
||||
value={adminId}
|
||||
password={true}
|
||||
pink={pickmeMode}
|
||||
disabled
|
||||
/>
|
||||
</td>
|
||||
<td className="text-center">{adminLevel}</td>
|
||||
<td className="inline-flex gap-1 items-center justify-center">
|
||||
<CopyAdminLink {...{ classId, adminId }} />
|
||||
<RemoveAdminButton
|
||||
{...{ adminId, editorAdminId, adminName, update }}
|
||||
disabled={[-1, 2].includes(adminLevel)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
const RemoveAdminButton = ({
|
||||
adminId,
|
||||
editorAdminId,
|
||||
adminName,
|
||||
update,
|
||||
disabled,
|
||||
}) => {
|
||||
const [showDiag, setShowDiag] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DengiYesOrNoDialog
|
||||
show={showDiag}
|
||||
content={{
|
||||
message: (
|
||||
<p>
|
||||
вы точно хотите удалить админа <b>{adminName}</b>?
|
||||
</p>
|
||||
),
|
||||
yesBtn: "да",
|
||||
noBtn: "нет",
|
||||
yesIcon: <BiTrash />,
|
||||
noIcon: <BiX size={20} />,
|
||||
bgDissmis: true,
|
||||
yesBtnStyle: "bg-red-500! text-white! md:hover:bg-red-600!",
|
||||
}}
|
||||
onAnswer={async (answ) => {
|
||||
setShowDiag(false);
|
||||
if (answ) {
|
||||
setDeleting(true);
|
||||
const c = await ClassUpdateRequest(API_BASE_URL, editorAdminId, {
|
||||
action: "delete_admin",
|
||||
value: adminId,
|
||||
});
|
||||
if (c[1] == true) {
|
||||
update();
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<DengiButton
|
||||
disabled={adminId === editorAdminId || disabled}
|
||||
onClick={() => {
|
||||
if (deleting) return;
|
||||
setShowDiag(true);
|
||||
}}
|
||||
>
|
||||
{deleting ? <LoadingSpinner size={20} /> : <BiTrash />}
|
||||
</DengiButton>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const CopyClassLink = ({ classId }) => {
|
||||
const [linkCopied, setLinkCopied] = useState(false);
|
||||
|
||||
return (
|
||||
<DengiButton
|
||||
onClick={() => {
|
||||
copyDXLink(classId).then((a) => {
|
||||
if (a) {
|
||||
setLinkCopied(true);
|
||||
setTimeout(() => {
|
||||
setLinkCopied(false);
|
||||
}, 2000);
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
<BiLink />
|
||||
<span>{linkCopied ? "скопировано" : "коп ссылку"}</span>
|
||||
</DengiButton>
|
||||
);
|
||||
};
|
||||
const CopyAdminLink = ({ classId, adminId }) => {
|
||||
const [showDiag, setShowDiag] = useState(false);
|
||||
const [seenDiag, setSeenDiag] = useState(false);
|
||||
const [linkCopied, setLinkCopied] = useState(false);
|
||||
const copy = () => {
|
||||
copyDXLink(classId, adminId).then((a) => {
|
||||
if (a) {
|
||||
setLinkCopied(true);
|
||||
setTimeout(() => {
|
||||
setLinkCopied(false);
|
||||
}, 2000);
|
||||
}
|
||||
});
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<DengiYesOrNoDialog
|
||||
show={showDiag}
|
||||
content={{
|
||||
message: (
|
||||
<p>
|
||||
<b>любой</b>, кто получит ссылку,{" "}
|
||||
<b>будет иметь доступ к админке</b>.<br /> вы желаете продолжить?
|
||||
</p>
|
||||
),
|
||||
yesBtn: "да",
|
||||
noBtn: "нет",
|
||||
yesIcon: <BiCopy />,
|
||||
noIcon: <BiX size={20} />,
|
||||
bgDissmis: true,
|
||||
yesBtnStyle: "bg-orange-500! text-white! md:hover:bg-orange-600!",
|
||||
}}
|
||||
onAnswer={(answ) => {
|
||||
if (answ) {
|
||||
setSeenDiag(true);
|
||||
copy();
|
||||
}
|
||||
setShowDiag(false);
|
||||
}}
|
||||
/>
|
||||
<DengiButton onClick={() => (seenDiag ? copy() : setShowDiag(true))}>
|
||||
{linkCopied ? <BiCheckCircle /> : <BiLink />}
|
||||
</DengiButton>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const CoolBg = ({ pickmeMode }) => {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`fixed top-0 left-0 w-full h-full -z-5 overflow-hidden bg-gradient-to-tl to-black to-50% bg-fixed ${
|
||||
pickmeMode ? "from-pink-400/10" : "from-white/2"
|
||||
}`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Header = ({ setsetRoute, setRoute, pickmeMode }) => {
|
||||
return (
|
||||
<div className={"flex items-center w-full justify-between px-5"}>
|
||||
<span className="flex gap-2 items-center">
|
||||
<BiLeftArrowAlt
|
||||
size={35}
|
||||
onClick={() => {
|
||||
setsetRoute(setRoute, "/app");
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
className={
|
||||
"rounded-full " +
|
||||
(pickmeMode
|
||||
? "lg:hover:bg-pink-400"
|
||||
: "lg:hover:bg-white lg:hover:fill-black") +
|
||||
" lg:hover:scale-105 transition cursor-pointer"
|
||||
}
|
||||
/>
|
||||
<img src={Logo} alt="logo" width={100} draggable={false} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,9 +0,0 @@
|
||||
export default function Panel() {
|
||||
return <>
|
||||
<div>
|
||||
<div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</>;
|
||||
}
|
||||
+174
-96
@@ -1,20 +1,22 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Fragment, memo, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
BiDotsHorizontalRounded,
|
||||
BiExit,
|
||||
BiSolidUser,
|
||||
BiStar,
|
||||
BiPencil,
|
||||
BiLink,
|
||||
BiWrench,
|
||||
} from "react-icons/bi";
|
||||
import Logo from "../assets/logo.svg";
|
||||
import {
|
||||
Switch,
|
||||
LoadingSpinner,
|
||||
DatePicker,
|
||||
TextPreloader,
|
||||
// CoolMouseBg,
|
||||
} from "../trash/trash-components";
|
||||
import {
|
||||
AdminReq,
|
||||
AdminUserFRequest,
|
||||
classUserFRequest,
|
||||
RaspGet,
|
||||
@@ -24,7 +26,7 @@ import { AnimatePresence, LayoutGroup, motion } from "motion/react";
|
||||
import { apiFormatDate, isTimeBeforeNow } from "../trash/dengi";
|
||||
import { useAtom } from "jotai";
|
||||
import { API_BASE_URL } from "../App";
|
||||
import { copyToClipboard, ifstyle } from "../trash/helpers";
|
||||
import { copyDXLink, ifstyle } from "../trash/helpers";
|
||||
|
||||
export function DX4App({
|
||||
classIDAtom,
|
||||
@@ -41,6 +43,7 @@ export function DX4App({
|
||||
const [pickmeMode, setPickmeMode] = useAtom(pickmeModeAtom);
|
||||
const [rasp, setRasp] = useState([false, {}, 0]);
|
||||
const [selectedDate, setSelectedDate] = useState(new Date());
|
||||
const [managing, setManaging] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -49,14 +52,14 @@ export function DX4App({
|
||||
if (!dengi_cash.wasCached(cacheName)) {
|
||||
RaspGet(API_BASE_URL, classId, selectedDate).then(
|
||||
(d) => {
|
||||
if (cancelled) return;
|
||||
if (d[0] == 1) {
|
||||
try {
|
||||
setRasp([
|
||||
true,
|
||||
d[1]["data"] && d[1]["data"][0] ? d[1]["data"] : -322,
|
||||
d[1]["type"] ? d[1]["type"] : -1,
|
||||
]);
|
||||
if (!cancelled)
|
||||
setRasp([
|
||||
true,
|
||||
d[1]["data"] && d[1]["data"][0] ? d[1]["data"] : -322,
|
||||
d[1]["type"] ? d[1]["type"] : -1,
|
||||
]);
|
||||
//console.log(d, d[1] ? d[1] : false);
|
||||
dengi_cash.cache(cacheName, [
|
||||
d[1]["data"] && d[1]["data"][0] ? d[1]["data"] : -322,
|
||||
@@ -64,18 +67,20 @@ export function DX4App({
|
||||
]);
|
||||
} catch (exception) {
|
||||
//console.error(exception);
|
||||
setRasp([true, exception.toString()]);
|
||||
if (!cancelled) setRasp([true, exception.toString()]);
|
||||
}
|
||||
} else {
|
||||
setRasp([true, "ошибка: " + d[1].toString()]);
|
||||
if (!cancelled) setRasp([true, "ошибка: " + d[1].toString()]);
|
||||
//console.log(d[1]);
|
||||
}
|
||||
},
|
||||
(err) => {
|
||||
if (cancelled) return;
|
||||
setRasp([true, "ошибка: " + err]);
|
||||
}
|
||||
);
|
||||
} else {
|
||||
if (cancelled) return;
|
||||
setRasp([true, ...dengi_cash.get(cacheName)]);
|
||||
}
|
||||
|
||||
@@ -133,8 +138,8 @@ export function DX4App({
|
||||
else promises.push(Promise.resolve([0]));
|
||||
if (adminId !== "")
|
||||
promises.push(
|
||||
dengi_cash.withCacheAsync(`adminfr-${adminId}`, async () =>
|
||||
AdminUserFRequest(API_BASE_URL, adminId)
|
||||
dengi_cash.withCacheAsync(`admindata-${adminId}`, async () =>
|
||||
AdminReq(API_BASE_URL, adminId)
|
||||
)
|
||||
);
|
||||
else promises.push(Promise.resolve([0]));
|
||||
@@ -143,6 +148,8 @@ export function DX4App({
|
||||
const classF = classRes[0] === 1 ? classRes[1].userclassname : false;
|
||||
const adminF = adminRes[0] === 1 ? adminRes[1].adminName : false;
|
||||
setUserFrNames({ admin: adminF, class: classF });
|
||||
if (adminRes[0] === 1)
|
||||
setManaging([-1, 2].includes(adminRes[1].adminPrems));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
@@ -259,28 +266,65 @@ export function DX4App({
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{adminId != "" && managing && (
|
||||
<motion.div
|
||||
className="flex w-full justify-end"
|
||||
layout="position"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
>
|
||||
<a
|
||||
// onClick={() => {
|
||||
// setsetRoute(setRoute, `/editor/${apiFormatDate(selectedDate)}`);
|
||||
// }}
|
||||
href={`#/classprofile`}
|
||||
className={`flex items-center justify-center border p-1 gap-2 px-2 cursor-pointer rounded-xl transition-colors ${
|
||||
pickmeMode
|
||||
? "border-pink-400/30 text-pink-300/80 lg:hover:text-white lg:hover:border-pink-500"
|
||||
: "border-zinc-700 text-zinc-400 lg:hover:text-white lg:hover:border-white"
|
||||
} `}
|
||||
>
|
||||
<BiWrench /> управление
|
||||
</a>
|
||||
</motion.div>
|
||||
)}
|
||||
<motion.div
|
||||
layout="position"
|
||||
className="flex flex-col gap-5 w-full not-md:px-4"
|
||||
>
|
||||
{adminId != "" && (
|
||||
<motion.div className="flex w-full justify-end">
|
||||
<div className="flex gap-2 items-center justify-center">
|
||||
<DatePicker
|
||||
date={selectedDate}
|
||||
setDate={setSelectedDate}
|
||||
indicator={
|
||||
typeof rasp[2] == "number" && [1, 2].includes(rasp[2])
|
||||
}
|
||||
pink={pickmeMode}
|
||||
/>
|
||||
{adminId != "" && (
|
||||
<a
|
||||
// onClick={() => {
|
||||
// setsetRoute(setRoute, `/editor/${apiFormatDate(selectedDate)}`);
|
||||
// }}
|
||||
href={`#/editor/${apiFormatDate(selectedDate)}`}
|
||||
className="flex items-center justify-center border p-1 gap-2 px-2 lg:hover:border-white cursor-pointer rounded-xl transition-colors border-zinc-700 text-zinc-400 lg:hover:text-white"
|
||||
href={
|
||||
userFrNames.admin
|
||||
? `#/editor/${apiFormatDate(selectedDate)}`
|
||||
: undefined
|
||||
}
|
||||
className={`flex items-center justify-center p-1.5 cursor-pointer border transition *:transition-colors rounded-full ${
|
||||
pickmeMode
|
||||
? "border-pink-400/30 lg:hover:text-white hover:lg:bg-pink-500"
|
||||
: "border-zinc-700 text-white lg:hover:text-black lg:hover:bg-white"
|
||||
} `}
|
||||
>
|
||||
<BiPencil /> открыть в редакторе
|
||||
{userFrNames.admin ? (
|
||||
<BiPencil size={28} />
|
||||
) : (
|
||||
<LoadingSpinner size={28} />
|
||||
)}
|
||||
</a>
|
||||
</motion.div>
|
||||
)}
|
||||
<DatePicker
|
||||
date={selectedDate}
|
||||
setDate={setSelectedDate}
|
||||
indicator={typeof rasp[2] == "number" && [1, 2].includes(rasp[2])}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<LayoutGroup>
|
||||
<RaspView {...{ rasp, pickmeMode, selectedDate }} />
|
||||
@@ -306,7 +350,7 @@ export function DX4App({
|
||||
</>
|
||||
);
|
||||
}
|
||||
const CoolMainBg = React.memo(({ pickmeMode }) => {
|
||||
const CoolMainBg = memo(({ pickmeMode }) => {
|
||||
return (
|
||||
<div
|
||||
className={`fixed top-0 left-0 w-full h-full -z-5 overflow-hidden bg-gradient-to-br to-black to-50% bg-fixed
|
||||
@@ -315,7 +359,7 @@ const CoolMainBg = React.memo(({ pickmeMode }) => {
|
||||
);
|
||||
});
|
||||
|
||||
export const RaspView = React.memo(({ rasp, pickmeMode, selectedDate }) => {
|
||||
export const RaspView = memo(({ rasp, pickmeMode, selectedDate }) => {
|
||||
return (
|
||||
<AnimatePresence mode="popLayout">
|
||||
{rasp[0] && typeof rasp[1] == "object" && (
|
||||
@@ -324,10 +368,14 @@ export const RaspView = React.memo(({ rasp, pickmeMode, selectedDate }) => {
|
||||
//layout="position"
|
||||
key={selectedDate}
|
||||
className={
|
||||
"w-full border-collapse border-spacing-x-0 border-spacing-y-[10px] bg-gradient-to-br backdrop-blur-xl mb-10 " +
|
||||
"w-full border-collapse border-spacing-x-0 border-spacing-y-[10px] bg-gradient-to-br mb-10 " +
|
||||
(pickmeMode ? "from-pink-400/30" : "from-zinc-800/40") +
|
||||
" to-transparent rounded-2xl selectable"
|
||||
}
|
||||
onContextMenu={(ev) => {
|
||||
ev.preventDefault();
|
||||
itemRedo.action();
|
||||
}}
|
||||
variants={{
|
||||
hid: { opacity: 0 },
|
||||
show: { opacity: 1 },
|
||||
@@ -372,61 +420,7 @@ export const RaspView = React.memo(({ rasp, pickmeMode, selectedDate }) => {
|
||||
</thead>
|
||||
<tbody className="*:*:text-center *:*:py-2 *:not-last:border-b *:not-last:border-zinc-800 text-white">
|
||||
{rasp[1].map((d, i) => (
|
||||
<tr
|
||||
//layout="position"
|
||||
key={`table-header-${i}`}
|
||||
className="transition-colors *:transition-colors lg:hover:bg-zinc-300/15"
|
||||
//rasp-key={"rasp_key_" + i + Array(d).join()}
|
||||
>
|
||||
<td
|
||||
//layout="position"
|
||||
//layoutId={`table-num-${i}`}
|
||||
>
|
||||
{d["num"]}
|
||||
</td>
|
||||
<td
|
||||
//layout="position"
|
||||
//layoutId={`table-subj-${i}`}
|
||||
>
|
||||
{d.subject
|
||||
.toString()
|
||||
.split("\n")
|
||||
.map((line, i, array) => (
|
||||
<React.Fragment key={i}>
|
||||
{line}
|
||||
{i < array.length - 1 && <br />}
|
||||
</React.Fragment>
|
||||
)) || d.subject}
|
||||
</td>
|
||||
<td
|
||||
//layout="position"
|
||||
//layoutId={`table-cab-${i}`}
|
||||
>
|
||||
{d.cabinet
|
||||
.toString()
|
||||
.split("\n")
|
||||
.map((line, i, array) => (
|
||||
<React.Fragment key={i}>
|
||||
{line}
|
||||
{i < array.length - 1 && <br />}
|
||||
</React.Fragment>
|
||||
)) || d.cabinet}
|
||||
</td>
|
||||
<td>
|
||||
<div
|
||||
//layout="position"
|
||||
//layoutId={`table-time-${i}`}
|
||||
className="flex items-center justify-center min-h-9"
|
||||
>
|
||||
<div className="flex gap-0.5 flex-col *:block w-[70%] *:font-light">
|
||||
<span className="text-left">
|
||||
{d["t-begin"]} -
|
||||
</span>
|
||||
<span className="text-right">{d["t-end"]}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<TableItem {...{ d }} key={`table-header-${i}`} />
|
||||
))}
|
||||
</tbody>
|
||||
</motion.table>
|
||||
@@ -457,7 +451,7 @@ export const RaspView = React.memo(({ rasp, pickmeMode, selectedDate }) => {
|
||||
);
|
||||
});
|
||||
|
||||
const UserSettings = React.memo(
|
||||
const UserSettings = memo(
|
||||
({
|
||||
openUserSettings,
|
||||
userFrNames,
|
||||
@@ -476,8 +470,8 @@ const UserSettings = React.memo(
|
||||
<motion.div
|
||||
className="flex flex-col bg-grain gap-2 bg-zinc-700/20 p-2 rounded-2xl"
|
||||
variants={{
|
||||
hidden: { opacity: 0, y: 10, filter: "blur(2px)" },
|
||||
show: { opacity: 1, y: 0, filter: "blur(0)" },
|
||||
hidden: { opacity: 0, y: 10 },
|
||||
show: { opacity: 1, y: 0 },
|
||||
}}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
@@ -507,21 +501,20 @@ const UserSettings = React.memo(
|
||||
// ],
|
||||
[
|
||||
<BiSolidUser />,
|
||||
`админ ${
|
||||
userFrNames["admin"] ? userFrNames["admin"] : adminId
|
||||
}`,
|
||||
() => {},
|
||||
<>
|
||||
{`админ ${userFrNames["admin"] ? userFrNames["admin"] : ""}`}
|
||||
{!userFrNames["admin"] && <TextPreloader width="30px" />}
|
||||
</>,
|
||||
() => {
|
||||
setsetRoute(setRoute, "/adminprofile");
|
||||
},
|
||||
adminId != "",
|
||||
],
|
||||
[
|
||||
<BiLink />,
|
||||
classLinkCopied ? "скопированно" : "коп. ссылку",
|
||||
classLinkCopied ? "скопировано" : "коп. ссылку",
|
||||
() => {
|
||||
copyToClipboard(
|
||||
location.href.substring(0, location.href.search("#")) +
|
||||
"#//" +
|
||||
classId
|
||||
).then((a) => {
|
||||
copyDXLink(classId).then((a) => {
|
||||
if (a) {
|
||||
setClassLinkCopied(true);
|
||||
setTimeout(() => {
|
||||
@@ -556,3 +549,88 @@ const UserSettings = React.memo(
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
class GlobalListener {
|
||||
constructor() {
|
||||
this.listeners = [];
|
||||
}
|
||||
listen(fn, once = false) {
|
||||
const wrapper = (...args) => {
|
||||
try {
|
||||
fn(...args);
|
||||
} finally {
|
||||
if (once) this.listeners = this.listeners.filter((l) => l !== wrapper);
|
||||
}
|
||||
};
|
||||
this.listeners.push(wrapper);
|
||||
return () => {
|
||||
this.listeners = this.listeners.filter((l) => l !== wrapper);
|
||||
};
|
||||
}
|
||||
action(...args) {
|
||||
this.listeners.slice().forEach((fn) => {
|
||||
try {
|
||||
fn(...args);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const itemRedo = new GlobalListener();
|
||||
|
||||
const TableItem = memo(({ d }) => {
|
||||
const [sel, setSel] = useState(false);
|
||||
useEffect(() => {
|
||||
itemRedo.listen(() => {
|
||||
setSel(false);
|
||||
});
|
||||
}, []);
|
||||
return (
|
||||
<tr
|
||||
//layout="position"
|
||||
className={
|
||||
"transition-colors *:transition-colors " +
|
||||
(sel
|
||||
? "bg-zinc-300/15 lg:hover:bg-zinc-300/20"
|
||||
: " lg:hover:bg-zinc-300/15")
|
||||
}
|
||||
onClick={() => {
|
||||
setSel(!sel);
|
||||
}}
|
||||
>
|
||||
<td>{d["num"]}</td>
|
||||
<td>
|
||||
{d.subject
|
||||
.toString()
|
||||
.split("\n")
|
||||
.map((line, i, array) => (
|
||||
<Fragment key={i}>
|
||||
{line}
|
||||
{i < array.length - 1 && <br />}
|
||||
</Fragment>
|
||||
)) || d.subject}
|
||||
</td>
|
||||
<td>
|
||||
{d.cabinet
|
||||
.toString()
|
||||
.split("\n")
|
||||
.map((line, i, array) => (
|
||||
<Fragment key={i}>
|
||||
{line}
|
||||
{i < array.length - 1 && <br />}
|
||||
</Fragment>
|
||||
)) || d.cabinet}
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex items-center justify-center min-h-9">
|
||||
<div className="flex gap-0.5 flex-col *:block w-[70%] *:font-light">
|
||||
<span className="text-left">{d["t-begin"]} -</span>
|
||||
<span className="text-right">{d["t-end"]}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
|
||||
+20
-29
@@ -7,15 +7,7 @@ import { BiHide, BiShow, BiSolidErrorCircle, BiBlock } from "react-icons/bi";
|
||||
import { LoginReq, AdminReq } from "../trash/req_mgr";
|
||||
import { API_BASE_URL } from "../App";
|
||||
import { b64d } from "../trash/helpers";
|
||||
const AvailableSymbols = (() => {
|
||||
const a = "abcdefghijklmnopqrstuvwxyz"; // alphabet
|
||||
const A = a.toUpperCase(); // ALPHABET
|
||||
const n = "1234567890"; // numbers
|
||||
const s = "_-"; // specials
|
||||
return (a + n + A + s).split("");
|
||||
})();
|
||||
|
||||
const AvailableAdminSymbols = [...AvailableSymbols, ..."$()*^%@!+=.".split("")];
|
||||
import { AvailableAdminSymbols, AvailableSymbols } from "../trash/dengi";
|
||||
|
||||
export function LoginPage({
|
||||
classIDAtom,
|
||||
@@ -51,7 +43,7 @@ export function LoginPage({
|
||||
setsetRoute(setRoute, "/app");
|
||||
} else {
|
||||
const al = await AdminReq(API_BASE_URL, aid);
|
||||
console.log(al);
|
||||
// console.log(al);
|
||||
if (al[0] === 1) {
|
||||
// yes, I know that check must go on backend, but im stupid lazy piece of shit 💀💀💀
|
||||
// TODO: FIX LATER
|
||||
@@ -61,8 +53,8 @@ export function LoginPage({
|
||||
setsetRoute(setRoute, "/app");
|
||||
} else {
|
||||
// you are not from here
|
||||
console.log(al);
|
||||
setErrorText("админ не пренадлежит этому классу");
|
||||
// console.log(al);
|
||||
setErrorText("админ не найден");
|
||||
}
|
||||
} else {
|
||||
setErrorText(al[1]);
|
||||
@@ -108,9 +100,9 @@ export function LoginPage({
|
||||
<AnimatePresence mode="popLayout">
|
||||
{errorText.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ y: 50, opacity: 0, filter: "blur(5px)" }}
|
||||
animate={{ y: 0, opacity: 1, filter: "blur(0px)" }}
|
||||
exit={{ y: 50, opacity: 0, filter: "blur(5px)" }}
|
||||
initial={{ y: 50, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 50, opacity: 0 }}
|
||||
layout="position"
|
||||
className="p-2 bg-gradient-to-r from-zinc-900 to-white/40 text-white rounded-xl w-[90vw] max-w-[600px] flex gap-2 items-center justify-between"
|
||||
>
|
||||
@@ -128,24 +120,23 @@ export function LoginPage({
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<motion.div
|
||||
className="flex lg:backdrop-blur-xl flex-col rounded-2xl w-[90vw] items-center justify-center py-3 max-w-[600px] bg-grain focus-within:border-white/10 border border-transparent transition-colors"
|
||||
className="flex flex-col rounded-2xl w-[90vw] items-center justify-center py-3 max-w-[600px] bg-grain focus-within:border-white/10 border border-transparent transition-colors relative"
|
||||
initial={{
|
||||
y: 30,
|
||||
filter: "blur(5px)",
|
||||
opacity: 0,
|
||||
}}
|
||||
layout="position"
|
||||
animate={{ y: 0, filter: "blur(0)", opacity: 1 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
>
|
||||
<motion.img src={Logo} alt="logo dengi" width={200} />
|
||||
<motion.h1 className="text-zinc-600">добро пожаловать.</motion.h1>
|
||||
<motion.h2 className="text-3xl font-light">вход</motion.h2>
|
||||
<img src={Logo} alt="logo dengi" width={200} />
|
||||
<h1 className="text-zinc-600">добро пожаловать.</h1>
|
||||
<h2 className="text-3xl font-light">вход</h2>
|
||||
<form
|
||||
action="#"
|
||||
className="mt-2 flex flex-col gap-3 *:outline-none *:border *:border-transparent *:focus:border-white"
|
||||
onSubmit={formSubmit}
|
||||
>
|
||||
<motion.input
|
||||
<input
|
||||
type="text"
|
||||
name="d"
|
||||
autoComplete="off"
|
||||
@@ -170,7 +161,7 @@ export function LoginPage({
|
||||
disabled={isLogining}
|
||||
minLength={3}
|
||||
/>
|
||||
<motion.span className="w-full flex gap-2 items-center justify-center ">
|
||||
<span className="w-full flex gap-2 items-center justify-center ">
|
||||
<Switch
|
||||
type="checkbox"
|
||||
onChange={(e) => setIsAdmin(e)}
|
||||
@@ -178,7 +169,7 @@ export function LoginPage({
|
||||
name=""
|
||||
/>
|
||||
админка
|
||||
</motion.span>
|
||||
</span>
|
||||
<CoolInputWithHide
|
||||
{...{ enabled: isAdmin, setEnteredAId, isLogining, adminEnteredId }}
|
||||
/>
|
||||
@@ -200,8 +191,8 @@ export function LoginPage({
|
||||
<AnimatePresence mode="popLayout">
|
||||
{isLogining && (
|
||||
<motion.div
|
||||
initial={{ y: -40, opacity: 0, filter: "blur(5px)" }}
|
||||
animate={{ y: 0, opacity: 1, filter: "blur(0px)" }}
|
||||
initial={{ y: -40, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
className="p-2 w-[90vw] max-w-[600px] border-2 rounded-xl border-zinc-500 flex gap-2 items-center justify-center hover:border-white transition-colors bg-grain"
|
||||
layout="position"
|
||||
>
|
||||
@@ -224,7 +215,7 @@ function CoolLoginButton({ display, isLogining }) {
|
||||
initial={{ y: 10, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
ref={logbuttonRef}
|
||||
exit={{ y: 40, opacity: 0, rotate: 20, filter: "blur(3px)" }}
|
||||
exit={{ y: 40, opacity: 0, rotate: 20 }}
|
||||
onMouseMove={(e) => {
|
||||
const rect = e.target.getBoundingClientRect();
|
||||
e.target.style.setProperty(
|
||||
@@ -257,10 +248,10 @@ function CoolInputWithHide({
|
||||
exit={{ y: 10, opacity: 0 }}
|
||||
className="flex *:outline-none *:border *:border-transparent *:focus:border-white"
|
||||
>
|
||||
<motion.input
|
||||
<input
|
||||
type={!showAId ? "password" : "text"}
|
||||
name="ad"
|
||||
className="bg-zinc-800/50 p-2 rounded-l-2xl max-w-[19b0px]"
|
||||
className="bg-zinc-800/50 p-2 rounded-l-2xl"
|
||||
placeholder="id админки"
|
||||
autoComplete="off"
|
||||
disabled={isLogining}
|
||||
|
||||
+42
-19
@@ -1,4 +1,4 @@
|
||||
import { motion, AnimatePresence, LayoutGroup } from "motion/react";
|
||||
import { motion, LayoutGroup } from "motion/react";
|
||||
|
||||
import { useAtom } from "jotai";
|
||||
import { useState, useEffect } from "react";
|
||||
@@ -7,20 +7,16 @@ import { AdminUserFRequest, classUserFRequest } from "../trash/req_mgr";
|
||||
import {
|
||||
BiCalendar,
|
||||
BiCalendarEdit,
|
||||
BiCheck,
|
||||
BiExit,
|
||||
BiPencil,
|
||||
BiPlus,
|
||||
BiRedo,
|
||||
BiTime,
|
||||
BiTrash,
|
||||
BiUndo,
|
||||
BiX,
|
||||
} from "react-icons/bi";
|
||||
import { formatDate, parseApiDate } from "../trash/dengi";
|
||||
import {
|
||||
DengiButton,
|
||||
DengiYesOrNoDialog,
|
||||
TextPreloader,
|
||||
// CoolMouseBg
|
||||
} from "../trash/trash-components";
|
||||
import { TableEditor } from "../trash/editorTable";
|
||||
@@ -30,6 +26,7 @@ import { API_BASE_URL } from "../App";
|
||||
export default function EditorPage(props) {
|
||||
const [classId, _setUserId] = useAtom(props.classIDAtom);
|
||||
const [adminId, _setAdminId] = useAtom(props.adminAtom);
|
||||
const [pickmeMode, _setPickmeMode] = useAtom(props.pickmeModeAtom);
|
||||
const [route, setRoute] = useAtom(props.routeAtom);
|
||||
const dateRaw = props.secondPathSegment(route);
|
||||
useEffect(() => {
|
||||
@@ -78,7 +75,7 @@ export default function EditorPage(props) {
|
||||
}, [classId, adminId]);
|
||||
// on rerenderr
|
||||
useEffect(() => {
|
||||
console.log("editor cache clean");
|
||||
// console.log("editor cache clean");
|
||||
props.dengi_cash.delete(
|
||||
"editor_schedule",
|
||||
"editor_changes",
|
||||
@@ -163,9 +160,17 @@ export default function EditorPage(props) {
|
||||
<span>
|
||||
{userFrNames["class"] ? userFrNames["class"] : classId}
|
||||
</span>
|
||||
<span className="block h-[1px] w-[20px] -rotate-45 bg-zinc-600" />
|
||||
<span
|
||||
className={`block h-[1px] w-[20px] -rotate-45 ${
|
||||
pickmeMode ? "bg-pink-600" : "bg-zinc-600"
|
||||
} `}
|
||||
/>
|
||||
<span>
|
||||
{userFrNames["admin"] ? userFrNames["admin"] : adminId}
|
||||
{userFrNames["admin"] ? (
|
||||
userFrNames["admin"]
|
||||
) : (
|
||||
<TextPreloader />
|
||||
)}
|
||||
</span>
|
||||
</motion.span>
|
||||
<motion.span
|
||||
@@ -188,13 +193,20 @@ export default function EditorPage(props) {
|
||||
)
|
||||
}
|
||||
>
|
||||
<BiPencil size={25} />
|
||||
<BiPencil
|
||||
color={pickmeMode ? "#FF0078" : undefined}
|
||||
size={25}
|
||||
/>
|
||||
{formatDate(date)}
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="#/app"
|
||||
className="flex items-center justify-center border p-1 gap-2 px-2 lg:hover:border-white cursor-pointer rounded-xl transition-colors border-zinc-700 text-zinc-400 lg:hover:text-white"
|
||||
className={`flex items-center justify-center border p-1 gap-2 px-2 cursor-pointer rounded-xl transition-colors ${
|
||||
pickmeMode
|
||||
? "border-pink-400/30 text-pink-300/80 lg:hover:text-white lg:hover:border-pink-500"
|
||||
: "border-zinc-700 text-zinc-400 lg:hover:text-white lg:hover:border-white"
|
||||
} `}
|
||||
onClick={(e) => {
|
||||
if (changesWereMade) {
|
||||
e.preventDefault();
|
||||
@@ -207,12 +219,20 @@ export default function EditorPage(props) {
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-5 select-none bg-grain bg-zinc-800/35 p-2 rounded-xl">
|
||||
<div
|
||||
className={`flex items-center justify-center gap-5 select-none bg-grain ${
|
||||
pickmeMode ? "bg-pink-900/35" : "bg-zinc-800/35"
|
||||
} p-2 rounded-xl`}
|
||||
>
|
||||
<LayoutGroup>
|
||||
{tabs.map((item, idx) => (
|
||||
<div
|
||||
className={
|
||||
"flex items-center justify-center gap-1 p-2 rounded-2xl md:hover:bg-white/10 transition-colors relative md:hover:text-white cursor-pointer " +
|
||||
`flex items-center justify-center gap-1 p-2 rounded-2xl ${
|
||||
pickmeMode
|
||||
? "md:hover:bg-pink-400/10"
|
||||
: "md:hover:bg-white/10"
|
||||
} transition-colors relative md:hover:text-white cursor-pointer ` +
|
||||
(idx == selectedTab ? "text-white" : "text-zinc-500")
|
||||
}
|
||||
key={idx}
|
||||
@@ -230,7 +250,9 @@ export default function EditorPage(props) {
|
||||
{idx == selectedTab && (
|
||||
<motion.div
|
||||
layoutId="editor_tabs_underline"
|
||||
className="absolute -bottom-[4px] left-0 h-[3px] bg-white/70 rounded-full w-full"
|
||||
className={`absolute -bottom-[4px] left-0 h-[3px] ${
|
||||
pickmeMode ? "bg-pink-500/70" : "bg-white/70"
|
||||
} rounded-full w-full`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -246,6 +268,7 @@ export default function EditorPage(props) {
|
||||
setChangesWereMade,
|
||||
date,
|
||||
adminId,
|
||||
pickmeMode,
|
||||
}}
|
||||
dengiCash={props.dengi_cash}
|
||||
/>
|
||||
@@ -255,16 +278,16 @@ export default function EditorPage(props) {
|
||||
</motion.div>
|
||||
{/* <CoolMouseBg /> */}
|
||||
</div>
|
||||
<DefaultEditorBG />
|
||||
<DefaultEditorBG pickmeMode={pickmeMode} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
function DefaultEditorBG() {
|
||||
function DefaultEditorBG({ pickmeMode }) {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"w-full h-screen bg-gradient-to-br fixed top-0 left-0 via-black via-50% gap-5 overflow-hidden from-white/2 to-blue-500/10 -z-5 bg-fixed"
|
||||
}
|
||||
className={`w-full h-screen bg-gradient-to-br fixed top-0 left-0 via-black via-50% gap-5 overflow-hidden ${
|
||||
!pickmeMode ? "from-white/2" : "from-pink-400/10"
|
||||
} to-blue-500/10 -z-5 bg-fixed`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+6
-207
@@ -1,212 +1,11 @@
|
||||
import React, { 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 { TextPreloader } from "../trash/trash-components";
|
||||
|
||||
// Configuration
|
||||
const COLUMNS = [
|
||||
{ key: 'position', label: 'Pos', width: 'w-16', placeholder: '#' },
|
||||
{ key: 'subject', label: 'Subject', width: 'flex-1', placeholder: 'Enter subject' },
|
||||
{ key: 'time', label: 'Time', width: 'w-24', placeholder: '00:00' },
|
||||
{ key: 'room', label: 'Room', width: 'w-20', placeholder: 'A-1' },
|
||||
];
|
||||
|
||||
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 SortableRow({ item, columns, onUpdate, onDelete }) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: item.id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
export default function TestPage() {
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className="bg-gray-800 rounded-lg overflow-hidden mb-2"
|
||||
>
|
||||
<div className="flex items-stretch">
|
||||
{columns.map((col) => (
|
||||
<input
|
||||
key={col.key}
|
||||
type="text"
|
||||
value={item[col.key] || ''}
|
||||
onChange={(e) => onUpdate(item.id, col.key, e.target.value)}
|
||||
className={`${col.width} px-3 py-3 bg-transparent text-white border-r border-gray-700 last:border-r-0 focus:outline-none focus:bg-gray-700/50 placeholder-gray-500`}
|
||||
placeholder={col.placeholder}
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="w-12 bg-gray-900 flex items-center justify-center cursor-move touch-none select-none text-gray-400 hover:text-white"
|
||||
>
|
||||
⋮
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onDelete(item.id)}
|
||||
className="w-12 bg-red-600/20 text-red-400 hover:bg-red-600/40 hover:text-red-300 active:bg-red-600/60"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<>
|
||||
<div className="bg-black w-screen h-screen flex items-center justify-center">
|
||||
<TextPreloader />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ScheduleEditor({
|
||||
initialData = INITIAL_DATA,
|
||||
columns = COLUMNS,
|
||||
onChange,
|
||||
}) {
|
||||
const [items, setItems] = useState(initialData);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
}),
|
||||
useSensor(TouchSensor, {
|
||||
activationConstraint: {
|
||||
delay: 200,
|
||||
tolerance: 8,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const updateItems = (newItems) => {
|
||||
setItems(newItems);
|
||||
onChange?.(newItems);
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
// Update positions based on new order
|
||||
const positions = newItems.map(item => item.position);
|
||||
const reorderedItems = newItems.map((item, idx) => ({
|
||||
...item,
|
||||
position: positions[idx]
|
||||
}));
|
||||
|
||||
updateItems(reorderedItems);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = (id, field, value) => {
|
||||
const newItems = items.map(item =>
|
||||
item.id === id ? { ...item, [field]: value } : item
|
||||
);
|
||||
updateItems(newItems);
|
||||
};
|
||||
|
||||
const handleDelete = (id) => {
|
||||
const newItems = items.filter(item => item.id !== id);
|
||||
updateItems(newItems);
|
||||
};
|
||||
|
||||
const handleAddRow = () => {
|
||||
const newId = String(Date.now());
|
||||
const lastPosition = items.length > 0
|
||||
? String(parseInt(items[items.length - 1].position || '0') + 1)
|
||||
: '1';
|
||||
|
||||
const newItem = { id: newId, position: lastPosition };
|
||||
columns.forEach(col => {
|
||||
if (col.key !== 'position') {
|
||||
newItem[col.key] = '';
|
||||
}
|
||||
});
|
||||
|
||||
updateItems([...items, newItem]);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
updateItems([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 p-4 pb-24">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<h1 className="text-2xl font-bold mb-6 text-white">
|
||||
Schedule Editor
|
||||
</h1>
|
||||
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={items.map(item => item.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div>
|
||||
{items.map((item) => (
|
||||
<SortableRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
columns={columns}
|
||||
onUpdate={handleUpdate}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
{items.length === 0 && (
|
||||
<div className="border-2 border-dashed border-gray-700 rounded-lg p-12 text-center text-gray-500">
|
||||
No items. Add a row to get started.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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 className="mt-6 p-4 rounded-lg bg-gray-800 text-sm text-gray-300">
|
||||
<p className="font-bold mb-2 text-white">Instructions:</p>
|
||||
<ul className="space-y-1">
|
||||
<li>• Drag rows by the handle (⋮) on the right to reorder</li>
|
||||
<li>• Positions move with their rows when dragged</li>
|
||||
<li>• Click any field to edit</li>
|
||||
<li>• Click × to delete a row</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import React, { 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';
|
||||
|
||||
// Configuration
|
||||
const COLUMNS = [
|
||||
{ key: 'position', label: 'Pos', width: 'w-16', placeholder: '#' },
|
||||
{ key: 'subject', label: 'Subject', width: 'flex-1', placeholder: 'Enter subject' },
|
||||
{ key: 'time', label: 'Time', width: 'w-24', placeholder: '00:00' },
|
||||
{ key: 'room', label: 'Room', width: 'w-20', placeholder: 'A-1' },
|
||||
];
|
||||
|
||||
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 SortableRow({ item, columns, onUpdate, onDelete }) {
|
||||
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-gray-800 rounded-lg overflow-hidden mb-2"
|
||||
>
|
||||
<div className="flex items-stretch">
|
||||
{columns.map((col) => (
|
||||
<input
|
||||
key={col.key}
|
||||
type="text"
|
||||
value={item[col.key] || ''}
|
||||
onChange={(e) => onUpdate(item.id, col.key, e.target.value)}
|
||||
className={`${col.width} px-3 py-3 bg-transparent text-white border-r border-gray-700 last:border-r-0 focus:outline-none focus:bg-gray-700/50 placeholder-gray-500`}
|
||||
placeholder={col.placeholder}
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="w-12 bg-gray-900 flex items-center justify-center cursor-move touch-none select-none text-gray-400 hover:text-white"
|
||||
>
|
||||
⋮
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onDelete(item.id)}
|
||||
className="w-12 bg-red-600/20 text-red-400 hover:bg-red-600/40 hover:text-red-300 active:bg-red-600/60"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ScheduleEditor({
|
||||
initialData = INITIAL_DATA,
|
||||
columns = COLUMNS,
|
||||
onChange,
|
||||
}) {
|
||||
const [items, setItems] = useState(initialData);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
}),
|
||||
useSensor(TouchSensor, {
|
||||
activationConstraint: {
|
||||
delay: 200,
|
||||
tolerance: 8,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const updateItems = (newItems) => {
|
||||
setItems(newItems);
|
||||
onChange?.(newItems);
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
// Update positions based on new order
|
||||
const positions = newItems.map(item => item.position);
|
||||
const reorderedItems = newItems.map((item, idx) => ({
|
||||
...item,
|
||||
position: positions[idx]
|
||||
}));
|
||||
|
||||
updateItems(reorderedItems);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = (id, field, value) => {
|
||||
const newItems = items.map(item =>
|
||||
item.id === id ? { ...item, [field]: value } : item
|
||||
);
|
||||
updateItems(newItems);
|
||||
};
|
||||
|
||||
const handleDelete = (id) => {
|
||||
const newItems = items.filter(item => item.id !== id);
|
||||
updateItems(newItems);
|
||||
};
|
||||
|
||||
const handleAddRow = () => {
|
||||
const newId = String(Date.now());
|
||||
const lastPosition = items.length > 0
|
||||
? String(parseInt(items[items.length - 1].position || '0') + 1)
|
||||
: '1';
|
||||
|
||||
const newItem = { id: newId, position: lastPosition };
|
||||
columns.forEach(col => {
|
||||
if (col.key !== 'position') {
|
||||
newItem[col.key] = '';
|
||||
}
|
||||
});
|
||||
|
||||
updateItems([...items, newItem]);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
updateItems([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 p-4 pb-24">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<h1 className="text-2xl font-bold mb-6 text-white">
|
||||
Schedule Editor
|
||||
</h1>
|
||||
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={items.map(item => item.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div>
|
||||
{items.map((item) => (
|
||||
<SortableRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
columns={columns}
|
||||
onUpdate={handleUpdate}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
{items.length === 0 && (
|
||||
<div className="border-2 border-dashed border-gray-700 rounded-lg p-12 text-center text-gray-500">
|
||||
No items. Add a row to get started.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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 className="mt-6 p-4 rounded-lg bg-gray-800 text-sm text-gray-300">
|
||||
<p className="font-bold mb-2 text-white">Instructions:</p>
|
||||
<ul className="space-y-1">
|
||||
<li>• Drag rows by the handle (⋮) on the right to reorder</li>
|
||||
<li>• Positions move with their rows when dragged</li>
|
||||
<li>• Click any field to edit</li>
|
||||
<li>• Click × to delete a row</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user