aboutsummaryrefslogtreecommitdiff
path: root/FrontEnd/src/migrating
diff options
context:
space:
mode:
Diffstat (limited to 'FrontEnd/src/migrating')
-rw-r--r--FrontEnd/src/migrating/admin/Admin.tsx27
-rw-r--r--FrontEnd/src/migrating/admin/AdminNav.tsx29
-rw-r--r--FrontEnd/src/migrating/admin/MoreAdmin.tsx7
-rw-r--r--FrontEnd/src/migrating/admin/UserAdmin.tsx301
-rw-r--r--FrontEnd/src/migrating/admin/index.css33
-rw-r--r--FrontEnd/src/migrating/admin/index.tsx7
-rw-r--r--FrontEnd/src/migrating/center/CenterBoards.tsx131
-rw-r--r--FrontEnd/src/migrating/center/TimelineBoard.tsx390
-rw-r--r--FrontEnd/src/migrating/center/TimelineCreateDialog.tsx57
-rw-r--r--FrontEnd/src/migrating/center/index.css43
-rw-r--r--FrontEnd/src/migrating/center/index.tsx60
11 files changed, 1085 insertions, 0 deletions
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 (
+ <>
+ <div className="container">
+ <AdminNav className="mt-2" />
+ <Routes>
+ <Route index element={<UserAdmin />} />
+ <Route path="user" element={<UserAdmin />} />
+ <Route path="more" element={<MoreAdmin />} />
+ </Routes>
+ </div>
+ </>
+ );
+};
+
+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 (
+ <Tabs
+ className={className}
+ activeTabName={name}
+ tabs={[
+ {
+ name: "user",
+ text: "admin:nav.users",
+ link: "/admin/user",
+ },
+ {
+ name: "more",
+ text: "admin:nav.more",
+ link: "/admin/more",
+ },
+ ]}
+ />
+ );
+}
+
+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 (
+ <OperationDialog
+ title="admin:user.dialog.create.title"
+ inputPrompt="admin:user.dialog.create.prompt"
+ inputs={[
+ { key: "username", type: "text", label: "admin:user.username" },
+ { key: "password", type: "text", label: "admin:user.password" },
+ ]}
+ onProcess={({ username, password }) =>
+ 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 <span style={{ color: "blue" }}>{props.children}</span>;
+};
+
+const UserDeleteDialog: React.FC<{
+ open: boolean;
+ close: () => void;
+ user: HttpUser;
+ onSuccess: () => void;
+}> = ({ open, close, user, onSuccess }) => {
+ return (
+ <OperationDialog
+ open={open}
+ onClose={close}
+ title="admin:user.dialog.delete.title"
+ themeColor="danger"
+ inputPrompt={() => (
+ <Trans i18nKey="user.dialog.delete.prompt" ns="admin">
+ 0<UsernameLabel>{user.username}</UsernameLabel>2
+ </Trans>
+ )}
+ onProcess={() => getHttpUserClient().delete(user.username)}
+ onSuccessAndClose={onSuccess}
+ />
+ );
+};
+
+const UserModifyDialog: React.FC<{
+ open: boolean;
+ close: () => void;
+ user: HttpUser;
+ onSuccess: () => void;
+}> = ({ open, close, user, onSuccess }) => {
+ return (
+ <OperationDialog
+ open={open}
+ onClose={close}
+ title="admin:user.dialog.modify.title"
+ inputPromptNode={
+ <Trans i18nKey="admin:user.dialog.modify.prompt">
+ 0<UsernameLabel>{user.username}</UsernameLabel>2
+ </Trans>
+ }
+ 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 (
+ <OperationDialog
+ open={open}
+ onClose={close}
+ title="admin:user.dialog.modifyPermissions.title"
+ themeColor="danger"
+ inputPrompt={() => (
+ <Trans i18nKey="admin:user.dialog.modifyPermissions.prompt">
+ 0<UsernameLabel>{user.username}</UsernameLabel>2
+ </Trans>
+ )}
+ inputScheme={kUserPermissionList.map<OperationDialogBoolInput>(
+ (permission, index) => ({
+ type: "bool",
+ label: { type: "custom", value: permission },
+ initValue: oldPermissionBoolList[index],
+ }),
+ )}
+ onProcess={async (newPermissionBoolList): Promise<boolean[]> => {
+ 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<UserItemProps> = ({ user, onChange }) => {
+ const { t } = useTranslation();
+
+ const [dialog, setDialog] = useState<
+ "delete" | "modify" | "permission" | null
+ >(null);
+
+ const [editMaskVisible, setEditMaskVisible] = React.useState<boolean>(false);
+
+ return (
+ <>
+ <div className="admin-user-item">
+ <IconButton
+ icon="pencil-square"
+ className="cru-float-right"
+ onClick={() => setEditMaskVisible(true)}
+ />
+ <h5 className="cru-color-primary">{user.username}</h5>
+ <small className="d-block cru-color-secondary">
+ {t("admin:user.nickname")}
+ {user.nickname}
+ </small>
+ <small className="d-block cru-color-secondary">
+ {t("admin:user.uniqueId")}
+ {user.uniqueId}
+ </small>
+ <small className="d-block cru-color-secondary">
+ {t("admin:user.permissions")}
+ {user.permissions.map((permission) => {
+ return (
+ <span key={permission} className="cru-color-danger">
+ {permission}
+ </span>
+ );
+ })}
+ </small>
+ <div
+ className={classnames("edit-mask", !editMaskVisible && "d-none")}
+ onClick={() => setEditMaskVisible(false)}
+ >
+ <FlatButton
+ text="admin:user.modify"
+ onClick={() => setDialog("modify")}
+ />
+ <FlatButton
+ text="admin:user.modifyPermissions"
+ onClick={() => setDialog("permission")}
+ />
+ <FlatButton
+ text="admin:user.delete"
+ color="danger"
+ onClick={() => setDialog("delete")}
+ />
+ </div>
+ </div>
+ <UserDeleteDialog
+ open={dialog === "delete"}
+ close={() => setDialog(null)}
+ user={user}
+ onSuccess={onChange}
+ />
+ <UserModifyDialog
+ open={dialog === "modify"}
+ close={() => setDialog(null)}
+ user={user}
+ onSuccess={onChange}
+ />
+ <UserPermissionModifyDialog
+ open={dialog === "permission"}
+ close={() => setDialog(null)}
+ user={user}
+ onSuccess={onChange}
+ />
+ </>
+ );
+};
+
+const UserAdmin: React.FC = () => {
+ const [users, setUsers] = useState<HttpUser[] | null>(null);
+ const [dialog, setDialog] = useState<"create" | null>(null);
+ const [usersVersion, setUsersVersion] = useState<number>(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 (
+ <UserItem key={user.username} user={user} onChange={updateUsers} />
+ );
+ });
+
+ return (
+ <>
+ <div className="row justify-content-end my-2">
+ <div className="col col-auto">
+ <Button
+ text="admin:create"
+ color="success"
+ onClick={() => setDialog("create")}
+ />
+ </div>
+ </div>
+ {userComponents}
+ <CreateUserDialog
+ open={dialog === "create"}
+ close={() => setDialog(null)}
+ onSuccess={updateUsers}
+ />
+ </>
+ );
+ } else {
+ return <Spinner />;
+ }
+};
+
+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 (
+ <>
+ <div className="row justify-content-center">
+ <div className="col col-12 col-md-6">
+ <div className="row">
+ <div className="col col-12 my-2">
+ <TimelineBoard
+ title={t("home.bookmarkTimeline")}
+ load={() =>
+ 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();
+ },
+ }}
+ />
+ </div>
+ <div className="col col-12 my-2">
+ <TimelineBoard
+ title={t("home.highlightTimeline")}
+ load={() =>
+ 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
+ }
+ />
+ </div>
+ </div>
+ </div>
+ <div className="col-12 col-md-6 my-2">
+ <TimelineBoard
+ title={t("home.relatedTimeline")}
+ load={() =>
+ getHttpTimelineClient()
+ .listTimeline({ relate: user.username })
+ .then((l) =>
+ l.map((t, index) => ({
+ timelineOwner: t.owner.username,
+ timelineName: t.nameV2,
+ position: index + 1,
+ }))
+ )
+ }
+ />
+ </div>
+ </div>
+ </>
+ );
+};
+
+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<TimelineBoardItemProps> = ({
+ timeline,
+ arbitraryOffset,
+ offset,
+ actions,
+}) => {
+ const content = (
+ <>
+ <TimelineLogo className="icon" />
+ <span className="title">
+ {timeline.timelineOwner}/{timeline.timelineName}
+ </span>
+ <span className="flex-grow-1"></span>
+ {actions != null ? (
+ <div className="right">
+ <IconButton
+ icon="trash"
+ color="danger"
+ className="px-2"
+ onClick={actions.onDelete}
+ />
+ <IconButton
+ icon="grip-vertical"
+ className="px-2 touch-action-none"
+ onPointerDown={(e) => {
+ 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}
+ />
+ </div>
+ ) : 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 ? (
+ <Link
+ to={`${timeline.timelineOwner}/${timeline.timelineName}`}
+ className="timeline-board-item"
+ >
+ {content}
+ </Link>
+ ) : (
+ <div style={offsetStyle} className="timeline-board-item">
+ {content}
+ </div>
+ );
+};
+
+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<TimelineBoardItemContainerProps> = ({
+ timelines,
+ editHandler,
+}) => {
+ const [moveState, setMoveState] = React.useState<null | {
+ index: number;
+ offset: number;
+ startPointY: number;
+ }>(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 (
+ <TimelineBoardItem
+ key={timeline.timelineOwner + "/" + timeline.timelineName}
+ timeline={timeline}
+ offset={offset}
+ arbitraryOffset={arbitraryOffset}
+ actions={
+ editHandler != null
+ ? {
+ onDelete: () => {
+ 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<TimelineBoardUIProps> = (props) => {
+ const { title, state, timelines, className, editHandler } = props;
+
+ const editable = editHandler != null;
+
+ const [editing, setEditing] = React.useState<boolean>(false);
+
+ return (
+ <Card className={classnames("timeline-board", className)}>
+ <div className="timeline-board-header">
+ {title != null && <h3>{title}</h3>}
+ {editable &&
+ (editing ? (
+ <FlatButton
+ text="done"
+ onClick={() => {
+ setEditing(false);
+ }}
+ />
+ ) : (
+ <FlatButton
+ text="edit"
+ onClick={() => {
+ setEditing(true);
+ }}
+ />
+ ))}
+ </div>
+ {(() => {
+ if (state === "loading") {
+ return (
+ <div className="d-flex flex-grow-1 justify-content-center align-items-center">
+ <Spinner />
+ </div>
+ );
+ } else if (state === "offline") {
+ return (
+ <div className="d-flex flex-grow-1 justify-content-center align-items-center">
+ <LoadFailReload onReload={props.onReload} />
+ </div>
+ );
+ } else {
+ return (
+ <TimelineBoardItemContainer
+ timelines={timelines}
+ editHandler={
+ editHandler && editing
+ ? {
+ onDelete: editHandler.onDelete,
+ onMove: (owner, timeline, index, offset) => {
+ if (index + offset >= timelines.length) {
+ offset = timelines.length - index - 1;
+ } else if (index + offset < 0) {
+ offset = -index;
+ }
+ editHandler.onMove(owner, timeline, index, offset);
+ },
+ }
+ : undefined
+ }
+ />
+ );
+ }
+ })()}
+ </Card>
+ );
+};
+
+export interface TimelineBoardProps {
+ title?: string | null;
+ className?: string;
+ load: () => Promise<TimelineBookmark[]>;
+ editHandler?: {
+ onMove: (
+ owner: string,
+ timeline: string,
+ index: number,
+ offset: number
+ ) => Promise<void>;
+ onDelete: (owner: string, timeline: string) => Promise<void>;
+ };
+}
+
+const TimelineBoard: React.FC<TimelineBoardProps> = ({
+ className,
+ title,
+ load,
+ editHandler,
+}) => {
+ const [state, setState] = React.useState<"offline" | "loading" | "loaded">(
+ "loading"
+ );
+ const [timelines, setTimelines] = React.useState<TimelineBookmark[]>([]);
+
+ 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 (
+ <TimelineBoardUI
+ title={title}
+ className={className}
+ state={state}
+ timelines={timelines}
+ onReload={() => {
+ 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<TimelineCreateDialogProps> = (props) => {
+ const navigate = useNavigate();
+
+ const user = useUserLoggedIn();
+
+ return (
+ <OperationDialog
+ open={props.open}
+ onClose={props.close}
+ themeColor="success"
+ title="home.createDialog.title"
+ inputScheme={
+ [
+ {
+ type: "text",
+ label: "home.createDialog.name",
+ helperText: "home.createDialog.nameFormat",
+ },
+ ] as const
+ }
+ inputValidator={([name]) => {
+ 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<HttpTimelineInfo> =>
+ 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<string>("");
+
+ const [dialog, setDialog] = React.useState<"create" | null>(null);
+
+ return (
+ <>
+ <div className="container">
+ <div className="row my-3 justify-content-center">
+ <div className="col col-12 col-md-8">
+ <SearchInput
+ className="justify-content-center"
+ value={navText}
+ onChange={setNavText}
+ onButtonClick={() => {
+ navigate(`search?q=${navText}`);
+ }}
+ additionalButton={
+ user != null && (
+ <Button
+ text="home.createButton"
+ color="success"
+ onClick={() => {
+ setDialog("create");
+ }}
+ />
+ )
+ }
+ />
+ </div>
+ </div>
+ <CenterBoards />
+ </div>
+ <TimelineCreateDialog
+ open={dialog === "create"}
+ close={() => {
+ setDialog(null);
+ }}
+ />
+ </>
+ );
+};
+
+export default HomePage;