aboutsummaryrefslogtreecommitdiff
path: root/FrontEnd
diff options
context:
space:
mode:
authorcrupest <crupest@outlook.com>2020-11-04 12:29:55 +0800
committerGitHub <noreply@github.com>2020-11-04 12:29:55 +0800
commit1e9e5eb89099ffaa454635fdd7271cece757fb2d (patch)
tree1f19646ac27a3dcd4680e871f34218b85b0915d8 /FrontEnd
parentf3503b399ff7b65f363beca313c8a0c336884bec (diff)
parent1ba32f233677cad56636462b1f6552ff6e800801 (diff)
downloadtimeline-1e9e5eb89099ffaa454635fdd7271cece757fb2d.tar.gz
timeline-1e9e5eb89099ffaa454635fdd7271cece757fb2d.tar.bz2
timeline-1e9e5eb89099ffaa454635fdd7271cece757fb2d.zip
Merge pull request #165 from crupest/timeline
Redesign timeline ui.
Diffstat (limited to 'FrontEnd')
-rw-r--r--FrontEnd/src/app/common.ts26
-rw-r--r--FrontEnd/src/app/index.sass3
-rw-r--r--FrontEnd/src/app/views/admin/UserAdmin.tsx22
-rw-r--r--FrontEnd/src/app/views/common/AppBar.tsx26
-rw-r--r--FrontEnd/src/app/views/common/OperationDialog.tsx222
-rw-r--r--FrontEnd/src/app/views/common/SearchInput.tsx12
-rw-r--r--FrontEnd/src/app/views/home/BoardWithUser.tsx8
-rw-r--r--FrontEnd/src/app/views/home/BoardWithoutUser.tsx2
-rw-r--r--FrontEnd/src/app/views/home/TimelineCreateDialog.tsx39
-rw-r--r--FrontEnd/src/app/views/home/index.tsx4
-rw-r--r--FrontEnd/src/app/views/settings/index.tsx55
-rw-r--r--FrontEnd/src/app/views/timeline-common/Timeline.tsx9
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelineItem.tsx58
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx17
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx18
-rw-r--r--FrontEnd/src/app/views/timeline-common/timeline-background.svg4
-rw-r--r--FrontEnd/src/app/views/timeline-common/timeline-common.sass39
-rw-r--r--FrontEnd/src/app/views/timeline/TimelineDeleteDialog.tsx14
-rw-r--r--FrontEnd/src/app/views/timeline/TimelineInfoCard.tsx2
-rw-r--r--FrontEnd/src/app/views/user/ChangeNicknameDialog.tsx2
-rw-r--r--FrontEnd/src/app/views/user/UserInfoCard.tsx5
21 files changed, 317 insertions, 270 deletions
diff --git a/FrontEnd/src/app/common.ts b/FrontEnd/src/app/common.ts
index 0a2d345f..681568bb 100644
--- a/FrontEnd/src/app/common.ts
+++ b/FrontEnd/src/app/common.ts
@@ -1,5 +1,6 @@
import React from "react";
import { Observable, Subject } from "rxjs";
+import { TFunction } from "i18next";
// This error is thrown when ui goes wrong with bad logic.
// Such as a variable should not be null, but it does.
@@ -42,3 +43,28 @@ export function useValueEventEmiiter<T>(): [
return [getter, trigger];
}, []);
}
+
+export type I18nText =
+ | string
+ | { type: "custom"; value: string }
+ | { type: "i18n"; value: string };
+
+export function convertI18nText(text: I18nText, t: TFunction): string;
+export function convertI18nText(
+ text: I18nText | null | undefined,
+ t: TFunction
+): string | null;
+export function convertI18nText(
+ text: I18nText | null | undefined,
+ t: TFunction
+): string | null {
+ if (text == null) {
+ return null;
+ } else if (typeof text === "string") {
+ return t(text);
+ } else if (text.type === "i18n") {
+ return t(text.value);
+ } else {
+ return text.value;
+ }
+}
diff --git a/FrontEnd/src/app/index.sass b/FrontEnd/src/app/index.sass
index 0202e633..abe663d1 100644
--- a/FrontEnd/src/app/index.sass
+++ b/FrontEnd/src/app/index.sass
@@ -35,6 +35,7 @@ small
.icon-button
font-size: 1.4em
+ cursor: pointer
&.large
font-size: 1.6em
@@ -51,8 +52,8 @@ textarea
@extend .shadow
@extend .border
@extend .rounded
- @extend .bg-light
border-color: $gray-200
+ background: $light
.full-viewport-center-child
position: fixed
diff --git a/FrontEnd/src/app/views/admin/UserAdmin.tsx b/FrontEnd/src/app/views/admin/UserAdmin.tsx
index 18b77ca8..0f5f8796 100644
--- a/FrontEnd/src/app/views/admin/UserAdmin.tsx
+++ b/FrontEnd/src/app/views/admin/UserAdmin.tsx
@@ -152,16 +152,18 @@ const CreateUserDialog: React.FC<CreateUserDialogProps> = (props) => {
title="Create"
titleColor="create"
inputPrompt="You are creating a new user."
- inputScheme={[
- { type: "text", label: "Username" },
- { type: "text", label: "Password" },
- { type: "bool", label: "Administrator" },
- ]}
+ inputScheme={
+ [
+ { type: "text", label: "Username" },
+ { type: "text", label: "Password" },
+ { type: "bool", label: "Administrator" },
+ ] as const
+ }
onProcess={([username, password, administrator]) =>
props.process({
- username: username as string,
- password: password as string,
- administrator: administrator as boolean,
+ username: username,
+ password: password,
+ administrator: administrator,
})
}
close={props.close}
@@ -221,7 +223,7 @@ const UserChangeUsernameDialog: React.FC<UserModifyDialogProps<string>> = (
)}
inputScheme={[{ type: "text", label: "New Username" }]}
onProcess={([newUsername]) => {
- return props.process(newUsername as string);
+ return props.process(newUsername);
}}
/>
);
@@ -245,7 +247,7 @@ const UserChangePasswordDialog: React.FC<UserModifyDialogProps<string>> = (
)}
inputScheme={[{ type: "text", label: "New Password" }]}
onProcess={([newPassword]) => {
- return props.process(newPassword as string);
+ return props.process(newPassword);
}}
/>
);
diff --git a/FrontEnd/src/app/views/common/AppBar.tsx b/FrontEnd/src/app/views/common/AppBar.tsx
index ee4ead8f..699c596e 100644
--- a/FrontEnd/src/app/views/common/AppBar.tsx
+++ b/FrontEnd/src/app/views/common/AppBar.tsx
@@ -2,6 +2,7 @@ import React from "react";
import { useTranslation } from "react-i18next";
import { LinkContainer } from "react-router-bootstrap";
import { Navbar, Nav } from "react-bootstrap";
+import { NavLink } from "react-router-dom";
import { useUser, useAvatar } from "@/services/user";
@@ -28,18 +29,17 @@ const AppBar: React.FC = (_) => {
<Navbar.Toggle />
<Navbar.Collapse>
<Nav className="mr-auto">
- <LinkContainer to="/settings">
- <Nav.Link>{t("nav.settings")}</Nav.Link>
- </LinkContainer>
-
- <LinkContainer to="/about">
- <Nav.Link>{t("nav.about")}</Nav.Link>
- </LinkContainer>
+ <NavLink to="/settings" className="nav-link" activeClassName="active">
+ {t("nav.settings")}
+ </NavLink>
+ <NavLink to="/about" className="nav-link" activeClassName="active">
+ {t("nav.about")}
+ </NavLink>
{isAdministrator && (
- <LinkContainer to="/admin">
- <Nav.Link>Administration</Nav.Link>
- </LinkContainer>
+ <NavLink to="/admin" className="nav-link" activeClassName="active">
+ Administration
+ </NavLink>
)}
</Nav>
<Nav className="ml-auto mr-2">
@@ -51,9 +51,9 @@ const AppBar: React.FC = (_) => {
/>
</LinkContainer>
) : (
- <LinkContainer to="/login">
- <Nav.Link>{t("nav.login")}</Nav.Link>
- </LinkContainer>
+ <NavLink to="/login" className="nav-link" activeClassName="active">
+ {t("nav.login")}
+ </NavLink>
)}
</Nav>
</Navbar.Collapse>
diff --git a/FrontEnd/src/app/views/common/OperationDialog.tsx b/FrontEnd/src/app/views/common/OperationDialog.tsx
index 841392a6..e32e9277 100644
--- a/FrontEnd/src/app/views/common/OperationDialog.tsx
+++ b/FrontEnd/src/app/views/common/OperationDialog.tsx
@@ -2,7 +2,7 @@ import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { Form, Button, Modal } from "react-bootstrap";
-import { UiLogicError } from "@/common";
+import { convertI18nText, I18nText, UiLogicError } from "@/common";
import LoadingButton from "./LoadingButton";
@@ -27,45 +27,33 @@ const DefaultErrorPrompt: React.FC<DefaultErrorPromptProps> = (props) => {
return result;
};
-export type OperationInputOptionalError = undefined | null | string;
-
-export interface OperationInputErrorInfo {
- [index: number]: OperationInputOptionalError;
-}
-
-export type OperationInputValidator<TValue> = (
- value: TValue,
- values: (string | boolean)[]
-) => OperationInputOptionalError | OperationInputErrorInfo;
-
export interface OperationTextInputInfo {
type: "text";
+ label?: I18nText;
password?: boolean;
- label?: string;
initValue?: string;
textFieldProps?: Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"type" | "value" | "onChange" | "aria-relevant"
>;
helperText?: string;
- validator?: OperationInputValidator<string>;
}
export interface OperationBoolInputInfo {
type: "bool";
- label: string;
+ label: I18nText;
initValue?: boolean;
}
export interface OperationSelectInputInfoOption {
value: string;
- label: string;
+ label: I18nText;
icon?: React.ReactElement;
}
export interface OperationSelectInputInfo {
type: "select";
- label: string;
+ label: I18nText;
options: OperationSelectInputInfoOption[];
initValue?: string;
}
@@ -75,27 +63,67 @@ export type OperationInputInfo =
| OperationBoolInputInfo
| OperationSelectInputInfo;
+type MapOperationInputInfoValueType<T> = T extends OperationTextInputInfo
+ ? string
+ : T extends OperationBoolInputInfo
+ ? boolean
+ : T extends OperationSelectInputInfo
+ ? string
+ : never;
+
+type MapOperationInputInfoValueTypeList<
+ Tuple extends readonly OperationInputInfo[]
+> = {
+ [Index in keyof Tuple]: MapOperationInputInfoValueType<Tuple[Index]>;
+} & { length: Tuple["length"] };
+
interface OperationResult {
type: "success" | "failure";
data: unknown;
}
-interface OperationDialogProps {
+export type OperationInputError =
+ | {
+ [index: number]: I18nText | null | undefined;
+ }
+ | null
+ | undefined;
+
+const isNoError = (error: OperationInputError): boolean => {
+ if (error == null) return true;
+ for (const key in error) {
+ if (error[key] != null) return false;
+ }
+ return true;
+};
+
+export interface OperationDialogProps<
+ OperationInputInfoList extends readonly OperationInputInfo[]
+> {
open: boolean;
close: () => void;
- title: React.ReactNode;
+ title: I18nText | (() => React.ReactNode);
titleColor?: "default" | "dangerous" | "create" | string;
- onProcess: (inputs: (string | boolean)[]) => Promise<unknown>;
- inputScheme?: OperationInputInfo[];
- inputPrompt?: string | (() => React.ReactNode);
+ onProcess: (
+ inputs: MapOperationInputInfoValueTypeList<OperationInputInfoList>
+ ) => Promise<unknown>;
+ inputScheme?: OperationInputInfoList;
+ inputValidator?: (
+ inputs: MapOperationInputInfoValueTypeList<OperationInputInfoList>
+ ) => OperationInputError;
+ inputPrompt?: I18nText | (() => React.ReactNode);
processPrompt?: () => React.ReactNode;
successPrompt?: (data: unknown) => React.ReactNode;
failurePrompt?: (error: unknown) => React.ReactNode;
onSuccessAndClose?: () => void;
}
-const OperationDialog: React.FC<OperationDialogProps> = (props) => {
- const inputScheme = props.inputScheme ?? [];
+const OperationDialog = <
+ OperationInputInfoList extends readonly OperationInputInfo[]
+>(
+ props: OperationDialogProps<OperationInputInfoList>
+): React.ReactElement => {
+ const inputScheme = props.inputScheme as readonly OperationInputInfo[];
const { t } = useTranslation();
@@ -112,7 +140,10 @@ const OperationDialog: React.FC<OperationDialogProps> = (props) => {
}
})
);
- const [inputError, setInputError] = useState<OperationInputErrorInfo>({});
+ const [dirtyList, setDirtyList] = useState<boolean[]>(() =>
+ inputScheme.map(() => false)
+ );
+ const [inputError, setInputError] = useState<OperationInputError>();
const close = (): void => {
if (step !== "process") {
@@ -131,20 +162,26 @@ const OperationDialog: React.FC<OperationDialogProps> = (props) => {
const onConfirm = (): void => {
setStep("process");
- props.onProcess(values).then(
- (d: unknown) => {
- setStep({
- type: "success",
- data: d,
- });
- },
- (e: unknown) => {
- setStep({
- type: "failure",
- data: e,
- });
- }
- );
+ props
+ .onProcess(
+ (values as unknown) as MapOperationInputInfoValueTypeList<
+ OperationInputInfoList
+ >
+ )
+ .then(
+ (d: unknown) => {
+ setStep({
+ type: "success",
+ data: d,
+ });
+ },
+ (e: unknown) => {
+ setStep({
+ type: "failure",
+ data: e,
+ });
+ }
+ );
};
let body: React.ReactNode;
@@ -154,65 +191,37 @@ const OperationDialog: React.FC<OperationDialogProps> = (props) => {
let inputPrompt =
typeof props.inputPrompt === "function"
? props.inputPrompt()
- : props.inputPrompt;
+ : convertI18nText(props.inputPrompt, t);
inputPrompt = <h6>{inputPrompt}</h6>;
- const updateValue = (
- index: number,
- newValue: string | boolean
- ): (string | boolean)[] => {
+ const validate = (values: (string | boolean)[]): boolean => {
+ const { inputValidator } = props;
+ if (inputValidator != null) {
+ const result = inputValidator(
+ (values as unknown) as MapOperationInputInfoValueTypeList<
+ OperationInputInfoList
+ >
+ );
+ setInputError(result);
+ return isNoError(result);
+ }
+ return true;
+ };
+
+ const updateValue = (index: number, newValue: string | boolean): void => {
const oldValues = values;
const newValues = oldValues.slice();
newValues[index] = newValue;
setValues(newValues);
- return newValues;
- };
-
- const testErrorInfo = (errorInfo: OperationInputErrorInfo): boolean => {
- for (let i = 0; i < inputScheme.length; i++) {
- if (inputScheme[i].type === "text" && errorInfo[i] != null) {
- return true;
- }
- }
- return false;
- };
-
- const calculateError = (
- oldError: OperationInputErrorInfo,
- index: number,
- newError: OperationInputOptionalError | OperationInputErrorInfo
- ): OperationInputErrorInfo => {
- if (newError === undefined) {
- return oldError;
- } else if (newError === null || typeof newError === "string") {
- return { ...oldError, [index]: newError };
- } else {
- const newInputError: OperationInputErrorInfo = { ...oldError };
- for (const [index, error] of Object.entries(newError)) {
- if (error !== undefined) {
- newInputError[+index] = error as OperationInputOptionalError;
- }
- }
- return newInputError;
+ if (dirtyList[index] === false) {
+ const newDirtyList = dirtyList.slice();
+ newDirtyList[index] = true;
+ setDirtyList(newDirtyList);
}
+ validate(newValues);
};
- const validateAll = (): boolean => {
- let newInputError = inputError;
- for (let i = 0; i < inputScheme.length; i++) {
- const item = inputScheme[i];
- if (item.type === "text") {
- newInputError = calculateError(
- newInputError,
- i,
- item.validator?.(values[i] as string, values)
- );
- }
- }
- const result = !testErrorInfo(newInputError);
- setInputError(newInputError);
- return result;
- };
+ const canProcess = isNoError(inputError);
body = (
<>
@@ -220,26 +229,23 @@ const OperationDialog: React.FC<OperationDialogProps> = (props) => {
{inputPrompt}
{inputScheme.map((item, index) => {
const value = values[index];
- const error: string | undefined = ((e) =>
- typeof e === "string" ? t(e) : undefined)(inputError?.[index]);
+ const error: string | null =
+ dirtyList[index] && inputError != null
+ ? convertI18nText(inputError[index], t)
+ : null;
if (item.type === "text") {
return (
<Form.Group key={index}>
- {item.label && <Form.Label>{t(item.label)}</Form.Label>}
+ {item.label && (
+ <Form.Label>{convertI18nText(item.label, t)}</Form.Label>
+ )}
<Form.Control
type={item.password === true ? "password" : "text"}
value={value as string}
onChange={(e) => {
const v = e.target.value;
- const newValues = updateValue(index, v);
- setInputError(
- calculateError(
- inputError,
- index,
- item.validator?.(v, newValues)
- )
- );
+ updateValue(index, v);
}}
isInvalid={error != null}
disabled={process}
@@ -263,7 +269,7 @@ const OperationDialog: React.FC<OperationDialogProps> = (props) => {
onChange={(event) => {
updateValue(index, event.currentTarget.checked);
}}
- label={t(item.label)}
+ label={convertI18nText(item.label, t)}
disabled={process}
/>
</Form.Group>
@@ -271,7 +277,7 @@ const OperationDialog: React.FC<OperationDialogProps> = (props) => {
} else if (item.type === "select") {
return (
<Form.Group key={index}>
- <Form.Label>{t(item.label)}</Form.Label>
+ <Form.Label>{convertI18nText(item.label, t)}</Form.Label>
<Form.Control
as="select"
value={value as string}
@@ -284,7 +290,7 @@ const OperationDialog: React.FC<OperationDialogProps> = (props) => {
return (
<option value={option.value} key={i}>
{option.icon}
- {t(option.label)}
+ {convertI18nText(option.label, t)}
</option>
);
})}
@@ -301,9 +307,10 @@ const OperationDialog: React.FC<OperationDialogProps> = (props) => {
<LoadingButton
variant="primary"
loading={process}
- disabled={testErrorInfo(inputError)}
+ disabled={!canProcess}
onClick={() => {
- if (validateAll()) {
+ setDirtyList(inputScheme.map(() => true));
+ if (validate(values)) {
onConfirm();
}
}}
@@ -338,7 +345,10 @@ const OperationDialog: React.FC<OperationDialogProps> = (props) => {
);
}
- const title = typeof props.title === "string" ? t(props.title) : props.title;
+ const title =
+ typeof props.title === "function"
+ ? props.title()
+ : convertI18nText(props.title, t);
return (
<Modal show={props.open} onHide={close}>
diff --git a/FrontEnd/src/app/views/common/SearchInput.tsx b/FrontEnd/src/app/views/common/SearchInput.tsx
index 9833d515..1373bd68 100644
--- a/FrontEnd/src/app/views/common/SearchInput.tsx
+++ b/FrontEnd/src/app/views/common/SearchInput.tsx
@@ -36,7 +36,7 @@ const SearchInput: React.FC<SearchInputProps> = (props) => {
);
return (
- <Form inline className={clsx("my-2", props.className)}>
+ <Form inline className={clsx(" flex-sm-nowrap", props.className)}>
<Form.Control
className="mr-sm-2 flex-grow-1"
value={props.value}
@@ -44,10 +44,12 @@ const SearchInput: React.FC<SearchInputProps> = (props) => {
onKeyPress={onInputKeyPress}
placeholder={props.placeholder}
/>
- <div className="mt-2 mt-sm-0 order-sm-last ml-sm-3">
- {props.additionalButton}
- </div>
- <div className="mt-2 mt-sm-0 ml-auto ml-sm-0">
+ {props.additionalButton ? (
+ <div className="mt-2 mt-sm-0 flex-shrink-0 order-sm-last ml-sm-2">
+ {props.additionalButton}
+ </div>
+ ) : null}
+ <div className="mt-2 mt-sm-0 flex-shrink-0 ml-auto ml-sm-0">
{props.loading ? (
<Spinner variant="primary" animation="border" />
) : (
diff --git a/FrontEnd/src/app/views/home/BoardWithUser.tsx b/FrontEnd/src/app/views/home/BoardWithUser.tsx
index dcd39cbe..fbe1dd89 100644
--- a/FrontEnd/src/app/views/home/BoardWithUser.tsx
+++ b/FrontEnd/src/app/views/home/BoardWithUser.tsx
@@ -62,9 +62,9 @@ const BoardWithUser: React.FC<{ user: UserWithToken }> = ({ user }) => {
}, [user, joinTimelines]);
return (
- <Row className="my-2 justify-content-center">
+ <Row className="my-3 justify-content-center">
{ownTimelines === "offline" && joinTimelines === "offline" ? (
- <Col className="py-2" sm="8" lg="6">
+ <Col sm="8" lg="6">
<OfflineBoard
onReload={() => {
setOwnTimelines("loading");
@@ -74,7 +74,7 @@ const BoardWithUser: React.FC<{ user: UserWithToken }> = ({ user }) => {
</Col>
) : (
<>
- <Col sm="6" lg="5" className="py-2">
+ <Col sm="6" lg="5" className="mb-3 mb-sm-0">
<TimelineBoard
title={t("home.ownTimeline")}
timelines={ownTimelines}
@@ -83,7 +83,7 @@ const BoardWithUser: React.FC<{ user: UserWithToken }> = ({ user }) => {
}}
/>
</Col>
- <Col sm="6" lg="5" className="py-2">
+ <Col sm="6" lg="5">
<TimelineBoard
title={t("home.joinTimeline")}
timelines={joinTimelines}
diff --git a/FrontEnd/src/app/views/home/BoardWithoutUser.tsx b/FrontEnd/src/app/views/home/BoardWithoutUser.tsx
index ebfddb50..7e30f799 100644
--- a/FrontEnd/src/app/views/home/BoardWithoutUser.tsx
+++ b/FrontEnd/src/app/views/home/BoardWithoutUser.tsx
@@ -34,7 +34,7 @@ const BoardWithoutUser: React.FC = () => {
}, [publicTimelines]);
return (
- <Row className="my-2 justify-content-center">
+ <Row className="my-3 justify-content-center">
{publicTimelines === "offline" ? (
<Col sm="8" lg="6">
<OfflineBoard
diff --git a/FrontEnd/src/app/views/home/TimelineCreateDialog.tsx b/FrontEnd/src/app/views/home/TimelineCreateDialog.tsx
index d9467719..786ebb5d 100644
--- a/FrontEnd/src/app/views/home/TimelineCreateDialog.tsx
+++ b/FrontEnd/src/app/views/home/TimelineCreateDialog.tsx
@@ -20,27 +20,28 @@ const TimelineCreateDialog: React.FC<TimelineCreateDialogProps> = (props) => {
close={props.close}
titleColor="success"
title="home.createDialog.title"
- inputScheme={[
- {
- type: "text",
- label: "home.createDialog.name",
- helperText: "home.createDialog.nameFormat",
- validator: (name) => {
- if (name.length === 0) {
- return "home.createDialog.noEmpty";
- } else if (name.length > 26) {
- return "home.createDialog.tooLong";
- } else if (!validateTimelineName(name)) {
- return "home.createDialog.badFormat";
- } else {
- return null;
- }
+ 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]) => {
- nameSaved = name as string;
- return timelineService.createTimeline(nameSaved).toPromise();
+ return timelineService.createTimeline(name).toPromise();
}}
onSuccessAndClose={() => {
history.push(`timelines/${nameSaved}`);
diff --git a/FrontEnd/src/app/views/home/index.tsx b/FrontEnd/src/app/views/home/index.tsx
index 760adcea..0c36545c 100644
--- a/FrontEnd/src/app/views/home/index.tsx
+++ b/FrontEnd/src/app/views/home/index.tsx
@@ -34,8 +34,8 @@ const HomePage: React.FC = () => {
return (
<>
<Container fluid>
- <Row className="justify-content-center">
- <Col xs={12} sm={10} md={8} lg={6}>
+ <Row className="my-3 justify-content-center">
+ <Col xs={12} sm={8} lg={6}>
<SearchInput
className="justify-content-center"
value={navText}
diff --git a/FrontEnd/src/app/views/settings/index.tsx b/FrontEnd/src/app/views/settings/index.tsx
index 964e7442..4d4f18b5 100644
--- a/FrontEnd/src/app/views/settings/index.tsx
+++ b/FrontEnd/src/app/views/settings/index.tsx
@@ -4,9 +4,7 @@ import { useTranslation } from "react-i18next";
import { Form, Container, Row, Col, Button, Modal } from "react-bootstrap";
import { useUser, userService } from "@/services/user";
-import OperationDialog, {
- OperationInputErrorInfo,
-} from "../common/OperationDialog";
+import OperationDialog from "../common/OperationDialog";
interface ChangePasswordDialogProps {
open: boolean;
@@ -15,60 +13,47 @@ interface ChangePasswordDialogProps {
const ChangePasswordDialog: React.FC<ChangePasswordDialogProps> = (props) => {
const history = useHistory();
- const { t } = useTranslation();
const [redirect, setRedirect] = useState<boolean>(false);
return (
<OperationDialog
open={props.open}
- title={t("settings.dialogChangePassword.title")}
+ title="settings.dialogChangePassword.title"
titleColor="dangerous"
- inputPrompt={t("settings.dialogChangePassword.prompt")}
+ inputPrompt="settings.dialogChangePassword.prompt"
inputScheme={[
{
type: "text",
- label: t("settings.dialogChangePassword.inputOldPassword"),
+ label: "settings.dialogChangePassword.inputOldPassword",
password: true,
- validator: (v) =>
- v === ""
- ? "settings.dialogChangePassword.errorEmptyOldPassword"
- : null,
},
{
type: "text",
- label: t("settings.dialogChangePassword.inputNewPassword"),
+ label: "settings.dialogChangePassword.inputNewPassword",
password: true,
- validator: (v, values) => {
- const error: OperationInputErrorInfo = {};
- error[1] =
- v === ""
- ? "settings.dialogChangePassword.errorEmptyNewPassword"
- : null;
- if (v === values[2]) {
- error[2] = null;
- } else {
- if (values[2] !== "") {
- error[2] = "settings.dialogChangePassword.errorRetypeNotMatch";
- }
- }
- return error;
- },
},
{
type: "text",
- label: t("settings.dialogChangePassword.inputRetypeNewPassword"),
+ label: "settings.dialogChangePassword.inputRetypeNewPassword",
password: true,
- validator: (v, values) =>
- v !== values[1]
- ? "settings.dialogChangePassword.errorRetypeNotMatch"
- : null,
},
]}
+ inputValidator={([oldPassword, newPassword, retypedNewPassword]) => {
+ const result: Record<number, string> = {};
+ if (oldPassword === "") {
+ result[0] = "settings.dialogChangePassword.errorEmptyOldPassword";
+ }
+ if (newPassword === "") {
+ result[1] = "settings.dialogChangePassword.errorEmptyNewPassword";
+ }
+ if (retypedNewPassword !== newPassword) {
+ result[2] = "settings.dialogChangePassword.errorRetypeNotMatch";
+ }
+ return result;
+ }}
onProcess={async ([oldPassword, newPassword]) => {
- await userService
- .changePassword(oldPassword as string, newPassword as string)
- .toPromise();
+ await userService.changePassword(oldPassword, newPassword).toPromise();
await userService.logout();
setRedirect(true);
}}
diff --git a/FrontEnd/src/app/views/timeline-common/Timeline.tsx b/FrontEnd/src/app/views/timeline-common/Timeline.tsx
index fd051d45..9047919c 100644
--- a/FrontEnd/src/app/views/timeline-common/Timeline.tsx
+++ b/FrontEnd/src/app/views/timeline-common/Timeline.tsx
@@ -4,6 +4,7 @@ import clsx from "clsx";
import { TimelinePostInfo } from "@/services/timeline";
import TimelineItem from "./TimelineItem";
+import TimelineTop from "./TimelineTop";
export interface TimelinePostInfoEx extends TimelinePostInfo {
deletable: boolean;
@@ -13,6 +14,7 @@ export type TimelineDeleteCallback = (index: number, id: number) => void;
export interface TimelineProps {
className?: string;
+ style?: React.CSSProperties;
posts: TimelinePostInfoEx[];
onDelete: TimelineDeleteCallback;
onResize?: () => void;
@@ -51,7 +53,12 @@ const Timeline: React.FC<TimelineProps> = (props) => {
}, [posts, onDelete]);
return (
- <div ref={props.containerRef} className={clsx("timeline", props.className)}>
+ <div
+ ref={props.containerRef}
+ style={props.style}
+ className={clsx("timeline", props.className)}
+ >
+ <TimelineTop height="56px" />
{(() => {
const length = posts.length;
return posts.map((post, i) => {
diff --git a/FrontEnd/src/app/views/timeline-common/TimelineItem.tsx b/FrontEnd/src/app/views/timeline-common/TimelineItem.tsx
index 4db23371..5ccc5523 100644
--- a/FrontEnd/src/app/views/timeline-common/TimelineItem.tsx
+++ b/FrontEnd/src/app/views/timeline-common/TimelineItem.tsx
@@ -93,12 +93,12 @@ const TimelineItem: React.FC<TimelineItemProps> = (props) => {
{current && <div className="timeline-line-segment current-end" />}
</div>
</div>
- <div className="timeline-content-area">
+ <div className="timeline-item-card">
<div>
<span className="mr-2">
- <span className="text-primary white-space-no-wrap mr-2">
+ <small className="text-secondary white-space-no-wrap mr-2">
{props.post.time.toLocaleString(i18n.languages)}
- </span>
+ </small>
<small className="text-dark">{props.post.author.nickname}</small>
</span>
{more != null ? (
@@ -138,33 +138,33 @@ const TimelineItem: React.FC<TimelineItemProps> = (props) => {
}
})()}
</div>
+ {more != null && more.isOpen ? (
+ <>
+ <div
+ className="position-absolute position-lt w-100 h-100 mask d-flex justify-content-center align-items-center"
+ onClick={more.toggle}
+ >
+ <Svg
+ src={trashIcon}
+ className="text-danger icon-button large"
+ onClick={(e) => {
+ toggleDeleteDialog();
+ e.stopPropagation();
+ }}
+ />
+ </div>
+ {deleteDialog ? (
+ <TimelinePostDeleteConfirmDialog
+ toggle={() => {
+ toggleDeleteDialog();
+ more.toggle();
+ }}
+ onConfirm={more.onDelete}
+ />
+ ) : null}
+ </>
+ ) : null}
</div>
- {more != null && more.isOpen ? (
- <>
- <div
- className="position-absolute position-lt w-100 h-100 mask d-flex justify-content-center align-items-center"
- onClick={more.toggle}
- >
- <Svg
- src={trashIcon}
- className="text-danger icon-button large"
- onClick={(e) => {
- toggleDeleteDialog();
- e.stopPropagation();
- }}
- />
- </div>
- {deleteDialog ? (
- <TimelinePostDeleteConfirmDialog
- toggle={() => {
- toggleDeleteDialog();
- more.toggle();
- }}
- onConfirm={more.onDelete}
- />
- ) : null}
- </>
- ) : null}
</div>
);
};
diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx
index 6c2c43c1..036577b1 100644
--- a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx
+++ b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx
@@ -16,7 +16,6 @@ import Timeline, {
TimelinePostInfoEx,
TimelineDeleteCallback,
} from "./Timeline";
-import TimelineTop from "./TimelineTop";
import TimelinePostEdit, { TimelinePostSendCallback } from "./TimelinePostEdit";
import { TimelineSyncStatus } from "./SyncStatusBadge";
@@ -48,13 +47,10 @@ export default function TimelinePageTemplateUI<TManageItems>(
const { t } = useTranslation();
- const bottomSpaceRef = React.useRef<HTMLDivElement | null>(null);
+ const [bottomSpaceHeight, setBottomSpaceHeight] = React.useState<number>(0);
const onPostEditHeightChange = React.useCallback((height: number): void => {
- const { current: bottomSpaceDiv } = bottomSpaceRef;
- if (bottomSpaceDiv != null) {
- bottomSpaceDiv.style.height = `${height}px`;
- }
+ setBottomSpaceHeight(height);
if (height === 0) {
const alertHost = getAlertHost();
if (alertHost != null) {
@@ -178,6 +174,9 @@ export default function TimelinePageTemplateUI<TManageItems>(
timelineBody = (
<Timeline
+ style={{
+ minHeight: `calc(100vh - 56px - ${bottomSpaceHeight}px)`,
+ }}
containerRef={timelineRef}
posts={posts}
onDelete={props.onDelete}
@@ -188,7 +187,10 @@ export default function TimelinePageTemplateUI<TManageItems>(
timelineBody = (
<>
{timelineBody}
- <div ref={bottomSpaceRef} className="flex-fix-length" />
+ <div
+ style={{ height: bottomSpaceHeight }}
+ className="flex-fix-length"
+ />
<TimelinePostEdit
className="fixed-bottom"
onPost={props.onPost}
@@ -226,7 +228,6 @@ export default function TimelinePageTemplateUI<TManageItems>(
collapse={cardCollapse}
toggleCollapse={toggleCardCollapse}
/>
- <TimelineTop height="56px" />
{timelineBody}
</>
);
diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx
index 223525f9..ee49586e 100644
--- a/FrontEnd/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx
+++ b/FrontEnd/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx
@@ -6,9 +6,7 @@ import {
TimelineChangePropertyRequest,
} from "@/services/timeline";
-import OperationDialog, {
- OperationSelectInputInfoOption,
-} from "../common/OperationDialog";
+import OperationDialog from "../common/OperationDialog";
export interface TimelinePropertyInfo {
title: string;
@@ -45,12 +43,10 @@ const TimelinePropertyChangeDialog: React.FC<TimelinePropertyChangeDialogProps>
{
type: "select",
label: "timeline.dialogChangeProperty.visibility",
- options: kTimelineVisibilities.map<OperationSelectInputInfoOption>(
- (v) => ({
- label: labelMap[v],
- value: v,
- })
- ),
+ options: kTimelineVisibilities.map((v) => ({
+ label: labelMap[v],
+ value: v,
+ })),
initValue: props.oldInfo.visibility,
},
{
@@ -64,13 +60,13 @@ const TimelinePropertyChangeDialog: React.FC<TimelinePropertyChangeDialogProps>
onProcess={([newTitle, newVisibility, newDescription]) => {
const req: TimelineChangePropertyRequest = {};
if (newTitle !== props.oldInfo.title) {
- req.title = newTitle as string;
+ req.title = newTitle;
}
if (newVisibility !== props.oldInfo.visibility) {
req.visibility = newVisibility as TimelineVisibility;
}
if (newDescription !== props.oldInfo.description) {
- req.description = newDescription as string;
+ req.description = newDescription;
}
return props.onProcess(req);
}}
diff --git a/FrontEnd/src/app/views/timeline-common/timeline-background.svg b/FrontEnd/src/app/views/timeline-common/timeline-background.svg
new file mode 100644
index 00000000..b72c448b
--- /dev/null
+++ b/FrontEnd/src/app/views/timeline-common/timeline-background.svg
@@ -0,0 +1,4 @@
+<svg viewBox="0 0 100 80" xmlns="http://www.w3.org/2000/svg" stroke="rgba(255,196,0,0.6)">
+ <line x1="0" y1="0" x2="100" y2="40" stroke-width="5" />
+ <line x1="0" y1="80" x2="100" y2="40" stroke-width="5" />
+</svg> \ No newline at end of file
diff --git a/FrontEnd/src/app/views/timeline-common/timeline-common.sass b/FrontEnd/src/app/views/timeline-common/timeline-common.sass
index 4151bfcc..8d9ee04d 100644
--- a/FrontEnd/src/app/views/timeline-common/timeline-common.sass
+++ b/FrontEnd/src/app/views/timeline-common/timeline-common.sass
@@ -4,8 +4,13 @@
z-index: 0
position: relative
+ background-image: url("views/timeline-common/timeline-background.svg")
+ background-size: 100% auto
+ background-repeat: no-repeat repeat
+
&-item
- display: flex
+ position: relative
+ padding: 0.5em
$timeline-line-width: 7px
$timeline-line-node-radius: 18px
@@ -30,11 +35,15 @@ $timeline-line-color-current: #36c2e6
.timeline-line
&-area-container
+ position: absolute
display: flex
justify-content: flex-end
padding-right: 5px
+ z-index: 1
- flex: 0 0 auto
+ top: 0em
+ bottom: 0em
+ left: 0.5em
width: 60px
&-area
@@ -48,14 +57,14 @@ $timeline-line-color-current: #36c2e6
background: $timeline-line-color
&.start
- height: 14px
+ height: 1.4em
flex: 0 0 auto
&.end
flex: 1 1 auto
&.current-end
- height: 20px
+ height: 2em
flex: 0 0 auto
background: linear-gradient($timeline-line-color-current, transparent)
@@ -78,13 +87,16 @@ $timeline-line-color-current: #36c2e6
animation-name: timeline-line-node-noncurrent
.timeline-top
- display: flex
- justify-content: space-between
+ position: relative
+ text-align: right
.timeline-line-segment
flex: 1 1 auto
.current
+ &.timeline-item
+ padding-bottom: 2.5em
+
.timeline-line
&-segment
@@ -97,9 +109,15 @@ $timeline-line-color-current: #36c2e6
&-node
animation-name: timeline-line-node-current
-.timeline-content-area
- padding: 10px 0
- flex-grow: 1
+.timeline-item-card
+ @extend .cru-card
+ @extend .clearfix
+ position: relative
+ padding: 0.5em 2em 0.5em 60px
+ transition: background 0.5s
+
+ &:hover
+ background: $gray-200
.timeline-item-delete-button
position: absolute
@@ -121,9 +139,6 @@ $timeline-line-color-current: #36c2e6
background: change-color($color: white, $alpha: 0.8)
z-index: 100
-.timeline-page-top-space
- transition: height 0.5s
-
.timeline-sync-state-badge
font-size: 0.8em
padding: 3px 8px
diff --git a/FrontEnd/src/app/views/timeline/TimelineDeleteDialog.tsx b/FrontEnd/src/app/views/timeline/TimelineDeleteDialog.tsx
index 894b8195..33609158 100644
--- a/FrontEnd/src/app/views/timeline/TimelineDeleteDialog.tsx
+++ b/FrontEnd/src/app/views/timeline/TimelineDeleteDialog.tsx
@@ -33,15 +33,15 @@ const TimelineDeleteDialog: React.FC<TimelineDeleteDialog> = (props) => {
inputScheme={[
{
type: "text",
- validator: (value) => {
- if (value !== name) {
- return "timeline.deleteDialog.notMatch";
- } else {
- return null;
- }
- },
},
]}
+ inputValidator={([value]) => {
+ if (value !== name) {
+ return { 0: "timeline.deleteDialog.notMatch" };
+ } else {
+ return null;
+ }
+ }}
onProcess={() => {
return timelineService.deleteTimeline(name).toPromise();
}}
diff --git a/FrontEnd/src/app/views/timeline/TimelineInfoCard.tsx b/FrontEnd/src/app/views/timeline/TimelineInfoCard.tsx
index abc3d0c9..1070c6d7 100644
--- a/FrontEnd/src/app/views/timeline/TimelineInfoCard.tsx
+++ b/FrontEnd/src/app/views/timeline/TimelineInfoCard.tsx
@@ -36,7 +36,7 @@ const TimelineInfoCard: React.FC<TimelineInfoCardProps> = (props) => {
collapse={collapse}
toggleCollapse={toggleCollapse}
>
- <h3 className="text-primary mx-3 d-inline-block align-middle">
+ <h3 className="text-primary d-inline-block align-middle">
{timeline.title}
<small className="ml-3 text-secondary">{timeline.name}</small>
</h3>
diff --git a/FrontEnd/src/app/views/user/ChangeNicknameDialog.tsx b/FrontEnd/src/app/views/user/ChangeNicknameDialog.tsx
index 251b18c5..0e95b05b 100644
--- a/FrontEnd/src/app/views/user/ChangeNicknameDialog.tsx
+++ b/FrontEnd/src/app/views/user/ChangeNicknameDialog.tsx
@@ -18,7 +18,7 @@ const ChangeNicknameDialog: React.FC<ChangeNicknameDialogProps> = (props) => {
{ type: "text", label: "userPage.dialogChangeNickname.inputLabel" },
]}
onProcess={([newNickname]) => {
- return props.onProcess(newNickname as string);
+ return props.onProcess(newNickname);
}}
close={props.close}
/>
diff --git a/FrontEnd/src/app/views/user/UserInfoCard.tsx b/FrontEnd/src/app/views/user/UserInfoCard.tsx
index e4edd80d..4a0c9e87 100644
--- a/FrontEnd/src/app/views/user/UserInfoCard.tsx
+++ b/FrontEnd/src/app/views/user/UserInfoCard.tsx
@@ -35,16 +35,13 @@ const UserInfoCard: React.FC<UserInfoCardProps> = (props) => {
collapse={collapse}
toggleCollapse={toggleCollapse}
>
- <h3 className="text-primary mx-3 d-inline-block align-middle">
+ <h3 className="text-primary d-inline-block align-middle">
{timeline.title}
<small className="ml-3 text-secondary">{timeline.name}</small>
</h3>
<div className="align-middle">
<BlobImage blob={avatar} className="avatar small rounded-circle mr-3" />
{timeline.owner.nickname}
- <small className="ml-3 text-secondary">
- @{timeline.owner.username}
- </small>
</div>
<p className="mb-0">{timeline.description}</p>
<small className="mt-1 d-block">