diff options
Diffstat (limited to 'FrontEnd/src/pages')
54 files changed, 3159 insertions, 0 deletions
diff --git a/FrontEnd/src/pages/404/index.css b/FrontEnd/src/pages/404/index.css new file mode 100644 index 00000000..cf5efbe7 --- /dev/null +++ b/FrontEnd/src/pages/404/index.css @@ -0,0 +1,7 @@ +.page-404 { + width: 100%; + text-align: center; + padding-top: 1em; + font-size: 2em; + color: var(--cru-danger-color); +}
\ No newline at end of file diff --git a/FrontEnd/src/pages/404/index.tsx b/FrontEnd/src/pages/404/index.tsx new file mode 100644 index 00000000..751a450b --- /dev/null +++ b/FrontEnd/src/pages/404/index.tsx @@ -0,0 +1,5 @@ +import "./index.css"; + +export default function NotFoundPage() { + return <div className="page-404">Ah-oh, 404!</div>; +} diff --git a/FrontEnd/src/pages/about/index.css b/FrontEnd/src/pages/about/index.css new file mode 100644 index 00000000..1ce7a7c8 --- /dev/null +++ b/FrontEnd/src/pages/about/index.css @@ -0,0 +1,7 @@ +.about-page { + line-height: 1.5; +} + +.about-page a { + color: var(--cru-surface-on-color); +} diff --git a/FrontEnd/src/pages/about/index.tsx b/FrontEnd/src/pages/about/index.tsx new file mode 100644 index 00000000..bce64322 --- /dev/null +++ b/FrontEnd/src/pages/about/index.tsx @@ -0,0 +1,87 @@ +import "./index.css"; + +import { useC } from "~src/common"; +import Page from "~src/components/Page"; + +interface Credit { + name: string; + url: string; +} + +type Credits = Credit[]; + +const frontendCredits: Credits = [ + { + name: "react.js", + url: "https://reactjs.org", + }, + { + name: "typescript", + url: "https://www.typescriptlang.org", + }, + { + name: "bootstrap", + url: "https://getbootstrap.com", + }, + { + name: "parcel.js", + url: "https://parceljs.org", + }, + { + name: "eslint", + url: "https://eslint.org", + }, + { + name: "prettier", + url: "https://prettier.io", + }, +]; + +const backendCredits: Credits = [ + { + name: "ASP.NET Core", + url: "https://dotnet.microsoft.com/learn/aspnet/what-is-aspnet-core", + }, + { name: "sqlite", url: "https://sqlite.org" }, + { + name: "ImageSharp", + url: "https://github.com/SixLabors/ImageSharp", + }, +]; + +export default function AboutPage() { + const c = useC(); + + return ( + <Page className="about-page"> + <h2>{c("about.credits.title")}</h2> + <p>{c("about.credits.content")}</p> + <h3>{c("about.credits.frontend")}</h3> + <ul> + {frontendCredits.map((item, index) => { + return ( + <li key={index}> + <a href={item.url} target="_blank" rel="noopener noreferrer"> + {item.name} + </a> + </li> + ); + })} + <li>...</li> + </ul> + <h3>{c("about.credits.backend")}</h3> + <ul> + {backendCredits.map((item, index) => { + return ( + <li key={index}> + <a href={item.url} target="_blank" rel="noopener noreferrer"> + {item.name} + </a> + </li> + ); + })} + <li>...</li> + </ul> + </Page> + ); +} diff --git a/FrontEnd/src/pages/home/index.css b/FrontEnd/src/pages/home/index.css new file mode 100644 index 00000000..16601d8a --- /dev/null +++ b/FrontEnd/src/pages/home/index.css @@ -0,0 +1,13 @@ +.home-page { + width: 100%; + text-align: center; + padding-top: 1em; + font-size: 2em; + color: var(--cru-primary-color); +} + +.home-page-2 { + width: 100%; + text-align: center; + margin-top: 2em; +} diff --git a/FrontEnd/src/pages/home/index.tsx b/FrontEnd/src/pages/home/index.tsx new file mode 100644 index 00000000..c29a1ca5 --- /dev/null +++ b/FrontEnd/src/pages/home/index.tsx @@ -0,0 +1,12 @@ +import "./index.css"; + +export default function HomePage() { + return ( + <> + <div className="home-page">Be patient! I'm working on this...</div> + <div className="home-page-2"> + Have a look at <a href="/crupest">here</a>! + </div> + </> + ); +} diff --git a/FrontEnd/src/pages/loading/index.css b/FrontEnd/src/pages/loading/index.css new file mode 100644 index 00000000..08e43c22 --- /dev/null +++ b/FrontEnd/src/pages/loading/index.css @@ -0,0 +1,7 @@ +.loading-page { + width: 100%; + text-align: center; + padding-top: 1em; + font-size: 2em; + color: var(--cru-primary-color); +}
\ No newline at end of file diff --git a/FrontEnd/src/pages/loading/index.tsx b/FrontEnd/src/pages/loading/index.tsx new file mode 100644 index 00000000..29d27adc --- /dev/null +++ b/FrontEnd/src/pages/loading/index.tsx @@ -0,0 +1,11 @@ +import Spinner from "~src/components/Spinner"; + +import "./index.css"; + +export default function LoadingPage() { + return ( + <div className="loading-page"> + <Spinner /> + </div> + ); +} diff --git a/FrontEnd/src/pages/login/index.css b/FrontEnd/src/pages/login/index.css new file mode 100644 index 00000000..ef97359c --- /dev/null +++ b/FrontEnd/src/pages/login/index.css @@ -0,0 +1,14 @@ +.login-page {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+.login-page-welcome {
+ text-align: center;
+ font-size: 2em;
+}
+
+.login-page-error {
+ color: var(--cru-danger-color);
+}
\ No newline at end of file diff --git a/FrontEnd/src/pages/login/index.tsx b/FrontEnd/src/pages/login/index.tsx new file mode 100644 index 00000000..39ea3831 --- /dev/null +++ b/FrontEnd/src/pages/login/index.tsx @@ -0,0 +1,127 @@ +import { useState, useEffect } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { Trans } from "react-i18next"; + +import { useUser, userService } from "~src/services/user"; + +import { useC } from "~src/components/common"; +import LoadingButton from "~src/components/button/LoadingButton"; +import { InputGroup, useInputs } from "~src/components/input/InputGroup"; +import Page from "~src/components/Page"; + +import "./index.css"; + +export default function LoginPage() { + const c = useC(); + + const user = useUser(); + + const navigate = useNavigate(); + + const [process, setProcess] = useState<boolean>(false); + const [error, setError] = useState<string | null>(null); + + const { hasErrorAndDirty, confirm, setAllDisabled, inputGroupProps } = + useInputs({ + init: { + scheme: { + inputs: [ + { + key: "username", + type: "text", + label: "user.username", + }, + { + key: "password", + type: "text", + label: "user.password", + password: true, + }, + { + key: "rememberMe", + type: "bool", + label: "user.rememberMe", + }, + ], + validator: ({ username, password }, errors) => { + if (username === "") { + errors["username"] = "login.emptyUsername"; + } + if (password === "") { + errors["password"] = "login.emptyPassword"; + } + }, + }, + dataInit: {}, + }, + }); + + useEffect(() => { + if (user != null) { + const id = setTimeout(() => navigate("/"), 3000); + return () => { + clearTimeout(id); + }; + } + }, [navigate, user]); + + if (user != null) { + return <p>{c("login.alreadyLogin")}</p>; + } + + const submit = (): void => { + const confirmResult = confirm(); + if (confirmResult.type === "ok") { + const { username, password, rememberMe } = confirmResult.values; + setAllDisabled(true); + setProcess(true); + userService + .login( + { + username: username as string, + password: password as string, + }, + rememberMe as boolean, + ) + .then( + () => { + if (history.length === 0) { + navigate("/"); + } else { + navigate(-1); + } + }, + (e: Error) => { + setProcess(false); + setAllDisabled(false); + setError(e.message); + }, + ); + } + }; + + return ( + <Page className="login-page"> + <div className="login-page-container"> + <div className="login-page-welcome">{c("welcome")}</div> + <InputGroup {...inputGroupProps} /> + {error ? <p className="login-page-error">{c(error)}</p> : null} + <div className="login-page-button-row"> + <LoadingButton + loading={process} + onClick={(e) => { + submit(); + e.preventDefault(); + }} + disabled={hasErrorAndDirty} + > + {c("user.login")} + </LoadingButton> + </div> + <Trans i18nKey="login.noAccount"> + 0<Link to="/register">1</Link>2 + </Trans> + </div> + </Page> + ); +} diff --git a/FrontEnd/src/pages/register/index.css b/FrontEnd/src/pages/register/index.css new file mode 100644 index 00000000..c0078b28 --- /dev/null +++ b/FrontEnd/src/pages/register/index.css @@ -0,0 +1,5 @@ +.register-page { + display: flex; + flex-direction: column; + align-items: center; +} diff --git a/FrontEnd/src/pages/register/index.tsx b/FrontEnd/src/pages/register/index.tsx new file mode 100644 index 00000000..fa25c2c2 --- /dev/null +++ b/FrontEnd/src/pages/register/index.tsx @@ -0,0 +1,130 @@ +import { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; + +import { HttpBadRequestError } from "~src/http/common"; +import { getHttpTokenClient } from "~src/http/token"; +import { userService, useUser } from "~src/services/user"; + +import { LoadingButton } from "~src/components/button"; +import { useInputs, InputGroup } from "~src/components/input/InputGroup"; + +import "./index.css"; + +export default function RegisterPage() { + const navigate = useNavigate(); + + const { t } = useTranslation(); + + const user = useUser(); + + const { hasErrorAndDirty, confirm, setAllDisabled, inputGroupProps } = + useInputs({ + init: { + scheme: { + inputs: [ + { + key: "username", + type: "text", + label: "register.username", + }, + { + key: "password", + type: "text", + label: "register.password", + password: true, + }, + { + key: "confirmPassword", + type: "text", + label: "register.confirmPassword", + password: true, + }, + { + key: "registerCode", + + type: "text", + label: "register.registerCode", + }, + ], + validator: ( + { username, password, confirmPassword, registerCode }, + errors, + ) => { + if (username === "") { + errors["username"] = "register.error.usernameEmpty"; + } + if (password === "") { + errors["password"] = "register.error.passwordEmpty"; + } + if (confirmPassword !== password) { + errors["confirmPassword"] = "register.error.confirmPasswordWrong"; + } + if (registerCode === "") { + errors["registerCode"] = "register.error.registerCodeEmpty"; + } + }, + }, + dataInit: {}, + }, + }); + + const [process, setProcess] = useState<boolean>(false); + const [resultError, setResultError] = useState<string | null>(null); + + useEffect(() => { + if (user != null) { + navigate("/"); + } + }, [navigate, user]); + + return ( + <div className="container register-page"> + <InputGroup {...inputGroupProps} /> + {resultError && <div className="cru-color-danger">{t(resultError)}</div>} + <LoadingButton + text="register.register" + loading={process} + disabled={hasErrorAndDirty} + onClick={() => { + const confirmResult = confirm(); + if (confirmResult.type === "ok") { + const { username, password, registerCode } = confirmResult.values; + setProcess(true); + setAllDisabled(true); + void getHttpTokenClient() + .register({ + username: username as string, + password: password as string, + registerCode: registerCode as string, + }) + .then( + () => { + void userService + .login( + { + username: username as string, + password: password as string, + }, + true, + ) + .then(() => { + navigate("/"); + }); + }, + (error) => { + if (error instanceof HttpBadRequestError) { + setResultError("register.error.registerCodeInvalid"); + } else { + setResultError("error.network"); + } + setProcess(false); + setAllDisabled(false); + }, + ); + } + }} + /> + </div> + ); +} diff --git a/FrontEnd/src/pages/setting/ChangeAvatarDialog.css b/FrontEnd/src/pages/setting/ChangeAvatarDialog.css new file mode 100644 index 00000000..c9eb8011 --- /dev/null +++ b/FrontEnd/src/pages/setting/ChangeAvatarDialog.css @@ -0,0 +1,22 @@ +.change-avatar-dialog-prompt { + margin: 0.5em 0; +} + +.change-avatar-dialog-prompt.success { + color: var(--cru-create-color); +} + +.change-avatar-dialog-prompt.error { + color: var(--cru-danger-color); +} + +.change-avatar-cropper { + max-width: 400px; + max-height: 400px; +} + +.change-avatar-preview-image { + min-width: 50%; + max-width: 100%; + max-height: 300px; +}
\ No newline at end of file diff --git a/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx b/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx new file mode 100644 index 00000000..0df10411 --- /dev/null +++ b/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx @@ -0,0 +1,276 @@ +import { useState, ChangeEvent, ComponentPropsWithoutRef } from "react"; + +import { useC, Text, UiLogicError } from "~src/common"; + +import { useUser } from "~src/services/user"; + +import { getHttpUserClient } from "~src/http/user"; + +import { ImageCropper, useImageCrop } from "~src/components/ImageCropper"; +import BlobImage from "~src/components/BlobImage"; +import { ButtonRowV2 } from "~src/components/button"; +import { + Dialog, + DialogContainer, + useDialogController, +} from "~src/components/dialog"; + +import "./ChangeAvatarDialog.css"; + +export default function ChangeAvatarDialog() { + const c = useC(); + + const user = useUser(); + + const controller = useDialogController(); + + type State = + | "select" + | "crop" + | "process-crop" + | "preview" + | "uploading" + | "success" + | "error"; + const [state, setState] = useState<State>("select"); + + const [file, setFile] = useState<File | null>(null); + + const { canCrop, crop, imageCropperProps } = useImageCrop(file, { + constraint: { + ratio: 1, + }, + }); + + const [resultBlob, setResultBlob] = useState<Blob | null>(null); + const [message, setMessage] = useState<Text>( + "settings.dialogChangeAvatar.prompt.select", + ); + + const close = controller.closeDialog; + + const onSelectFile = (e: ChangeEvent<HTMLInputElement>): void => { + const files = e.target.files; + if (files == null || files.length === 0) { + setFile(null); + } else { + setFile(files[0]); + } + }; + + const onCropNext = () => { + if (!canCrop) { + throw new UiLogicError(); + } + + setState("process-crop"); + + void crop().then((b) => { + setState("preview"); + setResultBlob(b); + }); + }; + + const onCropPrevious = () => { + setFile(null); + setState("select"); + }; + + const onPreviewPrevious = () => { + setState("crop"); + }; + + const upload = () => { + if (resultBlob == null) { + throw new UiLogicError(); + } + + if (user == null) { + throw new UiLogicError(); + } + + setState("uploading"); + controller.setCanSwitchDialog(false); + getHttpUserClient() + .putAvatar(user.username, resultBlob) + .then( + () => { + setState("success"); + }, + () => { + setState("error"); + setMessage("operationDialog.error"); + }, + ) + .finally(() => { + controller.setCanSwitchDialog(true); + }); + }; + + const cancelButton = { + key: "cancel", + text: "operationDialog.cancel", + onClick: close, + } as const; + + const createPreviousButton = (onClick: () => void) => + ({ + key: "previous", + text: "operationDialog.previousStep", + onClick, + }) as const; + + const buttonsMap: Record< + State, + ComponentPropsWithoutRef<typeof ButtonRowV2>["buttons"] + > = { + select: [ + cancelButton, + { + key: "next", + action: "major", + text: "operationDialog.nextStep", + onClick: () => setState("crop"), + disabled: file == null, + }, + ], + crop: [ + cancelButton, + createPreviousButton(onCropPrevious), + { + key: "next", + action: "major", + text: "operationDialog.nextStep", + onClick: onCropNext, + disabled: !canCrop, + }, + ], + "process-crop": [cancelButton, createPreviousButton(onPreviewPrevious)], + preview: [ + cancelButton, + createPreviousButton(onPreviewPrevious), + { + key: "upload", + action: "major", + text: "settings.dialogChangeAvatar.upload", + onClick: upload, + }, + ], + uploading: [], + success: [ + { + key: "ok", + text: "operationDialog.ok", + color: "create", + onClick: close, + }, + ], + error: [ + cancelButton, + { + key: "retry", + action: "major", + text: "operationDialog.retry", + onClick: upload, + }, + ], + }; + + return ( + <Dialog> + <DialogContainer + title="settings.dialogChangeAvatar.title" + titleColor="primary" + buttonsV2={buttonsMap[state]} + > + {(() => { + if (state === "select") { + return ( + <div className="change-avatar-dialog-container"> + <div className="change-avatar-dialog-prompt"> + {c("settings.dialogChangeAvatar.prompt.select")} + </div> + <input + className="change-avatar-select-input" + type="file" + accept="image/*" + onChange={onSelectFile} + /> + </div> + ); + } else if (state === "crop") { + if (file == null) { + throw new UiLogicError(); + } + return ( + <div className="change-avatar-dialog-container"> + <ImageCropper + {...imageCropperProps} + containerClassName="change-avatar-cropper" + /> + <div className="change-avatar-dialog-prompt"> + {c("settings.dialogChangeAvatar.prompt.crop")} + </div> + </div> + ); + } else if (state === "process-crop") { + return ( + <div className="change-avatar-dialog-container"> + <div className="change-avatar-dialog-prompt"> + {c("settings.dialogChangeAvatar.prompt.processingCrop")} + </div> + </div> + ); + } else if (state === "preview") { + return ( + <div className="change-avatar-dialog-container"> + <BlobImage + className="change-avatar-preview-image" + src={resultBlob} + alt={ + c("settings.dialogChangeAvatar.previewImgAlt") ?? undefined + } + /> + <div className="change-avatar-dialog-prompt"> + {c("settings.dialogChangeAvatar.prompt.preview")} + </div> + </div> + ); + } else if (state === "uploading") { + return ( + <div className="change-avatar-dialog-container"> + <BlobImage + className="change-avatar-preview-image" + src={resultBlob} + /> + <div className="change-avatar-dialog-prompt"> + {c("settings.dialogChangeAvatar.prompt.uploading")} + </div> + </div> + ); + } else if (state === "success") { + return ( + <div className="change-avatar-dialog-container"> + <div className="change-avatar-dialog-prompt success"> + {c("operationDialog.success")} + </div> + </div> + ); + } else { + return ( + <div className="change-avatar-dialog-container"> + <BlobImage + className="change-avatar-preview-image" + src={resultBlob} + /> + <div className="change-avatar-dialog-prompt error"> + {c(message)} + </div> + </div> + ); + } + })()} + </DialogContainer> + </Dialog> + ); +} diff --git a/FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx b/FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx new file mode 100644 index 00000000..912f554f --- /dev/null +++ b/FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx @@ -0,0 +1,26 @@ +import { getHttpUserClient } from "~src/http/user"; +import { useUserLoggedIn } from "~src/services/user"; + +import { OperationDialog } from "~src/components/dialog"; + +export default function ChangeNicknameDialog() { + const user = useUserLoggedIn(); + + return ( + <OperationDialog + title="settings.dialogChangeNickname.title" + inputs={[ + { + key: "newNickname", + type: "text", + label: "settings.dialogChangeNickname.inputLabel", + }, + ]} + onProcess={({ newNickname }) => { + return getHttpUserClient().patch(user.username, { + nickname: newNickname, + }); + }} + /> + ); +} diff --git a/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx b/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx new file mode 100644 index 00000000..c3111ac8 --- /dev/null +++ b/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx @@ -0,0 +1,70 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import { userService } from "~src/services/user"; + +import { OperationDialog } from "~src/components/dialog"; + +export function ChangePasswordDialog() { + const navigate = useNavigate(); + + const [redirect, setRedirect] = useState<boolean>(false); + + return ( + <OperationDialog + title="settings.dialogChangePassword.title" + color="danger" + inputPrompt="settings.dialogChangePassword.prompt" + inputs={{ + inputs: [ + { + key: "oldPassword", + type: "text", + label: "settings.dialogChangePassword.inputOldPassword", + password: true, + }, + { + key: "newPassword", + type: "text", + label: "settings.dialogChangePassword.inputNewPassword", + password: true, + }, + { + key: "retypedNewPassword", + type: "text", + label: "settings.dialogChangePassword.inputRetypeNewPassword", + password: true, + }, + ], + validator: ( + { oldPassword, newPassword, retypedNewPassword }, + errors, + ) => { + if (oldPassword === "") { + errors["oldPassword"] = + "settings.dialogChangePassword.errorEmptyOldPassword"; + } + if (newPassword === "") { + errors["newPassword"] = + "settings.dialogChangePassword.errorEmptyNewPassword"; + } + if (retypedNewPassword !== newPassword) { + errors["retypedNewPassword"] = + "settings.dialogChangePassword.errorRetypeNotMatch"; + } + }, + }} + onProcess={async ({ oldPassword, newPassword }) => { + await userService.changePassword(oldPassword, newPassword); + setRedirect(true); + }} + onSuccessAndClose={() => { + if (redirect) { + navigate("/login"); + } + }} + /> + ); +} + +export default ChangePasswordDialog; diff --git a/FrontEnd/src/pages/setting/index.css b/FrontEnd/src/pages/setting/index.css new file mode 100644 index 00000000..19e7cff4 --- /dev/null +++ b/FrontEnd/src/pages/setting/index.css @@ -0,0 +1,76 @@ +.setting-section {
+ padding: 1em 0;
+ margin: 1em 0;
+}
+
+.setting-section-title {
+ padding: 0 1em;
+}
+
+.setting-section-item-area {
+ margin-top: 1em;
+ border-top: 1px solid var(--cru-primary-color);
+}
+
+.setting-item-container {
+ padding: 0.5em 1em;
+ transition: background-color 0.3s;
+ background-color: var(--cru-clickable-grayscale-normal-color);
+ border-bottom: 1px solid var(--cru-clickable-grayscale-active-color);
+ display: flex;
+ align-items: center;
+}
+
+.setting-item-container:hover {
+ background-color: var(--cru-clickable-grayscale-hover-color);
+}
+
+.setting-item-container:focus {
+ background-color: var(--cru-clickable-grayscale-focus-color);
+}
+
+.setting-item-container:active {
+ background-color: var(--cru-clickable-grayscale-active-color);
+}
+
+.setting-item-container.danger {
+ color: var(--cru-danger-color);
+}
+
+.setting-item-label-sub {
+ color: var(--cru-text-minor-color);
+}
+
+.setting-item-value-area {
+ margin-left: auto;
+}
+
+.setting-item-container.setting-type-button {
+ cursor: pointer;
+}
+
+.register-code {
+ background: var(--cru-text-major-color);
+ color: var(--cru-background-color);
+ border-radius: 3px;
+ padding: 0.2em;
+ cursor: pointer;
+}
+
+@media (max-width: 576) {
+ .setting-item-container.setting-type-select {
+ flex-direction: column;
+ }
+
+ .setting-item-container.setting-type-select .setting-item-value-area {
+ margin-top: 1em;
+ }
+
+ .register-code-setting-item {
+ flex-direction: column;
+ }
+
+ .register-code-setting-item .register-code {
+ margin-top: 1em;
+ }
+}
\ No newline at end of file diff --git a/FrontEnd/src/pages/setting/index.tsx b/FrontEnd/src/pages/setting/index.tsx new file mode 100644 index 00000000..88ab5cb2 --- /dev/null +++ b/FrontEnd/src/pages/setting/index.tsx @@ -0,0 +1,297 @@ +import { + useState, + useEffect, + ReactNode, + ComponentPropsWithoutRef, +} from "react"; +import { useTranslation } from "react-i18next"; // For change language. +import { useNavigate } from "react-router-dom"; +import classNames from "classnames"; + +import { useUser, userService } from "~src/services/user"; +import { getHttpUserClient } from "~src/http/user"; + +import { useC, Text } from "~src/common"; + +import { pushAlert } from "~src/components/alert"; +import { + useDialog, + DialogProvider, + ConfirmDialog, +} from "~src/components/dialog"; +import Card from "~src/components/Card"; +import Spinner from "~src/components/Spinner"; +import Page from "~src/components/Page"; + +import ChangePasswordDialog from "./ChangePasswordDialog"; +import ChangeAvatarDialog from "./ChangeAvatarDialog"; +import ChangeNicknameDialog from "./ChangeNicknameDialog"; + +import "./index.css"; + +interface SettingSectionProps + extends Omit<ComponentPropsWithoutRef<typeof Card>, "title"> { + title: Text; + children?: ReactNode; +} + +function SettingSection({ + title, + className, + children, + ...otherProps +}: SettingSectionProps) { + const c = useC(); + + return ( + <Card className={classNames(className, "setting-section")} {...otherProps}> + <h2 className="setting-section-title">{c(title)}</h2> + <div className="setting-section-item-area">{children}</div> + </Card> + ); +} + +interface SettingItemContainerProps + extends Omit<ComponentPropsWithoutRef<"div">, "title"> { + title: Text; + description?: Text; + danger?: boolean; + extraClassName?: string; +} + +function SettingItemContainer({ + title, + description, + danger, + extraClassName, + className, + children, + ...otherProps +}: SettingItemContainerProps) { + const c = useC(); + + return ( + <div + className={classNames( + className, + "setting-item-container", + danger && "danger", + extraClassName, + )} + {...otherProps} + > + <div className="setting-item-label-area"> + <div className="setting-item-label-title">{c(title)}</div> + <small className="setting-item-label-sub">{c(description)}</small> + </div> + <div className="setting-item-value-area">{children}</div> + </div> + ); +} + +type ButtonSettingItemProps = Omit<SettingItemContainerProps, "extraClassName">; + +function ButtonSettingItem(props: ButtonSettingItemProps) { + return ( + <SettingItemContainer extraClassName="setting-type-button" {...props} /> + ); +} + +interface SelectSettingItemProps + extends Omit<SettingItemContainerProps, "onSelect" | "extraClassName"> { + options: { + value: string; + label: Text; + }[]; + value?: string | null; + onSelect: (value: string) => void; +} + +function SelectSettingsItem({ + options, + value, + onSelect, + ...extraProps +}: SelectSettingItemProps) { + const c = useC(); + + return ( + <SettingItemContainer extraClassName="setting-type-select" {...extraProps}> + {value == null ? ( + <Spinner /> + ) : ( + <select + className="select-setting-item-select" + value={value} + onChange={(e) => { + onSelect(e.target.value); + }} + > + {options.map(({ value, label }) => ( + <option key={value} value={value}> + {c(label)} + </option> + ))} + </select> + )} + </SettingItemContainer> + ); +} + +function RegisterCodeSettingItem() { + const user = useUser(); + + // undefined: loading + const [registerCode, setRegisterCode] = useState<undefined | null | string>(); + + const { controller, createDialogSwitch } = useDialog({ + confirm: ( + <ConfirmDialog + title="settings.renewRegisterCode" + body="settings.renewRegisterCodeDesc" + onConfirm={() => { + if (user == null) throw new Error(); + void getHttpUserClient() + .renewRegisterCode(user.username) + .then(() => { + setRegisterCode(undefined); + }); + }} + /> + ), + }); + + useEffect(() => { + setRegisterCode(undefined); + }, [user]); + + useEffect(() => { + if (user != null && registerCode === undefined) { + void getHttpUserClient() + .getRegisterCode(user.username) + .then((code) => { + setRegisterCode(code.registerCode ?? null); + }); + } + }, [user, registerCode]); + + return ( + <> + <SettingItemContainer + title="settings.myRegisterCode" + description="settings.myRegisterCodeDesc" + className="register-code-setting-item" + onClick={createDialogSwitch("confirm")} + > + {registerCode === undefined ? ( + <Spinner /> + ) : registerCode === null ? ( + <span>Noop</span> + ) : ( + <code + className="register-code" + onClick={(event) => { + void navigator.clipboard.writeText(registerCode).then(() => { + pushAlert({ + color: "create", + message: "settings.myRegisterCodeCopied", + }); + }); + event.stopPropagation(); + }} + > + {registerCode} + </code> + )} + </SettingItemContainer> + <DialogProvider controller={controller} /> + </> + ); +} + +function LanguageChangeSettingItem() { + const { i18n } = useTranslation(); + + const language = i18n.language.slice(0, 2); + + return ( + <SelectSettingsItem + title="settings.languagePrimary" + description="settings.languageSecondary" + options={[ + { + value: "zh", + label: { + type: "custom", + value: "䏿–‡", + }, + }, + { + value: "en", + label: { + type: "custom", + value: "English", + }, + }, + ]} + value={language} + onSelect={(value) => { + void i18n.changeLanguage(value); + }} + /> + ); +} + +export default function SettingPage() { + const user = useUser(); + const navigate = useNavigate(); + + const { controller, createDialogSwitch } = useDialog({ + "change-nickname": <ChangeNicknameDialog />, + "change-avatar": <ChangeAvatarDialog />, + "change-password": <ChangePasswordDialog />, + logout: ( + <ConfirmDialog + title="settings.dialogConfirmLogout.title" + body="settings.dialogConfirmLogout.prompt" + onConfirm={() => { + void userService.logout().then(() => { + navigate("/"); + }); + }} + /> + ), + }); + + return ( + <Page noTopPadding> + {user ? ( + <SettingSection title="settings.subheader.account"> + <RegisterCodeSettingItem /> + <ButtonSettingItem + title="settings.changeAvatar" + onClick={createDialogSwitch("change-avatar")} + /> + <ButtonSettingItem + title="settings.changeNickname" + onClick={createDialogSwitch("change-nickname")} + /> + <ButtonSettingItem + title="settings.changePassword" + onClick={createDialogSwitch("change-password")} + danger + /> + <ButtonSettingItem + title="settings.logout" + onClick={createDialogSwitch("logout")} + danger + /> + </SettingSection> + ) : null} + <SettingSection title="settings.subheader.customization"> + <LanguageChangeSettingItem /> + </SettingSection> + <DialogProvider controller={controller} /> + </Page> + ); +} diff --git a/FrontEnd/src/pages/timeline/ConnectionStatusBadge.css b/FrontEnd/src/pages/timeline/ConnectionStatusBadge.css new file mode 100644 index 00000000..0a6979cb --- /dev/null +++ b/FrontEnd/src/pages/timeline/ConnectionStatusBadge.css @@ -0,0 +1,38 @@ +.connection-status-badge {
+ font-size: 0.8em;
+ border-radius: 5px;
+ padding: 0.1em 1em;
+}
+
+.connection-status-badge::before {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ display: inline-block;
+ content: "";
+ margin-right: 0.6em;
+}
+
+.connection-status-badge.success {
+ color: var(--cru-create-color);
+}
+
+.connection-status-badge.success::before {
+ background-color: var(--cru-create-color);
+}
+
+.connection-status-badge.warning {
+ color: var(--cru-warn-color);
+}
+
+.connection-status-badge.warning::before {
+ background-color: var(--cru-warn-color);
+}
+
+.connection-status-badge.danger {
+ color: var(--cru-danger-color);
+}
+
+.connection-status-badge.danger::before {
+ background-color: var(--cru-danger-color);
+}
diff --git a/FrontEnd/src/pages/timeline/ConnectionStatusBadge.tsx b/FrontEnd/src/pages/timeline/ConnectionStatusBadge.tsx new file mode 100644 index 00000000..63990878 --- /dev/null +++ b/FrontEnd/src/pages/timeline/ConnectionStatusBadge.tsx @@ -0,0 +1,36 @@ +import classNames from "classnames"; +import { HubConnectionState } from "@microsoft/signalr"; + +import { useC }from '~/src/components/common'; + +import "./ConnectionStatusBadge.css"; + +interface ConnectionStatusBadgeProps { + status: HubConnectionState; + className?: string; +} + +const classNameMap: Record<HubConnectionState, string> = { + Connected: "success", + Connecting: "warning", + Disconnected: "danger", + Disconnecting: "warning", + Reconnecting: "warning", +}; + +export default function ConnectionStatusBadge({status, className}: ConnectionStatusBadgeProps) { + const c = useC(); + + return ( + <div + className={classNames( + "connection-status-badge", + classNameMap[status], + className + )} + > + {c(`connectionState.${status}`)} + </div> + ); +}; + diff --git a/FrontEnd/src/pages/timeline/Timeline.css b/FrontEnd/src/pages/timeline/Timeline.css new file mode 100644 index 00000000..db25eda0 --- /dev/null +++ b/FrontEnd/src/pages/timeline/Timeline.css @@ -0,0 +1,42 @@ +.timeline-container { + --timeline-background-color: hsl(0, 0%, 95%); + --timeline-shadow-color: hsla(0, 0%, 0%, 0.5); + --timeline-card-shadow: 2px 1px 10px -2px var(--timeline-shadow-color); + --timeline-post-card-background-color: hsl(0, 0%, 100%); + --timeline-post-card-shadow: 0px 0px 11px -2px var(--timeline-shadow-color); + --timeline-post-card-border-radius: 10px; + --timeline-post-text-color: hsl(0, 0%, 0%); + --timeline-datetime-label-background-color: hsl(0, 0%, 30%); +} + +@media (prefers-color-scheme: dark) { + .timeline-container { + --timeline-background-color: hsl(0, 0%, 0%); + --timeline-post-card-background-color: hsl(0, 0%, 15%); + --timeline-post-card-shadow: none; + } +} + +.timeline { + z-index: 0; + position: relative; + width: 100%; + padding-top: 10px; + background: var(--timeline-background-color); +} + +.timeline-sync-state-badge { + font-size: 0.8em; + padding: 3px 8px; + border-radius: 5px; + background: #e8fbff; +} + +.timeline-sync-state-badge-pin { + display: inline-block; + width: 0.4em; + height: 0.4em; + border-radius: 50%; + vertical-align: middle; + margin-right: 0.6em; +} diff --git a/FrontEnd/src/pages/timeline/Timeline.tsx b/FrontEnd/src/pages/timeline/Timeline.tsx new file mode 100644 index 00000000..32cbf8c8 --- /dev/null +++ b/FrontEnd/src/pages/timeline/Timeline.tsx @@ -0,0 +1,180 @@ +import { useState, useEffect } from "react"; +import classNames from "classnames"; +import { HubConnectionState } from "@microsoft/signalr"; + +import { + HttpForbiddenError, + HttpNetworkError, + HttpNotFoundError, +} from "~src/http/common"; +import { + getHttpTimelineClient, + HttpTimelineInfo, + HttpTimelinePostInfo, +} from "~src/http/timeline"; + +import { getTimelinePostUpdate$ } from "~src/services/timeline"; + +import { useScrollToBottom } from "~src/components/hooks"; + +import TimelinePostList from "./TimelinePostList"; +import TimelineInfoCard from "./TimelineInfoCard"; +import TimelinePostEdit from "./edit/TimelinePostCreateView"; + +import "./Timeline.css"; + +export interface TimelineProps { + className?: string; + timelineOwner: string; + timelineName: string; +} + +export function Timeline(props: TimelineProps) { + const { timelineOwner, timelineName, className } = props; + + const [timeline, setTimeline] = useState<HttpTimelineInfo | null>(null); + const [posts, setPosts] = useState<HttpTimelinePostInfo[] | null>(null); + const [signalrState, setSignalrState] = useState<HubConnectionState>( + HubConnectionState.Connecting, + ); + const [error, setError] = useState< + "offline" | "forbid" | "notfound" | "error" | null + >(null); + + const [currentPage, setCurrentPage] = useState(1); + const [totalPage, setTotalPage] = useState(0); + + const [timelineReloadKey, setTimelineReloadKey] = useState(0); + const [postsReloadKey, setPostsReloadKey] = useState(0); + + const updateTimeline = (): void => setTimelineReloadKey((o) => o + 1); + const updatePosts = (): void => setPostsReloadKey((o) => o + 1); + + useEffect(() => { + setTimeline(null); + setPosts(null); + setError(null); + setSignalrState(HubConnectionState.Connecting); + }, [timelineOwner, timelineName]); + + useEffect(() => { + getHttpTimelineClient() + .getTimeline(timelineOwner, timelineName) + .then( + (t) => { + setTimeline(t); + }, + (error) => { + if (error instanceof HttpNetworkError) { + setError("offline"); + } else if (error instanceof HttpForbiddenError) { + setError("forbid"); + } else if (error instanceof HttpNotFoundError) { + setError("notfound"); + } else { + console.error(error); + setError("error"); + } + }, + ); + }, [timelineOwner, timelineName, timelineReloadKey]); + + useEffect(() => { + getHttpTimelineClient() + .listPost(timelineOwner, timelineName, 1) + .then( + (page) => { + setPosts( + page.items.filter((p): p is HttpTimelinePostInfo => !p.deleted), + ); + setTotalPage(page.totalPageCount); + }, + (error) => { + if (error instanceof HttpNetworkError) { + setError("offline"); + } else if (error instanceof HttpForbiddenError) { + setError("forbid"); + } else if (error instanceof HttpNotFoundError) { + setError("notfound"); + } else { + console.error(error); + setError("error"); + } + }, + ); + }, [timelineOwner, timelineName, postsReloadKey]); + + useEffect(() => { + const timelinePostUpdate$ = getTimelinePostUpdate$( + timelineOwner, + timelineName, + ); + const subscription = timelinePostUpdate$.subscribe(({ update, state }) => { + if (update) { + setPostsReloadKey((o) => o + 1); + } + setSignalrState(state); + }); + return () => { + subscription.unsubscribe(); + }; + }, [timelineOwner, timelineName]); + + useScrollToBottom(() => { + console.log(`Load page ${currentPage + 1}.`); + setCurrentPage(currentPage + 1); + void getHttpTimelineClient() + .listPost(timelineOwner, timelineName, currentPage + 1) + .then( + (page) => { + const ps = page.items.filter( + (p): p is HttpTimelinePostInfo => !p.deleted, + ); + setPosts((old) => [...(old ?? []), ...ps]); + }, + (error) => { + if (error instanceof HttpNetworkError) { + setError("offline"); + } else if (error instanceof HttpForbiddenError) { + setError("forbid"); + } else if (error instanceof HttpNotFoundError) { + setError("notfound"); + } else { + console.error(error); + setError("error"); + } + }, + ); + }, currentPage < totalPage); + + if (error === "offline") { + return <div className={className}>Offline.</div>; + } else if (error === "notfound") { + return <div className={className}>Not exist.</div>; + } else if (error === "forbid") { + return <div className={className}>Forbid.</div>; + } else if (error === "error") { + return <div className={className}>Error.</div>; + } + return ( + <div className="timeline-container"> + {timeline && ( + <TimelineInfoCard + timeline={timeline} + connectionStatus={signalrState} + onReload={updateTimeline} + /> + )} + {posts && ( + <div className={classNames("timeline", className)}> + {timeline?.postable && ( + <TimelinePostEdit timeline={timeline} onPosted={updatePosts} /> + )} + <TimelinePostList posts={posts} onReload={updatePosts} /> + </div> + )} + </div> + ); +} + +export default Timeline; diff --git a/FrontEnd/src/pages/timeline/TimelineDateLabel.css b/FrontEnd/src/pages/timeline/TimelineDateLabel.css new file mode 100644 index 00000000..47a4cb44 --- /dev/null +++ b/FrontEnd/src/pages/timeline/TimelineDateLabel.css @@ -0,0 +1,9 @@ +.timeline-post-date-badge { + display: inline-block; + padding: 0.2em 0.5em; + border-radius: 0.4em; + background: var(--timeline-datetime-label-background-color); + color: white; + font-size: 0.8em; + margin-left: 5em; +} diff --git a/FrontEnd/src/pages/timeline/TimelineDateLabel.tsx b/FrontEnd/src/pages/timeline/TimelineDateLabel.tsx new file mode 100644 index 00000000..eaadcc1a --- /dev/null +++ b/FrontEnd/src/pages/timeline/TimelineDateLabel.tsx @@ -0,0 +1,13 @@ +import TimelinePostContainer from "./TimelinePostContainer"; + +import "./TimelineDateLabel.css"; + +export default function TimelineDateLabel({ date }: { date: Date }) { + return ( + <TimelinePostContainer> + <div className="timeline-post-date-badge"> + {date.toLocaleDateString()} + </div> + </TimelinePostContainer> + ); +} diff --git a/FrontEnd/src/pages/timeline/TimelineDeleteDialog.tsx b/FrontEnd/src/pages/timeline/TimelineDeleteDialog.tsx new file mode 100644 index 00000000..d1af364b --- /dev/null +++ b/FrontEnd/src/pages/timeline/TimelineDeleteDialog.tsx @@ -0,0 +1,54 @@ +import { useNavigate } from "react-router-dom"; +import { Trans } from "react-i18next"; + +import { getHttpTimelineClient, HttpTimelineInfo } from "~src/http/timeline"; + +import { OperationDialog } from "~src/components/dialog"; + +interface TimelineDeleteDialog { + timeline: HttpTimelineInfo; +} + +export default function TimelineDeleteDialog({ timeline }: TimelineDeleteDialog) { + const navigate = useNavigate(); + + return ( + <OperationDialog + title="timeline.deleteDialog.title" + color="danger" + inputPromptNode={ + <Trans + i18nKey="timeline.deleteDialog.inputPrompt" + values={{ name: timeline.nameV2 }} + > + 0<code>1</code>2 + </Trans> + } + inputs={{ + inputs: [ + { + key: "name", + type: "text", + label: "", + }, + ], + validator: ({ name }, errors) => { + if (name !== timeline.nameV2) { + errors.name = "timeline.deleteDialog.notMatch"; + } + }, + }} + onProcess={() => { + return getHttpTimelineClient().deleteTimeline( + timeline.owner.username, + timeline.nameV2, + ); + }} + onSuccessAndClose={() => { + navigate("/", { replace: true }); + }} + /> + ); +}; + +TimelineDeleteDialog; diff --git a/FrontEnd/src/pages/timeline/TimelineInfoCard.css b/FrontEnd/src/pages/timeline/TimelineInfoCard.css new file mode 100644 index 00000000..afcb6409 --- /dev/null +++ b/FrontEnd/src/pages/timeline/TimelineInfoCard.css @@ -0,0 +1,63 @@ +.timeline-card { + position: fixed; + z-index: 1029; + top: 56px; + right: 0; + margin: 0.5em; + padding: 0.5em; + box-shadow: var(--timeline-card-shadow); +} + +@media (min-width: 576px) { + .timeline-card-expand { + min-width: 400px; + } +} + +.timeline-card-title { + display: inline-block; + vertical-align: middle; + color: var(--cru-text-major-color); + margin: 0.5em 1em; +} + +.timeline-card-title-name { + margin-inline-start: 1em; + color: var(--cru-text-minor-color); +} + +.timeline-card-user { + display: flex; + align-items: center; + margin: 0 1em 0.5em; +} + +.timeline-card-user-avatar { + width: 2em; + height: 2em; + border-radius: 50%; +} + +.timeline-card-user-nickname { + margin-inline: 0.6em; +} + +.timeline-card-description { + margin: 0 1em 0.5em; +} + +.timeline-card-top-right-area { + float: right; + display: flex; + align-items: center; + margin: 0 1em; +} + +.timeline-card-buttons { + display: flex; + justify-content: end; +} + +.timeline-card-button { + margin: 0 0.2em; +} diff --git a/FrontEnd/src/pages/timeline/TimelineInfoCard.tsx b/FrontEnd/src/pages/timeline/TimelineInfoCard.tsx new file mode 100644 index 00000000..2bc40877 --- /dev/null +++ b/FrontEnd/src/pages/timeline/TimelineInfoCard.tsx @@ -0,0 +1,208 @@ +import { useState } from "react"; +import { HubConnectionState } from "@microsoft/signalr"; + +import { useUser } from "~src/services/user"; + +import { HttpTimelineInfo } from "~src/http/timeline"; +import { getHttpBookmarkClient } from "~src/http/bookmark"; + +import { pushAlert } from "~src/components/alert"; +import { useMobile } from "~src/components/hooks"; +import { IconButton } from "~src/components/button"; +import { + Dialog, + FullPageDialog, + DialogProvider, + useDialog, +} from "~src/components/dialog"; +import UserAvatar from "~src/components/user/UserAvatar"; +import PopupMenu from "~src/components/menu/PopupMenu"; +import Card from "~src/components/Card"; + +import TimelineDeleteDialog from "./TimelineDeleteDialog"; +import ConnectionStatusBadge from "./ConnectionStatusBadge"; +import TimelineMember from "./TimelineMember"; +import TimelinePropertyChangeDialog from "./TimelinePropertyChangeDialog"; + +import "./TimelineInfoCard.css"; + +function CollapseButton({ + collapse, + onClick, + className, +}: { + collapse: boolean; + onClick: () => void; + className?: string; +}) { + return ( + <IconButton + color="primary" + icon={collapse ? "info-circle" : "x-circle"} + onClick={onClick} + className={className} + /> + ); +} + +interface TimelineInfoCardProps { + timeline: HttpTimelineInfo; + connectionStatus: HubConnectionState; + onReload: () => void; +} + +function TimelineInfoContent({ + timeline, + onReload, +}: Omit<TimelineInfoCardProps, "connectionStatus">) { + const user = useUser(); + + const { controller, createDialogSwitch } = useDialog({ + member: ( + <Dialog> + <TimelineMember timeline={timeline} onChange={onReload} /> + </Dialog> + ), + property: ( + <TimelinePropertyChangeDialog timeline={timeline} onChange={onReload} /> + ), + delete: <TimelineDeleteDialog timeline={timeline} />, + }); + + return ( + <div> + <h3 className="timeline-card-title"> + {timeline.title} + <small className="timeline-card-title-name">{timeline.nameV2}</small> + </h3> + <div className="timeline-card-user"> + <UserAvatar + username={timeline.owner.username} + className="timeline-card-user-avatar" + /> + <span className="timeline-card-user-nickname"> + {timeline.owner.nickname} + </span> + <small className="timeline-card-user-username"> + @{timeline.owner.username} + </small> + </div> + <p className="timeline-card-description">{timeline.description}</p> + <div className="timeline-card-buttons"> + {user && ( + <IconButton + icon={timeline.isBookmark ? "bookmark-fill" : "bookmark"} + color="primary" + className="timeline-card-button" + onClick={() => { + getHttpBookmarkClient() + [timeline.isBookmark ? "delete" : "post"]( + user.username, + timeline.owner.username, + timeline.nameV2, + ) + .then(onReload, () => { + pushAlert({ + message: timeline.isBookmark + ? "timeline.removeBookmarkFail" + : "timeline.addBookmarkFail", + color: "danger", + }); + }); + }} + /> + )} + <IconButton + icon="people" + color="primary" + className="timeline-card-button" + onClick={createDialogSwitch("member")} + /> + {timeline.manageable && ( + <PopupMenu + items={[ + { + type: "button", + text: "timeline.manageItem.property", + onClick: createDialogSwitch("property"), + }, + { type: "divider" }, + { + type: "button", + onClick: createDialogSwitch("delete"), + color: "danger", + text: "timeline.manageItem.delete", + }, + ]} + containerClassName="d-inline" + > + <IconButton + color="primary" + className="timeline-card-button" + icon="three-dots-vertical" + /> + </PopupMenu> + )} + </div> + <DialogProvider controller={controller} /> + </div> + ); +} + +export default function TimelineInfoCard(props: TimelineInfoCardProps) { + const { timeline, connectionStatus, onReload } = props; + + const [collapse, setCollapse] = useState(true); + + const isMobile = useMobile((mobile) => { + if (!mobile) { + switchDialog(null); + } else { + setCollapse(true); + } + }); + + const { controller, switchDialog } = useDialog( + { + "full-page": ( + <FullPageDialog> + <TimelineInfoContent timeline={timeline} onReload={onReload} /> + </FullPageDialog> + ), + }, + { + onClose: { + "full-page": () => { + setCollapse(true); + }, + }, + }, + ); + + return ( + <Card + color="secondary" + className={`timeline-card timeline-card-${ + collapse ? "collapse" : "expand" + }`} + > + <div className="timeline-card-top-right-area"> + <ConnectionStatusBadge status={connectionStatus} /> + <CollapseButton + collapse={collapse} + onClick={() => { + const open = collapse; + setCollapse(!open); + if (isMobile && open) { + switchDialog("full-page"); + } + }} + /> + </div> + {!collapse && !isMobile && ( + <TimelineInfoContent timeline={timeline} onReload={onReload} /> + )} + <DialogProvider controller={controller} /> + </Card> + ); +} diff --git a/FrontEnd/src/pages/timeline/TimelineMember.css b/FrontEnd/src/pages/timeline/TimelineMember.css new file mode 100644 index 00000000..3ad74c57 --- /dev/null +++ b/FrontEnd/src/pages/timeline/TimelineMember.css @@ -0,0 +1,20 @@ +.timeline-member-item {
+ align-items: center;
+ display: flex;
+ padding: 0.6em;
+}
+
+.timeline-member-avatar {
+ height: 50px;
+ width: 50px;
+ border-radius: 50%;
+}
+
+.timeline-member-info {
+ margin-left: 1em;
+ margin-right: auto;
+}
+
+.timeline-member-user-search {
+ margin-top: 1em;
+}
diff --git a/FrontEnd/src/pages/timeline/TimelineMember.tsx b/FrontEnd/src/pages/timeline/TimelineMember.tsx new file mode 100644 index 00000000..0812016f --- /dev/null +++ b/FrontEnd/src/pages/timeline/TimelineMember.tsx @@ -0,0 +1,189 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { convertI18nText, I18nText } from "~src/common"; + +import { HttpUser } from "~src/http/user"; +import { getHttpSearchClient } from "~src/http/search"; +import { getHttpTimelineClient, HttpTimelineInfo } from "~src/http/timeline"; + +import SearchInput from "~src/components/SearchInput"; +import UserAvatar from "~src/components/user/UserAvatar"; +import { IconButton } from "~src/components/button"; +import { ListContainer, ListItemContainer } from "~src/components/list"; + +import "./TimelineMember.css"; + +function TimelineMemberItem({ + user, + add, + onAction, +}: { + user: HttpUser; + add?: boolean; + onAction?: (username: string) => void; +}) { + return ( + <ListItemContainer className="timeline-member-item"> + <UserAvatar username={user.username} className="timeline-member-avatar" /> + <div className="timeline-member-info"> + <div className="timeline-member-nickname">{user.nickname}</div> + <small className="timeline-member-username"> + {"@" + user.username} + </small> + </div> + {onAction ? ( + <div className="timeline-member-action"> + <IconButton + icon={add ? "plus-lg" : "trash"} + color={add ? "create" : "danger"} + onClick={() => { + onAction(user.username); + }} + /> + </div> + ) : null} + </ListItemContainer> + ); +} + +function TimelineMemberUserSearch({ + timeline, + onChange, +}: { + timeline: HttpTimelineInfo; + onChange: () => void; +}) { + const { t } = useTranslation(); + + const [userSearchText, setUserSearchText] = useState<string>(""); + const [userSearchState, setUserSearchState] = useState< + | { + type: "users"; + data: HttpUser[]; + } + | { type: "error"; data: I18nText } + | { type: "loading" } + | { type: "init" } + >({ type: "init" }); + + return ( + <div className="timeline-member-user-search"> + <SearchInput + className="" + value={userSearchText} + onChange={(v) => { + setUserSearchText(v); + }} + loading={userSearchState.type === "loading"} + onButtonClick={() => { + if (userSearchText === "") { + setUserSearchState({ + type: "error", + data: "login.emptyUsername", + }); + return; + } + setUserSearchState({ type: "loading" }); + getHttpSearchClient() + .searchUsers(userSearchText) + .then( + (users) => { + users = users.filter( + (user) => + timeline.members.findIndex( + (m) => m.username === user.username, + ) === -1 && timeline.owner.username !== user.username, + ); + setUserSearchState({ type: "users", data: users }); + }, + (e) => { + setUserSearchState({ + type: "error", + data: { type: "custom", value: String(e) }, + }); + }, + ); + }} + /> + {(() => { + if (userSearchState.type === "users") { + const users = userSearchState.data; + if (users.length === 0) { + return <div>{t("timeline.member.noUserAvailableToAdd")}</div>; + } else { + return ( + <div className=""> + {users.map((user) => ( + <TimelineMemberItem + key={user.username} + user={user} + add + onAction={() => { + void getHttpTimelineClient() + .memberPut( + timeline.owner.username, + timeline.nameV2, + user.username, + ) + .then(() => { + setUserSearchText(""); + setUserSearchState({ type: "init" }); + onChange(); + }); + }} + /> + ))} + </div> + ); + } + } else if (userSearchState.type === "error") { + return ( + <div className="cru-color-danger"> + {convertI18nText(userSearchState.data, t)} + </div> + ); + } + })()} + </div> + ); +} + +interface TimelineMemberProps { + timeline: HttpTimelineInfo; + onChange: () => void; +} + +export default function TimelineMember(props: TimelineMemberProps) { + const { timeline, onChange } = props; + const members = [timeline.owner, ...timeline.members]; + + return ( + <div className="container px-4 py-3"> + <ListContainer> + {members.map((member, index) => ( + <TimelineMemberItem + key={member.username} + user={member} + onAction={ + timeline.manageable && index !== 0 + ? () => { + void getHttpTimelineClient() + .memberDelete( + timeline.owner.username, + timeline.nameV2, + member.username, + ) + .then(onChange); + } + : undefined + } + /> + ))} + </ListContainer> + {timeline.manageable ? ( + <TimelineMemberUserSearch timeline={timeline} onChange={onChange} /> + ) : null} + </div> + ); +} diff --git a/FrontEnd/src/pages/timeline/TimelinePostCard.css b/FrontEnd/src/pages/timeline/TimelinePostCard.css new file mode 100644 index 00000000..f60610c0 --- /dev/null +++ b/FrontEnd/src/pages/timeline/TimelinePostCard.css @@ -0,0 +1,9 @@ +.timeline-post-card { + padding: 1em 1em 1em 3em; + background-color: var(--timeline-post-card-background-color); + box-shadow: var(--timeline-post-card-shadow); + border-radius: var(--timeline-post-card-border-radius); + border: none; + position: relative; + z-index: 1; +}
\ No newline at end of file diff --git a/FrontEnd/src/pages/timeline/TimelinePostCard.tsx b/FrontEnd/src/pages/timeline/TimelinePostCard.tsx new file mode 100644 index 00000000..d3fd3215 --- /dev/null +++ b/FrontEnd/src/pages/timeline/TimelinePostCard.tsx @@ -0,0 +1,22 @@ +import { ReactNode } from "react"; +import classNames from "classnames"; + +import Card from "~src/components/Card"; + +import "./TimelinePostCard.css"; + +interface TimelinePostCardProps { + className?: string; + children?: ReactNode; +} + +export default function TimelinePostCard({ + className, + children, +}: TimelinePostCardProps) { + return ( + <Card color="primary" className={classNames("timeline-post-card", className)}> + {children} + </Card> + ); +} diff --git a/FrontEnd/src/pages/timeline/TimelinePostContainer.css b/FrontEnd/src/pages/timeline/TimelinePostContainer.css new file mode 100644 index 00000000..a12f70b1 --- /dev/null +++ b/FrontEnd/src/pages/timeline/TimelinePostContainer.css @@ -0,0 +1,3 @@ +.timeline-post-container { + padding: 0.5em 1em; +}
\ No newline at end of file diff --git a/FrontEnd/src/pages/timeline/TimelinePostContainer.tsx b/FrontEnd/src/pages/timeline/TimelinePostContainer.tsx new file mode 100644 index 00000000..9dc211b2 --- /dev/null +++ b/FrontEnd/src/pages/timeline/TimelinePostContainer.tsx @@ -0,0 +1,20 @@ +import { ReactNode } from "react"; +import classNames from "classnames"; + +import "./TimelinePostContainer.css"; + +interface TimelinePostContainerProps { + className?: string; + children?: ReactNode; +} + +export default function TimelinePostContainer({ + className, + children, +}: TimelinePostContainerProps) { + return ( + <div className={classNames("timeline-post-container", className)}> + {children} + </div> + ); +} diff --git a/FrontEnd/src/pages/timeline/TimelinePostList.css b/FrontEnd/src/pages/timeline/TimelinePostList.css new file mode 100644 index 00000000..bd575554 --- /dev/null +++ b/FrontEnd/src/pages/timeline/TimelinePostList.css @@ -0,0 +1,10 @@ +.timeline-post-timeline { + position: absolute; + left: 2.5em; + width: 1em; + top: 0; + bottom: 0; + background-color: var(--timeline-post-line-color); + box-shadow: var(--timeline-post-line-shadow); + z-index: -1; +}
\ No newline at end of file diff --git a/FrontEnd/src/pages/timeline/TimelinePostList.tsx b/FrontEnd/src/pages/timeline/TimelinePostList.tsx new file mode 100644 index 00000000..66262ccd --- /dev/null +++ b/FrontEnd/src/pages/timeline/TimelinePostList.tsx @@ -0,0 +1,75 @@ +import { useMemo, Fragment } from "react"; + +import { HttpTimelinePostInfo } from "~src/http/timeline"; + +import TimelinePostView from "./TimelinePostView"; +import TimelineDateLabel from "./TimelineDateLabel"; + +import "./TimelinePostList.css"; + +function dateEqual(left: Date, right: Date): boolean { + return ( + left.getDate() == right.getDate() && + left.getMonth() == right.getMonth() && + left.getFullYear() == right.getFullYear() + ); +} + +interface TimelinePostListViewProps { + posts: HttpTimelinePostInfo[]; + onReload: () => void; +} + +export default function TimelinePostList(props: TimelinePostListViewProps) { + const { posts, onReload } = props; + + const groupedPosts = useMemo< + { + date: Date; + posts: (HttpTimelinePostInfo & { index: number })[]; + }[] + >(() => { + const result: { + date: Date; + posts: (HttpTimelinePostInfo & { index: number })[]; + }[] = []; + let index = 0; + for (const post of posts) { + const time = new Date(post.time); + if (result.length === 0) { + result.push({ date: time, posts: [{ ...post, index }] }); + } else { + const lastGroup = result[result.length - 1]; + if (dateEqual(lastGroup.date, time)) { + lastGroup.posts.push({ ...post, index }); + } else { + result.push({ date: time, posts: [{ ...post, index }] }); + } + } + index++; + } + return result; + }, [posts]); + + return ( + <div> + {groupedPosts.map((group) => { + return ( + <Fragment key={group.date.toDateString()}> + <TimelineDateLabel date={group.date} /> + {group.posts.map((post) => { + return ( + <TimelinePostView + key={post.id} + post={post} + onChanged={onReload} + onDeleted={onReload} + /> + ); + })} + </Fragment> + ); + })} + </div> + ); +} diff --git a/FrontEnd/src/pages/timeline/TimelinePostView.css b/FrontEnd/src/pages/timeline/TimelinePostView.css new file mode 100644 index 00000000..a8db46bf --- /dev/null +++ b/FrontEnd/src/pages/timeline/TimelinePostView.css @@ -0,0 +1,37 @@ +.timeline-post-header { + display: flex; + align-items: center; +} + +.timeline-post-author-avatar { + border-radius: 50%; + width: 2em; + height: 2em; +} + +.timeline-post-author-nickname { + margin: 0 1em; +} + +.timeline-post-edit-button { + float: right; +} + +.timeline-post-options-mask { + position: absolute; + inset: 0; + background-color: hsla(0, 0%, 100%, 0.9); + display: flex; + align-items: center; + justify-content: space-around; +} + +@media (prefers-color-scheme: dark) { + .timeline-post-options-mask { + background-color: hsla(0, 0%, 0%, 0.8); + } +} + +.timeline-post-content { + margin-top: 0.5em; +} diff --git a/FrontEnd/src/pages/timeline/TimelinePostView.tsx b/FrontEnd/src/pages/timeline/TimelinePostView.tsx new file mode 100644 index 00000000..4f0460ff --- /dev/null +++ b/FrontEnd/src/pages/timeline/TimelinePostView.tsx @@ -0,0 +1,123 @@ +import { useState } from "react"; + +import { + getHttpTimelineClient, + HttpTimelinePostInfo, +} from "~src/http/timeline"; + +import { pushAlert } from "~src/components/alert"; +import { useClickOutside } from "~src/components/hooks"; +import UserAvatar from "~src/components/user/UserAvatar"; +import { DialogProvider, useDialog } from "~src/components/dialog"; +import FlatButton from "~src/components/button/FlatButton"; +import ConfirmDialog from "~src/components/dialog/ConfirmDialog"; +import TimelinePostContentView from "./view/TimelinePostContentView"; +import IconButton from "~src/components/button/IconButton"; + +import TimelinePostContainer from "./TimelinePostContainer"; +import TimelinePostCard from "./TimelinePostCard"; + +import "./TimelinePostView.css"; + +interface TimelinePostViewProps { + post: HttpTimelinePostInfo; + className?: string; + onChanged: (post: HttpTimelinePostInfo) => void; + onDeleted: () => void; +} + +export default function TimelinePostView(props: TimelinePostViewProps) { + const { post, onDeleted } = props; + + const [operationMaskVisible, setOperationMaskVisible] = + useState<boolean>(false); + + const { controller, switchDialog } = useDialog( + { + delete: ( + <ConfirmDialog + title="timeline.post.deleteDialog.title" + body="timeline.post.deleteDialog.prompt" + onConfirm={() => { + void getHttpTimelineClient() + .deletePost(post.timelineOwnerV2, post.timelineNameV2, post.id) + .then(onDeleted, () => { + pushAlert({ + color: "danger", + message: "timeline.deletePostFailed", + }); + }); + }} + /> + ), + }, + { + onClose: { + delete: () => { + setOperationMaskVisible(false); + }, + }, + }, + ); + + const [maskElement, setMaskElement] = useState<HTMLElement | null>(null); + useClickOutside(maskElement, () => setOperationMaskVisible(false)); + + return ( + <TimelinePostContainer> + <TimelinePostCard className="cru-primary"> + {post.editable && ( + <IconButton + color="primary" + icon="chevron-down" + className="timeline-post-edit-button" + onClick={(e) => { + setOperationMaskVisible(true); + e.stopPropagation(); + }} + /> + )} + <div className="timeline-post-header"> + <UserAvatar + username={post.author.username} + className="timeline-post-author-avatar" + /> + <small className="timeline-post-author-nickname"> + {post.author.nickname} + </small> + <small className="timeline-post-time"> + {new Date(post.time).toLocaleTimeString()} + </small> + </div> + <div className="timeline-post-content"> + <TimelinePostContentView post={post} /> + </div> + {operationMaskVisible ? ( + <div + ref={setMaskElement} + className="timeline-post-options-mask" + onClick={() => { + setOperationMaskVisible(false); + }} + > + <FlatButton + text="changeProperty" + onClick={(e) => { + e.stopPropagation(); + }} + /> + <FlatButton + text="delete" + color="danger" + onClick={(e) => { + switchDialog("delete"); + e.stopPropagation(); + }} + /> + </div> + ) : null} + </TimelinePostCard> + <DialogProvider controller={controller} /> + </TimelinePostContainer> + ); +} diff --git a/FrontEnd/src/pages/timeline/TimelinePropertyChangeDialog.tsx b/FrontEnd/src/pages/timeline/TimelinePropertyChangeDialog.tsx new file mode 100644 index 00000000..79838d58 --- /dev/null +++ b/FrontEnd/src/pages/timeline/TimelinePropertyChangeDialog.tsx @@ -0,0 +1,79 @@ +import { + getHttpTimelineClient, + HttpTimelineInfo, + HttpTimelinePatchRequest, + kTimelineVisibilities, + TimelineVisibility, +} from "~src/http/timeline"; + +import OperationDialog from "~src/components/dialog/OperationDialog"; + +interface TimelinePropertyChangeDialogProps { + timeline: HttpTimelineInfo; + onChange: () => void; +} + +const labelMap: { [key in TimelineVisibility]: string } = { + Private: "timeline.visibility.private", + Public: "timeline.visibility.public", + Register: "timeline.visibility.register", +}; + +export default function TimelinePropertyChangeDialog({ + timeline, + onChange, +}: TimelinePropertyChangeDialogProps) { + return ( + <OperationDialog + title={"timeline.dialogChangeProperty.title"} + inputs={{ + scheme: { + inputs: [ + { + key: "title", + type: "text", + label: "timeline.dialogChangeProperty.titleField", + }, + { + key: "visibility", + type: "select", + label: "timeline.dialogChangeProperty.visibility", + options: kTimelineVisibilities.map((v) => ({ + label: labelMap[v], + value: v, + })), + }, + { + key: "description", + type: "text", + label: "timeline.dialogChangeProperty.description", + }, + ], + }, + dataInit: { + values: { + title: timeline.title, + visibility: timeline.visibility, + description: timeline.description, + }, + }, + }} + onProcess={({ title, visibility, description }) => { + const req: HttpTimelinePatchRequest = {}; + if (title !== timeline.title) { + req.title = title; + } + if (visibility !== timeline.visibility) { + req.visibility = visibility; + } + if (description !== timeline.description) { + req.description = description; + } + return getHttpTimelineClient() + .patchTimeline(timeline.owner.username, timeline.nameV2, req) + .then(onChange); + }} + /> + ); +} + diff --git a/FrontEnd/src/pages/timeline/edit/ImagePostEdit.css b/FrontEnd/src/pages/timeline/edit/ImagePostEdit.css new file mode 100644 index 00000000..232681c8 --- /dev/null +++ b/FrontEnd/src/pages/timeline/edit/ImagePostEdit.css @@ -0,0 +1,5 @@ +.timeline-edit-image-image { + max-width: 100px; + max-height: 100px; +} + diff --git a/FrontEnd/src/pages/timeline/edit/ImagePostEdit.tsx b/FrontEnd/src/pages/timeline/edit/ImagePostEdit.tsx new file mode 100644 index 00000000..c62c8ee5 --- /dev/null +++ b/FrontEnd/src/pages/timeline/edit/ImagePostEdit.tsx @@ -0,0 +1,36 @@ +import classNames from "classnames"; + +import BlobImage from "~/src/components/BlobImage"; + +import "./ImagePostEdit.css"; + +interface TimelinePostEditImageProps { + file: File | null; + onChange: (file: File | null) => void; + disabled: boolean; + className?: string; +} + +export default function ImagePostEdit(props: TimelinePostEditImageProps) { + const { file, onChange, disabled, className } = props; + + return ( + <div className={classNames("timeline-edit-image-container", className)}> + <input + type="file" + accept="image/*" + disabled={disabled} + onChange={(e) => { + const files = e.target.files; + if (files == null || files.length === 0) { + onChange(null); + } else { + onChange(files[0]); + } + }} + className="timeline-edit-image-input" + /> + {file && <BlobImage src={file} className="timeline-edit-image-image" />} + </div> + ); +} diff --git a/FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.css b/FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.css new file mode 100644 index 00000000..c5b41b40 --- /dev/null +++ b/FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.css @@ -0,0 +1,24 @@ +.timeline-edit-markdown-tab-page {
+ min-height: 8em;
+ display: flex;
+}
+
+.timeline-edit-markdown-text {
+ width: 100%;
+}
+
+.timeline-edit-markdown-images {
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.timeline-edit-markdown-images img {
+ max-width: 100%;
+ max-height: 200px;
+}
+
+.timeline-edit-markdown-preview img {
+ max-width: 100%;
+ max-height: 200px;
+}
+
diff --git a/FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.tsx b/FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.tsx new file mode 100644 index 00000000..36a5572b --- /dev/null +++ b/FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.tsx @@ -0,0 +1,199 @@ +import { useEffect, useState } from "react"; +import classnames from "classnames"; +import { marked } from "marked"; + +import { HttpTimelinePostPostRequestData } from "~src/http/timeline"; + +import base64 from "~src/utilities/base64"; + +import { array } from "~src/components/common"; +import { TabPages } from "~src/components/tab"; +import { IconButton } from "~src/components/button"; +import BlobImage from "~src/components/BlobImage"; + +import "./MarkdownPostEdit.css"; + +class MarkedRenderer extends marked.Renderer { + constructor(public images: string[]) { + super(); + } + + // Custom image parser for indexed image link. + image(href: string, title: string | null, text: string): string { + const i = parseInt(href); + if (!isNaN(i) && i > 0 && i <= this.images.length) { + href = this.images[i - 1]; + } + + return super.image(href, title, text); + } +} + +function generateMarkedOptions(imageUrls: string[]) { + return { + renderer: new MarkedRenderer(imageUrls), + async: false, + } as const; +} + +function renderHtml(text: string, imageUrls: string[]): string { + return marked.parse(text, generateMarkedOptions(imageUrls)); +} + +async function build( + text: string, + images: File[], +): Promise<HttpTimelinePostPostRequestData[]> { + return [ + { + contentType: "text/markdown", + data: await base64(text), + }, + ...(await Promise.all( + images.map(async (image) => { + const data = await base64(image); + return { contentType: image.type, data }; + }), + )), + ]; +} + +export function useMarkdownEdit(disabled: boolean): { + hasContent: boolean; + clear: () => void; + build: () => Promise<HttpTimelinePostPostRequestData[]>; + markdownEditProps: Omit<MarkdownPostEditProps, "className">; +} { + const [text, setText] = useState<string>(""); + const [images, setImages] = useState<File[]>([]); + + return { + hasContent: text !== "" || images.length !== 0, + clear: () => { + setText(""); + setImages([]); + }, + build: () => { + return build(text, images); + }, + markdownEditProps: { + disabled, + text, + images, + onTextChange: setText, + onImageAppend: (image) => setImages(array.copy_push(images, image)), + onImageMove: (o, n) => setImages(array.copy_move(images, o, n)), + onImageDelete: (i) => setImages(array.copy_delete(images, i)), + }, + }; +} + +function MarkdownPreview({ text, images }: { text: string; images: File[] }) { + const [html, setHtml] = useState(""); + + useEffect(() => { + const imageUrls = images.map((image) => URL.createObjectURL(image)); + + setHtml(renderHtml(text, imageUrls)); + + return () => { + imageUrls.forEach((url) => URL.revokeObjectURL(url)); + }; + }, [text, images]); + + return ( + <div + className="timeline-edit-markdown-preview" + dangerouslySetInnerHTML={{ __html: html }} + /> + ); +} + +interface MarkdownPostEditProps { + disabled: boolean; + text: string; + images: File[]; + onTextChange: (text: string) => void; + onImageAppend: (image: File) => void; + onImageMove: (oldIndex: number, newIndex: number) => void; + onImageDelete: (index: number) => void; + className?: string; +} + +export function MarkdownPostEdit({ + disabled, + text, + images, + onTextChange, + onImageAppend, + // onImageMove, + onImageDelete, + className, +}: MarkdownPostEditProps) { + return ( + <TabPages + className={className} + pageContainerClassName="timeline-edit-markdown-tab-page" + dense + pages={[ + { + name: "text", + text: "edit", + page: ( + <textarea + value={text} + disabled={disabled} + className="timeline-edit-markdown-text" + onChange={(event) => { + onTextChange(event.currentTarget.value); + }} + /> + ), + }, + { + name: "images", + text: "image", + page: ( + <div className="timeline-edit-markdown-images"> + {images.map((image, index) => ( + <div + key={image.name} + className="timeline-edit-markdown-image-container" + > + <BlobImage src={image} /> + <IconButton + icon="trash" + color="danger" + className={classnames( + "timeline-edit-markdown-image-delete", + process && "d-none", + )} + onClick={() => { + onImageDelete(index); + }} + /> + </div> + ))} + <input + type="file" + accept="image/jpeg,image/jpg,image/png,image/gif,image/webp" + onChange={(event: React.ChangeEvent<HTMLInputElement>) => { + const { files } = event.currentTarget; + if (files != null && files.length !== 0) { + onImageAppend(files[0]); + } + }} + disabled={disabled} + /> + </div> + ), + }, + { + name: "preview", + text: "preview", + page: <MarkdownPreview text={text} images={images} />, + }, + ]} + /> + ); +} diff --git a/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.css b/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.css new file mode 100644 index 00000000..d1a61793 --- /dev/null +++ b/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.css @@ -0,0 +1,12 @@ +.timeline-edit-plain-text-container { + width: 100%; + height: 100%; +} + +.timeline-edit-plain-text-input { + width: 100%; + height: 100%; + padding: 0.5em; + border-radius: 4px; +} + diff --git a/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.tsx b/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.tsx new file mode 100644 index 00000000..7f3663b2 --- /dev/null +++ b/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.tsx @@ -0,0 +1,29 @@ +import classNames from "classnames"; + +import "./PlainTextPostEdit.css"; + +interface TimelinePostEditTextProps { + text: string; + disabled: boolean; + onChange: (text: string) => void; + className?: string; +} + +export default function TimelinePostEditText(props: TimelinePostEditTextProps) { + const { text, disabled, onChange, className } = props; + + return ( + <div + className={classNames("timeline-edit-plain-text-container", className)} + > + <textarea + value={text} + disabled={disabled} + onChange={(event) => { + onChange(event.target.value); + }} + className={classNames("timeline-edit-plain-text-input")} + /> + </div> + ); +} diff --git a/FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.css b/FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.css new file mode 100644 index 00000000..6efe93e9 --- /dev/null +++ b/FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.css @@ -0,0 +1,35 @@ +.timeline-post-create-card {
+ position: sticky !important;
+ top: 106px;
+ z-index: 100;
+ margin-right: 200px;
+}
+
+@media (max-width: 576px) {
+ .timeline-post-create-container {
+ padding-top: 60px;
+ }
+
+ .timeline-post-create-card {
+ margin-right: 0;
+ }
+}
+
+.timeline-post-create {
+ display: flex;
+}
+
+.timeline-post-create-edit-area {
+ flex-grow: 1;
+}
+
+.timeline-post-create-right-area {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ margin-left: 1em;
+}
+
+.timeline-post-create-send {
+ margin-top: auto;
+}
diff --git a/FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.tsx b/FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.tsx new file mode 100644 index 00000000..c0a80ad0 --- /dev/null +++ b/FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.tsx @@ -0,0 +1,193 @@ +import { useState } from "react"; +import classNames from "classnames"; + +import { UiLogicError } from "~src/common"; + +import { + getHttpTimelineClient, + HttpTimelineInfo, + HttpTimelinePostInfo, + HttpTimelinePostPostRequestData, +} from "~src/http/timeline"; + +import base64 from "~src/utilities/base64"; + +import { useC } from "~/src/components/common"; +import { pushAlert } from "~src/components/alert"; +import { IconButton, LoadingButton } from "~src/components/button"; +import PopupMenu from "~src/components/menu/PopupMenu"; +import { useWindowLeave } from "~src/components/hooks"; + +import TimelinePostCard from "../TimelinePostCard"; +import TimelinePostContainer from "../TimelinePostContainer"; +import PlainTextPostEdit from "./PlainTextPostEdit"; +import ImagePostEdit from "./ImagePostEdit"; +import { MarkdownPostEdit, useMarkdownEdit } from "./MarkdownPostEdit"; + +import "./TimelinePostCreateView.css"; + +type PostKind = "text" | "markdown" | "image"; + +const postKindIconMap: Record<PostKind, string> = { + text: "fonts", + markdown: "markdown", + image: "image", +}; + +export interface TimelinePostEditProps { + className?: string; + timeline: HttpTimelineInfo; + onPosted: (newPost: HttpTimelinePostInfo) => void; +} + +function TimelinePostEdit(props: TimelinePostEditProps) { + const { timeline, className, onPosted } = props; + + const c = useC(); + + const [process, setProcess] = useState<boolean>(false); + + const [kind, setKind] = useState<PostKind>("text"); + + const draftTextLocalStorageKey = `timeline.${timeline.owner.username}.${timeline.nameV2}.postDraft.text`; + const [text, setText] = useState<string>( + () => window.localStorage.getItem(draftTextLocalStorageKey) ?? "", + ); + const [image, setImage] = useState<File | null>(null); + const { + hasContent: mdHasContent, + build: mdBuild, + clear: mdClear, + markdownEditProps, + } = useMarkdownEdit(process); + + useWindowLeave(!mdHasContent && !image); + + const canSend = + (kind === "text" && text.length !== 0) || + (kind === "image" && image != null) || + (kind === "markdown" && mdHasContent); + + const onPostError = (): void => { + pushAlert({ + color: "danger", + message: "timeline.sendPostFailed", + }); + }; + + const onSend = async (): Promise<void> => { + setProcess(true); + + let requestDataList: HttpTimelinePostPostRequestData[]; + switch (kind) { + case "text": + requestDataList = [ + { + contentType: "text/plain", + data: await base64(text), + }, + ]; + break; + case "image": + if (image == null) { + throw new UiLogicError(); + } + requestDataList = [ + { + contentType: image.type, + data: await base64(image), + }, + ]; + break; + case "markdown": + if (!mdHasContent) { + throw new UiLogicError(); + } + requestDataList = await mdBuild(); + break; + default: + throw new UiLogicError("Unknown content type."); + } + + try { + const res = await getHttpTimelineClient().postPost( + timeline.owner.username, + timeline.nameV2, + { + dataList: requestDataList, + }, + ); + + if (kind === "text") { + setText(""); + window.localStorage.removeItem(draftTextLocalStorageKey); + } else if (kind === "image") { + setImage(null); + } else if (kind === "markdown") { + mdClear(); + } + onPosted(res); + } catch (e) { + onPostError(); + } finally { + setProcess(false); + } + }; + + return ( + <TimelinePostContainer + className={classNames(className, "timeline-post-create-container")} + > + <TimelinePostCard className="timeline-post-create-card"> + <div className="timeline-post-create"> + <div className="timeline-post-create-edit-area"> + {kind === "text" && ( + <PlainTextPostEdit + text={text} + disabled={process} + onChange={(text) => { + setText(text); + window.localStorage.setItem(draftTextLocalStorageKey, text); + }} + /> + )} + {kind === "image" && ( + <ImagePostEdit + file={image} + onChange={setImage} + disabled={process} + /> + )} + {kind === "markdown" && <MarkdownPostEdit {...markdownEditProps} />} + </div> + <div className="timeline-post-create-right-area"> + <PopupMenu + containerClassName="timeline-post-create-kind-select" + items={(["text", "image", "markdown"] as const).map((kind) => ({ + type: "button", + text: `timeline.post.type.${kind}`, + iconClassName: postKindIconMap[kind], + onClick: () => { + setKind(kind); + }, + }))} + > + <IconButton color="primary" icon={postKindIconMap[kind]} /> + </PopupMenu> + <LoadingButton + className="timeline-post-create-send" + onClick={() => void onSend()} + color="primary" + disabled={!canSend} + loading={process} + > + {c("timeline.send")} + </LoadingButton> + </div> + </div> + </TimelinePostCard> + </TimelinePostContainer> + ); +} + +export default TimelinePostEdit; diff --git a/FrontEnd/src/pages/timeline/index.tsx b/FrontEnd/src/pages/timeline/index.tsx new file mode 100644 index 00000000..6cd1ded0 --- /dev/null +++ b/FrontEnd/src/pages/timeline/index.tsx @@ -0,0 +1,16 @@ +import { useParams } from "react-router-dom"; + +import { UiLogicError } from "~src/common"; + +import Timeline from "./Timeline"; + +export default function TimelinePage() { + const { owner, timeline: timelineNameParam } = useParams(); + + if (owner == null || owner == "") + throw new UiLogicError("Route param owner is not set."); + + const timeline = timelineNameParam || "self"; + + return <Timeline timelineOwner={owner} timelineName={timeline} />; +}; diff --git a/FrontEnd/src/pages/timeline/view/ImagePostView.css b/FrontEnd/src/pages/timeline/view/ImagePostView.css new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/FrontEnd/src/pages/timeline/view/ImagePostView.css diff --git a/FrontEnd/src/pages/timeline/view/ImagePostView.tsx b/FrontEnd/src/pages/timeline/view/ImagePostView.tsx new file mode 100644 index 00000000..85179475 --- /dev/null +++ b/FrontEnd/src/pages/timeline/view/ImagePostView.tsx @@ -0,0 +1,38 @@ +import { useEffect, useState } from "react"; +import classNames from "classnames"; + +import { + HttpTimelinePostInfo, + getHttpTimelineClient, +} from "~src/http/timeline"; + +import "./ImagePostView.css"; + +interface ImagePostViewProps { + post?: HttpTimelinePostInfo; + className?: string; +} + +export default function ImagePostView({ post, className }: ImagePostViewProps) { + const [url, setUrl] = useState<string | null>(null); + + useEffect(() => { + if (post) { + setUrl( + getHttpTimelineClient().generatePostDataUrl( + post.timelineOwnerV2, + post.timelineNameV2, + post.id, + ), + ); + } else { + setUrl(null); + } + }, [post]); + + return ( + <div className={classNames("timeline-view-image-container", className)}> + <img src={url ?? undefined} className="timeline-view-image" /> + </div> + ); +} diff --git a/FrontEnd/src/pages/timeline/view/MarkdownPostView.css b/FrontEnd/src/pages/timeline/view/MarkdownPostView.css new file mode 100644 index 00000000..48a893eb --- /dev/null +++ b/FrontEnd/src/pages/timeline/view/MarkdownPostView.css @@ -0,0 +1,4 @@ +.timeline-view-markdown img { + max-width: 100%; + max-height: 200px; +} diff --git a/FrontEnd/src/pages/timeline/view/MarkdownPostView.tsx b/FrontEnd/src/pages/timeline/view/MarkdownPostView.tsx new file mode 100644 index 00000000..9bb9f980 --- /dev/null +++ b/FrontEnd/src/pages/timeline/view/MarkdownPostView.tsx @@ -0,0 +1,59 @@ +import { useMemo, useState } from "react"; +import { marked } from "marked"; +import classNames from "classnames"; + +import { + HttpTimelinePostInfo, + getHttpTimelineClient, +} from "~src/http/timeline"; + +import { useAutoUnsubscribePromise } from "~src/components/hooks"; +import Skeleton from "~src/components/Skeleton"; + +import "./MarkdownPostView.css"; + +interface MarkdownPostViewProps { + post?: HttpTimelinePostInfo; + className?: string; +} + +export default function MarkdownPostView({ + post, + className, +}: MarkdownPostViewProps) { + const [markdown, setMarkdown] = useState<string | null>(null); + + useAutoUnsubscribePromise( + () => { + if (post) { + return getHttpTimelineClient().getPostDataAsString( + post.timelineOwnerV2, + post.timelineNameV2, + post.id, + ); + } + }, + setMarkdown, + [post], + ); + + const markdownHtml = useMemo<string | null>(() => { + if (markdown == null) return null; + return marked.parse(markdown, { + async: false, + }); + }, [markdown]); + + return ( + <div className={classNames("timeline-view-markdown-container", className)}> + {markdownHtml == null ? ( + <Skeleton /> + ) : ( + <div + className="timeline-view-markdown" + dangerouslySetInnerHTML={{ __html: markdownHtml }} + /> + )} + </div> + ); +} diff --git a/FrontEnd/src/pages/timeline/view/PlainTextPostView.css b/FrontEnd/src/pages/timeline/view/PlainTextPostView.css new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/FrontEnd/src/pages/timeline/view/PlainTextPostView.css diff --git a/FrontEnd/src/pages/timeline/view/PlainTextPostView.tsx b/FrontEnd/src/pages/timeline/view/PlainTextPostView.tsx new file mode 100644 index 00000000..b964187d --- /dev/null +++ b/FrontEnd/src/pages/timeline/view/PlainTextPostView.tsx @@ -0,0 +1,50 @@ +import { useState } from "react"; +import classNames from "classnames"; + +import { + HttpTimelinePostInfo, + getHttpTimelineClient, +} from "~src/http/timeline"; + +import Skeleton from "~src/components/Skeleton"; +import { useAutoUnsubscribePromise } from "~src/components/hooks"; + +import "./PlainTextPostView.css"; + +interface PlainTextPostViewProps { + post?: HttpTimelinePostInfo; + className?: string; +} + +export default function PlainTextPostView({ + post, + className, +}: PlainTextPostViewProps) { + const [text, setText] = useState<string | null>(null); + + useAutoUnsubscribePromise( + () => { + if (post) { + return getHttpTimelineClient().getPostDataAsString( + post.timelineOwnerV2, + post.timelineNameV2, + post.id, + ); + } + }, + setText, + [post], + ); + + return ( + <div + className={classNames("timeline-view-plain-text-container", className)} + > + {text == null ? ( + <Skeleton /> + ) : ( + <div className="timeline-view-plain-text">{text}</div> + )} + </div> + ); +} diff --git a/FrontEnd/src/pages/timeline/view/TimelinePostContentView.tsx b/FrontEnd/src/pages/timeline/view/TimelinePostContentView.tsx new file mode 100644 index 00000000..851a9a33 --- /dev/null +++ b/FrontEnd/src/pages/timeline/view/TimelinePostContentView.tsx @@ -0,0 +1,37 @@ +import ImagePostView from "./ImagePostView"; +import MarkdownPostView from "./MarkdownPostView"; +import PlainTextPostView from "./PlainTextPostView"; + +import type { HttpTimelinePostInfo } from "~src/http/timeline"; + +interface TimelinePostContentViewProps { + post?: HttpTimelinePostInfo; + className?: string; +} + +const viewMap: Record<string, React.FC<TimelinePostContentViewProps>> = { + "text/plain": PlainTextPostView, + "text/markdown": MarkdownPostView, + "image/png": ImagePostView, + "image/jpeg": ImagePostView, + "image/gif": ImagePostView, + "image/webp": ImagePostView, +}; + +export default function TimelinePostContentView({ + post, + className, +}: TimelinePostContentViewProps) { + if (post == null) { + return <div />; + } + + const type = post.dataList[0].kind; + + if (type in viewMap) { + const View = viewMap[type]; + return <View post={post} className={className} />; + } + + return <div>Unknown post type.</div>; +} |