This commit is contained in:
2026-01-07 22:27:52 +03:00
parent 481e532dce
commit ff1174671a
55 changed files with 11201 additions and 858 deletions
+212
View File
@@ -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>
);
};
+521
View File
@@ -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>
);
};
-9
View File
@@ -1,9 +0,0 @@
export default function Panel() {
return <>
<div>
<div>
</div>
</div>
</>;
}
+174 -96
View File
@@ -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"]}&nbsp;&nbsp;-
</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"]}&nbsp;&nbsp;-</span>
<span className="text-right">{d["t-end"]}</span>
</div>
</div>
</td>
</tr>
);
});
+20 -29
View File
@@ -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
View File
@@ -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
View File
@@ -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>
);
}
+212
View File
@@ -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>
);
}