From 645a88e7e35d15cec6106709c42b071bec045e0d Mon Sep 17 00:00:00 2001 From: crupest Date: Wed, 2 Aug 2023 02:52:07 +0800 Subject: ... --- FrontEnd/src/migrating/admin/Admin.tsx | 27 ++ FrontEnd/src/migrating/admin/AdminNav.tsx | 29 ++ FrontEnd/src/migrating/admin/MoreAdmin.tsx | 7 + FrontEnd/src/migrating/admin/UserAdmin.tsx | 301 ++++++++++++++++ FrontEnd/src/migrating/admin/index.css | 33 ++ FrontEnd/src/migrating/admin/index.tsx | 7 + FrontEnd/src/migrating/center/CenterBoards.tsx | 131 +++++++ FrontEnd/src/migrating/center/TimelineBoard.tsx | 390 +++++++++++++++++++++ .../src/migrating/center/TimelineCreateDialog.tsx | 57 +++ FrontEnd/src/migrating/center/index.css | 43 +++ FrontEnd/src/migrating/center/index.tsx | 60 ++++ 11 files changed, 1085 insertions(+) create mode 100644 FrontEnd/src/migrating/admin/Admin.tsx create mode 100644 FrontEnd/src/migrating/admin/AdminNav.tsx create mode 100644 FrontEnd/src/migrating/admin/MoreAdmin.tsx create mode 100644 FrontEnd/src/migrating/admin/UserAdmin.tsx create mode 100644 FrontEnd/src/migrating/admin/index.css create mode 100644 FrontEnd/src/migrating/admin/index.tsx create mode 100644 FrontEnd/src/migrating/center/CenterBoards.tsx create mode 100644 FrontEnd/src/migrating/center/TimelineBoard.tsx create mode 100644 FrontEnd/src/migrating/center/TimelineCreateDialog.tsx create mode 100644 FrontEnd/src/migrating/center/index.css create mode 100644 FrontEnd/src/migrating/center/index.tsx (limited to 'FrontEnd/src/migrating') diff --git a/FrontEnd/src/migrating/admin/Admin.tsx b/FrontEnd/src/migrating/admin/Admin.tsx new file mode 100644 index 00000000..986c36b4 --- /dev/null +++ b/FrontEnd/src/migrating/admin/Admin.tsx @@ -0,0 +1,27 @@ +import { Route, Routes } from "react-router-dom"; +import { useTranslation } from "react-i18next"; + +import AdminNav from "./AdminNav"; +import UserAdmin from "./UserAdmin"; +import MoreAdmin from "./MoreAdmin"; + +import "./index.css"; + +const Admin: React.FC = () => { + useTranslation("admin"); + + return ( + <> +
+ + + } /> + } /> + } /> + +
+ + ); +}; + +export default Admin; diff --git a/FrontEnd/src/migrating/admin/AdminNav.tsx b/FrontEnd/src/migrating/admin/AdminNav.tsx new file mode 100644 index 00000000..b7385e5c --- /dev/null +++ b/FrontEnd/src/migrating/admin/AdminNav.tsx @@ -0,0 +1,29 @@ +import { useLocation } from "react-router-dom"; + +import Tabs from "../common/tab/Tabs"; + +export function AdminNav({ className }: { className?: string }) { + const location = useLocation(); + const name = location.pathname.split("/")[2] ?? "user"; + + return ( + + ); +} + +export default AdminNav; diff --git a/FrontEnd/src/migrating/admin/MoreAdmin.tsx b/FrontEnd/src/migrating/admin/MoreAdmin.tsx new file mode 100644 index 00000000..d49d211f --- /dev/null +++ b/FrontEnd/src/migrating/admin/MoreAdmin.tsx @@ -0,0 +1,7 @@ +import * as React from "react"; + +const MoreAdmin: React.FC = () => { + return <>More...; +}; + +export default MoreAdmin; diff --git a/FrontEnd/src/migrating/admin/UserAdmin.tsx b/FrontEnd/src/migrating/admin/UserAdmin.tsx new file mode 100644 index 00000000..08560c87 --- /dev/null +++ b/FrontEnd/src/migrating/admin/UserAdmin.tsx @@ -0,0 +1,301 @@ +// eslint-disable +// @ts-nocheck + +import { useState, useEffect } from "react"; +import * as React from "react"; +import { Trans, useTranslation } from "react-i18next"; +import classnames from "classnames"; + +import { getHttpUserClient, HttpUser, kUserPermissionList } from "@/http/user"; + +import OperationDialog from "../common/dialog/OperationDialog"; +import Button from "../common/button/Button"; +import Spinner from "../common/Spinner"; +import FlatButton from "../common/button/FlatButton"; +import IconButton from "../common/button/IconButton"; + +const CreateUserDialog: React.FC<{ + open: boolean; + close: () => void; + onSuccess: (user: HttpUser) => void; +}> = ({ open, close, onSuccess }) => { + return ( + + getHttpUserClient().post({ + username: username as string, + password: password as string, + }) + } + onClose={close} + open={open} + onSuccessAndClose={onSuccess} + /> + ); +}; + +const UsernameLabel: React.FC<{ children: React.ReactNode }> = (props) => { + return {props.children}; +}; + +const UserDeleteDialog: React.FC<{ + open: boolean; + close: () => void; + user: HttpUser; + onSuccess: () => void; +}> = ({ open, close, user, onSuccess }) => { + return ( + ( + + 0{user.username}2 + + )} + onProcess={() => getHttpUserClient().delete(user.username)} + onSuccessAndClose={onSuccess} + /> + ); +}; + +const UserModifyDialog: React.FC<{ + open: boolean; + close: () => void; + user: HttpUser; + onSuccess: () => void; +}> = ({ open, close, user, onSuccess }) => { + return ( + + 0{user.username}2 + + } + inputs={ + [ + { + type: "text", + label: "admin:user.username", + initValue: user.username, + }, + { type: "text", label: "admin:user.password" }, + { + type: "text", + label: "admin:user.nickname", + initValue: user.nickname, + }, + ] as const + } + onProcess={([username, password, nickname]) => + getHttpUserClient().patch(user.username, { + username: username !== user.username ? username : undefined, + password: password !== "" ? password : undefined, + nickname: nickname !== user.nickname ? nickname : undefined, + }) + } + onSuccessAndClose={onSuccess} + /> + ); +}; + +const UserPermissionModifyDialog: React.FC<{ + open: boolean; + close: () => void; + user: HttpUser; + onSuccess: () => void; +}> = ({ open, close, user, onSuccess }) => { + const oldPermissionBoolList: boolean[] = kUserPermissionList.map( + (permission) => user.permissions.includes(permission), + ); + + return ( + ( + + 0{user.username}2 + + )} + inputScheme={kUserPermissionList.map( + (permission, index) => ({ + type: "bool", + label: { type: "custom", value: permission }, + initValue: oldPermissionBoolList[index], + }), + )} + onProcess={async (newPermissionBoolList): Promise => { + for (let index = 0; index < kUserPermissionList.length; index++) { + const oldValue = oldPermissionBoolList[index]; + const newValue = newPermissionBoolList[index]; + const permission = kUserPermissionList[index]; + if (oldValue === newValue) continue; + if (newValue) { + await getHttpUserClient().putUserPermission( + user.username, + permission, + ); + } else { + await getHttpUserClient().deleteUserPermission( + user.username, + permission, + ); + } + } + return newPermissionBoolList; + }} + onSuccessAndClose={onSuccess} + /> + ); +}; + +interface UserItemProps { + user: HttpUser; + onChange: () => void; +} + +const UserItem: React.FC = ({ user, onChange }) => { + const { t } = useTranslation(); + + const [dialog, setDialog] = useState< + "delete" | "modify" | "permission" | null + >(null); + + const [editMaskVisible, setEditMaskVisible] = React.useState(false); + + return ( + <> +
+ setEditMaskVisible(true)} + /> +
{user.username}
+ + {t("admin:user.nickname")} + {user.nickname} + + + {t("admin:user.uniqueId")} + {user.uniqueId} + + + {t("admin:user.permissions")} + {user.permissions.map((permission) => { + return ( + + {permission} + + ); + })} + +
setEditMaskVisible(false)} + > + setDialog("modify")} + /> + setDialog("permission")} + /> + setDialog("delete")} + /> +
+
+ setDialog(null)} + user={user} + onSuccess={onChange} + /> + setDialog(null)} + user={user} + onSuccess={onChange} + /> + setDialog(null)} + user={user} + onSuccess={onChange} + /> + + ); +}; + +const UserAdmin: React.FC = () => { + const [users, setUsers] = useState(null); + const [dialog, setDialog] = useState<"create" | null>(null); + const [usersVersion, setUsersVersion] = useState(0); + const updateUsers = (): void => { + setUsersVersion(usersVersion + 1); + }; + + useEffect(() => { + let subscribe = true; + void getHttpUserClient() + .list() + .then((us) => { + if (subscribe) { + setUsers(us.items); + } + }); + return () => { + subscribe = false; + }; + }, [usersVersion]); + + if (users) { + const userComponents = users.map((user) => { + return ( + + ); + }); + + return ( + <> +
+
+
+
+ {userComponents} + setDialog(null)} + onSuccess={updateUsers} + /> + + ); + } else { + return ; + } +}; + +export default UserAdmin; diff --git a/FrontEnd/src/migrating/admin/index.css b/FrontEnd/src/migrating/admin/index.css new file mode 100644 index 00000000..17e24586 --- /dev/null +++ b/FrontEnd/src/migrating/admin/index.css @@ -0,0 +1,33 @@ +.admin-user-item { + position: relative; + border: var(--cru-primary-color) solid; + border-width: 1px 1px 0; + padding: 1em; +} + +.admin-user-item:last-of-type { + border-bottom-width: 1px; +} + +.admin-user-item .edit-mask { + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + background: rgba(255, 255, 255, 0.9); + position: absolute; + display: flex; + justify-content: space-around; + align-items: center; +} + +@media (max-width: 576px) { + .admin-user-item .edit-mask { + flex-direction: column; + } +} + +.admin-user-item .edit-mask button { + margin: 0.5em 2em; +} diff --git a/FrontEnd/src/migrating/admin/index.tsx b/FrontEnd/src/migrating/admin/index.tsx new file mode 100644 index 00000000..0467711d --- /dev/null +++ b/FrontEnd/src/migrating/admin/index.tsx @@ -0,0 +1,7 @@ +import { lazy } from "react"; + +const Admin = lazy( + () => import(/* webpackChunkName: "admin" */ "./Admin") +); + +export default Admin; diff --git a/FrontEnd/src/migrating/center/CenterBoards.tsx b/FrontEnd/src/migrating/center/CenterBoards.tsx new file mode 100644 index 00000000..a8be2c29 --- /dev/null +++ b/FrontEnd/src/migrating/center/CenterBoards.tsx @@ -0,0 +1,131 @@ +import * as React from "react"; +import { useTranslation } from "react-i18next"; + +import { highlightTimelineUsername } from "@/common"; + +import { pushAlert } from "@/services/alert"; +import { useUserLoggedIn } from "@/services/user"; + +import { getHttpTimelineClient } from "@/http/timeline"; +import { getHttpBookmarkClient } from "@/http/bookmark"; + +import TimelineBoard from "./TimelineBoard"; + +const CenterBoards: React.FC = () => { + const { t } = useTranslation(); + + const user = useUserLoggedIn(); + + return ( + <> +
+
+
+
+ + getHttpBookmarkClient() + .list(user.username) + .then((p) => p.items) + } + editHandler={{ + onDelete: (owner, timeline) => { + return getHttpBookmarkClient() + .delete(user.username, owner, timeline) + .catch((e) => { + pushAlert({ + message: "home.message.deleteBookmarkFail", + type: "danger", + }); + throw e; + }); + }, + onMove: (owner, timeline, index, offset) => { + return getHttpBookmarkClient() + .move( + user.username, + owner, + timeline, + index + offset + 1 // +1 because backend contract: index starts at 1 + ) + .catch((e) => { + pushAlert({ + message: "home.message.moveBookmarkFail", + type: "danger", + }); + throw e; + }) + .then(); + }, + }} + /> +
+
+ + getHttpBookmarkClient() + .list(highlightTimelineUsername) + .then((p) => p.items) + } + editHandler={ + user.username === highlightTimelineUsername + ? { + onDelete: (owner, timeline) => { + return getHttpBookmarkClient() + .delete(highlightTimelineUsername, owner, timeline) + .catch((e) => { + pushAlert({ + message: "home.message.deleteHighlightFail", + type: "danger", + }); + throw e; + }); + }, + onMove: (owner, timeline, index, offset) => { + return getHttpBookmarkClient() + .move( + highlightTimelineUsername, + owner, + timeline, + index + offset + 1 // +1 because backend contract: index starts at 1 + ) + .catch((e) => { + pushAlert({ + message: "home.message.moveBookmarkFail", + type: "danger", + }); + throw e; + }) + .then(); + }, + } + : undefined + } + /> +
+
+
+
+ + getHttpTimelineClient() + .listTimeline({ relate: user.username }) + .then((l) => + l.map((t, index) => ({ + timelineOwner: t.owner.username, + timelineName: t.nameV2, + position: index + 1, + })) + ) + } + /> +
+
+ + ); +}; + +export default CenterBoards; diff --git a/FrontEnd/src/migrating/center/TimelineBoard.tsx b/FrontEnd/src/migrating/center/TimelineBoard.tsx new file mode 100644 index 00000000..b3ccdf8c --- /dev/null +++ b/FrontEnd/src/migrating/center/TimelineBoard.tsx @@ -0,0 +1,390 @@ +import * as React from "react"; +import classnames from "classnames"; +import { Link } from "react-router-dom"; + +import { TimelineBookmark } from "@/http/bookmark"; + +import TimelineLogo from "../common/TimelineLogo"; +import LoadFailReload from "../common/LoadFailReload"; +import FlatButton from "../common/button/FlatButton"; +import Card from "../common/Card"; +import Spinner from "../common/Spinner"; +import IconButton from "../common/button/IconButton"; + +interface TimelineBoardItemProps { + timeline: TimelineBookmark; + // In height. + offset?: number; + // In px. + arbitraryOffset?: number; + // If not null, will disable navigation on click. + actions?: { + onDelete: () => void; + onMove: { + start: (e: React.PointerEvent) => void; + moving: (e: React.PointerEvent) => void; + end: (e: React.PointerEvent) => void; + }; + }; +} + +const TimelineBoardItem: React.FC = ({ + timeline, + arbitraryOffset, + offset, + actions, +}) => { + const content = ( + <> + + + {timeline.timelineOwner}/{timeline.timelineName} + + + {actions != null ? ( +
+ + { + e.currentTarget.setPointerCapture(e.pointerId); + actions.onMove.start(e); + }} + onPointerUp={(e) => { + actions.onMove.end(e); + try { + e.currentTarget.releasePointerCapture(e.pointerId); + } catch (_) { + void null; + } + }} + onPointerMove={actions.onMove.moving} + /> +
+ ) : null} + + ); + + const offsetStyle: React.CSSProperties = { + transform: + arbitraryOffset != null + ? `translate(0,${arbitraryOffset}px)` + : offset != null + ? `translate(0,${offset * 100}%)` + : undefined, + transition: offset != null ? "transform 0.5s" : undefined, + zIndex: arbitraryOffset != null ? 1 : undefined, + }; + + return actions == null ? ( + + {content} + + ) : ( +
+ {content} +
+ ); +}; + +interface TimelineBoardItemContainerProps { + timelines: TimelineBookmark[]; + editHandler?: { + // offset may exceed index range plusing index. + onMove: ( + owner: string, + timeline: string, + index: number, + offset: number + ) => void; + onDelete: (owner: string, timeline: string) => void; + }; +} + +const TimelineBoardItemContainer: React.FC = ({ + timelines, + editHandler, +}) => { + const [moveState, setMoveState] = React.useState(null); + + return ( + <> + {timelines.map((timeline, index) => { + const height = 48; + + let offset: number | undefined = undefined; + let arbitraryOffset: number | undefined = undefined; + if (moveState != null) { + if (index === moveState.index) { + arbitraryOffset = moveState.offset; + } else { + if (moveState.offset >= 0) { + const offsetCount = Math.round(moveState.offset / height); + if ( + index > moveState.index && + index <= moveState.index + offsetCount + ) { + offset = -1; + } else { + offset = 0; + } + } else { + const offsetCount = Math.round(-moveState.offset / height); + if ( + index < moveState.index && + index >= moveState.index - offsetCount + ) { + offset = 1; + } else { + offset = 0; + } + } + } + } + + return ( + { + editHandler.onDelete( + timeline.timelineOwner, + timeline.timelineName + ); + }, + onMove: { + start: (e) => { + if (moveState != null) return; + setMoveState({ + index, + offset: 0, + startPointY: e.clientY, + }); + }, + moving: (e) => { + if (moveState == null) return; + setMoveState({ + index, + offset: e.clientY - moveState.startPointY, + startPointY: moveState.startPointY, + }); + }, + end: () => { + if (moveState != null) { + const offsetCount = Math.round( + moveState.offset / height + ); + editHandler.onMove( + timeline.timelineOwner, + timeline.timelineName, + moveState.index, + offsetCount + ); + } + setMoveState(null); + }, + }, + } + : undefined + } + /> + ); + })} + + ); +}; + +interface TimelineBoardUIProps { + title?: string | null; + state: "offline" | "loading" | "loaded"; + timelines: TimelineBookmark[]; + onReload: () => void; + className?: string; + editHandler?: { + onMove: ( + owner: string, + timeline: string, + index: number, + offset: number + ) => void; + onDelete: (owner: string, timeline: string) => void; + }; +} + +const TimelineBoardUI: React.FC = (props) => { + const { title, state, timelines, className, editHandler } = props; + + const editable = editHandler != null; + + const [editing, setEditing] = React.useState(false); + + return ( + +
+ {title != null &&

{title}

} + {editable && + (editing ? ( + { + setEditing(false); + }} + /> + ) : ( + { + setEditing(true); + }} + /> + ))} +
+ {(() => { + if (state === "loading") { + return ( +
+ +
+ ); + } else if (state === "offline") { + return ( +
+ +
+ ); + } else { + return ( + { + if (index + offset >= timelines.length) { + offset = timelines.length - index - 1; + } else if (index + offset < 0) { + offset = -index; + } + editHandler.onMove(owner, timeline, index, offset); + }, + } + : undefined + } + /> + ); + } + })()} +
+ ); +}; + +export interface TimelineBoardProps { + title?: string | null; + className?: string; + load: () => Promise; + editHandler?: { + onMove: ( + owner: string, + timeline: string, + index: number, + offset: number + ) => Promise; + onDelete: (owner: string, timeline: string) => Promise; + }; +} + +const TimelineBoard: React.FC = ({ + className, + title, + load, + editHandler, +}) => { + const [state, setState] = React.useState<"offline" | "loading" | "loaded">( + "loading" + ); + const [timelines, setTimelines] = React.useState([]); + + React.useEffect(() => { + let subscribe = true; + if (state === "loading") { + void load().then( + (timelines) => { + if (subscribe) { + setState("loaded"); + setTimelines(timelines); + } + }, + () => { + setState("offline"); + } + ); + } + return () => { + subscribe = false; + }; + }, [load, state]); + + return ( + { + setState("loaded"); + }} + editHandler={ + typeof timelines === "object" && editHandler != null + ? { + onMove: (owner, timeline, index, offset) => { + const newTimelines = timelines.slice(); + const [t] = newTimelines.splice(index, 1); + newTimelines.splice(index + offset, 0, t); + setTimelines(newTimelines); + editHandler + .onMove(owner, timeline, index, offset) + .then(null, () => { + setTimelines(timelines); + }); + }, + onDelete: (owner, timeline) => { + const newTimelines = timelines.slice(); + newTimelines.splice( + timelines.findIndex( + (t) => + t.timelineOwner === owner && t.timelineName === timeline + ), + 1 + ); + setTimelines(newTimelines); + editHandler.onDelete(owner, timeline).then(null, () => { + setTimelines(timelines); + }); + }, + } + : undefined + } + /> + ); +}; + +export default TimelineBoard; diff --git a/FrontEnd/src/migrating/center/TimelineCreateDialog.tsx b/FrontEnd/src/migrating/center/TimelineCreateDialog.tsx new file mode 100644 index 00000000..63742936 --- /dev/null +++ b/FrontEnd/src/migrating/center/TimelineCreateDialog.tsx @@ -0,0 +1,57 @@ +import * as React from "react"; +import { useNavigate } from "react-router-dom"; + +import { validateTimelineName } from "@/services/timeline"; +import { getHttpTimelineClient, HttpTimelineInfo } from "@/http/timeline"; + +import OperationDialog from "../common/dialog/OperationDialog"; +import { useUserLoggedIn } from "@/services/user"; + +interface TimelineCreateDialogProps { + open: boolean; + close: () => void; +} + +const TimelineCreateDialog: React.FC = (props) => { + const navigate = useNavigate(); + + const user = useUserLoggedIn(); + + return ( + { + if (name.length === 0) { + return { 0: "home.createDialog.noEmpty" }; + } else if (name.length > 26) { + return { 0: "home.createDialog.tooLong" }; + } else if (!validateTimelineName(name)) { + return { 0: "home.createDialog.badFormat" }; + } else { + return null; + } + }} + onProcess={([name]): Promise => + getHttpTimelineClient().postTimeline({ name }) + } + onSuccessAndClose={(timeline: HttpTimelineInfo) => { + navigate(`${user.username}/${timeline.nameV2}`); + }} + failurePrompt={(e) => `${e as string}`} + /> + ); +}; + +export default TimelineCreateDialog; diff --git a/FrontEnd/src/migrating/center/index.css b/FrontEnd/src/migrating/center/index.css new file mode 100644 index 00000000..a779ff90 --- /dev/null +++ b/FrontEnd/src/migrating/center/index.css @@ -0,0 +1,43 @@ +.timeline-board { + min-height: 200px; + height: 100%; + position: relative; + padding: 1em 0; + display: flex; + flex-direction: column; +} + +.timeline-board-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 1em; +} + +.timeline-board-item { + font-size: 1.1em; + height: 48px; + transition: background 0.3s; + display: flex; + align-items: center; + padding: 0 1em; +} + +.timeline-board-item .icon { + height: 1.3em; + color: black; +} + +.timeline-board-item:hover { + background: #dee2e6; +} +.timeline-board-item .right { + display: flex; + align-items: center; + flex-shrink: 0; +} +.timeline-board-item .title { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/FrontEnd/src/migrating/center/index.tsx b/FrontEnd/src/migrating/center/index.tsx new file mode 100644 index 00000000..77af2c20 --- /dev/null +++ b/FrontEnd/src/migrating/center/index.tsx @@ -0,0 +1,60 @@ +import * as React from "react"; +import { useNavigate } from "react-router-dom"; + +import { useUserLoggedIn } from "@/services/user"; + +import SearchInput from "../common/SearchInput"; +import Button from "../common/button/Button"; +import CenterBoards from "./CenterBoards"; +import TimelineCreateDialog from "./TimelineCreateDialog"; + +import "./index.css"; + +const HomePage: React.FC = () => { + const navigate = useNavigate(); + + const user = useUserLoggedIn(); + + const [navText, setNavText] = React.useState(""); + + const [dialog, setDialog] = React.useState<"create" | null>(null); + + return ( + <> +
+
+
+ { + navigate(`search?q=${navText}`); + }} + additionalButton={ + user != null && ( +
+
+ +
+ { + setDialog(null); + }} + /> + + ); +}; + +export default HomePage; -- cgit v1.2.3