first commit; dx 1.0 beta
This commit is contained in:
+134
@@ -0,0 +1,134 @@
|
||||
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { atomWithStorage } from "jotai/utils";
|
||||
import { useEffect } from "react";
|
||||
import { lazy } from "react";
|
||||
import { DengiCaching } from "./trash/dengiCashingSystem";
|
||||
import { ifstyle } from "./trash/helpers";
|
||||
import { LoginPage } from "./pages/LoginPage";
|
||||
import { DX4App } from "./pages/DX4AppPage";
|
||||
export const API_BASE_URL = "http://192.168.3.10:8321/api.php";
|
||||
|
||||
const classIDAtom = atomWithStorage("class-id", "");
|
||||
const adminAtom = atomWithStorage("admin-id", "");
|
||||
|
||||
const pickmeModeAtom = atomWithStorage("pickmeMode", false);
|
||||
|
||||
const EditorPage = lazy(() => import("./pages/editor"));
|
||||
const TestPage = lazy(() => import("./pages/test"));
|
||||
|
||||
|
||||
const dengi_cash = new DengiCaching();
|
||||
|
||||
// KATAMAZ DENGI ROUTER 4 PRO MAX ULTIMATE
|
||||
// 💀💀
|
||||
// PROS - vrode raboatat,
|
||||
// too simple, lightweight, stupidly fast
|
||||
// (beacause you basicly download entire site, lol)
|
||||
// CONS - shit
|
||||
function firstPathSegment(path) {
|
||||
const m = path.match(/^\/?([^/]+)/);
|
||||
return m ? m[1] : "";
|
||||
}
|
||||
const secondPathSegment = (p) => p.replace(/^\/?[^/]+/, "").replace(/^\/+/, "");
|
||||
|
||||
const routeAtom = atom("/");
|
||||
const basePageProps = {
|
||||
// atoms
|
||||
classIDAtom,
|
||||
adminAtom,
|
||||
routeAtom,
|
||||
|
||||
// fucntions
|
||||
setsetRoute,
|
||||
secondPathSegment,
|
||||
ifstyle,
|
||||
|
||||
// constants
|
||||
API_BASE_URL,
|
||||
|
||||
// caching
|
||||
dengi_cash,
|
||||
|
||||
// etc
|
||||
pickmeModeAtom, // <3
|
||||
};
|
||||
const pages = {
|
||||
"/": <LoginPage {...basePageProps} />,
|
||||
"/app": <DX4App {...basePageProps} />,
|
||||
"/exit": <ExitPage {...basePageProps} />,
|
||||
"/editor": <EditorPage {...basePageProps} />,
|
||||
"/test": <TestPage />,
|
||||
};
|
||||
|
||||
let changeAnchor = true;
|
||||
let defaultAnchor = document.location.hash.substring(1);
|
||||
|
||||
// lol 💀
|
||||
function setsetRoute(setRoute, path) {
|
||||
changeAnchor = true;
|
||||
setRoute(path);
|
||||
document.location.hash = path;
|
||||
}
|
||||
|
||||
export default function DX4() {
|
||||
const [route, _setRoute] = useAtom(routeAtom);
|
||||
useEffect(() => {
|
||||
if (
|
||||
document.location.hash &&
|
||||
document.location.hash.substring(1) !== route
|
||||
) {
|
||||
_setRoute(document.location.hash.substring(1));
|
||||
}
|
||||
window.addEventListener("load", () => {
|
||||
if (defaultAnchor == "" || defaultAnchor == "/") {
|
||||
document.location.hash = defaultAnchor;
|
||||
}
|
||||
});
|
||||
}, [route, _setRoute]);
|
||||
window.addEventListener("hashchange", () => {
|
||||
if (document.location.hash == "") {
|
||||
document.location.hash = "/";
|
||||
}
|
||||
if (changeAnchor) {
|
||||
changeAnchor = false;
|
||||
return;
|
||||
}
|
||||
_setRoute(document.location.hash.substring(1));
|
||||
});
|
||||
const page = Object.entries(pages).find(
|
||||
([key]) => firstPathSegment(key) == firstPathSegment(route)
|
||||
);
|
||||
return (
|
||||
(page ? page[1] : false) || <ERROR404 /> || <div>some shit happened</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ERROR404() {
|
||||
const route = useAtom(routeAtom)[0];
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen flex-col gap-10">
|
||||
<div className="text-6xl font-black bg-gradient-to-r from-red-400 to-pink-300 bg-clip-text text-transparent">
|
||||
404((((
|
||||
</div>
|
||||
<div>{route}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export function ExitPage() {
|
||||
const [_userId, setUserId] = useAtom(classIDAtom);
|
||||
const setRoute = useAtom(routeAtom)[1];
|
||||
return (
|
||||
<div className="w-screen h-screen flex items-center justify-center">
|
||||
<button
|
||||
className="w-20 h-20 bg-red-500 rounded-full text-white"
|
||||
onClick={() => {
|
||||
setUserId("");
|
||||
setsetRoute(setRoute, "/");
|
||||
}}
|
||||
>
|
||||
viyti
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_2026_10)">
|
||||
<g filter="url(#filter0_d_2026_10)">
|
||||
<mask id="mask0_2026_10" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="89" y="110" width="325" height="281">
|
||||
<path d="M229.595 373.25L321.333 295.083L229.595 214.853V373.25Z" fill="white"/>
|
||||
<path d="M321.333 295.083L229.595 373.25M321.333 295.083L229.595 214.853M321.333 295.083L402.292 214.853M321.333 295.083L402.292 378.833M229.595 373.25V214.853M229.595 373.25H104.393C101.851 373.25 100.461 370.288 102.085 368.333L229.595 214.853M229.595 214.853V122" stroke="white" stroke-width="23" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_2026_10)">
|
||||
<rect width="350" height="287" transform="matrix(1 0 0 -1 76 395)" fill="url(#paint0_linear_2026_10)"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_d_2026_10" x="34.365" y="55" width="434.927" height="390.833" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="27.75"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.34 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_2026_10"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_2026_10" result="shape"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_2026_10" x1="-73.3091" y1="366.596" x2="487.371" y2="-46.6548" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="0.528846" stop-color="#9595A1"/>
|
||||
<stop offset="1" stop-color="#545568"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_2026_10">
|
||||
<rect width="500" height="500" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,35 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
font-family: "Jost", sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: dark;
|
||||
color: #ffffffde;
|
||||
background-color: #141414;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
* {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ensure selectable elements still selectable */
|
||||
.selectable,
|
||||
.selectable::after,
|
||||
.selectable::before,
|
||||
.selectable *,
|
||||
.selectable *::before,
|
||||
.selectable *::after,
|
||||
input {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.bg-grain {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100'%3E%3Cfilter id='noise' x='0' y='0'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='matrix' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.8 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.45'/%3E%3C/svg%3E");
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import App from "./App.jsx";
|
||||
// font jost
|
||||
//import "@fontsource/jost/100-italic.css";
|
||||
import "@fontsource/jost/200-italic.css";
|
||||
import "@fontsource/jost/300-italic.css";
|
||||
import "@fontsource/jost/400-italic.css";
|
||||
import "@fontsource/jost/500-italic.css";
|
||||
import "@fontsource/jost/600-italic.css";
|
||||
import "@fontsource/jost/700-italic.css";
|
||||
import "@fontsource/jost/800-italic.css";
|
||||
//import "@fontsource/jost/900-italic.css";
|
||||
//import "@fontsource/jost/100.css"; //
|
||||
import "@fontsource/jost/200.css";
|
||||
import "@fontsource/jost/300.css";
|
||||
import "@fontsource/jost/400.css";
|
||||
import "@fontsource/jost/500.css";
|
||||
import "@fontsource/jost/600.css";
|
||||
import "@fontsource/jost/700.css";
|
||||
import "@fontsource/jost/800.css";
|
||||
//import "@fontsource/jost/900.css";
|
||||
|
||||
createRoot(document.getElementById("root")).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,9 @@
|
||||
export default function Panel() {
|
||||
return <>
|
||||
<div>
|
||||
<div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
BiDotsHorizontalRounded,
|
||||
BiExit,
|
||||
BiSolidUser,
|
||||
BiStar,
|
||||
BiPencil,
|
||||
BiLink,
|
||||
} from "react-icons/bi";
|
||||
import Logo from "../assets/logo.svg";
|
||||
import {
|
||||
Switch,
|
||||
LoadingSpinner,
|
||||
DatePicker,
|
||||
// CoolMouseBg,
|
||||
} from "../trash/trash-components";
|
||||
import {
|
||||
AdminUserFRequest,
|
||||
classUserFRequest,
|
||||
RaspGet,
|
||||
} from "../trash/req_mgr";
|
||||
import { AnimatePresence, 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";
|
||||
|
||||
export function DX4App({
|
||||
classIDAtom,
|
||||
adminAtom,
|
||||
setsetRoute,
|
||||
routeAtom,
|
||||
pickmeModeAtom,
|
||||
dengi_cash,
|
||||
}) {
|
||||
const [classId, setUserId] = useAtom(classIDAtom);
|
||||
const [adminId, _setAdminId] = useAtom(adminAtom);
|
||||
const [openUserSettings, setOpenUserSettings] = useState(false);
|
||||
const setRoute = useAtom(routeAtom)[1];
|
||||
const [pickmeMode, setPickmeMode] = useAtom(pickmeModeAtom);
|
||||
const [rasp, setRasp] = useState([false, {}, 0]);
|
||||
const [selectedDate, setSelectedDate] = useState(new Date());
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setRasp([false, {}]);
|
||||
const cacheName = `rasp-${classId}-${selectedDate.toDateString()}`;
|
||||
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,
|
||||
]);
|
||||
//console.log(d, d[1] ? d[1] : false);
|
||||
dengi_cash.cache(cacheName, [
|
||||
d[1]["data"] && d[1]["data"][0] ? d[1]["data"] : -322,
|
||||
d[1]["type"] ? d[1]["type"] : -1,
|
||||
]);
|
||||
} catch (exception) {
|
||||
//console.error(exception);
|
||||
setRasp([true, exception.toString()]);
|
||||
}
|
||||
} else {
|
||||
setRasp([true, "ошибка: " + d[1].toString()]);
|
||||
//console.log(d[1]);
|
||||
}
|
||||
},
|
||||
(err) => {
|
||||
setRasp([true, "ошибка: " + err]);
|
||||
}
|
||||
);
|
||||
} else {
|
||||
setRasp([true, ...dengi_cash.get(cacheName)]);
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
//console.log(dengi_cash);
|
||||
}, [selectedDate]);
|
||||
|
||||
const [userFrNames, setUserFrNames] = useState({
|
||||
admin: false,
|
||||
class: false,
|
||||
});
|
||||
|
||||
const [classLinkCopied, setClassLinkCopied] = useState(false);
|
||||
const lessonsEndedTriggered = useRef(false);
|
||||
|
||||
//TODO: Auto change day v2
|
||||
useEffect(() => {
|
||||
if (
|
||||
lessonsEndedTriggered.current ||
|
||||
typeof rasp[1] != "object" ||
|
||||
rasp[1].lenght <= 0 //||
|
||||
//apiFormatDate(selectedDate) !== apiFormatDate(new Date())
|
||||
)
|
||||
return;
|
||||
try {
|
||||
if (!rasp[1].at) return;
|
||||
const tEnd = rasp[1].at(-1)["t-end"];
|
||||
if (!/\d{1,2}:\d{2}$/.test(tEnd)) return; // not time type
|
||||
if (isTimeBeforeNow(tEnd)) {
|
||||
console.log(true);
|
||||
const d = new Date(selectedDate);
|
||||
d.setDate(d.getDate() + 1);
|
||||
setSelectedDate(d);
|
||||
|
||||
lessonsEndedTriggered.current = true;
|
||||
|
||||
console.log("LOG: Auto change day triggired");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return;
|
||||
}
|
||||
}, [rasp]);
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
let cancelled = false;
|
||||
const promises = [];
|
||||
if (classId !== "")
|
||||
promises.push(
|
||||
dengi_cash.withCacheAsync(`userfr-${classId}`, async () =>
|
||||
classUserFRequest(API_BASE_URL, classId)
|
||||
)
|
||||
);
|
||||
else promises.push(Promise.resolve([0]));
|
||||
if (adminId !== "")
|
||||
promises.push(
|
||||
dengi_cash.withCacheAsync(`adminfr-${adminId}`, async () =>
|
||||
AdminUserFRequest(API_BASE_URL, adminId)
|
||||
)
|
||||
);
|
||||
else promises.push(Promise.resolve([0]));
|
||||
Promise.all(promises).then(([classRes, adminRes]) => {
|
||||
if (cancelled) return;
|
||||
const classF = classRes[0] === 1 ? classRes[1].userclassname : false;
|
||||
const adminF = adminRes[0] === 1 ? adminRes[1].adminName : false;
|
||||
setUserFrNames({ admin: adminF, class: classF });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
})();
|
||||
}, [classId, adminId]);
|
||||
|
||||
if (classId === "") {
|
||||
setsetRoute(setRoute, "/");
|
||||
}
|
||||
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 }}
|
||||
>
|
||||
<motion.div
|
||||
className={
|
||||
"flex items-center w-full justify-between px-5" +
|
||||
ifstyle(openUserSettings, " pt-5")
|
||||
}
|
||||
layout="position"
|
||||
>
|
||||
<motion.img
|
||||
src={Logo}
|
||||
alt="logo"
|
||||
width={100}
|
||||
draggable={false}
|
||||
layout="position"
|
||||
/>
|
||||
<motion.div className="flex gap-5 items-center">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{!openUserSettings && (
|
||||
<motion.span
|
||||
className="font-light"
|
||||
variants={{ hidden: { opacity: 0 }, show: { opacity: 1 } }}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
exit="hidden"
|
||||
>
|
||||
{userFrNames["class"] ? userFrNames["class"] : classId}
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<motion.span
|
||||
layout="position"
|
||||
className={
|
||||
"rounded-full w-fit h-fit" +
|
||||
ifstyle(openUserSettings, "border")
|
||||
}
|
||||
>
|
||||
<BiDotsHorizontalRounded
|
||||
size={35}
|
||||
onClick={() => setOpenUserSettings(!openUserSettings)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setPickmeMode(!pickmeMode);
|
||||
}}
|
||||
className={
|
||||
"rounded-full " +
|
||||
(pickmeMode
|
||||
? "lg:hover:bg-pink-400"
|
||||
: "lg:hover:bg-white lg:hover:fill-black") +
|
||||
" lg:hover:scale-105 transition cursor-pointer"
|
||||
}
|
||||
/>
|
||||
</motion.span>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{openUserSettings && (
|
||||
<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)" },
|
||||
}}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
exit="hidden"
|
||||
>
|
||||
{[
|
||||
// icon
|
||||
// text
|
||||
// onclick
|
||||
// condition
|
||||
[
|
||||
<BiExit />,
|
||||
"выход",
|
||||
() => {
|
||||
setUserId("");
|
||||
setsetRoute(setRoute, "/");
|
||||
},
|
||||
true,
|
||||
],
|
||||
// [
|
||||
// <BiStar />,
|
||||
// "пикми режим",
|
||||
// () => {
|
||||
// setPickmeMode(!pickmeMode);
|
||||
// },
|
||||
// true,
|
||||
// ],
|
||||
[
|
||||
<BiSolidUser />,
|
||||
`админ ${
|
||||
userFrNames["admin"] ? userFrNames["admin"] : adminId
|
||||
}`,
|
||||
() => {},
|
||||
adminId != "",
|
||||
],
|
||||
[
|
||||
<BiLink />,
|
||||
classLinkCopied ? "скопированно" : "коп. ссылку",
|
||||
() => {
|
||||
copyToClipboard(
|
||||
location.href.substring(
|
||||
0,
|
||||
location.href.search("#")
|
||||
) +
|
||||
"#//" +
|
||||
classId
|
||||
).then((a) => {
|
||||
if (a) {
|
||||
setClassLinkCopied(true);
|
||||
setTimeout(() => {
|
||||
setClassLinkCopied(false);
|
||||
}, 2000);
|
||||
}
|
||||
});
|
||||
},
|
||||
classId != "",
|
||||
],
|
||||
].map(
|
||||
(item, i) =>
|
||||
item[3] && (
|
||||
<div
|
||||
key={i}
|
||||
className={
|
||||
"flex gap-2 items-center justify-center transition-colors " +
|
||||
(pickmeMode
|
||||
? "lg:hover:bg-pink-400"
|
||||
: "lg:hover:bg-white lg:hover:text-black") +
|
||||
" p-1 rounded-xl cursor-pointer"
|
||||
}
|
||||
onClick={item[2]}
|
||||
>
|
||||
{item[0]} {item[1]}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
{adminId != "" && (
|
||||
<motion.div
|
||||
layout="position"
|
||||
className="flex w-full justify-end px-5"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<BiPencil /> открыть в редакторе
|
||||
</a>
|
||||
</motion.div>
|
||||
)}
|
||||
<motion.div
|
||||
layout="position"
|
||||
className="flex flex-col gap-5 w-full not-md:px-4"
|
||||
>
|
||||
<DatePicker
|
||||
date={selectedDate}
|
||||
setDate={setSelectedDate}
|
||||
indicator={typeof rasp[2] == "number" && [1, 2].includes(rasp[2])}
|
||||
/>
|
||||
<RaspView {...{ rasp, pickmeMode, selectedDate }} />
|
||||
<AnimatePresence mode="popLayout">
|
||||
{!rasp[0] && (
|
||||
<motion.div
|
||||
className="w-full flex justify-center"
|
||||
initial="hid"
|
||||
animate="show"
|
||||
exit="hid"
|
||||
variants={{
|
||||
show: { opacity: 1, scale: 1 },
|
||||
hid: { opacity: 0, scale: 0 },
|
||||
}}
|
||||
>
|
||||
<LoadingSpinner size={25} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
{/* <CoolMouseBg /> */}
|
||||
</motion.div>
|
||||
<CoolMainBg {...{ pickmeMode }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
function CoolMainBg({ 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
|
||||
${pickmeMode ? "from-pink-400/10" : "from-white/2"}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function RaspView({ rasp, pickmeMode, selectedDate }) {
|
||||
const [tableColHover, setTableColHover] = useState(-1);
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="popLayout">
|
||||
{rasp[0] && typeof rasp[1] == "object" && (
|
||||
<motion.table
|
||||
//layoutId="schedule_table"
|
||||
//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 " +
|
||||
(pickmeMode ? "from-pink-400/30" : "from-zinc-800/40") +
|
||||
" to-transparent rounded-2xl selectable"
|
||||
}
|
||||
variants={{
|
||||
hid: { opacity: 0 },
|
||||
show: { opacity: 1 },
|
||||
}}
|
||||
initial="hid"
|
||||
animate="show"
|
||||
exit="hid"
|
||||
>
|
||||
<thead className="">
|
||||
<tr className=" *:text-center text-zinc-400 *:py-2 border-b *:my-5 *:min-w-15 *:lg:hover:text-zinc-200 *:transition-colors *:font-light">
|
||||
<th
|
||||
className="max-w-10"
|
||||
//layout="position"
|
||||
//layoutId="table-num-h"
|
||||
onMouseEnter={() => {
|
||||
setTableColHover(0);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setTableColHover(-1);
|
||||
}}
|
||||
>
|
||||
n урока
|
||||
</th>
|
||||
<th
|
||||
//layout="position"
|
||||
//layoutId="table-subj-h"
|
||||
onMouseEnter={() => {
|
||||
setTableColHover(1);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setTableColHover(-1);
|
||||
}}
|
||||
>
|
||||
предмет
|
||||
</th>
|
||||
<th
|
||||
//layout="position"
|
||||
//layoutId="table-cab-h"
|
||||
onMouseEnter={() => {
|
||||
setTableColHover(2);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setTableColHover(-1);
|
||||
}}
|
||||
>
|
||||
каб
|
||||
</th>
|
||||
<th
|
||||
//layout="position"
|
||||
//layoutId="table-t-h"
|
||||
onMouseEnter={() => {
|
||||
setTableColHover(3);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setTableColHover(-1);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{rasp[2] == 2 && (
|
||||
<span className="w-2 h-2 animate-pulse rounded-full bg-zinc-300 block" />
|
||||
)}
|
||||
время
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</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
|
||||
className={
|
||||
(tableColHover == 0 ? "lg:bg-zinc-300/15" : "") +
|
||||
" font-bold"
|
||||
}
|
||||
//layout="position"
|
||||
//layoutId={`table-num-${i}`}
|
||||
>
|
||||
{d["num"]}
|
||||
</td>
|
||||
<td
|
||||
className={tableColHover == 1 ? "lg:bg-zinc-300/15" : ""}
|
||||
//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}`}
|
||||
className={tableColHover == 2 ? "lg:bg-zinc-300/15" : ""}
|
||||
>
|
||||
{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 className={tableColHover == 3 ? "lg:bg-zinc-300/15" : ""}>
|
||||
<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>
|
||||
))}
|
||||
</tbody>
|
||||
</motion.table>
|
||||
)}
|
||||
{typeof rasp[1] == "number" && rasp[1] === -322 && (
|
||||
<motion.div
|
||||
className="w-full flex justify-center"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, x: 10 }}
|
||||
>
|
||||
<span className="text-2xl font-bold">нет уроков</span>
|
||||
</motion.div>
|
||||
)}
|
||||
{typeof rasp[1] == "string" && rasp[1].startsWith("ошибка") && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, x: 10 }}
|
||||
className="w-full flex justify-center"
|
||||
>
|
||||
<span className="text-xl text-center text-transparent bg-clip-text bg-gradient-to-r from-red-300 to-red-500">
|
||||
{rasp[1]}
|
||||
</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useAtom } from "jotai";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import Logo from "../assets/logo.svg";
|
||||
import { Switch, LoadingSpinner } from "../trash/trash-components";
|
||||
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("")];
|
||||
|
||||
export function LoginPage({
|
||||
classIDAtom,
|
||||
adminAtom,
|
||||
setsetRoute,
|
||||
routeAtom,
|
||||
secondPathSegment,
|
||||
}) {
|
||||
const [enteredId, setEnteredId] = useState("");
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [adminEnteredId, setEnteredAId] = useState("");
|
||||
const [userId, setUserId] = useAtom(classIDAtom);
|
||||
const [route, setRoute] = useAtom(routeAtom);
|
||||
const [isLogining, setLogining] = useState(false); // disables all inputs, shows
|
||||
const [errorText, setErrorText] = useState("");
|
||||
const [adminId, setAdminId] = useAtom(adminAtom);
|
||||
/** @type {string} */
|
||||
let autolog = secondPathSegment(route);
|
||||
|
||||
const formSubmit = async (
|
||||
event = null,
|
||||
cid = enteredId,
|
||||
aid = adminEnteredId,
|
||||
isa = isAdmin
|
||||
) => {
|
||||
event && event.preventDefault();
|
||||
setLogining(true);
|
||||
setErrorText("");
|
||||
const l = await LoginReq(API_BASE_URL, cid);
|
||||
if (l[0] === 1) {
|
||||
if (!isa) {
|
||||
setUserId(l[1]["classid"]);
|
||||
setsetRoute(setRoute, "/app");
|
||||
} else {
|
||||
const al = await AdminReq(API_BASE_URL, aid);
|
||||
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
|
||||
if (l[1]["classid"] == al[1]["adminClass"]) {
|
||||
setUserId(l[1]["classid"]);
|
||||
setAdminId(al[1]["adminId"]);
|
||||
setsetRoute(setRoute, "/app");
|
||||
} else {
|
||||
// you are not from here
|
||||
console.log(al);
|
||||
setErrorText("админ не пренадлежит этому классу");
|
||||
}
|
||||
} else {
|
||||
setErrorText(al[1]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setErrorText(l[1]);
|
||||
}
|
||||
|
||||
setLogining(false);
|
||||
};
|
||||
|
||||
if (userId !== "") {
|
||||
setsetRoute(setRoute, "/app");
|
||||
return;
|
||||
} else if (adminId !== "") {
|
||||
setAdminId(""); // NOTE: adminId with no userID is insecure
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
useEffect(() => {
|
||||
// NOTE: this hook shouldn't called if user already
|
||||
// loggined, so i doesn't violating rules of hooks
|
||||
|
||||
if (autolog && autolog != "") {
|
||||
let name, admin;
|
||||
if (autolog.includes(";")) {
|
||||
// /name;admin
|
||||
let a = autolog.split(";");
|
||||
name = a[0];
|
||||
admin = b64d(a[1]);
|
||||
} else {
|
||||
// /name
|
||||
name = autolog;
|
||||
}
|
||||
setEnteredId(name);
|
||||
admin && setEnteredAId(admin);
|
||||
formSubmit(null, name, admin, Boolean(admin));
|
||||
}
|
||||
}, [autolog]);
|
||||
|
||||
return (
|
||||
<div className="w-full h-screen flex flex-col items-center justify-center bg-gradient-to-br from-white/50 to-transparent to-50% gap-5 overflow-hidden">
|
||||
<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)" }}
|
||||
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"
|
||||
>
|
||||
<BiSolidErrorCircle className="fill-red-300" size={20} />
|
||||
<span>
|
||||
ошибка:{" "}
|
||||
<span className="font-extrabold font-mono selectable">
|
||||
{errorText}
|
||||
</span>
|
||||
</span>
|
||||
<div className="cursor-pointer" onClick={() => setErrorText("")}>
|
||||
<BiBlock size={20} />
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</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"
|
||||
initial={{
|
||||
y: 30,
|
||||
filter: "blur(5px)",
|
||||
opacity: 0,
|
||||
}}
|
||||
layout="position"
|
||||
animate={{ y: 0, filter: "blur(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>
|
||||
<form
|
||||
action="#"
|
||||
className="mt-2 flex flex-col gap-3 *:outline-none *:border *:border-transparent *:focus:border-white"
|
||||
onSubmit={formSubmit}
|
||||
>
|
||||
<motion.input
|
||||
type="text"
|
||||
name="d"
|
||||
autoComplete="off"
|
||||
value={enteredId}
|
||||
onInput={async (event) => {
|
||||
/** @type {string} */
|
||||
let val = event.target.value;
|
||||
let nval = "";
|
||||
val.split("").forEach((i) => {
|
||||
// console.log(i);
|
||||
if (AvailableSymbols.includes(i)) {
|
||||
// console.log(true);
|
||||
nval = nval + i;
|
||||
}
|
||||
});
|
||||
nval = nval.replaceAll(" ", "").toUpperCase().substring(0, 10);
|
||||
event.target.value = nval;
|
||||
setEnteredId(nval);
|
||||
}}
|
||||
className="bg-zinc-800/50 p-2 rounded-2xl w-full min-w-[206px]"
|
||||
placeholder="id класса"
|
||||
disabled={isLogining}
|
||||
minLength={3}
|
||||
/>
|
||||
<motion.span className="w-full flex gap-2 items-center justify-center ">
|
||||
<Switch
|
||||
type="checkbox"
|
||||
onChange={(e) => setIsAdmin(e)}
|
||||
disabled={isLogining}
|
||||
name=""
|
||||
/>
|
||||
админка
|
||||
</motion.span>
|
||||
<CoolInputWithHide
|
||||
{...{ enabled: isAdmin, setEnteredAId, isLogining, adminEnteredId }}
|
||||
/>
|
||||
<CoolLoginButton display={enteredId.length > 2} />
|
||||
</form>
|
||||
{enteredId.length > 2 && !isLogining && (
|
||||
<div className="text-xs my-3 text-zinc-500">
|
||||
входя, вы принимаете{" "}
|
||||
<a
|
||||
target="_blank"
|
||||
href="./terms.html"
|
||||
className="text-white border-b border-transparent lg:hover:border-white transition-colors"
|
||||
>
|
||||
пользовательское соглашение
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{isLogining && (
|
||||
<motion.div
|
||||
initial={{ y: -40, opacity: 0, filter: "blur(5px)" }}
|
||||
animate={{ y: 0, opacity: 1, filter: "blur(0px)" }}
|
||||
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"
|
||||
>
|
||||
<LoadingSpinner size={15} />
|
||||
загрузка...
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CoolLoginButton({ display, isLogining }) {
|
||||
const logbuttonRef = useRef(null);
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="popLayout">
|
||||
{display && (
|
||||
<motion.button
|
||||
initial={{ y: 10, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
ref={logbuttonRef}
|
||||
exit={{ y: 40, opacity: 0, rotate: 20, filter: "blur(3px)" }}
|
||||
onMouseMove={(e) => {
|
||||
const rect = e.target.getBoundingClientRect();
|
||||
e.target.style.setProperty(
|
||||
"--tw-gradient-via-position",
|
||||
`${((e.clientX - rect.left) / rect.width) * 100}%`
|
||||
);
|
||||
}}
|
||||
className="bg-gradient-to-r from-neutral-900 to-zinc-900 p-2 rounded-xl not-disabled:cursor-pointer transition-colors duration-200 not-disabled:hover:via-white via-30% not-disabled:hover:text-black disabled:via-white disabled:via-0%! disabled:animate-pulse"
|
||||
disabled={isLogining}
|
||||
>
|
||||
войти
|
||||
</motion.button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
function CoolInputWithHide({
|
||||
isLogining,
|
||||
setEnteredAId,
|
||||
adminEnteredId,
|
||||
enabled,
|
||||
}) {
|
||||
const [showAId, setShowAID] = useState(false);
|
||||
return (
|
||||
<AnimatePresence mode="popLayout">
|
||||
{enabled && (
|
||||
<motion.div
|
||||
initial={{ y: 10, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 10, opacity: 0 }}
|
||||
className="flex *:outline-none *:border *:border-transparent *:focus:border-white"
|
||||
>
|
||||
<motion.input
|
||||
type={!showAId ? "password" : "text"}
|
||||
name="ad"
|
||||
className="bg-zinc-800/50 p-2 rounded-l-2xl max-w-[19b0px]"
|
||||
placeholder="id админки"
|
||||
autoComplete="off"
|
||||
disabled={isLogining}
|
||||
value={adminEnteredId}
|
||||
required
|
||||
onInput={(e) => {
|
||||
/** @type {string}*/
|
||||
let val = e.target.value;
|
||||
let nval = "";
|
||||
val.split("").forEach((i) => {
|
||||
if (AvailableAdminSymbols.includes(i)) {
|
||||
nval = nval + i;
|
||||
}
|
||||
});
|
||||
e.target.value = nval;
|
||||
setEnteredAId(nval);
|
||||
}}
|
||||
/>
|
||||
<motion.div
|
||||
layoutCrossfade
|
||||
className={
|
||||
" p-2 rounded-r-2xl border-l border-l-zinc-500 transition-colors " +
|
||||
(showAId ? " bg-white" : " bg-zinc-800/50")
|
||||
}
|
||||
onClick={() => setShowAID(!showAId)}
|
||||
>
|
||||
{!showAId ? (
|
||||
<BiShow color="#fff" size={23} />
|
||||
) : (
|
||||
<BiHide color="#000" size={23} />
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import { motion, AnimatePresence, LayoutGroup } from "motion/react";
|
||||
|
||||
import { useAtom } from "jotai";
|
||||
import { useState, useEffect } from "react";
|
||||
import Logo from "../assets/logo.svg";
|
||||
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,
|
||||
// CoolMouseBg
|
||||
} from "../trash/trash-components";
|
||||
import { TableEditor } from "../trash/editorTable";
|
||||
import { ifstyle } from "../trash/helpers";
|
||||
import { API_BASE_URL } from "../App";
|
||||
|
||||
export default function EditorPage(props) {
|
||||
const [classId, _setUserId] = useAtom(props.classIDAtom);
|
||||
const [adminId, _setAdminId] = useAtom(props.adminAtom);
|
||||
const [route, setRoute] = useAtom(props.routeAtom);
|
||||
const dateRaw = props.secondPathSegment(route);
|
||||
useEffect(() => {
|
||||
if (
|
||||
dateRaw == "" ||
|
||||
adminId == "" ||
|
||||
!RegExp(/\d{2}-\d{2}-\d{2}$/gm).test(dateRaw)
|
||||
) {
|
||||
props.setsetRoute(setRoute, "/app");
|
||||
}
|
||||
}, [dateRaw]);
|
||||
const date = parseApiDate(dateRaw);
|
||||
const [userFrNames, setUserFrNames] = useState({
|
||||
admin: false,
|
||||
class: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
let cancelled = false;
|
||||
const promises = [];
|
||||
if (classId !== "")
|
||||
promises.push(
|
||||
props.dengi_cash.withCacheAsync(`userfr-${classId}`, async () =>
|
||||
classUserFRequest(API_BASE_URL, classId)
|
||||
)
|
||||
);
|
||||
else promises.push(Promise.resolve([0]));
|
||||
if (adminId !== "")
|
||||
promises.push(
|
||||
props.dengi_cash.withCacheAsync(`adminfr-${adminId}`, async () =>
|
||||
AdminUserFRequest(API_BASE_URL, adminId)
|
||||
)
|
||||
);
|
||||
else promises.push(Promise.resolve([0]));
|
||||
Promise.all(promises).then(([classRes, adminRes]) => {
|
||||
if (cancelled) return;
|
||||
const classF = classRes[0] === 1 ? classRes[1].userclassname : false;
|
||||
const adminF = adminRes[0] === 1 ? adminRes[1].adminName : false;
|
||||
setUserFrNames({ admin: adminF, class: classF });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
})();
|
||||
}, [classId, adminId]);
|
||||
// on rerenderr
|
||||
useEffect(() => {
|
||||
console.log("editor cache clean");
|
||||
props.dengi_cash.delete(
|
||||
"editor_schedule",
|
||||
"editor_changes",
|
||||
"editor_rings"
|
||||
);
|
||||
}, []); // clean on full rerender
|
||||
// tabs
|
||||
const tabs = [
|
||||
[
|
||||
<>
|
||||
<BiCalendarEdit />
|
||||
замены
|
||||
</>,
|
||||
<>{/* content */}</>,
|
||||
],
|
||||
[
|
||||
<>
|
||||
<BiCalendar />
|
||||
расписание
|
||||
</>,
|
||||
<>{/* content */}</>,
|
||||
],
|
||||
[
|
||||
<>
|
||||
<BiTime />
|
||||
звонки
|
||||
</>,
|
||||
<>{/* content */}</>,
|
||||
],
|
||||
];
|
||||
const [selectedTab, selectTab] = useState(0);
|
||||
const [changesWereMade, setChangesWereMade] = useState(false);
|
||||
const [tabDiagShow, setTabDiagShow] = useState(false);
|
||||
const [tabDiagRequestTab, setTabDiagRequestTab] = useState(0); // 404 for exit
|
||||
return (
|
||||
<>
|
||||
<div className="overflow-x-hidden">
|
||||
<DengiYesOrNoDialog
|
||||
show={tabDiagShow}
|
||||
content={{
|
||||
message: "изменения будут удалены. продолжить?",
|
||||
yesBtn: "да",
|
||||
noBtn: "нет",
|
||||
yesIcon: <BiTrash />,
|
||||
noIcon: <BiX size={20} />,
|
||||
bgDissmis: true,
|
||||
yesBtnStyle: "bg-red-500! text-white! md:hover:bg-red-600!",
|
||||
}}
|
||||
onAnswer={(answ) => {
|
||||
if (answ && tabDiagRequestTab == 404) location.hash = "/app";
|
||||
else if (answ) selectTab(tabDiagRequestTab);
|
||||
setTabDiagShow(0);
|
||||
setTabDiagRequestTab(0);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full h-screen flex flex-col items-center justify-center z-5">
|
||||
<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 }}
|
||||
>
|
||||
<motion.div
|
||||
className="flex items-center w-full justify-between px-5"
|
||||
layout="position"
|
||||
>
|
||||
<motion.img
|
||||
src={Logo}
|
||||
alt="logo"
|
||||
width={100}
|
||||
draggable={false}
|
||||
layout="position"
|
||||
/>
|
||||
<motion.div className="flex gap-5 items-center">
|
||||
<motion.span
|
||||
className="font-light flex gap-2 items-center"
|
||||
variants={{ hidden: { opacity: 0 }, show: { opacity: 1 } }}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
exit="hidden"
|
||||
>
|
||||
<span>
|
||||
{userFrNames["class"] ? userFrNames["class"] : classId}
|
||||
</span>
|
||||
<span className="block h-[1px] w-[20px] -rotate-45 bg-zinc-600" />
|
||||
<span>
|
||||
{userFrNames["admin"] ? userFrNames["admin"] : adminId}
|
||||
</span>
|
||||
</motion.span>
|
||||
<motion.span
|
||||
layout="position"
|
||||
className={"rounded-full w-fit h-fit"}
|
||||
></motion.span>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
layout="position"
|
||||
className="flex w-full px-5 flex-col gap-5"
|
||||
>
|
||||
<div className="flex w-full justify-between">
|
||||
<div
|
||||
className={
|
||||
"flex items-center gap-2 transition-colors " +
|
||||
ifstyle(
|
||||
selectedTab == 2,
|
||||
"text-transparent pointer-events-none select-none"
|
||||
)
|
||||
}
|
||||
>
|
||||
<BiPencil 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"
|
||||
onClick={(e) => {
|
||||
if (changesWereMade) {
|
||||
e.preventDefault();
|
||||
setTabDiagRequestTab(404);
|
||||
setTabDiagShow(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<BiExit /> назад
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-5 select-none bg-grain 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 " +
|
||||
(idx == selectedTab ? "text-white" : "text-zinc-500")
|
||||
}
|
||||
key={idx}
|
||||
onClick={() => {
|
||||
if (selectedTab == idx) return;
|
||||
if (!changesWereMade) {
|
||||
selectTab(idx);
|
||||
} else {
|
||||
setTabDiagRequestTab(idx);
|
||||
setTabDiagShow(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{item}
|
||||
{idx == selectedTab && (
|
||||
<motion.div
|
||||
layoutId="editor_tabs_underline"
|
||||
className="absolute -bottom-[4px] left-0 h-[3px] bg-white/70 rounded-full w-full"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</LayoutGroup>
|
||||
</div>
|
||||
|
||||
<motion.div className="flex flex-col gap-2 pb-10">
|
||||
{
|
||||
<TableEditor
|
||||
{...{
|
||||
selectedTab,
|
||||
setChangesWereMade,
|
||||
date,
|
||||
adminId,
|
||||
}}
|
||||
dengiCash={props.dengi_cash}
|
||||
/>
|
||||
}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
{/* <CoolMouseBg /> */}
|
||||
</div>
|
||||
<DefaultEditorBG />
|
||||
</>
|
||||
);
|
||||
}
|
||||
function DefaultEditorBG() {
|
||||
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"
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ВАНЕЧКА ПИКМИЛЁВ <3
|
||||
// ВАНЕЧКА ПИКМИЛЁВ <3
|
||||
// ВАНЕЧКА ПИКМИЛЁВ <3
|
||||
// ВАНЕЧКА ПИКМИЛЁВ <3
|
||||
// ВАНЕЧКА ПИКМИЛЁВ <3
|
||||
// ВАНЕЧКА ПИКМИЛЁВ <3
|
||||
// ВАНЕЧКА ПИКМИЛЁВ <3
|
||||
// ВАНЕЧКА ПИКМИЛЁВ <3
|
||||
// ВАНЕЧКА ПИКМИЛЁВ <3
|
||||
// ВАНЕЧКА ПИКМИЛЁВ <3
|
||||
// ВАНЕЧКА ПИКМИЛЁВ <3
|
||||
// ВАНЕЧКА ПИКМИЛЁВ <3
|
||||
// ВАНЕЧКА ПИКМИЛЁВ <3
|
||||
// ВАНЕЧКА ПИКМИЛЁВ <3
|
||||
// ВАНЕЧКА ПИКМИЛЁВ <3
|
||||
// ВАНЕЧКА ПИКМИЛЁВ <3
|
||||
// ВАНЕЧКА ПИКМИЛЁВ <3
|
||||
// ВАНЕЧКА ПИКМИЛЁВ <3
|
||||
// ВАНЕЧКА ПИКМИЛЁВ <3
|
||||
// ВАНЕЧКА ПИКМИЛЁВ <3
|
||||
// ВАНЕЧКА ПИКМИЛЁВ <3
|
||||
// ВАНЕЧКА ПИКМИЛЁВ <3
|
||||
// ВАНЕЧКА ПИКМИЛЁВ <3
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
export const dayOfWeeksNames = [
|
||||
"понедельник",
|
||||
"вторник",
|
||||
"cреда",
|
||||
"четверг",
|
||||
"пятница",
|
||||
"суббота",
|
||||
"воскресенье",
|
||||
];
|
||||
export const monthNames = [
|
||||
"января",
|
||||
"февраля",
|
||||
"марта",
|
||||
"апреля",
|
||||
"мая",
|
||||
"июня",
|
||||
"июля",
|
||||
"августа",
|
||||
"сентября",
|
||||
"октября",
|
||||
"ноября",
|
||||
"декабря",
|
||||
];
|
||||
|
||||
export const dateTagsList = {
|
||||
today: "сегодня",
|
||||
tomorrow: "завтра",
|
||||
yesterday: "вчера",
|
||||
afterTomorrow: "послезавтра",
|
||||
};
|
||||
|
||||
export function getDateTags(date) {
|
||||
const x = new Date();
|
||||
x.setHours(0, 0, 0, 0);
|
||||
const t = new Date(date);
|
||||
t.setHours(0, 0, 0, 0);
|
||||
const diff = (t - x) / 86400000; // days difference
|
||||
return diff === 0
|
||||
? dateTagsList["today"]
|
||||
: diff === -1
|
||||
? dateTagsList["yesterday"]
|
||||
: diff === 1
|
||||
? dateTagsList["tomorrow"]
|
||||
: diff === 2
|
||||
? dateTagsList["afterTomorrow"]
|
||||
: "";
|
||||
}
|
||||
|
||||
export function usToRussianDay(usDay) {
|
||||
return (usDay + 6) % 7;
|
||||
}
|
||||
|
||||
export function formatDate(date) {
|
||||
return `${
|
||||
dayOfWeeksNames[usToRussianDay(date.getDay())]
|
||||
}, ${date.getDate()} ${monthNames[date.getMonth()]}`;
|
||||
}
|
||||
export function apiFormatDate(date) {
|
||||
return (
|
||||
("0" + date.getDate()).slice(-2) +
|
||||
"-" +
|
||||
("0" + (date.getMonth() + 1)).slice(-2) +
|
||||
"-" +
|
||||
String(date.getFullYear()).slice(-2)
|
||||
);
|
||||
}
|
||||
export const parseApiDate = (s) => {
|
||||
const [d, m, y] = s.split("-").map(Number);
|
||||
const yy = 2000 + y;
|
||||
return new Date(yy, m - 1, d);
|
||||
};
|
||||
|
||||
export const apiToEditorVocabluary = {
|
||||
// api: editor
|
||||
num: "position",
|
||||
cabinet: "room",
|
||||
subject: "subject",
|
||||
"t-begin": "time1",
|
||||
"t-end": "time2",
|
||||
};
|
||||
|
||||
export function convertApiToEditor(arr) {
|
||||
let uuid = Math.floor(Math.random() * 10000);
|
||||
return arr.map((item) => {
|
||||
const converted = {};
|
||||
for (const [apiKey, editorKey] of Object.entries(apiToEditorVocabluary)) {
|
||||
if (Object.prototype.hasOwnProperty.call(item, apiKey)) {
|
||||
converted[editorKey] = item[apiKey];
|
||||
}
|
||||
}
|
||||
converted.id = uuid;
|
||||
uuid += Math.floor(Math.random() * 10000);
|
||||
return converted;
|
||||
});
|
||||
}
|
||||
|
||||
export function convertEditorToApi(arr) {
|
||||
const editorToApi = Object.fromEntries(
|
||||
Object.entries(apiToEditorVocabluary).map(([apiKey, editorKey]) => [
|
||||
editorKey,
|
||||
apiKey,
|
||||
])
|
||||
);
|
||||
|
||||
return arr.reduce((acc, item) => {
|
||||
const converted = {};
|
||||
for (const [editorKey, apiKey] of Object.entries(editorToApi)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(item, editorKey)) continue;
|
||||
|
||||
const value = item[editorKey];
|
||||
if (
|
||||
(editorKey === "time1" || editorKey === "time2") &&
|
||||
(value === "" || value == null)
|
||||
) {
|
||||
// фиг вам
|
||||
continue;
|
||||
}
|
||||
if (editorKey === "subject" && value === "") {
|
||||
// ваще наху
|
||||
return acc;
|
||||
}
|
||||
|
||||
converted[apiKey] = value;
|
||||
}
|
||||
acc.push(converted);
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
|
||||
export function isTimeBeforeNow(timeStr) {
|
||||
const [h, m] = timeStr.split(':').map(Number);
|
||||
if (!Number.isFinite(h) || !Number.isFinite(m)) return false;
|
||||
const now = new Date();
|
||||
const t = new Date(now.getFullYear(), now.getMonth(), now.getDate(), h, m, 0, 0);
|
||||
return t.getTime() < now.getTime();
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
export class DengiCaching {
|
||||
vals = [
|
||||
/*
|
||||
{
|
||||
name:"unique-id-name",
|
||||
value:"somevalue",
|
||||
expires=Date
|
||||
}
|
||||
*/
|
||||
];
|
||||
constructor() {}
|
||||
tick() {
|
||||
this.vals.map((element, index) => {
|
||||
if (element.expires < new Date()) {
|
||||
if (index > -1) {
|
||||
this.vals = this.vals.splice(index, 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
withCache(name, ifNotFound) {
|
||||
this.tick();
|
||||
let a = this.get(name);
|
||||
if (a === undefined) {
|
||||
let c = ifNotFound();
|
||||
this.cache(name, c);
|
||||
return c;
|
||||
} else {
|
||||
return a;
|
||||
}
|
||||
}
|
||||
async withCacheAsync(name, ifNotFound) {
|
||||
this.tick();
|
||||
let a = this.get(name);
|
||||
if (a === undefined) {
|
||||
let c = await ifNotFound();
|
||||
this.cache(name, c);
|
||||
return c;
|
||||
} else {
|
||||
return a;
|
||||
}
|
||||
}
|
||||
delete(...names) {
|
||||
names.forEach((name) => {
|
||||
var index = this.vals.findIndex((a) => a.name == name);
|
||||
if (index > -1) {
|
||||
this.vals = this.vals.splice(index, 1);
|
||||
}
|
||||
this.tick();
|
||||
});
|
||||
}
|
||||
fullClear() {
|
||||
this.vals = [];
|
||||
}
|
||||
wasCached(name) {
|
||||
this.tick();
|
||||
return this.vals.find((a) => a.name == name) !== undefined;
|
||||
}
|
||||
get(name) {
|
||||
this.tick();
|
||||
const item = this.vals.find((a) => a.name === name);
|
||||
return item ? item.value : undefined;
|
||||
}
|
||||
cache(name, val) {
|
||||
this.tick();
|
||||
this.vals.push({
|
||||
name: name,
|
||||
value: val,
|
||||
expires: new Date(Date.now() + 60 * 60 * 1000),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,654 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
PointerSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { BiCopy, BiDotsVertical, BiX } from "react-icons/bi";
|
||||
import {
|
||||
DengiButton,
|
||||
DengiYesOrNoDialog,
|
||||
LoadingSpinner,
|
||||
} from "./trash-components";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import {
|
||||
BiCheck,
|
||||
BiPlus,
|
||||
BiRedo,
|
||||
BiTime,
|
||||
BiTrash,
|
||||
BiUndo,
|
||||
} from "react-icons/bi";
|
||||
// import { ifstyle } from "../App";
|
||||
import {
|
||||
apiFormatDate,
|
||||
convertApiToEditor,
|
||||
convertEditorToApi,
|
||||
formatDate,
|
||||
} from "./dengi";
|
||||
import {
|
||||
EditorPrefillRequest,
|
||||
EditorPublishRequest,
|
||||
EditorRemoveRequest,
|
||||
} from "./editor_req_mgr";
|
||||
import { API_BASE_URL } from "../App";
|
||||
|
||||
// Configuration
|
||||
const COLUMNS = [
|
||||
[
|
||||
{
|
||||
key: "position",
|
||||
label: "номер",
|
||||
width: "w-9",
|
||||
placeholder: "№",
|
||||
useinput: true,
|
||||
},
|
||||
{
|
||||
key: "subject",
|
||||
label: "предмет",
|
||||
width: "w-40",
|
||||
placeholder: "предмет",
|
||||
useinput: false,
|
||||
},
|
||||
{
|
||||
key: "room",
|
||||
label: "кабинет",
|
||||
width: "w-14",
|
||||
placeholder: "123",
|
||||
useinput: false,
|
||||
},
|
||||
{
|
||||
key: "time1",
|
||||
label: "начало",
|
||||
width: "w-16",
|
||||
placeholder: "00:00",
|
||||
useinput: true,
|
||||
},
|
||||
{
|
||||
key: "time2",
|
||||
label: "конец",
|
||||
width: "w-16",
|
||||
placeholder: "00:00",
|
||||
useinput: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
key: "position",
|
||||
label: "номер",
|
||||
width: "w-9",
|
||||
placeholder: "№",
|
||||
useinput: true,
|
||||
},
|
||||
{
|
||||
key: "subject",
|
||||
label: "предмет",
|
||||
width: "w-40",
|
||||
placeholder: "предмет",
|
||||
useinput: false,
|
||||
},
|
||||
{
|
||||
key: "room",
|
||||
label: "кабинет",
|
||||
width: "w-14",
|
||||
placeholder: "123",
|
||||
useinput: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
key: "position",
|
||||
label: "номер",
|
||||
width: "w-9",
|
||||
placeholder: "№",
|
||||
useinput: true,
|
||||
},
|
||||
{
|
||||
key: "time1",
|
||||
label: "начало",
|
||||
width: "w-16",
|
||||
placeholder: "00:00",
|
||||
useinput: true,
|
||||
},
|
||||
{
|
||||
key: "time2",
|
||||
label: "конец",
|
||||
width: "w-16",
|
||||
placeholder: "00:00",
|
||||
useinput: true,
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
const INITIAL_DATA = [
|
||||
// {
|
||||
// id: "1",
|
||||
// position: "1",
|
||||
// subject: "Mathematics",
|
||||
// time: "09:00",
|
||||
// room: "A-1",
|
||||
// },
|
||||
// { id: "2", position: "2", subject: "Physics", time: "10:00", room: "B-2" },
|
||||
// { id: "3", position: "3", subject: "Chemistry", time: "11:00", room: "A-3" },
|
||||
// { id: "4", position: "4", subject: "Biology", time: "13:00", room: "C-1" },
|
||||
];
|
||||
|
||||
function TableInput(props) {
|
||||
return props.useinput ? <input {...props} /> : <textarea {...props} />;
|
||||
}
|
||||
|
||||
function SortableRow({
|
||||
item,
|
||||
columns,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
setFocusEdit,
|
||||
setHoverEdit,
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: item.id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className="bg-zinc-900 bg-grain rounded-lg mb-2"
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex">
|
||||
{columns.map((col) => (
|
||||
<TableInput
|
||||
key={col.key}
|
||||
useinput={col.useinput}
|
||||
type="text"
|
||||
value={item[col.key] || ""}
|
||||
wrap="hard"
|
||||
rows={1}
|
||||
onChange={(e) => onUpdate(item.id, col.key, e.target.value)}
|
||||
className={`px-1 py-3 text-center bg-transparent text-white border-r border-zinc-700 last:border-r-0 last:rounded-tr-lg focus:outline-none focus:bg-gray-700/50 placeholder-gray-500 transition-colors hover:bg-gray-700/20 first:rounded-tl-lg max-h-min resize-none block min-w-0 grow ${col.width}`}
|
||||
placeholder={col.placeholder}
|
||||
onFocus={() => setFocusEdit(col.key)}
|
||||
onBlur={() => setFocusEdit(null)}
|
||||
onMouseOver={() => setHoverEdit(col.key)}
|
||||
onMouseLeave={() => setHoverEdit(null)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="w-[98%] mx-auto h-[1px] bg-zinc-500" />
|
||||
<div className="flex justify-end w-full">
|
||||
<button
|
||||
onClick={() => onDuplicate(item.id)}
|
||||
className="min-w-12 min-h-12 transition-colors bg-zinc-600/20 text-zinc-400 hover:bg-zinc-600/40 hover:text-zinc-300 flex items-center justify-center rounded-l-md"
|
||||
>
|
||||
<BiCopy size={20} />
|
||||
</button>
|
||||
<div
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="min-w-12 min-h-12 transition-colors hover:bg-zinc-200 flex items-center justify-center cursor-move touch-none select-none hover:text-black font-black text-white bg-zinc-700/60"
|
||||
>
|
||||
<BiDotsVertical size={24} />
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onDelete(item.id)}
|
||||
className="min-w-12 min-h-12 transition-colors bg-red-600/20 text-red-400 hover:bg-red-600/40 hover:text-red-300 active:bg-red-600/60 flex items-center justify-center rounded-r-md"
|
||||
>
|
||||
<BiX size={24} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
async function getPrefilledRasp(tab, date, adminId) {
|
||||
let a = await EditorPrefillRequest(
|
||||
API_BASE_URL,
|
||||
tab,
|
||||
adminId,
|
||||
apiFormatDate(date)
|
||||
);
|
||||
if (a[0] == 1 && a[1]) {
|
||||
return convertApiToEditor(a[1]);
|
||||
}
|
||||
}
|
||||
|
||||
export function TableEditor({
|
||||
initialData = INITIAL_DATA,
|
||||
onChange = () => {},
|
||||
selectedTab,
|
||||
columns = COLUMNS[selectedTab],
|
||||
setChangesWereMade,
|
||||
adminId,
|
||||
date,
|
||||
dengiCash,
|
||||
}) {
|
||||
const [items, setItems] = useState(initialData);
|
||||
const [undoItems, setUndoItems] = useState([]);
|
||||
const [redoItems, setRedoItems] = useState([]);
|
||||
const [isLoading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let rejected = false;
|
||||
//console.log("cleanup");
|
||||
// cleanup on change
|
||||
setUndoItems([]);
|
||||
setRedoItems([]);
|
||||
setLoading(true);
|
||||
getPrefilledRasp(selectedTab, date, adminId).then((i) => {
|
||||
if (rejected) return;
|
||||
console.log(i || []);
|
||||
setItems(i || []);
|
||||
setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
rejected = true;
|
||||
};
|
||||
}, [
|
||||
selectedTab,
|
||||
formatDate(date) /* NOTE: we need to format to rely only on DD-MM-YY,
|
||||
and forget about time at all*/,
|
||||
]);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
}),
|
||||
useSensor(TouchSensor, {
|
||||
activationConstraint: {
|
||||
delay: 200,
|
||||
tolerance: 8,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const updateItems = (newItems) => {
|
||||
setItems(newItems);
|
||||
onChange?.(newItems);
|
||||
};
|
||||
|
||||
useEffect(() => setChangesWereMade(undoItems.length > 0), [undoItems]);
|
||||
const handleDragEnd = (event) => {
|
||||
const { active, over } = event;
|
||||
if (over && active.id !== over.id) {
|
||||
const oldIndex = items.findIndex((item) => item.id === active.id);
|
||||
const newIndex = items.findIndex((item) => item.id === over.id);
|
||||
const newItems = arrayMove(items, oldIndex, newIndex);
|
||||
|
||||
// Swap positions: assign old positions to new order
|
||||
const reorderedItems = newItems.map((item, idx) => ({
|
||||
...item,
|
||||
position: items[idx].position, // Use position from original array at this index
|
||||
}));
|
||||
|
||||
setUndoItems([items, ...undoItems]);
|
||||
updateItems(reorderedItems);
|
||||
}
|
||||
};
|
||||
const updateTimeoutRef = useRef({});
|
||||
const previousValueRef = useRef({});
|
||||
|
||||
const handleUpdate = (id, field, value) => {
|
||||
const key = `${id}-${field}`;
|
||||
if (!updateTimeoutRef.current[key]) {
|
||||
const currentItem = items.find((item) => item.id === id);
|
||||
previousValueRef.current[key] = currentItem[field];
|
||||
}
|
||||
if (updateTimeoutRef.current[key]) {
|
||||
clearTimeout(updateTimeoutRef.current[key]);
|
||||
}
|
||||
const newItems = items.map((item) =>
|
||||
item.id === id ? { ...item, [field]: value } : item
|
||||
);
|
||||
updateItems(newItems);
|
||||
updateTimeoutRef.current[key] = setTimeout(() => {
|
||||
// Create items array with the ORIGINAL value from before editing started
|
||||
const itemsWithOriginalValue = items.map((item) =>
|
||||
item.id === id
|
||||
? { ...item, [field]: previousValueRef.current[key] }
|
||||
: item
|
||||
);
|
||||
setUndoItems([itemsWithOriginalValue, ...undoItems]);
|
||||
|
||||
// Clean up
|
||||
delete updateTimeoutRef.current[key];
|
||||
delete previousValueRef.current[key];
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const handleUndo = () => {
|
||||
if (isLoading) return;
|
||||
setRedoItems([items, ...redoItems]);
|
||||
updateItems(undoItems[0]);
|
||||
setUndoItems(undoItems.slice(1));
|
||||
};
|
||||
const handleRedo = () => {
|
||||
if (isLoading) return;
|
||||
setUndoItems([items, ...undoItems]);
|
||||
updateItems(redoItems[0]);
|
||||
setRedoItems(redoItems.slice(1));
|
||||
};
|
||||
|
||||
const handleDelete = (id) => {
|
||||
if (isLoading) return;
|
||||
const newItems = items.filter((item) => item.id !== id);
|
||||
setUndoItems([items, ...undoItems]);
|
||||
updateItems(newItems);
|
||||
};
|
||||
|
||||
const handleDuplicate = (id) => {
|
||||
const index = items.findIndex((item) => item.id === id);
|
||||
if (index === -1) return;
|
||||
|
||||
const itemToDuplicate = items[index];
|
||||
|
||||
// Create new item with unique id
|
||||
const newItem = {
|
||||
...itemToDuplicate,
|
||||
id: Date.now(), // or use uuid() or any unique id generator
|
||||
};
|
||||
|
||||
// Insert the new item right after the duplicated item
|
||||
const newItems = [
|
||||
...items.slice(0, index + 1),
|
||||
newItem,
|
||||
...items.slice(index + 1),
|
||||
];
|
||||
|
||||
// Reassign positions based on the original order
|
||||
// The duplicate takes the next position, and everything shifts
|
||||
const reorderedItems = newItems.map((item, idx) => ({
|
||||
...item,
|
||||
position: String(idx + 1), // or just (idx + 1) if position is a number
|
||||
}));
|
||||
|
||||
setUndoItems([items, ...undoItems]);
|
||||
updateItems(reorderedItems);
|
||||
};
|
||||
|
||||
const handleAddRow = () => {
|
||||
if (isLoading) return;
|
||||
const newId = String(Date.now());
|
||||
const sortedItems = [...items];
|
||||
sortedItems.sort((a, b) => a.position - b.position);
|
||||
const lastPosition =
|
||||
items.length > 0
|
||||
? String(
|
||||
parseInt(sortedItems[sortedItems.length - 1].position || "0") + 1
|
||||
)
|
||||
: "1";
|
||||
|
||||
const newItem = { id: newId, position: lastPosition };
|
||||
columns.forEach((col) => {
|
||||
if (col.key !== "position") {
|
||||
newItem[col.key] = "";
|
||||
}
|
||||
});
|
||||
setUndoItems([items, ...undoItems]);
|
||||
updateItems([...items, newItem]);
|
||||
setTimeout(
|
||||
() =>
|
||||
window.scrollTo({
|
||||
top: document.documentElement.scrollHeight - window.innerHeight,
|
||||
left: window.scrollX, // preserve current X
|
||||
behavior: "smooth",
|
||||
}),
|
||||
100
|
||||
);
|
||||
};
|
||||
|
||||
const [focusEdit, setFocusEdit] = useState(null);
|
||||
const [hoverEdit, setHoverEdit] = useState(null);
|
||||
|
||||
const [deleteDiag, setDeleteDiag] = useState(false);
|
||||
|
||||
const handleClear = () => {
|
||||
if (isLoading) return;
|
||||
setUndoItems([items, ...undoItems]);
|
||||
updateItems([]);
|
||||
};
|
||||
|
||||
const handleChangesDefault = () => {
|
||||
if (selectedTab != 0 || isLoading) return;
|
||||
setLoading(true);
|
||||
getPrefilledRasp(1, date, adminId).then((i) => {
|
||||
setUndoItems([items, ...undoItems]);
|
||||
setItems(i);
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
const handlePublish = () => {
|
||||
if (isLoading) return;
|
||||
setUndoItems([]);
|
||||
setRedoItems([]);
|
||||
console.log(convertEditorToApi(items));
|
||||
dengiCash.fullClear(); // keep it clean
|
||||
setLoading(true);
|
||||
EditorPublishRequest(
|
||||
API_BASE_URL,
|
||||
selectedTab,
|
||||
adminId,
|
||||
apiFormatDate(date),
|
||||
JSON.stringify(convertEditorToApi(items))
|
||||
).then(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
const handleDeleteAll = () => {
|
||||
// if (isLoading) return; // WARN!
|
||||
setDeleteDiag(true);
|
||||
};
|
||||
|
||||
let deleteDialogBusy = false; // NOTE: we don't need a state
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<DengiYesOrNoDialog
|
||||
content={{
|
||||
message: "ВСЁ БУДЕТ УДАЛЕНО! продолжить?",
|
||||
yesBtn: "да",
|
||||
noBtn: "нет",
|
||||
yesIcon: <BiTrash />,
|
||||
noIcon: <BiX size={20} />,
|
||||
bgDissmis: true,
|
||||
yesBtnStyle: "bg-red-500! text-white! md:hover:bg-red-600!",
|
||||
}}
|
||||
onAnswer={(answ) => {
|
||||
if (deleteDialogBusy) return;
|
||||
if (!answ) setDeleteDiag(false);
|
||||
else {
|
||||
dengiCash.fullClear();
|
||||
// scary
|
||||
deleteDialogBusy = true;
|
||||
EditorRemoveRequest(
|
||||
API_BASE_URL,
|
||||
selectedTab,
|
||||
adminId,
|
||||
apiFormatDate(date)
|
||||
).then(() => {
|
||||
deleteDialogBusy = false; // for 100% sure
|
||||
setDeleteDiag(false);
|
||||
location.hash = "/app";
|
||||
});
|
||||
}
|
||||
}}
|
||||
show={deleteDiag}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full overflow-hidden my-1 flex flex-col gap-3">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<DengiButton onClick={handleUndo} disabled={undoItems.length == 0}>
|
||||
<BiUndo />
|
||||
</DengiButton>
|
||||
<DengiButton onClick={handleRedo} disabled={redoItems.length == 0}>
|
||||
<BiRedo />
|
||||
</DengiButton>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{selectedTab == 0 && (
|
||||
<motion.div exit={{ opacity: 0 }}>
|
||||
<DengiButton
|
||||
className="border-red-600! md:not-disabled:not-hover:border-red-400!"
|
||||
onClick={handleChangesDefault}
|
||||
>
|
||||
<BiTime />
|
||||
убрать зам.
|
||||
</DengiButton>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<motion.div layout="position" className="bg-black">
|
||||
<DengiButton
|
||||
onClick={handleClear}
|
||||
className="border-red-600! md:not-disabled:not-hover:border-red-400!"
|
||||
>
|
||||
<BiTrash />
|
||||
очистить
|
||||
</DengiButton>
|
||||
</motion.div>
|
||||
</div>
|
||||
{!isLoading && (
|
||||
<div>
|
||||
<div className="flex w-full justify-around items-center mb-2">
|
||||
{columns.map((col, i) => (
|
||||
<>
|
||||
<div
|
||||
key={col.key + i}
|
||||
className={
|
||||
`max-${col.width} transition-colors text-` +
|
||||
((focusEdit == null && hoverEdit == null) ||
|
||||
focusEdit == col.key ||
|
||||
hoverEdit == col.key
|
||||
? "white"
|
||||
: "zinc-600")
|
||||
}
|
||||
// onMouseOver={() => setHoverEdit(col.key)}
|
||||
// onMouseLeave={() => setHoverEdit(null)}
|
||||
>
|
||||
{col.label}
|
||||
</div>
|
||||
|
||||
<span
|
||||
key={col.key + i + "divider"}
|
||||
className="w-4 h-[1px] bg-zinc-600 -rotate-45 last:hidden"
|
||||
/>
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={items.map((item) => item.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div>
|
||||
<AnimatePresence mode={items.length > 0 ? "sync" : "wait"}>
|
||||
{items.map((item) => (
|
||||
<motion.div
|
||||
initial={{ x: -15, filter: "blur(5px)", opacity: 0 }}
|
||||
animate={{ x: 0, filter: "blur(0px)", opacity: 1 }}
|
||||
exit={{ x: 15, filter: "blur(5px)", opacity: 0 }}
|
||||
key={item.id}
|
||||
>
|
||||
<SortableRow
|
||||
item={item}
|
||||
columns={columns}
|
||||
onUpdate={handleUpdate}
|
||||
onDelete={handleDelete}
|
||||
onDuplicate={handleDuplicate}
|
||||
setFocusEdit={setFocusEdit}
|
||||
setHoverEdit={setHoverEdit}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<motion.div
|
||||
initial={{
|
||||
filter: "blur(5px)",
|
||||
opacity: 0,
|
||||
scale: 0.8,
|
||||
}}
|
||||
animate={{
|
||||
filter: "blur(0px)",
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
}}
|
||||
className="border-2 border-dashed border-gray-700 rounded-lg p-12 text-center text-gray-500"
|
||||
>
|
||||
пусто
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
{/* <div className="mt-6 flex gap-3">
|
||||
<button
|
||||
onClick={handleAddRow}
|
||||
className="flex-1 bg-blue-600 text-white px-6 py-3 rounded-lg font-semibold hover:bg-blue-700 active:bg-blue-800"
|
||||
>
|
||||
+ Add Row
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="px-6 py-3 rounded-lg border-2 border-gray-700 text-gray-300 font-semibold hover:bg-gray-800 active:bg-gray-700"
|
||||
>
|
||||
Clear All
|
||||
</button>
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isLoading && (
|
||||
<div className="w-full flex items-center justify-center py-3">
|
||||
<LoadingSpinner size={35} />
|
||||
</div>
|
||||
)}
|
||||
<motion.div layout="position" className="flex w-full justify-end gap-2">
|
||||
<DengiButton
|
||||
onClick={handleDeleteAll}
|
||||
className="border-red-600! md:not-disabled:not-hover:border-red-400!"
|
||||
>
|
||||
<BiTrash />
|
||||
удалить всё
|
||||
</DengiButton>
|
||||
<DengiButton onClick={handleAddRow}>
|
||||
<BiPlus />
|
||||
</DengiButton>
|
||||
<DengiButton onClick={handlePublish}>
|
||||
<BiCheck />
|
||||
сохранить
|
||||
</DengiButton>
|
||||
</motion.div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
export async function EditorPrefillRequest(host, tab, adminid, date) {
|
||||
try {
|
||||
const req = await fetch(host + "?editor_data", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
adminId: adminid,
|
||||
tab: tab,
|
||||
day: date,
|
||||
}).toString(),
|
||||
});
|
||||
|
||||
if (
|
||||
req.ok &&
|
||||
req.headers.get("Content-Type")?.includes("application/json")
|
||||
) {
|
||||
return [1, await req.json()];
|
||||
} else {
|
||||
return [req.status, await req.text()];
|
||||
}
|
||||
} catch (err) {
|
||||
return [-1, err];
|
||||
}
|
||||
}
|
||||
export async function EditorPublishRequest(host, tab, adminid, date, newData) {
|
||||
try {
|
||||
const req = await fetch(host + "?editor_publish", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
adminId: adminid,
|
||||
tab: tab,
|
||||
day: date,
|
||||
newData: newData,
|
||||
}).toString(),
|
||||
});
|
||||
|
||||
if (
|
||||
req.ok &&
|
||||
req.headers.get("Content-Type")?.includes("application/json")
|
||||
) {
|
||||
return [1, await req.json()];
|
||||
} else {
|
||||
return [req.status, await req.text()];
|
||||
}
|
||||
} catch (err) {
|
||||
return [-1, err];
|
||||
}
|
||||
}
|
||||
|
||||
export async function EditorRemoveRequest(host, tab, adminid, date) {
|
||||
try {
|
||||
const req = await fetch(host + "?editor_fullitems_del", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
adminId: adminid,
|
||||
tab: tab,
|
||||
day: date,
|
||||
}).toString(),
|
||||
});
|
||||
|
||||
if (
|
||||
req.ok &&
|
||||
req.headers.get("Content-Type")?.includes("application/json")
|
||||
) {
|
||||
return [1, await req.json()];
|
||||
} else {
|
||||
return [req.status, await req.text()];
|
||||
}
|
||||
} catch (err) {
|
||||
return [-1, err];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
export function ifstyle(condition, then) {
|
||||
return condition ? " " + then : "";
|
||||
}
|
||||
export function b64e(str) {
|
||||
return btoa(
|
||||
encodeURIComponent(str).replace(
|
||||
/%([0-9A-F]{2})/g,
|
||||
function toSolidBytes(match, p1) {
|
||||
return String.fromCharCode("0x" + p1);
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function b64d(str) {
|
||||
return decodeURIComponent(
|
||||
atob(str)
|
||||
.split("")
|
||||
.map(function (c) {
|
||||
return "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2);
|
||||
})
|
||||
.join("")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/** by claude
|
||||
* Universal copy-to-clipboard function
|
||||
* Works with modern Clipboard API and falls back to legacy methods
|
||||
* @param {string} text - The text to copy to clipboard
|
||||
* @returns {Promise<boolean>} - Returns true if successful, false otherwise
|
||||
*/
|
||||
export async function copyToClipboard(text) {
|
||||
// Method 1: Modern Clipboard API (preferred)
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn('Clipboard API failed, trying fallback:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Method 2: Fallback for older browsers or non-secure contexts
|
||||
try {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
|
||||
// Make it invisible and non-intrusive
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.left = '-999999px';
|
||||
textarea.style.top = '-999999px';
|
||||
textarea.style.opacity = '0';
|
||||
textarea.setAttribute('readonly', '');
|
||||
|
||||
document.body.appendChild(textarea);
|
||||
|
||||
// Select and copy
|
||||
textarea.select();
|
||||
textarea.setSelectionRange(0, text.length);
|
||||
|
||||
const success = document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
|
||||
return success;
|
||||
} catch (err) {
|
||||
console.error('All copy methods failed:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* MD5 hash implementation
|
||||
* Based on: http://www.myersdaily.org/joseph/javascript/md5-text.html
|
||||
*/
|
||||
|
||||
const HEX_CHARS = '0123456789abcdef';
|
||||
|
||||
// Pre-compute add32 function based on environment
|
||||
const add32 = (() => {
|
||||
const test = (a, b) => (a + b) & 0xFFFFFFFF;
|
||||
// Test if simple addition works correctly
|
||||
if (test(0x80000000, 0x80000000) === 0) {
|
||||
return test;
|
||||
}
|
||||
// Fallback for environments with non-standard 32-bit arithmetic
|
||||
return (x, y) => {
|
||||
const lsw = (x & 0xFFFF) + (y & 0xFFFF);
|
||||
const msw = (x >> 16) + (y >> 16) + (lsw >> 16);
|
||||
return (msw << 16) | (lsw & 0xFFFF);
|
||||
};
|
||||
})();
|
||||
|
||||
const cmn = (q, a, b, x, s, t) => {
|
||||
a = add32(add32(a, q), add32(x, t));
|
||||
return add32((a << s) | (a >>> (32 - s)), b);
|
||||
};
|
||||
|
||||
const ff = (a, b, c, d, x, s, t) => cmn((b & c) | ((~b) & d), a, b, x, s, t);
|
||||
const gg = (a, b, c, d, x, s, t) => cmn((b & d) | (c & (~d)), a, b, x, s, t);
|
||||
const hh = (a, b, c, d, x, s, t) => cmn(b ^ c ^ d, a, b, x, s, t);
|
||||
const ii = (a, b, c, d, x, s, t) => cmn(c ^ (b | (~d)), a, b, x, s, t);
|
||||
|
||||
const md5blk = (s) => {
|
||||
const blks = [];
|
||||
for (let i = 0; i < 64; i += 4) {
|
||||
blks[i >> 2] = s.charCodeAt(i) +
|
||||
(s.charCodeAt(i + 1) << 8) +
|
||||
(s.charCodeAt(i + 2) << 16) +
|
||||
(s.charCodeAt(i + 3) << 24);
|
||||
}
|
||||
return blks;
|
||||
};
|
||||
|
||||
const md5cycle = (x, k) => {
|
||||
let a = x[0], b = x[1], c = x[2], d = x[3];
|
||||
|
||||
a = ff(a, b, c, d, k[0], 7, -680876936);
|
||||
d = ff(d, a, b, c, k[1], 12, -389564586);
|
||||
c = ff(c, d, a, b, k[2], 17, 606105819);
|
||||
b = ff(b, c, d, a, k[3], 22, -1044525330);
|
||||
a = ff(a, b, c, d, k[4], 7, -176418897);
|
||||
d = ff(d, a, b, c, k[5], 12, 1200080426);
|
||||
c = ff(c, d, a, b, k[6], 17, -1473231341);
|
||||
b = ff(b, c, d, a, k[7], 22, -45705983);
|
||||
a = ff(a, b, c, d, k[8], 7, 1770035416);
|
||||
d = ff(d, a, b, c, k[9], 12, -1958414417);
|
||||
c = ff(c, d, a, b, k[10], 17, -42063);
|
||||
b = ff(b, c, d, a, k[11], 22, -1990404162);
|
||||
a = ff(a, b, c, d, k[12], 7, 1804603682);
|
||||
d = ff(d, a, b, c, k[13], 12, -40341101);
|
||||
c = ff(c, d, a, b, k[14], 17, -1502002290);
|
||||
b = ff(b, c, d, a, k[15], 22, 1236535329);
|
||||
|
||||
a = gg(a, b, c, d, k[1], 5, -165796510);
|
||||
d = gg(d, a, b, c, k[6], 9, -1069501632);
|
||||
c = gg(c, d, a, b, k[11], 14, 643717713);
|
||||
b = gg(b, c, d, a, k[0], 20, -373897302);
|
||||
a = gg(a, b, c, d, k[5], 5, -701558691);
|
||||
d = gg(d, a, b, c, k[10], 9, 38016083);
|
||||
c = gg(c, d, a, b, k[15], 14, -660478335);
|
||||
b = gg(b, c, d, a, k[4], 20, -405537848);
|
||||
a = gg(a, b, c, d, k[9], 5, 568446438);
|
||||
d = gg(d, a, b, c, k[14], 9, -1019803690);
|
||||
c = gg(c, d, a, b, k[3], 14, -187363961);
|
||||
b = gg(b, c, d, a, k[8], 20, 1163531501);
|
||||
a = gg(a, b, c, d, k[13], 5, -1444681467);
|
||||
d = gg(d, a, b, c, k[2], 9, -51403784);
|
||||
c = gg(c, d, a, b, k[7], 14, 1735328473);
|
||||
b = gg(b, c, d, a, k[12], 20, -1926607734);
|
||||
|
||||
a = hh(a, b, c, d, k[5], 4, -378558);
|
||||
d = hh(d, a, b, c, k[8], 11, -2022574463);
|
||||
c = hh(c, d, a, b, k[11], 16, 1839030562);
|
||||
b = hh(b, c, d, a, k[14], 23, -35309556);
|
||||
a = hh(a, b, c, d, k[1], 4, -1530992060);
|
||||
d = hh(d, a, b, c, k[4], 11, 1272893353);
|
||||
c = hh(c, d, a, b, k[7], 16, -155497632);
|
||||
b = hh(b, c, d, a, k[10], 23, -1094730640);
|
||||
a = hh(a, b, c, d, k[13], 4, 681279174);
|
||||
d = hh(d, a, b, c, k[0], 11, -358537222);
|
||||
c = hh(c, d, a, b, k[3], 16, -722521979);
|
||||
b = hh(b, c, d, a, k[6], 23, 76029189);
|
||||
a = hh(a, b, c, d, k[9], 4, -640364487);
|
||||
d = hh(d, a, b, c, k[12], 11, -421815835);
|
||||
c = hh(c, d, a, b, k[15], 16, 530742520);
|
||||
b = hh(b, c, d, a, k[2], 23, -995338651);
|
||||
|
||||
a = ii(a, b, c, d, k[0], 6, -198630844);
|
||||
d = ii(d, a, b, c, k[7], 10, 1126891415);
|
||||
c = ii(c, d, a, b, k[14], 15, -1416354905);
|
||||
b = ii(b, c, d, a, k[5], 21, -57434055);
|
||||
a = ii(a, b, c, d, k[12], 6, 1700485571);
|
||||
d = ii(d, a, b, c, k[3], 10, -1894986606);
|
||||
c = ii(c, d, a, b, k[10], 15, -1051523);
|
||||
b = ii(b, c, d, a, k[1], 21, -2054922799);
|
||||
a = ii(a, b, c, d, k[8], 6, 1873313359);
|
||||
d = ii(d, a, b, c, k[15], 10, -30611744);
|
||||
c = ii(c, d, a, b, k[6], 15, -1560198380);
|
||||
b = ii(b, c, d, a, k[13], 21, 1309151649);
|
||||
a = ii(a, b, c, d, k[4], 6, -145523070);
|
||||
d = ii(d, a, b, c, k[11], 10, -1120210379);
|
||||
c = ii(c, d, a, b, k[2], 15, 718787259);
|
||||
b = ii(b, c, d, a, k[9], 21, -343485551);
|
||||
|
||||
x[0] = add32(a, x[0]);
|
||||
x[1] = add32(b, x[1]);
|
||||
x[2] = add32(c, x[2]);
|
||||
x[3] = add32(d, x[3]);
|
||||
};
|
||||
|
||||
const rhex = (n) => {
|
||||
let s = '';
|
||||
for (let j = 0; j < 4; j++) {
|
||||
s += HEX_CHARS[(n >> (j * 8 + 4)) & 0x0F] + HEX_CHARS[(n >> (j * 8)) & 0x0F];
|
||||
}
|
||||
return s;
|
||||
};
|
||||
|
||||
const hex = (x) => x.map(rhex).join('');
|
||||
|
||||
const md51 = (s) => {
|
||||
const n = s.length;
|
||||
const state = [1732584193, -271733879, -1732584194, 271733878];
|
||||
|
||||
let i;
|
||||
for (i = 64; i <= n; i += 64) {
|
||||
md5cycle(state, md5blk(s.substring(i - 64, i)));
|
||||
}
|
||||
|
||||
s = s.substring(i - 64);
|
||||
const tail = new Array(16).fill(0);
|
||||
|
||||
for (i = 0; i < s.length; i++) {
|
||||
tail[i >> 2] |= s.charCodeAt(i) << ((i % 4) << 3);
|
||||
}
|
||||
|
||||
tail[i >> 2] |= 0x80 << ((i % 4) << 3);
|
||||
|
||||
if (i > 55) {
|
||||
md5cycle(state, tail);
|
||||
tail.fill(0);
|
||||
}
|
||||
|
||||
tail[14] = n * 8;
|
||||
md5cycle(state, tail);
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate MD5 hash of a string
|
||||
* @param {string} s - Input string
|
||||
* @returns {string} MD5 hash in hexadecimal format
|
||||
*/
|
||||
export function md5(s) {
|
||||
return hex(md51(s));
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { apiFormatDate } from "./dengi";
|
||||
|
||||
export async function LoginReq(host, username) {
|
||||
try {
|
||||
const req = await fetch(host + "?login", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded", // 💀 важный заголовок
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
classId: username,
|
||||
}).toString(),
|
||||
});
|
||||
|
||||
if (
|
||||
req.ok &&
|
||||
req.headers.get("Content-Type")?.includes("application/json")
|
||||
) {
|
||||
return [1, await req.json()];
|
||||
} else {
|
||||
return [req.status, await req.text()];
|
||||
}
|
||||
} catch (err) {
|
||||
return [-1, err];
|
||||
}
|
||||
}
|
||||
export async function RaspGet(host, classId, day) {
|
||||
try {
|
||||
const req = await fetch(host + "?req_client_schedule", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded", // 💀 важный заголовок
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
classId: classId,
|
||||
day: apiFormatDate(day),
|
||||
}).toString(),
|
||||
});
|
||||
|
||||
if (
|
||||
req.ok &&
|
||||
req.headers.get("Content-Type")?.includes("application/json")
|
||||
) {
|
||||
return [1, await req.json()];
|
||||
} else {
|
||||
return [req.status, await req.text()];
|
||||
}
|
||||
} catch (err) {
|
||||
return [-1, err];
|
||||
}
|
||||
}
|
||||
export async function AdminReq(host, adminid) {
|
||||
try {
|
||||
const req = await fetch(host + "?admin_login", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded", // 💀 dengi
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
adminId: adminid,
|
||||
}).toString(),
|
||||
});
|
||||
|
||||
if (
|
||||
req.ok &&
|
||||
req.headers.get("Content-Type")?.includes("application/json")
|
||||
) {
|
||||
return [1, await req.json()];
|
||||
} else {
|
||||
return [req.status, await req.text()];
|
||||
}
|
||||
} catch (err) {
|
||||
return [-1, err];
|
||||
}
|
||||
}
|
||||
|
||||
export async function AdminUserFRequest(host, adminid) {
|
||||
try {
|
||||
const req = await fetch(host + "?get_admin_user_f", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
adminId: adminid,
|
||||
}).toString(),
|
||||
});
|
||||
|
||||
if (
|
||||
req.ok &&
|
||||
req.headers.get("Content-Type")?.includes("application/json")
|
||||
) {
|
||||
return [1, await req.json()];
|
||||
} else {
|
||||
return [req.status, await req.text()];
|
||||
}
|
||||
} catch (err) {
|
||||
return [-1, err];
|
||||
}
|
||||
}
|
||||
|
||||
export async function classUserFRequest(host, classid) {
|
||||
try {
|
||||
const req = await fetch(host + "?get_class_user_f", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
classId: classid,
|
||||
}).toString(),
|
||||
});
|
||||
|
||||
if (
|
||||
req.ok &&
|
||||
req.headers.get("Content-Type")?.includes("application/json")
|
||||
) {
|
||||
return [1, await req.json()];
|
||||
} else {
|
||||
return [req.status, await req.text()];
|
||||
}
|
||||
} catch (err) {
|
||||
return [-1, err];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { BiCheck, BiLeftArrowAlt, BiRightArrowAlt, BiX } from "react-icons/bi";
|
||||
import { formatDate, getDateTags } from "./dengi.js";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
|
||||
export function Switch({
|
||||
checked,
|
||||
defaultChecked = false,
|
||||
onChange,
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
className = "",
|
||||
size = "md", // "sm" | "md" | "lg"
|
||||
}) {
|
||||
const isControlled = checked !== undefined;
|
||||
const [internalChecked, setInternalChecked] = useState(defaultChecked);
|
||||
const current = isControlled ? checked : internalChecked;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isControlled) return;
|
||||
setInternalChecked(checked);
|
||||
}, [checked, isControlled]);
|
||||
|
||||
const sizes = {
|
||||
sm: { track: "w-9 h-5", thumb: "w-3 h-3", translate: "translate-x-4" },
|
||||
md: { track: "w-11 h-6", thumb: "w-4 h-4", translate: "translate-x-5" },
|
||||
lg: { track: "w-14 h-8", thumb: "w-6 h-6", translate: "translate-x-6" },
|
||||
};
|
||||
const s = sizes[size] || sizes.md;
|
||||
|
||||
const toggle = (next) => {
|
||||
if (disabled) return;
|
||||
const newState = typeof next === "boolean" ? next : !current;
|
||||
if (!isControlled) setInternalChecked(newState);
|
||||
onChange?.(newState);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={current}
|
||||
aria-label={ariaLabel}
|
||||
disabled={disabled}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
onClick={() => toggle()}
|
||||
className={[
|
||||
"inline-flex items-center p-0 focus:outline-none",
|
||||
// focus-visible: shows ring only when focused by keyboard (better UX)
|
||||
"focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-0.5 focus-visible:ring-offset-transparent",
|
||||
disabled ? "opacity-60" : "cursor-pointer",
|
||||
current ? "bg-zinc-500" : "bg-gray-300/10",
|
||||
"rounded-full transition-colors duration-150",
|
||||
s.track,
|
||||
className,
|
||||
].join(" ")}
|
||||
>
|
||||
<span
|
||||
className={[
|
||||
"relative block rounded-full bg-white shadow transform transition-transform ease-out duration-150",
|
||||
s.thumb,
|
||||
current ? s.translate : "",
|
||||
size === "sm" ? "m-1" : "m-1",
|
||||
].join(" ")}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoadingSpinner(props) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="animate-spin ease-out text-zinc-600 fill-white"
|
||||
viewBox="0 0 100 101"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={props.size}
|
||||
height={props.size}
|
||||
>
|
||||
<path
|
||||
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
|
||||
fill="currentFill"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function DatePickerButton({ next = false, date, setDate }) {
|
||||
return (
|
||||
<span
|
||||
className="cursor-pointer lg:hover:*:fill-black lg:hover:bg-white transition *:transition-colors rounded-full"
|
||||
onClick={() => {
|
||||
const d = new Date(date);
|
||||
d.setDate(d.getDate() + (next ? 1 : -1));
|
||||
setDate(d);
|
||||
}}
|
||||
>
|
||||
{!next ? <BiLeftArrowAlt size={34} /> : <BiRightArrowAlt size={34} />}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function DatePicker(props) {
|
||||
const dateTag = getDateTags(props.date);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-2 py-1 w-full bg-grain border-zinc-800/40 border rounded-2xl">
|
||||
<DatePickerButton date={props.date} setDate={props.setDate} />
|
||||
<AnimatePresence mode="popLayout">
|
||||
<motion.span
|
||||
key={formatDate(props.date)}
|
||||
className="cursor-pointer flex items-center gap-1 justify-center w-[80%] text-zinc-300 lg:hover:text-black *:transition-colors transition-colors p-1 rounded-3xl lg:hover:bg-white px-3 overflow-hidden lg:hover:*:text-black"
|
||||
variants={{
|
||||
a: { opacity: 0, filter: "blur(1px)" },
|
||||
b: { opacity: 1, filter: "blur(0px)" },
|
||||
}}
|
||||
initial="a"
|
||||
animate="b"
|
||||
exit="a"
|
||||
onClick={() => {
|
||||
props.setDate(new Date());
|
||||
}}
|
||||
>
|
||||
<motion.span
|
||||
className={
|
||||
"block min-w-2 min-h-2 rounded-full mx-1 transition-colors" +
|
||||
(props.indicator
|
||||
? " bg-zinc-300 animate-pulse"
|
||||
: " bg-transparent")
|
||||
}
|
||||
layoutId="datepicker_indicator"
|
||||
layout="position"
|
||||
/>
|
||||
<motion.span
|
||||
layoutId="datepicker_date"
|
||||
layout="position"
|
||||
className="font-semibold"
|
||||
>
|
||||
{formatDate(props.date)}
|
||||
</motion.span>
|
||||
{dateTag.length > 0 && (
|
||||
<>
|
||||
<motion.div
|
||||
className="flex"
|
||||
layoutId="datepicker_date_div"
|
||||
layout="position"
|
||||
>
|
||||
<span className="h-[1px] -rotate-50 bg-zinc-800 w-[20px] " />
|
||||
</motion.div>
|
||||
<motion.span
|
||||
initial={{ x: 10, opacity: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
className="text-zinc-600 italic"
|
||||
>
|
||||
{dateTag}
|
||||
</motion.span>
|
||||
</>
|
||||
)}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
<DatePickerButton next date={props.date} setDate={props.setDate} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DengiButton({
|
||||
onClick,
|
||||
children,
|
||||
disabled = false,
|
||||
className = "",
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
role="button"
|
||||
className={
|
||||
"flex bg-black items-center justify-center border p-1 gap-2 px-2 border-white " +
|
||||
(disabled
|
||||
? "cursor-not-allowed text-zinc-700 border-zinc-900 md:hover:border-red-950/50 "
|
||||
: "not-disabled:cursor-pointer md:not-hover:border-zinc-700 md:not-hover:text-zinc-400 text-white ") +
|
||||
"rounded-xl transition-colors h-10 min-w-10 not-md:p-2 " +
|
||||
(className ? className : "")
|
||||
}
|
||||
aria-disabled={disabled}
|
||||
onClick={disabled ? null : onClick}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// BAD IDEA::
|
||||
//
|
||||
// export function CoolMouseBg() {
|
||||
// const x = useSpring(0, { stiffness: 200, damping: 40 });
|
||||
// const y = useSpring(0, { stiffness: 200, damping: 40 });
|
||||
|
||||
// const [visible, setVisible] = useState(false);
|
||||
|
||||
// useEffect(() => {
|
||||
// const move = (e) => {
|
||||
// if (visible) {
|
||||
// x.set(e.clientX - 25);
|
||||
// y.set(e.clientY - 25);
|
||||
// } else {
|
||||
// setVisible(true);
|
||||
// }
|
||||
// };
|
||||
// window.addEventListener("mousemove", move);
|
||||
// return () => {
|
||||
// window.removeEventListener("mousemove", move);
|
||||
// };
|
||||
// }, [visible]);
|
||||
|
||||
// return (
|
||||
// <div className="not-md:hidden w-full h-full -z-2 overflow-hidden absolute top-0 left-0">
|
||||
// <motion.span
|
||||
// className="w-[50px] h-[50px] blur-3xl bg-white absolute rounded-full"
|
||||
// style={{
|
||||
// top: y,
|
||||
// left: x,
|
||||
// }}
|
||||
// initial={{ opacity: 0 }}
|
||||
// animate={{ opacity: visible ? 1 : 0 }}
|
||||
// transition={{ duration: 0.5, delay: 1 }}
|
||||
// />
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
||||
export function DengiYesOrNoDialog({
|
||||
content = {
|
||||
message: "?",
|
||||
yesBtn: "da",
|
||||
noBtn: "net",
|
||||
bgDissmis: true,
|
||||
yesIcon: <BiCheck />,
|
||||
noIcon: <BiX />,
|
||||
noBtnStyle: "",
|
||||
yesBtnStyle: "",
|
||||
},
|
||||
show,
|
||||
onAnswer,
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{/* Background */}
|
||||
<AnimatePresence>
|
||||
{show && (
|
||||
<motion.div
|
||||
variants={{ on: { opacity: 1 }, off: { opacity: 0 } }}
|
||||
initial="off"
|
||||
animate="on"
|
||||
exit="off"
|
||||
className="fixed z-99 bg-black/50 backdrop-blur-xl top-0 left-0 w-full h-full overflow-hidden flex items-center justify-center"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget && content.bgDissmis)
|
||||
onAnswer(false);
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
variants={{
|
||||
on: { filter: "blur(0px)", scale: 1, y: 0, opacity: 1 },
|
||||
off: { filter: "blur(2px) ", scale: 0.7, y: 10, opacity: 0 },
|
||||
}}
|
||||
initial="off"
|
||||
animate="on"
|
||||
exit="off"
|
||||
className="flex flex-col justify-center gap-4 p-4 border bg-black border-zinc-400 rounded-2xl"
|
||||
>
|
||||
<span>{content.message}</span>
|
||||
<div className="flex gap-2 justify-stretch">
|
||||
<DengiButton
|
||||
className={content.yesBtnStyle + " w-full"}
|
||||
onClick={() => onAnswer(true)}
|
||||
>
|
||||
{content.yesIcon}
|
||||
{content.yesBtn}
|
||||
</DengiButton>
|
||||
<DengiButton
|
||||
className={content.noBtnStyle + " w-full"}
|
||||
onClick={() => onAnswer(false)}
|
||||
>
|
||||
{content.noIcon}
|
||||
{content.noBtn}
|
||||
</DengiButton>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user