diff options
Diffstat (limited to 'FrontEnd/src/app')
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"> |