diff options
author | crupest <crupest@outlook.com> | 2023-09-20 20:26:42 +0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-09-20 20:26:42 +0800 |
commit | f836d77e73f3ea0af45c5f71dae7268143d6d86f (patch) | |
tree | 573cfafd972106d69bef0d41ff5f270ec3c43ec2 /FrontEnd/src | |
parent | 4a069bf1268f393d5467166356f691eb89963152 (diff) | |
parent | 901fe3d7c032d284da5c9bce24c4aaee9054c7ac (diff) | |
download | timeline-f836d77e73f3ea0af45c5f71dae7268143d6d86f.tar.gz timeline-f836d77e73f3ea0af45c5f71dae7268143d6d86f.tar.bz2 timeline-f836d77e73f3ea0af45c5f71dae7268143d6d86f.zip |
Merge pull request #1395 from crupest/dev
Refector 2023 v0.1
Diffstat (limited to 'FrontEnd/src')
231 files changed, 6781 insertions, 6808 deletions
diff --git a/FrontEnd/src/App.tsx b/FrontEnd/src/App.tsx index cfdab229..58463d08 100644 --- a/FrontEnd/src/App.tsx +++ b/FrontEnd/src/App.tsx @@ -1,51 +1,35 @@ -import * as React from "react"; +import { Suspense } from "react"; import { BrowserRouter, Route, Routes } from "react-router-dom"; -import AppBar from "./views/common/AppBar"; -import LoadingPage from "./views/common/LoadingPage"; -import Center from "./views/center"; -import Home from "./views/home"; -import Login from "./views/login"; -import Register from "./views/register"; -import Settings from "./views/settings"; -import About from "./views/about"; -import TimelinePage from "./views/timeline"; -import Search from "./views/search"; -import Admin from "./views/admin"; -import AlertHost from "./views/common/alert/AlertHost"; - -import { useUser } from "./services/user"; - -const NoMatch: React.FC = () => { - return <div>Ah-oh, 404!</div>; -}; - -function App(): JSX.Element { - const user = useUser(); +import AppBar from "./components/AppBar"; +import NotFoundPage from "./pages/404"; +import HomePage from "./pages/home"; +import AboutPage from "./pages/about"; +import SettingPage from "./pages/setting"; +import LoginPage from "./pages/login"; +import RegisterPage from "./pages/register"; +import TimelinePage from "./pages/timeline"; +import LoadingPage from "./pages/loading"; +import { AlertHost } from "./components/alert"; +export default function App() { return ( - <React.Suspense fallback={<LoadingPage />}> + <Suspense fallback={<LoadingPage />}> <BrowserRouter> <AppBar /> <div style={{ height: 56 }} /> <Routes> - <Route index element={user == null ? <Home /> : <Center />} /> - <Route path="home" element={<Home />} /> - <Route path="center" element={<Center />} /> - <Route path="login" element={<Login />} /> - <Route path="register" element={<Register />} /> - <Route path="settings" element={<Settings />} /> - <Route path="about" element={<About />} /> - <Route path="search" element={<Search />} /> - <Route path="admin/*" element={<Admin />} /> + <Route path="login" element={<LoginPage />} /> + <Route path="register" element={<RegisterPage />} /> + <Route path="settings" element={<SettingPage />} /> + <Route path="about" element={<AboutPage />} /> <Route path=":owner" element={<TimelinePage />} /> <Route path=":owner/:timeline" element={<TimelinePage />} /> - <Route element={<NoMatch />} /> + <Route path="" element={<HomePage />} /> + <Route path="*" element={<NotFoundPage />} /> </Routes> <AlertHost /> </BrowserRouter> - </React.Suspense> + </Suspense> ); } - -export default App; diff --git a/FrontEnd/src/common.ts b/FrontEnd/src/common.ts index 965f9933..1ca796c3 100644 --- a/FrontEnd/src/common.ts +++ b/FrontEnd/src/common.ts @@ -3,8 +3,7 @@ // This error should never occur. If it does, it indicates there is some logic bug in codes. export class UiLogicError extends Error {} -export const highlightTimelineUsername = "crupest"; - export type { I18nText } from "./i18n"; +export type { I18nText as Text } from "./i18n"; export { c, convertI18nText } from "./i18n"; export { default as useC } from "./utilities/hooks/use-c"; diff --git a/FrontEnd/src/components/AppBar.css b/FrontEnd/src/components/AppBar.css new file mode 100644 index 00000000..38497478 --- /dev/null +++ b/FrontEnd/src/components/AppBar.css @@ -0,0 +1,96 @@ +.app-bar { + height: 56px; + position: fixed; + z-index: 1030; + top: 0; + left: 0; + right: 0; + background-color: var(--cru-primary-color); +} + +.app-bar { + display: flex; +} + +.app-bar > * { + background-color: var(--cru-primary-color); +} + +.app-bar .app-bar-brand { + display: flex; + align-items: center; +} + +.app-bar .app-bar-brand-icon { + height: 2em; +} + +.app-bar .app-bar-space { + flex-grow: 1; +} + +.app-bar .app-bar-user-area { + display: flex; +} + +.app-bar a { + background-color: var(--cru-primary-color); + color: var(--cru-push-button-text-color); + text-decoration: none; + display: flex; + align-items: center; + padding: 0 1em; + transition: all 0.5s; +} + +.app-bar a:hover { + background-color: var(--cru-clickable-primary-hover-color); +} + +.app-bar a:focus { + background-color: var(--cru-clickable-primary-focus-color); +} + +.app-bar a:active { + background-color: var(--cru-clickable-primary-active-color); +} + +/* the current page */ +.app-bar a.active { + background-color: var(--cru-clickable-primary-focus-color); +} + +.app-bar .app-bar-avatar img { + width: 45px; + height: 45px; + background-color: white; + border-radius: 50%; +} + +.app-bar.desktop .app-bar-link-area { + display: flex; +} + +.app-bar.mobile .app-bar-link-area { + position: absolute; + z-index: -1; + left: 0; + right: 0; + top: 100%; + translate: 0 -100%; + transition: transform 0.5s; +} + +.app-bar.mobile a { + height: 56px; +} + +.app-bar.mobile.expand .app-bar-link-area { + transform: translateY(100%); +} + +.app-bar .toggler { + font-size: 2em; + padding-right: 0.5em; +} + diff --git a/FrontEnd/src/components/AppBar.tsx b/FrontEnd/src/components/AppBar.tsx new file mode 100644 index 00000000..d40c8105 --- /dev/null +++ b/FrontEnd/src/components/AppBar.tsx @@ -0,0 +1,106 @@ +import { useState } from "react"; +import classnames from "classnames"; +import { Link, NavLink } from "react-router-dom"; + +import { useUser } from "~src/services/user"; + +import { I18nText, useC } from "./common"; +import { useMobile } from "./hooks"; +import TimelineLogo from "./TimelineLogo"; +import { IconButton } from "./button"; +import UserAvatar from "./user/UserAvatar"; + +import "./AppBar.css"; + +function AppBarNavLink({ + link, + className, + label, + onClick, + children, +}: { + link: string; + className?: string; + label?: I18nText; + onClick?: () => void; + children?: React.ReactNode; +}) { + if (label != null && children != null) { + throw new Error("AppBarNavLink: label and children cannot be both set"); + } + + const c = useC(); + + return ( + <NavLink + to={link} + className={({ isActive }) => classnames(className, isActive && "active")} + onClick={onClick} + > + {children != null ? children : c(label)} + </NavLink> + ); +} + +export default function AppBar() { + const isMobile = useMobile(); + + const [isCollapse, setIsCollapse] = useState<boolean>(true); + const collapse = isMobile ? () => setIsCollapse(true) : undefined; + const toggleCollapse = () => setIsCollapse(!isCollapse); + + const user = useUser(); + const hasAdministrationPermission = user && user.hasAdministrationPermission; + + return ( + <nav + className={classnames( + isMobile ? "mobile" : "desktop", + "app-bar", + isCollapse || "expand", + )} + > + <Link to="/" className="app-bar-brand" onClick={collapse}> + <TimelineLogo className="app-bar-brand-icon" /> + Timeline + </Link> + + <div className="app-bar-link-area"> + <AppBarNavLink + link="/settings" + label="nav.settings" + onClick={collapse} + /> + <AppBarNavLink link="/about" label="nav.about" onClick={collapse} /> + {hasAdministrationPermission && ( + <AppBarNavLink + link="/admin" + label="nav.administration" + onClick={collapse} + /> + )} + </div> + + <div className="app-bar-space" /> + + <div className="app-bar-user-area"> + {user != null ? ( + <AppBarNavLink link="/" className="app-bar-avatar" onClick={collapse}> + <UserAvatar username={user.username} /> + </AppBarNavLink> + ) : ( + <AppBarNavLink link="/login" label="nav.login" onClick={collapse} /> + )} + </div> + + {isMobile && ( + <IconButton + icon="list" + color="light" + className="toggler" + onClick={toggleCollapse} + /> + )} + </nav> + ); +} diff --git a/FrontEnd/src/components/BlobImage.tsx b/FrontEnd/src/components/BlobImage.tsx new file mode 100644 index 00000000..047a13b4 --- /dev/null +++ b/FrontEnd/src/components/BlobImage.tsx @@ -0,0 +1,41 @@ +import { + useState, + useEffect, + useMemo, + Ref, + ComponentPropsWithoutRef, +} from "react"; + +type BlobImageProps = Omit<ComponentPropsWithoutRef<"img">, "src"> & { + imgRef?: Ref<HTMLImageElement>; + src?: Blob | string | null; + keyBySrc?: boolean; +}; + +export default function BlobImage(props: BlobImageProps) { + const { imgRef, src, keyBySrc, ...otherProps } = props; + + const [url, setUrl] = useState<string | null | undefined>(undefined); + + useEffect(() => { + if (src instanceof Blob) { + const url = URL.createObjectURL(src); + setUrl(url); + return () => { + URL.revokeObjectURL(url); + }; + } else { + setUrl(src); + } + }, [src]); + + const key = useMemo(() => { + if (keyBySrc) { + return url == null ? undefined : btoa(url); + } else { + return undefined; + } + }, [url, keyBySrc]); + + return <img key={key} ref={imgRef} {...otherProps} src={url ?? undefined} />; +} diff --git a/FrontEnd/src/components/Card.css b/FrontEnd/src/components/Card.css new file mode 100644 index 00000000..6d655eb9 --- /dev/null +++ b/FrontEnd/src/components/Card.css @@ -0,0 +1,20 @@ +.cru-card {
+ border-radius: var(--cru-card-border-radius);
+ transition: all 0.3s;
+}
+
+.cru-card-background-none {
+ background-color: transparent;
+}
+
+.cru-card-background-solid {
+ background-color: var(--cru-background-color);
+}
+
+.cru-card-background-grayscale {
+ background-color: var(--cru-container-background-color);
+}
+
+.cru-card-border-color {
+ border: 2px solid var(--cru-card-border-color);
+}
diff --git a/FrontEnd/src/components/Card.tsx b/FrontEnd/src/components/Card.tsx new file mode 100644 index 00000000..5d3ef630 --- /dev/null +++ b/FrontEnd/src/components/Card.tsx @@ -0,0 +1,39 @@ +import { ComponentPropsWithoutRef, Ref } from "react"; +import classNames from "classnames"; + +import { ThemeColor } from "./common"; + +import "./Card.css"; + +interface CardProps extends ComponentPropsWithoutRef<"div"> { + containerRef?: Ref<HTMLDivElement>; + color?: ThemeColor; + border?: "color" | "none"; + background?: "color" | "solid" | "grayscale" | "none"; +} + +export default function Card({ + color, + background, + border, + className, + children, + containerRef, + ...otherProps +}: CardProps) { + return ( + <div + ref={containerRef} + className={classNames( + "cru-card", + `cru-card-${color ?? "primary"}`, + `cru-card-border-${border ?? "color"}`, + `cru-card-background-${background ?? "solid"}`, + className, + )} + {...otherProps} + > + {children} + </div> + ); +} diff --git a/FrontEnd/src/components/Icon.css b/FrontEnd/src/components/Icon.css new file mode 100644 index 00000000..3c83b0e9 --- /dev/null +++ b/FrontEnd/src/components/Icon.css @@ -0,0 +1,4 @@ +.cru-icon { + color: var(--cru-theme-color); + font-size: 1.4rem; +} diff --git a/FrontEnd/src/components/Icon.tsx b/FrontEnd/src/components/Icon.tsx new file mode 100644 index 00000000..e5cf598e --- /dev/null +++ b/FrontEnd/src/components/Icon.tsx @@ -0,0 +1,28 @@ +import { ComponentPropsWithoutRef } from "react"; +import classNames from "classnames"; + +import { ThemeColor } from "./common"; + +import "./Icon.css"; + +interface IconButtonProps extends ComponentPropsWithoutRef<"i"> { + icon: string; + color?: ThemeColor; + size?: string | number; +} + +export default function Icon(props: IconButtonProps) { + const { icon, color, size, style, className, ...otherProps } = props; + + return ( + <i + style={size != null ? { ...style, fontSize: size } : style} + className={classNames( + `cru-theme-${color ?? "primary"}`, + `bi-${icon} cru-icon`, + className, + )} + {...otherProps} + /> + ); +} diff --git a/FrontEnd/src/views/common/ImageCropper.css b/FrontEnd/src/components/ImageCropper.css index 2c4d0a8c..03d2038f 100644 --- a/FrontEnd/src/views/common/ImageCropper.css +++ b/FrontEnd/src/components/ImageCropper.css @@ -1,18 +1,16 @@ -.image-cropper-container {
+.cru-image-cropper-container {
position: relative;
box-sizing: border-box;
+ display: flex;
user-select: none;
}
-.image-cropper-container img {
- position: absolute;
- left: 0;
- top: 0;
+.cru-image-cropper-container img {
width: 100%;
height: 100%;
}
-.image-cropper-mask-container {
+.cru-image-cropper-mask-container {
position: absolute;
left: 0;
top: 0;
@@ -21,18 +19,16 @@ overflow: hidden;
}
-.image-cropper-mask {
+.cru-image-cropper-mask {
position: absolute;
box-shadow: 0 0 0 10000px rgba(255, 255, 255, 0.8);
touch-action: none;
}
-.image-cropper-handler {
+.cru-image-cropper-handler {
position: absolute;
- width: 26px;
- height: 26px;
border: black solid 2px;
border-radius: 50%;
background: white;
touch-action: none;
-}
+}
\ No newline at end of file diff --git a/FrontEnd/src/components/ImageCropper.tsx b/FrontEnd/src/components/ImageCropper.tsx new file mode 100644 index 00000000..4dfdd0cd --- /dev/null +++ b/FrontEnd/src/components/ImageCropper.tsx @@ -0,0 +1,323 @@ +import { useState, useRef, PointerEvent } from "react"; +import classnames from "classnames"; + +import { UiLogicError, geometry } from "./common"; + +import BlobImage from "./BlobImage"; + +import "./ImageCropper.css"; + +const { Rect } = geometry; + +type Rect = geometry.Rect; +type Movement = geometry.Movement; + +export function crop( + image: HTMLImageElement, + clip: Rect, + mimeType: string, +): Promise<Blob> { + return new Promise((resolve, reject) => { + const canvas = document.createElement("canvas"); + canvas.width = clip.width; + canvas.height = clip.height; + const context = canvas.getContext("2d"); + + if (context == null) throw new Error("Failed to create context."); + + context.drawImage( + image, + clip.left, + clip.top, + clip.width, + clip.height, + 0, + 0, + clip.width, + clip.height, + ); + + canvas.toBlob((blob) => { + if (blob == null) { + reject(new Error("canvas.toBlob returns null")); + } else { + resolve(blob); + } + }, mimeType); + }); +} + +interface ImageInfo { + element: HTMLImageElement; + width: number; + height: number; + ratio: number; + landscape: boolean; + rect: Rect; +} + +export interface CropConstraint { + ratio?: number; + // minClipWidth?: number; + // minClipHeight?: number; + // maxClipWidth?: number; + // maxClipHeight?: number; +} + +function generateImageInfo(imageElement: HTMLImageElement): ImageInfo { + const { naturalWidth, naturalHeight } = imageElement; + const imageRatio = naturalHeight / naturalWidth; + + return { + element: imageElement, + width: naturalWidth, + height: naturalHeight, + ratio: imageRatio, + landscape: imageRatio < 1, + rect: new Rect(0, 0, naturalWidth, naturalHeight), + }; +} + +interface ImageCropperProps { + clip: Rect; + image: Blob | string | null; + imageElementCallback: (element: HTMLImageElement | null) => void; + onImageLoad: () => void; + onMove: (movement: Movement, originalClip: Rect) => void; + onResize: (movement: Movement, originalClip: Rect) => void; + containerClassName?: string; +} + +export function useImageCrop( + file: File | null, + options?: { + constraint?: CropConstraint; + }, +): { + clip: Rect; + setClip: (clip: Rect) => void; + canCrop: boolean; + crop: () => Promise<Blob>; + imageCropperProps: ImageCropperProps; +} { + const targetRatio = options?.constraint?.ratio; + + const [imageElement, setImageElement] = useState<HTMLImageElement | null>( + null, + ); + const [imageInfo, setImageInfo] = useState<ImageInfo | null>(null); + const [clip, setClip] = useState<Rect>(Rect.empty); + + if (imageElement == null && imageInfo != null) { + setImageInfo(null); + setClip(Rect.empty); + } + + const canCrop = file != null && imageElement != null && imageInfo != null; + + return { + clip, + setClip, + canCrop, + crop() { + if (!canCrop) throw new UiLogicError(); + return crop(imageElement, clip, file.type); + }, + imageCropperProps: { + clip, + image: file, + imageElementCallback: setImageElement, + onMove: (movement, originalClip) => { + if (imageInfo == null) return; + const newClip = geometry.adjustRectToContainer( + originalClip.copy().move(movement), + imageInfo.rect, + "move", + { + targetRatio, + }, + ); + setClip(newClip); + }, + onResize: (movement, originalClip) => { + if (imageInfo == null) return; + const newClip = geometry.adjustRectToContainer( + originalClip.copy().expand(movement), + imageInfo.rect, + "resize", + { targetRatio, resizeNoFlip: true, ratioCorrectBasedOn: "width" }, + ); + setClip(newClip); + }, + onImageLoad: () => { + if (imageElement == null) throw new UiLogicError(); + const image = generateImageInfo(imageElement); + setImageInfo(image); + setClip( + geometry.adjustRectToContainer(Rect.max, image.rect, "both", { + targetRatio, + }), + ); + }, + }, + }; +} + +interface PointerState { + x: number; + y: number; + pointerId: number; + originalClip: Rect; +} + +const imageCropperHandlerSize = 15; + +export function ImageCropper(props: ImageCropperProps) { + function convertClipToElement( + clip: Rect, + imageElement: HTMLImageElement, + ): Rect { + const xRatio = imageElement.clientWidth / imageElement.naturalWidth; + const yRatio = imageElement.clientHeight / imageElement.naturalHeight; + return Rect.from({ + left: xRatio * clip.left, + top: yRatio * clip.top, + width: xRatio * clip.width, + height: yRatio * clip.height, + }); + } + + function convertMovementFromElement( + move: Movement, + imageElement: HTMLImageElement, + ): Movement { + const xRatio = imageElement.naturalWidth / imageElement.clientWidth; + const yRatio = imageElement.naturalHeight / imageElement.clientHeight; + return { + x: xRatio * move.x, + y: yRatio * move.y, + }; + } + + const { + clip, + image, + imageElementCallback, + onImageLoad, + onMove, + onResize, + containerClassName, + } = props; + + const pointerStateRef = useRef<PointerState | null>(null); + const [imageElement, setImageElement] = useState<HTMLImageElement | null>( + null, + ); + + const clipInElement: Rect = + imageElement != null + ? convertClipToElement(clip, imageElement) + : Rect.empty; + + const actOnMovement = ( + e: PointerEvent, + change: (movement: Movement, originalClip: Rect) => void, + ) => { + if ( + imageElement == null || + pointerStateRef.current == null || + pointerStateRef.current.pointerId != e.pointerId + ) { + return; + } + + const { x, y, originalClip } = pointerStateRef.current; + + const movement = { + x: e.clientX - x, + y: e.clientY - y, + }; + + change(convertMovementFromElement(movement, imageElement), originalClip); + }; + + const onPointerDown = (e: PointerEvent) => { + if (imageElement == null || pointerStateRef.current != null) return; + + e.currentTarget.setPointerCapture(e.pointerId); + + pointerStateRef.current = { + x: e.clientX, + y: e.clientY, + pointerId: e.pointerId, + originalClip: clip, + }; + }; + + const onPointerUp = (e: PointerEvent) => { + if ( + pointerStateRef.current == null || + pointerStateRef.current.pointerId != e.pointerId + ) { + return; + } + + e.currentTarget.releasePointerCapture(e.pointerId); + pointerStateRef.current = null; + }; + + const onMaskPointerMove = (e: PointerEvent) => { + actOnMovement(e, onMove); + }; + + const onResizeHandlerPointerMove = (e: PointerEvent) => { + actOnMovement(e, onResize); + }; + + return ( + <div + className={classnames("cru-image-cropper-container", containerClassName)} + > + <BlobImage + imgRef={(element) => { + setImageElement(element); + imageElementCallback(element); + }} + src={image} + onLoad={onImageLoad} + /> + <div className="cru-image-cropper-mask-container"> + <div + className="cru-image-cropper-mask" + style={ + clipInElement == null + ? undefined + : { + left: clipInElement.left, + top: clipInElement.top, + width: clipInElement.width, + height: clipInElement.height, + } + } + onPointerMove={onMaskPointerMove} + onPointerDown={onPointerDown} + onPointerUp={onPointerUp} + /> + </div> + <div + className="cru-image-cropper-handler" + style={{ + left: + clipInElement.left + clipInElement.width - imageCropperHandlerSize, + top: + clipInElement.top + clipInElement.height - imageCropperHandlerSize, + width: imageCropperHandlerSize * 2, + height: imageCropperHandlerSize * 2, + }} + onPointerMove={onResizeHandlerPointerMove} + onPointerDown={onPointerDown} + onPointerUp={onPointerUp} + /> + </div> + ); +} diff --git a/FrontEnd/src/views/common/LoadFailReload.tsx b/FrontEnd/src/components/LoadFailReload.tsx index 81ba1f67..81ba1f67 100644 --- a/FrontEnd/src/views/common/LoadFailReload.tsx +++ b/FrontEnd/src/components/LoadFailReload.tsx diff --git a/FrontEnd/src/components/Page.css b/FrontEnd/src/components/Page.css new file mode 100644 index 00000000..b22d83af --- /dev/null +++ b/FrontEnd/src/components/Page.css @@ -0,0 +1,8 @@ +.cru-page { + padding: var(--cru-page-padding); +} + +.cru-page-no-top-padding { + padding-top: 0; +} + diff --git a/FrontEnd/src/components/Page.tsx b/FrontEnd/src/components/Page.tsx new file mode 100644 index 00000000..8c9febcc --- /dev/null +++ b/FrontEnd/src/components/Page.tsx @@ -0,0 +1,17 @@ +import { ComponentPropsWithoutRef, Ref } from "react"; +import classNames from "classnames"; + +import "./Page.css"; + +interface PageProps extends ComponentPropsWithoutRef<"div"> { + noTopPadding?: boolean; + pageRef?: Ref<HTMLDivElement>; +} + +export default function Page({ noTopPadding, pageRef, className, children }: PageProps) { + return ( + <div ref={pageRef} className={classNames(className, "cru-page", noTopPadding && "cru-page-no-top-padding")}> + {children} + </div> + ); +} diff --git a/FrontEnd/src/views/common/SearchInput.css b/FrontEnd/src/components/SearchInput.css index f0503016..818b2917 100644 --- a/FrontEnd/src/views/common/SearchInput.css +++ b/FrontEnd/src/components/SearchInput.css @@ -1,8 +1,8 @@ .cru-search-input {
display: flex;
- flex-wrap: wrap;
+ gap: 1em;
}
.cru-search-input-input {
width: 100%;
-}
+}
\ No newline at end of file diff --git a/FrontEnd/src/components/SearchInput.tsx b/FrontEnd/src/components/SearchInput.tsx new file mode 100644 index 00000000..b1de6227 --- /dev/null +++ b/FrontEnd/src/components/SearchInput.tsx @@ -0,0 +1,50 @@ +import classNames from "classnames"; + +import { useC, Text } from "./common"; +import { LoadingButton } from "./button"; + +import "./SearchInput.css"; + +interface SearchInputProps { + value: string; + onChange: (value: string) => void; + onButtonClick: () => void; + loading?: boolean; + className?: string; + buttonText?: Text; +} + +export default function SearchInput({ + value, + onChange, + onButtonClick, + loading, + className, + buttonText, +}: SearchInputProps) { + const c = useC(); + + return ( + <div className={classNames("cru-search-input", className)}> + <input + type="search" + className="cru-search-input-input" + value={value} + onChange={(event) => { + const { value } = event.currentTarget; + onChange(value); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + onButtonClick(); + event.preventDefault(); + } + }} + /> + + <LoadingButton loading={loading} onClick={onButtonClick}> + {c(buttonText ?? "search")} + </LoadingButton> + </div> + ); +} diff --git a/FrontEnd/src/components/Skeleton.css b/FrontEnd/src/components/Skeleton.css new file mode 100644 index 00000000..0f78d3b5 --- /dev/null +++ b/FrontEnd/src/components/Skeleton.css @@ -0,0 +1,20 @@ +.cru-skeleton {
+ padding: 0 1em;
+}
+
+.cru-skeleton-line {
+ height: 1em;
+ background-color: hsl(0 0% 90%);
+ margin: 0.7em 0;
+ border-radius: 0.2em;
+}
+
+@media (prefers-color-scheme: dark) {
+ .cru-skeleton-line {
+ background-color: hsl(0 0% 20%);
+ }
+}
+
+.cru-skeleton-line:last-child {
+ width: 50%;
+}
diff --git a/FrontEnd/src/components/Skeleton.tsx b/FrontEnd/src/components/Skeleton.tsx new file mode 100644 index 00000000..03f80df5 --- /dev/null +++ b/FrontEnd/src/components/Skeleton.tsx @@ -0,0 +1,22 @@ +import { ComponentPropsWithoutRef } from "react"; +import classNames from "classnames"; + +import { range } from "~src/utilities"; + +import "./Skeleton.css"; + +interface SkeletonProps extends ComponentPropsWithoutRef<"div"> { + lineNumber?: number; +} + +export default function Skeleton(props: SkeletonProps) { + const { lineNumber, className, ...otherProps } = props; + + return ( + <div className={classNames(className, "cru-skeleton")} {...otherProps}> + {range(lineNumber ?? 3).map((i) => ( + <div key={i} className="cru-skeleton-line" /> + ))} + </div> + ); +} diff --git a/FrontEnd/src/views/common/Spinner.css b/FrontEnd/src/components/Spinner.css index a1de68d2..a1de68d2 100644 --- a/FrontEnd/src/views/common/Spinner.css +++ b/FrontEnd/src/components/Spinner.css diff --git a/FrontEnd/src/components/Spinner.tsx b/FrontEnd/src/components/Spinner.tsx new file mode 100644 index 00000000..50ccf0b2 --- /dev/null +++ b/FrontEnd/src/components/Spinner.tsx @@ -0,0 +1,46 @@ +import { CSSProperties, ComponentPropsWithoutRef } from "react"; +import classNames from "classnames"; + +import "./Spinner.css"; + +const sizeMap: Record<string, string> = { + sm: "18px", + md: "30px", + lg: "42px", +}; + +function calculateSize(size: SpinnerProps["size"]) { + if (size == null) { + return "1em"; + } + if (typeof size === "number") { + return size; + } + if (size in sizeMap) { + return sizeMap[size]; + } + return size; +} + +export interface SpinnerProps extends ComponentPropsWithoutRef<"span"> { + size?: number | string; + className?: string; + style?: CSSProperties; +} + +export default function Spinner(props: SpinnerProps) { + const { size, className, style, ...otherProps } = props; + const calculatedSize = calculateSize(size); + + return ( + <span + className={classNames("cru-spinner", className)} + style={{ + width: calculatedSize, + height: calculatedSize, + ...style, + }} + {...otherProps} + /> + ); +} diff --git a/FrontEnd/src/views/common/TimelineLogo.tsx b/FrontEnd/src/components/TimelineLogo.tsx index e06ed0f5..e06ed0f5 100644 --- a/FrontEnd/src/views/common/TimelineLogo.tsx +++ b/FrontEnd/src/components/TimelineLogo.tsx diff --git a/FrontEnd/src/components/alert/AlertHost.tsx b/FrontEnd/src/components/alert/AlertHost.tsx new file mode 100644 index 00000000..59f8f27c --- /dev/null +++ b/FrontEnd/src/components/alert/AlertHost.tsx @@ -0,0 +1,82 @@ +import { useEffect, useState } from "react"; +import classNames from "classnames"; + +import { ThemeColor, useC, Text } from "../common"; +import IconButton from "../button/IconButton"; + +import { alertService, AlertInfoWithId } from "./AlertService"; + +import "./alert.css"; + +interface AutoCloseAlertProps { + color: ThemeColor; + message: Text; + onDismiss?: () => void; + onIn?: () => void; + onOut?: () => void; +} + +function Alert({ + color, + message, + onDismiss, + onIn, + onOut, +}: AutoCloseAlertProps) { + const c = useC(); + + return ( + <div + className={classNames("cru-alert", `cru-theme-${color}`)} + onPointerEnter={onIn} + onPointerLeave={onOut} + > + <div className="cru-alert-message">{c(message)}</div> + <IconButton + icon="x" + color="danger" + className="cru-alert-close-button" + onClick={onDismiss} + /> + </div> + ); +} + +export default function AlertHost() { + const [alerts, setAlerts] = useState<AlertInfoWithId[]>([]); + + useEffect(() => { + const listener = (alerts: AlertInfoWithId[]) => { + setAlerts(alerts); + }; + + alertService.registerListener(listener); + + return () => { + alertService.unregisterListener(listener); + }; + }, []); + + return ( + <div className="alert-container"> + {alerts.map((alert) => { + return ( + <Alert + key={alert.id} + message={alert.message} + color={alert.color ?? "primary"} + onIn={() => { + alertService.clearDismissTimer(alert.id); + }} + onOut={() => { + alertService.resetDismissTimer(alert.id); + }} + onDismiss={() => { + alertService.dismiss(alert.id); + }} + /> + ); + })} + </div> + ); +} diff --git a/FrontEnd/src/components/alert/AlertService.ts b/FrontEnd/src/components/alert/AlertService.ts new file mode 100644 index 00000000..b9cda752 --- /dev/null +++ b/FrontEnd/src/components/alert/AlertService.ts @@ -0,0 +1,114 @@ +import { ThemeColor, Text } from "../common"; + +const defaultDismissTime = 5000; + +export interface AlertInfo { + color?: ThemeColor; + message: Text; + dismissTime?: number | "never"; +} + +export interface AlertInfoWithId extends AlertInfo { + id: number; +} + +interface AlertServiceAlert extends AlertInfoWithId { + timerId: number | null; +} + +export type AlertsListener = (alerts: AlertInfoWithId[]) => void; + +export class AlertService { + private listeners: AlertsListener[] = []; + private alerts: AlertServiceAlert[] = []; + private currentId = 1; + + getAlert(alertId?: number | null | undefined): AlertServiceAlert | null { + for (const alert of this.alerts) { + if (alert.id === alertId) return alert; + } + return null; + } + + registerListener(listener: AlertsListener): void { + this.listeners.push(listener); + listener(this.alerts); + } + + unregisterListener(listener: AlertsListener): void { + this.listeners = this.listeners.filter((l) => l !== listener); + } + + notify() { + for (const listener of this.listeners) { + listener(this.alerts); + } + } + + push(alert: AlertInfo): void { + const newAlert: AlertServiceAlert = { + ...alert, + id: this.currentId++, + timerId: null, + }; + + this.alerts = [...this.alerts, newAlert]; + this._resetDismissTimer(newAlert); + + this.notify(); + } + + private _dismiss(alert: AlertServiceAlert) { + if (alert.timerId != null) { + window.clearTimeout(alert.timerId); + } + this.alerts = this.alerts.filter((a) => a !== alert); + this.notify(); + } + + dismiss(alertId?: number | null | undefined) { + const alert = this.getAlert(alertId); + if (alert != null) { + this._dismiss(alert); + } + } + + private _clearDismissTimer(alert: AlertServiceAlert) { + if (alert.timerId != null) { + window.clearTimeout(alert.timerId); + alert.timerId = null; + } + } + + clearDismissTimer(alertId?: number | null | undefined) { + const alert = this.getAlert(alertId); + if (alert != null) { + this._clearDismissTimer(alert); + } + } + + private _resetDismissTimer( + alert: AlertServiceAlert, + dismissTime?: number | null | undefined, + ) { + this._clearDismissTimer(alert); + + const realDismissTime = + dismissTime ?? alert.dismissTime ?? defaultDismissTime; + + if (typeof realDismissTime === "number") { + alert.timerId = window.setTimeout(() => { + this._dismiss(alert); + }, realDismissTime); + } + } + + resetDismissTimer(alertId?: number | null | undefined) { + const alert = this.getAlert(alertId); + if (alert != null) { + this._resetDismissTimer(alert); + } + } +} + +export const alertService = new AlertService(); diff --git a/FrontEnd/src/components/alert/alert.css b/FrontEnd/src/components/alert/alert.css new file mode 100644 index 00000000..948256de --- /dev/null +++ b/FrontEnd/src/components/alert/alert.css @@ -0,0 +1,21 @@ +.alert-container {
+ position: fixed;
+ z-index: 1040;
+}
+
+.cru-alert {
+ border-radius: 5px;
+ border: var(--cru-theme-color) 2px solid;
+ color: var(--cru-text-primary-color);
+ background-color: var(--cru-container-background-color);
+
+ margin: 1em;
+ padding: 0.5em 1em;
+
+ display: flex;
+ align-items: center;
+}
+
+.cru-alert-close-button {
+ margin-left: auto;
+}
\ No newline at end of file diff --git a/FrontEnd/src/components/alert/index.ts b/FrontEnd/src/components/alert/index.ts new file mode 100644 index 00000000..1be0c2ec --- /dev/null +++ b/FrontEnd/src/components/alert/index.ts @@ -0,0 +1,8 @@ +import { alertService, AlertInfo } from "./AlertService"; +import { default as AlertHost } from "./AlertHost"; + +export { alertService, AlertHost }; + +export function pushAlert(alert: AlertInfo): void { + alertService.push(alert); +} diff --git a/FrontEnd/src/components/breakpoints.ts b/FrontEnd/src/components/breakpoints.ts new file mode 100644 index 00000000..fb281610 --- /dev/null +++ b/FrontEnd/src/components/breakpoints.ts @@ -0,0 +1,3 @@ +export const breakpoints = { + sm: 576, +} as const; diff --git a/FrontEnd/src/components/button/Button.css b/FrontEnd/src/components/button/Button.css new file mode 100644 index 00000000..1da70f0e --- /dev/null +++ b/FrontEnd/src/components/button/Button.css @@ -0,0 +1,64 @@ +.cru-button {
+ font-size: 1rem;
+ padding: 0.4em 0.8em;
+ transition: all 0.3s;
+ border-radius: 0.2em;
+ border: 1px solid;
+ cursor: pointer;
+}
+
+.cru-button:not(.outline) {
+ color: var(--cru-push-button-text-color);
+ background-color: var(--cru-clickable-normal-color);
+ border-color: var(--cru-clickable-normal-color);
+}
+
+.cru-button:not(.outline):hover {
+ background-color: var(--cru-clickable-hover-color);
+ border-color: var(--cru-clickable-hover-color);
+}
+
+.cru-button:not(.outline):focus {
+ background-color: var(--cru-clickable-focus-color);
+ border-color: var(--cru-clickable-focus-color);
+}
+
+.cru-button:not(.outline):active {
+ background-color: var(--cru-clickable-active-color);
+ border-color: var(--cru-clickable-active-color);
+}
+
+.cru-button:not(.outline):disabled {
+ color: var(--cru-push-button-disabled-text-color);
+ background-color: var(--cru-push-button-disabled-color);
+ border-color: var(--cru-push-button-disabled-color);
+ cursor: auto;
+}
+
+
+.cru-button.outline {
+ color: var(--cru-clickable-normal-color);
+ border-color: var(--cru-clickable-normal-color);
+ background-color: transparent;
+}
+
+.cru-button.outline:hover {
+ color: var(--cru-clickable-hover-color);
+ border-color: var(--cru-clickable-hover-color);
+}
+
+.cru-button.outline:focus {
+ color: var(--cru-clickable-focus-color);
+ border-color: var(--cru-clickable-focus-color);
+}
+
+.cru-button.outline:active {
+ color: var(--cru-clickable-active-color);
+ border-color: var(--cru-clickable-active-color);
+}
+
+.cru-button.outline:disabled {
+ color: var(--cru-clickable-disabled-color);
+ border-color: var(--cru-clickable-disabled-color);
+ cursor: auto;
+}
diff --git a/FrontEnd/src/views/common/button/Button.tsx b/FrontEnd/src/components/button/Button.tsx index be605328..30ea8c11 100644 --- a/FrontEnd/src/views/common/button/Button.tsx +++ b/FrontEnd/src/components/button/Button.tsx @@ -1,14 +1,13 @@ import { ComponentPropsWithoutRef, Ref } from "react"; import classNames from "classnames"; -import { I18nText, useC } from "@/common"; -import { PaletteColorType } from "@/palette"; +import { Text, useC, ClickableColor } from "../common"; import "./Button.css"; interface ButtonProps extends ComponentPropsWithoutRef<"button"> { - color?: PaletteColorType; - text?: I18nText; + color?: ClickableColor; + text?: Text; outline?: boolean; buttonRef?: Ref<HTMLButtonElement> | null; } @@ -34,8 +33,8 @@ export default function Button(props: ButtonProps) { <button ref={buttonRef} className={classNames( - "cru-" + (color ?? "primary"), "cru-button", + `cru-clickable-${color ?? "primary"}`, outline && "outline", className, )} diff --git a/FrontEnd/src/components/button/ButtonRow.css b/FrontEnd/src/components/button/ButtonRow.css new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/FrontEnd/src/components/button/ButtonRow.css diff --git a/FrontEnd/src/components/button/ButtonRow.tsx b/FrontEnd/src/components/button/ButtonRow.tsx new file mode 100644 index 00000000..eea60cc4 --- /dev/null +++ b/FrontEnd/src/components/button/ButtonRow.tsx @@ -0,0 +1,62 @@ +import { ComponentPropsWithoutRef, Ref } from "react"; +import classNames from "classnames"; + +import Button from "./Button"; +import FlatButton from "./FlatButton"; +import IconButton from "./IconButton"; +import LoadingButton from "./LoadingButton"; + +import "./ButtonRow.css"; + +type ButtonRowButton = ( + | { + type: "normal"; + props: ComponentPropsWithoutRef<typeof Button>; + } + | { + type: "flat"; + props: ComponentPropsWithoutRef<typeof FlatButton>; + } + | { + type: "icon"; + props: ComponentPropsWithoutRef<typeof IconButton>; + } + | { type: "loading"; props: ComponentPropsWithoutRef<typeof LoadingButton> } +) & { key: string | number }; + +interface ButtonRowProps { + className?: string; + containerRef?: Ref<HTMLDivElement>; + buttons: ButtonRowButton[]; + buttonsClassName?: string; +} + +export default function ButtonRow({ + className, + containerRef, + buttons, + buttonsClassName, +}: ButtonRowProps) { + return ( + <div ref={containerRef} className={classNames("cru-button-row", className)}> + {buttons.map((button) => { + const { type, key, props } = button; + const newClassName = classNames(props.className, buttonsClassName); + switch (type) { + case "normal": + return <Button key={key} {...props} className={newClassName} />; + case "flat": + return <FlatButton key={key} {...props} className={newClassName} />; + case "icon": + return <IconButton key={key} {...props} className={newClassName} />; + case "loading": + return ( + <LoadingButton key={key} {...props} className={newClassName} /> + ); + default: + throw new Error(); + } + })} + </div> + ); +} diff --git a/FrontEnd/src/components/button/ButtonRowV2.tsx b/FrontEnd/src/components/button/ButtonRowV2.tsx new file mode 100644 index 00000000..a54425cc --- /dev/null +++ b/FrontEnd/src/components/button/ButtonRowV2.tsx @@ -0,0 +1,146 @@ +import { ComponentPropsWithoutRef, Ref } from "react"; +import classNames from "classnames"; + +import { Text, ClickableColor } from "../common"; + +import Button from "./Button"; +import FlatButton from "./FlatButton"; +import IconButton from "./IconButton"; +import LoadingButton from "./LoadingButton"; + +import "./ButtonRow.css"; + +type ButtonAction = "major" | "minor"; + +interface ButtonRowV2ButtonBase { + key: string | number; + action?: ButtonAction; + color?: ClickableColor; + disabled?: boolean; + onClick?: () => void; +} + +interface ButtonRowV2ButtonWithNoType extends ButtonRowV2ButtonBase { + type?: undefined | null; + text: Text; + outline?: boolean; + props?: ComponentPropsWithoutRef<typeof Button>; +} + +interface ButtonRowV2NormalButton extends ButtonRowV2ButtonBase { + type: "normal"; + text: Text; + outline?: boolean; + props?: ComponentPropsWithoutRef<typeof Button>; +} + +interface ButtonRowV2FlatButton extends ButtonRowV2ButtonBase { + type: "flat"; + text: Text; + props?: ComponentPropsWithoutRef<typeof FlatButton>; +} + +interface ButtonRowV2IconButton extends ButtonRowV2ButtonBase { + type: "icon"; + icon: string; + props?: ComponentPropsWithoutRef<typeof IconButton>; +} + +interface ButtonRowV2LoadingButton extends ButtonRowV2ButtonBase { + type: "loading"; + text: Text; + loading?: boolean; + props?: ComponentPropsWithoutRef<typeof LoadingButton>; +} + +type ButtonRowV2Button = + | ButtonRowV2ButtonWithNoType + | ButtonRowV2NormalButton + | ButtonRowV2FlatButton + | ButtonRowV2IconButton + | ButtonRowV2LoadingButton; + +interface ButtonRowV2Props { + className?: string; + containerRef?: Ref<HTMLDivElement>; + buttons: ButtonRowV2Button[]; + buttonsClassName?: string; +} + +export default function ButtonRowV2({ + className, + containerRef, + buttons, + buttonsClassName, +}: ButtonRowV2Props) { + return ( + <div ref={containerRef} className={classNames("cru-button-row", className)}> + {buttons.map((button) => { + const { key, action, color, disabled, onClick } = button; + + const realAction: ButtonAction = action ?? "minor"; + const realColor = + color ?? (realAction === "major" ? "primary" : "minor"); + + const commonProps = { key, color: realColor, disabled, onClick }; + const newClassName = classNames( + button.props?.className, + buttonsClassName, + ); + + switch (button.type) { + case null: + case undefined: + case "normal": { + const { text, outline, props } = button; + return ( + <Button + {...commonProps} + text={text} + outline={outline ?? realAction !== "major"} + {...props} + className={newClassName} + /> + ); + } + case "flat": { + const { text, props } = button; + return ( + <FlatButton + {...commonProps} + text={text} + {...props} + className={newClassName} + /> + ); + } + case "icon": { + const { icon, props } = button; + return ( + <IconButton + {...commonProps} + icon={icon} + {...props} + className={newClassName} + /> + ); + } + case "loading": { + const { text, loading, props } = button; + return ( + <LoadingButton + {...commonProps} + text={text} + loading={loading} + {...props} + className={newClassName} + /> + ); + } + default: + throw new Error(); + } + })} + </div> + ); +} diff --git a/FrontEnd/src/components/button/FlatButton.css b/FrontEnd/src/components/button/FlatButton.css new file mode 100644 index 00000000..2050946c --- /dev/null +++ b/FrontEnd/src/components/button/FlatButton.css @@ -0,0 +1,27 @@ +.cru-flat-button {
+ font-size: 1rem;
+ padding: 0.4em 0.8em;
+ transition: all 0.5s;
+ border-radius: 0.2em;
+ background-color: var(--cru-clickable-grayscale-normal-color);
+ border: 1px none;
+ color: var(--cru-clickable-normal-color);
+ cursor: pointer;
+}
+
+.cru-flat-button:hover {
+ background-color: var(--cru-clickable-grayscale-hover-color);
+}
+
+.cru-flat-button:focus {
+ background-color: var(--cru-clickable-grayscale-focus-color);
+}
+
+.cru-flat-button:active {
+ background-color: var(--cru-clickable-grayscale-active-color);
+}
+
+.cru-flat-button:disabled {
+ color: var(--cru-clickable-disabled-color);
+ cursor: auto;
+}
\ No newline at end of file diff --git a/FrontEnd/src/views/common/button/FlatButton.tsx b/FrontEnd/src/components/button/FlatButton.tsx index 49912b68..aad02e76 100644 --- a/FrontEnd/src/views/common/button/FlatButton.tsx +++ b/FrontEnd/src/components/button/FlatButton.tsx @@ -1,14 +1,13 @@ import { ComponentPropsWithoutRef, Ref } from "react"; import classNames from "classnames"; -import { I18nText, useC } from "@/common"; -import { PaletteColorType } from "@/palette"; +import { Text, useC, ClickableColor } from "../common"; import "./FlatButton.css"; interface FlatButtonProps extends ComponentPropsWithoutRef<"button"> { - color?: PaletteColorType; - text?: I18nText; + color?: ClickableColor; + text?: Text; buttonRef?: Ref<HTMLButtonElement> | null; } @@ -25,8 +24,8 @@ export default function FlatButton(props: FlatButtonProps) { <button ref={buttonRef} className={classNames( - "cru-" + (color ?? "primary"), "cru-flat-button", + `cru-clickable-${color ?? "primary"}`, className, )} {...otherProps} diff --git a/FrontEnd/src/components/button/IconButton.css b/FrontEnd/src/components/button/IconButton.css new file mode 100644 index 00000000..a3747201 --- /dev/null +++ b/FrontEnd/src/components/button/IconButton.css @@ -0,0 +1,30 @@ +.cru-icon-button { + color: var(--cru-clickable-normal-color); + font-size: 1.4rem; + background: none; + border: none; + transition: all 0.5s; + cursor: pointer; + user-select: none; +} + +.cru-icon-button:hover { + color: var(--cru-clickable-hover-color); +} + +.cru-icon-button:focus { + color: var(--cru-clickable-focus-color); +} + +.cru-icon-button:active { + color: var(--cru-clickable-active-color); +} + +.cru-flat-button:disabled { + color: var(--cru-clickable-disabled-color); + cursor: auto; +} + +.cru-icon-button.large { + font-size: 1.6rem; +} diff --git a/FrontEnd/src/views/common/button/IconButton.tsx b/FrontEnd/src/components/button/IconButton.tsx index 652a8b09..e0862167 100644 --- a/FrontEnd/src/views/common/button/IconButton.tsx +++ b/FrontEnd/src/components/button/IconButton.tsx @@ -1,14 +1,15 @@ import { ComponentPropsWithoutRef } from "react"; import classNames from "classnames"; -import { PaletteColorType } from "@/palette"; +import { ClickableColor } from "../common"; import "./IconButton.css"; interface IconButtonProps extends ComponentPropsWithoutRef<"i"> { icon: string; - color?: PaletteColorType; + color?: ClickableColor; large?: boolean; + disabled?: boolean; // TODO: Not implemented } export default function IconButton(props: IconButtonProps) { @@ -18,9 +19,9 @@ export default function IconButton(props: IconButtonProps) { <button className={classNames( "cru-icon-button", + `cru-clickable-${color ?? "grayscale"}`, large && "large", "bi-" + icon, - color ? "cru-" + color : "cru-primary", className, )} {...otherProps} diff --git a/FrontEnd/src/components/button/LoadingButton.css b/FrontEnd/src/components/button/LoadingButton.css new file mode 100644 index 00000000..23fadd3d --- /dev/null +++ b/FrontEnd/src/components/button/LoadingButton.css @@ -0,0 +1,13 @@ +.cru-loading-button { + display: flex; + align-items: center; +} + +.cru-loading-button-spinner { + margin-left: 0.5em; +} + +.cru-loading-button-loading { + color: var(--cru-clickable-normal-color) !important; + border-color: var(--cru-clickable-normal-color) !important; +}
\ No newline at end of file diff --git a/FrontEnd/src/components/button/LoadingButton.tsx b/FrontEnd/src/components/button/LoadingButton.tsx new file mode 100644 index 00000000..9d65a2b3 --- /dev/null +++ b/FrontEnd/src/components/button/LoadingButton.tsx @@ -0,0 +1,39 @@ +import classNames from "classnames"; + +import { I18nText, ClickableColor, useC } from "../common"; +import Spinner from "../Spinner"; + +import "./LoadingButton.css"; + +interface LoadingButtonProps extends React.ComponentPropsWithoutRef<"button"> { + color?: ClickableColor; + text?: I18nText; + loading?: boolean; +} + +export default function LoadingButton(props: LoadingButtonProps) { + const c = useC(); + + const { color, text, loading, disabled, className, children, ...otherProps } = + props; + + if (text != null && children != null) { + console.warn("You can't set both text and children props."); + } + + return ( + <button + disabled={disabled || loading} + className={classNames( + "cru-button outline cru-loading-button", + `cru-clickable-${color ?? "primary"}`, + loading && "cru-loading-button-loading", + className, + )} + {...otherProps} + > + {text != null ? c(text) : children} + {loading && <Spinner className="cru-loading-button-spinner" />} + </button> + ); +} diff --git a/FrontEnd/src/components/button/index.tsx b/FrontEnd/src/components/button/index.tsx new file mode 100644 index 00000000..b5aa5470 --- /dev/null +++ b/FrontEnd/src/components/button/index.tsx @@ -0,0 +1,15 @@ +import Button from "./Button"; +import FlatButton from "./FlatButton"; +import IconButton from "./IconButton"; +import LoadingButton from "./LoadingButton"; +import ButtonRow from "./ButtonRow"; +import ButtonRowV2 from "./ButtonRowV2"; + +export { + Button, + FlatButton, + IconButton, + LoadingButton, + ButtonRow, + ButtonRowV2, +}; diff --git a/FrontEnd/src/components/common.ts b/FrontEnd/src/components/common.ts new file mode 100644 index 00000000..a6c3e705 --- /dev/null +++ b/FrontEnd/src/components/common.ts @@ -0,0 +1,22 @@ +import "./index.css"; + +export type { Text, I18nText } from "~src/common"; +export { UiLogicError, c, convertI18nText, useC } from "~src/common"; + +export const themeColors = [ + "primary", + "secondary", + "danger", + "create", +] as const; + +export type ThemeColor = (typeof themeColors)[number]; + +export type ClickableColor = ThemeColor | "grayscale" | "light" | "minor"; + +export { breakpoints } from "./breakpoints"; + +export * as geometry from "~src/utilities/geometry"; + +export * as array from "~src/utilities/array" + diff --git a/FrontEnd/src/components/dialog/ConfirmDialog.tsx b/FrontEnd/src/components/dialog/ConfirmDialog.tsx new file mode 100644 index 00000000..8b0a4219 --- /dev/null +++ b/FrontEnd/src/components/dialog/ConfirmDialog.tsx @@ -0,0 +1,54 @@ +import { useC, Text, ThemeColor } from "../common"; + +import Dialog from "./Dialog"; +import DialogContainer from "./DialogContainer"; +import { useCloseDialog } from "./DialogProvider"; + +export default function ConfirmDialog({ + onConfirm, + title, + body, + color, +}: { + onConfirm: () => void; + title: Text; + body: Text; + color?: ThemeColor; + bodyColor?: ThemeColor; +}) { + const c = useC(); + + const closeDialog = useCloseDialog(); + + return ( + <Dialog color={color ?? "danger"}> + <DialogContainer + title={title} + titleColor={color ?? "danger"} + buttonsV2={[ + { + key: "cancel", + type: "normal", + action: "minor", + + text: "operationDialog.cancel", + onClick: closeDialog, + }, + { + key: "confirm", + type: "normal", + action: "major", + text: "operationDialog.confirm", + color: "danger", + onClick: () => { + onConfirm(); + closeDialog(); + }, + }, + ]} + > + <div>{c(body)}</div> + </DialogContainer> + </Dialog> + ); +} diff --git a/FrontEnd/src/components/dialog/Dialog.css b/FrontEnd/src/components/dialog/Dialog.css new file mode 100644 index 00000000..23b663db --- /dev/null +++ b/FrontEnd/src/components/dialog/Dialog.css @@ -0,0 +1,39 @@ +.cru-dialog-overlay {
+ position: fixed;
+ z-index: 1040;
+ left: 0;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ overflow: auto;
+ padding: 20vh 1em;
+}
+
+.cru-dialog-background {
+ position: absolute;
+ z-index: -1;
+ left: 0;
+ right: 0;
+ top: 0;
+ bottom: 0;
+ background-color: var(--cru-dialog-overlay-color);
+ opacity: 0.8;
+}
+
+.cru-dialog-container {
+ max-width: 100%;
+ min-width: 30vw;
+
+ margin: 2em auto;
+
+ border: var(--cru-theme-color) 2px solid;
+ border-radius: 5px;
+ padding: 1.5em;
+ background-color: var(--cru-dialog-container-background-color);
+}
+
+@media (min-width: 576px) {
+ .cru-dialog-container {
+ max-width: 800px;
+ }
+}
diff --git a/FrontEnd/src/components/dialog/Dialog.tsx b/FrontEnd/src/components/dialog/Dialog.tsx new file mode 100644 index 00000000..043a8eec --- /dev/null +++ b/FrontEnd/src/components/dialog/Dialog.tsx @@ -0,0 +1,55 @@ +import { ReactNode, useRef } from "react"; +import ReactDOM from "react-dom"; +import classNames from "classnames"; + +import { ThemeColor, UiLogicError } from "../common"; + +import { useCloseDialog } from "./DialogProvider"; + +import "./Dialog.css"; + +const optionalPortalElement = document.getElementById("portal"); +if (optionalPortalElement == null) { + throw new UiLogicError(); +} +const portalElement = optionalPortalElement; + +interface DialogProps { + color?: ThemeColor; + children?: ReactNode; + disableCloseOnClickOnOverlay?: boolean; +} + +export default function Dialog({ + color, + children, + disableCloseOnClickOnOverlay, +}: DialogProps) { + const closeDialog = useCloseDialog(); + + const lastPointerDownIdRef = useRef<number | null>(null); + + return ReactDOM.createPortal( + <div + className={classNames( + `cru-theme-${color ?? "primary"}`, + "cru-dialog-overlay", + )} + > + <div + className="cru-dialog-background" + onPointerDown={(e) => { + lastPointerDownIdRef.current = e.pointerId; + }} + onPointerUp={(e) => { + if (lastPointerDownIdRef.current === e.pointerId) { + if (!disableCloseOnClickOnOverlay) closeDialog(); + } + lastPointerDownIdRef.current = null; + }} + /> + <div className="cru-dialog-container">{children}</div> + </div>, + portalElement, + ); +} diff --git a/FrontEnd/src/components/dialog/DialogContainer.css b/FrontEnd/src/components/dialog/DialogContainer.css new file mode 100644 index 00000000..f0d27a66 --- /dev/null +++ b/FrontEnd/src/components/dialog/DialogContainer.css @@ -0,0 +1,20 @@ +.cru-dialog-container-title { + font-size: 1.2em; + font-weight: bold; + color: var(--cru-theme-color); + margin-bottom: 0.5em; +} + +.cru-dialog-container-hr { + margin: 1em 0; + border-color: var(--cru-text-minor-color); +} + +.cru-dialog-container-button-row { + display: flex; + justify-content: flex-end; +} + +.cru-dialog-container-button { + margin-left: 1em; +} diff --git a/FrontEnd/src/components/dialog/DialogContainer.tsx b/FrontEnd/src/components/dialog/DialogContainer.tsx new file mode 100644 index 00000000..6ee4e134 --- /dev/null +++ b/FrontEnd/src/components/dialog/DialogContainer.tsx @@ -0,0 +1,95 @@ +import { ComponentProps, Ref, ReactNode } from "react"; +import classNames from "classnames"; + +import { ThemeColor, Text, useC } from "../common"; +import { ButtonRow, ButtonRowV2 } from "../button"; + +import "./DialogContainer.css"; + +interface DialogContainerBaseProps { + className?: string; + title: Text; + titleColor?: ThemeColor; + titleClassName?: string; + titleRef?: Ref<HTMLDivElement>; + bodyContainerClassName?: string; + bodyContainerRef?: Ref<HTMLDivElement>; + buttonsClassName?: string; + buttonsContainerRef?: ComponentProps<typeof ButtonRow>["containerRef"]; + children: ReactNode; +} + +interface DialogContainerWithButtonsProps extends DialogContainerBaseProps { + buttons: ComponentProps<typeof ButtonRow>["buttons"]; +} + +interface DialogContainerWithButtonsV2Props extends DialogContainerBaseProps { + buttonsV2: ComponentProps<typeof ButtonRowV2>["buttons"]; +} + +type DialogContainerProps = + | DialogContainerWithButtonsProps + | DialogContainerWithButtonsV2Props; + +export default function DialogContainer(props: DialogContainerProps) { + const { + className, + title, + titleColor, + titleClassName, + titleRef, + bodyContainerClassName, + bodyContainerRef, + buttonsClassName, + buttonsContainerRef, + children, + } = props; + + const c = useC(); + + return ( + <div className={classNames(className)}> + <div + ref={titleRef} + className={classNames( + `cru-dialog-container-title cru-theme-${titleColor ?? "primary"}`, + titleClassName, + )} + > + {c(title)} + </div> + <hr className="cru-dialog-container-hr" /> + <div + ref={bodyContainerRef} + className={classNames( + "cru-dialog-container-body", + bodyContainerClassName, + )} + > + {children} + </div> + <hr className="cru-dialog-container-hr" /> + {"buttons" in props ? ( + <ButtonRow + containerRef={buttonsContainerRef} + className={classNames( + "cru-dialog-container-button-row", + buttonsClassName, + )} + buttons={props.buttons} + buttonsClassName="cru-dialog-container-button" + /> + ) : ( + <ButtonRowV2 + containerRef={buttonsContainerRef} + className={classNames( + "cru-dialog-container-button-row", + buttonsClassName, + )} + buttons={props.buttonsV2} + buttonsClassName="cru-dialog-container-button" + /> + )} + </div> + ); +} diff --git a/FrontEnd/src/components/dialog/DialogProvider.tsx b/FrontEnd/src/components/dialog/DialogProvider.tsx new file mode 100644 index 00000000..bb85e4cf --- /dev/null +++ b/FrontEnd/src/components/dialog/DialogProvider.tsx @@ -0,0 +1,95 @@ +import { useState, useContext, createContext, ReactNode } from "react"; + +import { UiLogicError } from "../common"; + +type DialogMap<D extends string> = { + [K in D]: ReactNode; +}; + +interface DialogController<D extends string> { + currentDialog: D | null; + currentDialogReactNode: ReactNode; + canSwitchDialog: boolean; + switchDialog: (newDialog: D | null) => void; + setCanSwitchDialog: (enable: boolean) => void; + closeDialog: () => void; + forceSwitchDialog: (newDialog: D | null) => void; + forceCloseDialog: () => void; +} + +export function useDialog<D extends string>( + dialogs: DialogMap<D>, + options?: { + initDialog?: D | null; + onClose?: { + [K in D]?: () => void; + }; + }, +): { + controller: DialogController<D>; + switchDialog: (newDialog: D | null) => void; + forceSwitchDialog: (newDialog: D | null) => void; + createDialogSwitch: (newDialog: D | null) => () => void; +} { + const [canSwitchDialog, setCanSwitchDialog] = useState<boolean>(true); + const [dialog, setDialog] = useState<D | null>(options?.initDialog ?? null); + + const forceSwitchDialog = (newDialog: D | null) => { + if (dialog != null) { + options?.onClose?.[dialog]?.(); + } + setDialog(newDialog); + setCanSwitchDialog(true); + }; + + const switchDialog = (newDialog: D | null) => { + if (canSwitchDialog) { + forceSwitchDialog(newDialog); + } + }; + + const controller: DialogController<D> = { + currentDialog: dialog, + currentDialogReactNode: dialog == null ? null : dialogs[dialog], + canSwitchDialog, + switchDialog, + setCanSwitchDialog, + closeDialog: () => switchDialog(null), + forceSwitchDialog, + forceCloseDialog: () => forceSwitchDialog(null), + }; + + return { + controller, + switchDialog, + forceSwitchDialog, + createDialogSwitch: (newDialog: D | null) => () => switchDialog(newDialog), + }; +} + +const DialogControllerContext = createContext<DialogController<string> | null>( + null, +); + +export function useDialogController(): DialogController<string> { + const controller = useContext(DialogControllerContext); + if (controller == null) throw new UiLogicError("not in dialog provider"); + return controller; +} + +export function useCloseDialog(): () => void { + const controller = useDialogController(); + return controller.closeDialog; +} + +export function DialogProvider<D extends string>({ + controller, +}: { + controller: DialogController<D>; +}) { + return ( + <DialogControllerContext.Provider value={controller as never}> + {controller.currentDialogReactNode} + </DialogControllerContext.Provider> + ); +} diff --git a/FrontEnd/src/components/dialog/FullPageDialog.css b/FrontEnd/src/components/dialog/FullPageDialog.css new file mode 100644 index 00000000..ce07c6ac --- /dev/null +++ b/FrontEnd/src/components/dialog/FullPageDialog.css @@ -0,0 +1,30 @@ +.cru-dialog-full-page {
+ position: fixed;
+ z-index: 1030;
+ left: 0;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ background-color: var(--cru-background-color);
+ padding-top: 56px;
+}
+
+.cru-dialog-full-page-top-bar {
+ height: 56px;
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ z-index: 1;
+ background-color: var(--cru-theme-color);
+ display: flex;
+ align-items: center;
+}
+
+.cru-dialog-full-page-content-container {
+ overflow: scroll;
+}
+
+.cru-dialog-full-page-back-button {
+ margin-left: 0.5em;
+}
diff --git a/FrontEnd/src/components/dialog/FullPageDialog.tsx b/FrontEnd/src/components/dialog/FullPageDialog.tsx new file mode 100644 index 00000000..575abf7f --- /dev/null +++ b/FrontEnd/src/components/dialog/FullPageDialog.tsx @@ -0,0 +1,52 @@ +import { ReactNode } from "react"; +import { createPortal } from "react-dom"; +import classNames from "classnames"; + +import { ThemeColor, UiLogicError } from "../common"; +import { IconButton } from "../button"; + +import { useCloseDialog } from "./DialogProvider"; + +import "./FullPageDialog.css"; + +const optionalPortalElement = document.getElementById("portal"); +if (optionalPortalElement == null) { + throw new UiLogicError(); +} +const portalElement = optionalPortalElement; + +interface FullPageDialogProps { + color?: ThemeColor; + contentContainerClassName?: string; + children: ReactNode; +} + +export default function FullPageDialog({ + color, + children, + contentContainerClassName, +}: FullPageDialogProps) { + const closeDialog = useCloseDialog(); + + return createPortal( + <div className={`cru-dialog-full-page cru-theme-${color ?? "primary"}`}> + <div className="cru-dialog-full-page-top-bar"> + <IconButton + icon="arrow-left" + color="light" + className="cru-dialog-full-page-back-button" + onClick={closeDialog} + /> + </div> + <div + className={classNames( + "cru-dialog-full-page-content-container", + contentContainerClassName, + )} + > + {children} + </div> + </div>, + portalElement, + ); +} diff --git a/FrontEnd/src/components/dialog/OperationDialog.css b/FrontEnd/src/components/dialog/OperationDialog.css new file mode 100644 index 00000000..28f73c9d --- /dev/null +++ b/FrontEnd/src/components/dialog/OperationDialog.css @@ -0,0 +1,4 @@ +.cru-operation-dialog-input-group {
+ display: block;
+ margin: 0.5em 0;
+}
diff --git a/FrontEnd/src/components/dialog/OperationDialog.tsx b/FrontEnd/src/components/dialog/OperationDialog.tsx new file mode 100644 index 00000000..6ca4d0a0 --- /dev/null +++ b/FrontEnd/src/components/dialog/OperationDialog.tsx @@ -0,0 +1,221 @@ +import { useState, ReactNode, ComponentProps } from "react"; +import classNames from "classnames"; + +import { useC, Text, ThemeColor } from "../common"; +import { + useInputs, + InputGroup, + Initializer as InputInitializer, + InputConfirmValueDict, +} from "../input"; +import { ButtonRowV2 } from "../button"; +import Dialog from "./Dialog"; +import DialogContainer from "./DialogContainer"; +import { useDialogController } from "./DialogProvider"; + +import "./OperationDialog.css"; + +interface OperationDialogPromptProps { + message?: Text; + customMessage?: Text; + customMessageNode?: ReactNode; + className?: string; +} + +function OperationDialogPrompt(props: OperationDialogPromptProps) { + const { message, customMessage, customMessageNode, className } = props; + + const c = useC(); + + return ( + <div className={classNames(className, "cru-operation-dialog-prompt")}> + {message && <p>{c(message)}</p>} + {customMessageNode ?? (customMessage != null ? c(customMessage) : null)} + </div> + ); +} + +export interface OperationDialogProps<TData> { + color?: ThemeColor; + inputColor?: ThemeColor; + title: Text; + inputPrompt?: Text; + inputPromptNode?: ReactNode; + successPrompt?: (data: TData) => Text; + successPromptNode?: (data: TData) => ReactNode; + failurePrompt?: (error: unknown) => Text; + failurePromptNode?: (error: unknown) => ReactNode; + + inputs: InputInitializer; + + onProcess: (inputs: InputConfirmValueDict) => Promise<TData>; + onSuccessAndClose?: (data: TData) => void; +} + +function OperationDialog<TData>(props: OperationDialogProps<TData>) { + const { + color, + inputColor, + title, + inputPrompt, + inputPromptNode, + successPrompt, + successPromptNode, + failurePrompt, + failurePromptNode, + inputs, + onProcess, + onSuccessAndClose, + } = props; + + if (process.env.NODE_ENV === "development") { + if (inputPrompt && inputPromptNode) { + console.log("InputPrompt and inputPromptNode are both set."); + } + if (successPrompt && successPromptNode) { + console.log("SuccessPrompt and successPromptNode are both set."); + } + if (failurePrompt && failurePromptNode) { + console.log("FailurePrompt and failurePromptNode are both set."); + } + } + + type Step = + | { type: "input" } + | { type: "process" } + | { + type: "success"; + data: TData; + } + | { + type: "failure"; + data: unknown; + }; + + const dialogController = useDialogController(); + + const [step, setStep] = useState<Step>({ type: "input" }); + + const { inputGroupProps, hasErrorAndDirty, setAllDisabled, confirm } = + useInputs({ + init: inputs, + }); + + function close() { + if (step.type !== "process") { + dialogController.closeDialog(); + if (step.type === "success" && onSuccessAndClose) { + onSuccessAndClose?.(step.data); + } + } else { + console.log("Attempt to close modal dialog when processing."); + } + } + + function onConfirm() { + const result = confirm(); + if (result.type === "ok") { + setStep({ type: "process" }); + dialogController.setCanSwitchDialog(false); + setAllDisabled(true); + onProcess(result.values) + .then( + (d) => { + setStep({ + type: "success", + data: d, + }); + }, + (e: unknown) => { + setStep({ + type: "failure", + data: e, + }); + }, + ) + .finally(() => { + dialogController.setCanSwitchDialog(true); + }); + } + } + + let body: ReactNode; + let buttons: ComponentProps<typeof ButtonRowV2>["buttons"]; + + if (step.type === "input" || step.type === "process") { + const isProcessing = step.type === "process"; + + body = ( + <div> + <OperationDialogPrompt + customMessage={inputPrompt} + customMessageNode={inputPromptNode} + /> + <InputGroup + containerClassName="cru-operation-dialog-input-group" + color={inputColor ?? "primary"} + {...inputGroupProps} + /> + </div> + ); + buttons = [ + { + key: "cancel", + text: "operationDialog.cancel", + onClick: close, + disabled: isProcessing, + }, + { + key: "confirm", + type: "loading", + action: "major", + text: "operationDialog.confirm", + color, + loading: isProcessing, + disabled: hasErrorAndDirty, + onClick: onConfirm, + }, + ]; + } else { + const result = step; + + const promptProps: OperationDialogPromptProps = + result.type === "success" + ? { + message: "operationDialog.success", + customMessage: successPrompt?.(result.data), + customMessageNode: successPromptNode?.(result.data), + } + : { + message: "operationDialog.error", + customMessage: failurePrompt?.(result.data), + customMessageNode: failurePromptNode?.(result.data), + }; + body = ( + <div> + <OperationDialogPrompt {...promptProps} /> + </div> + ); + + buttons = [ + { + key: "ok", + type: "normal", + action: "major", + color: "create", + text: "operationDialog.ok", + onClick: close, + }, + ]; + } + + return ( + <Dialog color={color}> + <DialogContainer title={title} titleColor={color} buttonsV2={buttons}> + {body} + </DialogContainer> + </Dialog> + ); +} + +export default OperationDialog; diff --git a/FrontEnd/src/components/dialog/index.tsx b/FrontEnd/src/components/dialog/index.tsx new file mode 100644 index 00000000..9ca06de2 --- /dev/null +++ b/FrontEnd/src/components/dialog/index.tsx @@ -0,0 +1,12 @@ +export { default as Dialog } from "./Dialog"; +export { default as FullPageDialog } from "./FullPageDialog"; +export { default as OperationDialog } from "./OperationDialog"; +export { default as ConfirmDialog } from "./ConfirmDialog"; +export { default as DialogContainer } from "./DialogContainer"; + +export { + useDialog, + useDialogController, + useCloseDialog, + DialogProvider, +} from "./DialogProvider"; diff --git a/FrontEnd/src/components/hooks/index.ts b/FrontEnd/src/components/hooks/index.ts new file mode 100644 index 00000000..98ce729e --- /dev/null +++ b/FrontEnd/src/components/hooks/index.ts @@ -0,0 +1,5 @@ +export { useMobile } from "./responsive"; +export { default as useClickOutside } from "./useClickOutside"; +export { default as useScrollToBottom } from "./useScrollToBottom"; +export { default as useWindowLeave } from "./useWindowLeave"; +export { default as useAutoUnsubscribePromise } from "./useAutoUnsubscribePromise"; diff --git a/FrontEnd/src/components/hooks/responsive.ts b/FrontEnd/src/components/hooks/responsive.ts new file mode 100644 index 00000000..42c134ef --- /dev/null +++ b/FrontEnd/src/components/hooks/responsive.ts @@ -0,0 +1,7 @@ +import { useMediaQuery } from "react-responsive"; + +import { breakpoints } from "../breakpoints"; + +export function useMobile(onChange?: (mobile: boolean) => void): boolean { + return useMediaQuery({ maxWidth: breakpoints.sm }, undefined, onChange); +} diff --git a/FrontEnd/src/components/hooks/useAutoUnsubscribePromise.ts b/FrontEnd/src/components/hooks/useAutoUnsubscribePromise.ts new file mode 100644 index 00000000..01c5a1db --- /dev/null +++ b/FrontEnd/src/components/hooks/useAutoUnsubscribePromise.ts @@ -0,0 +1,24 @@ +import { useEffect, DependencyList } from "react"; + +export default function useAutoUnsubscribePromise<T>( + promiseGenerator: () => Promise<T> | null | undefined, + resultHandler: (data: T) => void, + dependencies?: DependencyList | undefined, +) { + useEffect(() => { + let subscribe = true; + const promise = promiseGenerator(); + if (promise) { + void promise.then((data) => { + if (subscribe) { + resultHandler(data); + } + }); + + return () => { + subscribe = false; + }; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [promiseGenerator, resultHandler, ...(dependencies ?? [])]); +} diff --git a/FrontEnd/src/utilities/hooks/useClickOutside.ts b/FrontEnd/src/components/hooks/useClickOutside.ts index 6dcbf7b3..828ce7e3 100644 --- a/FrontEnd/src/utilities/hooks/useClickOutside.ts +++ b/FrontEnd/src/components/hooks/useClickOutside.ts @@ -3,7 +3,7 @@ import { useRef, useEffect } from "react"; export default function useClickOutside( element: HTMLElement | null | undefined, onClickOutside: () => void, - nextTick?: boolean + nextTick?: boolean, ): void { const onClickOutsideRef = useRef<() => void>(onClickOutside); diff --git a/FrontEnd/src/utilities/hooks/useScrollToBottom.ts b/FrontEnd/src/components/hooks/useScrollToBottom.ts index 216746f4..79fcda16 100644 --- a/FrontEnd/src/utilities/hooks/useScrollToBottom.ts +++ b/FrontEnd/src/components/hooks/useScrollToBottom.ts @@ -1,6 +1,5 @@ import { useRef, useEffect } from "react"; -import { fromEvent } from "rxjs"; -import { filter, throttleTime } from "rxjs/operators"; +import { fromEvent, filter, throttleTime } from "rxjs"; function useScrollToBottom( handler: () => void, @@ -8,7 +7,7 @@ function useScrollToBottom( option = { maxOffset: 5, throttle: 1000, - } + }, ): void { const handlerRef = useRef<(() => void) | null>(null); @@ -26,9 +25,9 @@ function useScrollToBottom( filter( () => window.scrollY >= - document.body.scrollHeight - window.innerHeight - option.maxOffset + document.body.scrollHeight - window.innerHeight - option.maxOffset, ), - throttleTime(option.throttle) + throttleTime(option.throttle), ) .subscribe(() => { if (enable) { diff --git a/FrontEnd/src/components/hooks/useWindowLeave.ts b/FrontEnd/src/components/hooks/useWindowLeave.ts new file mode 100644 index 00000000..ecd999d4 --- /dev/null +++ b/FrontEnd/src/components/hooks/useWindowLeave.ts @@ -0,0 +1,22 @@ +import { useEffect } from "react"; + +import { useC, Text } from "../common"; + +export default function useWindowLeave( + allow: boolean, + message: Text = "timeline.confirmLeave", +) { + const c = useC(); + + useEffect(() => { + if (!allow) { + window.onbeforeunload = () => { + return c(message); + }; + + return () => { + window.onbeforeunload = null; + }; + } + }, [c, allow, message]); +} diff --git a/FrontEnd/src/components/index.css b/FrontEnd/src/components/index.css new file mode 100644 index 00000000..83b48318 --- /dev/null +++ b/FrontEnd/src/components/index.css @@ -0,0 +1,49 @@ +@import "./theme.css";
+
+* {
+ box-sizing: border-box;
+ margin-inline: 0;
+ margin-block: 0;
+}
+
+body {
+ font-family: var(--cru-default-font-family);
+ background: var(--cru-body-background-color);
+ color: var(--cru-text-major-color);
+ line-height: 1.2;
+}
+
+textarea {
+ transition: border-color 0.3s;
+ border-color: var(--cru-text-minor-color);
+ background: var(--cru-background-color);
+}
+
+textarea:hover {
+ border-color: var(--cru-clickable-primary-hover-color);
+}
+
+textarea:focus {
+ border-color: var(--cru-clickable-primary-normal-color);
+}
+
+.alert-container {
+ position: fixed;
+ z-index: 1070;
+}
+
+@media (min-width: 576px) {
+ .alert-container {
+ bottom: 0;
+ right: 0;
+ }
+}
+
+@media (max-width: 575.98px) {
+ .alert-container {
+ bottom: 0;
+ right: 0;
+ left: 0;
+ text-align: center;
+ }
+}
diff --git a/FrontEnd/src/components/input/InputGroup.css b/FrontEnd/src/components/input/InputGroup.css new file mode 100644 index 00000000..7e905b1e --- /dev/null +++ b/FrontEnd/src/components/input/InputGroup.css @@ -0,0 +1,54 @@ +.cru-input-group { + display: block; +} + +.cru-input-container { + margin: 0.4em 0; +} + +.cru-input-label { + display: block; + color: var(--cru-clickable-normal-color); + font-size: 0.9em; + margin-bottom: 0.3em; +} + +.cru-input-label-inline { + margin-inline-start: 0.5em; +} + +.cru-input-type-text input { + appearance: none; + display: block; + border: 1px solid; + /* color: var(--cru-surface-on-color); */ + /* background-color: var(--cru-surface-color); */ + margin: 0; + font-size: 1em; + padding: 0.2em; +} + +.cru-input-type-text input:hover { + border-color: var(--cru-clickable-hover-color); +} + +.cru-input-type-text input:focus { + border-color: var(--cru-clickable-focus-color); +} + +.cru-input-type-text input:disabled { + border-color: var(--cru-clickable-disabled-color); +} + +.cru-input-error { + display: block; + font-size: 0.8em; + color: var(--cru-danger-color); + margin-top: 0.4em; +} + +.cru-input-helper { + display: block; + font-size: 0.8em; + color: var(--cru-primary-color); +}
\ No newline at end of file diff --git a/FrontEnd/src/components/input/InputGroup.tsx b/FrontEnd/src/components/input/InputGroup.tsx new file mode 100644 index 00000000..47a43b38 --- /dev/null +++ b/FrontEnd/src/components/input/InputGroup.tsx @@ -0,0 +1,463 @@ +/** + * Some notes for InputGroup: + * This is one of the most complicated components in this project. + * Probably because the feature is complex and involved user inputs. + * + * I hope it contains following features: + * - Input features + * - Supports a wide range of input types. + * - Validator to validate user inputs. + * - Can set initial values. + * - Dirty, aka, has user touched this input. + * - Developer friendly + * - Easy to use APIs. + * - Type check as much as possible. + * - UI + * - Configurable appearance. + * - Can display helper and error messages. + * - Easy to extend, like new input types. + * + * So here is some design decisions: + * Inputs are identified by its _key_. + * `InputGroup` component takes care of only UI and no logic. + * `useInputs` hook takes care of logic and generate props for `InputGroup`. + */ + +import { useState, Ref, useId } from "react"; +import classNames from "classnames"; + +import { useC, Text, ThemeColor } from "../common"; + +import "./InputGroup.css"; + +export interface InputBase { + key: string; + label: Text; + helper?: Text; + disabled?: boolean; + error?: Text; +} + +export interface TextInput extends InputBase { + type: "text"; + value: string; + password?: boolean; +} + +export interface BoolInput extends InputBase { + type: "bool"; + value: boolean; +} + +export interface SelectInputOption { + value: string; + label: Text; + icon?: string; +} + +export interface SelectInput extends InputBase { + type: "select"; + value: string; + options: SelectInputOption[]; +} + +export type Input = TextInput | BoolInput | SelectInput; + +export type InputValue = Input["value"]; + +export type InputValueDict = Record<string, InputValue>; +export type InputErrorDict = Record<string, Text>; +export type InputDisabledDict = Record<string, boolean>; +export type InputDirtyDict = Record<string, boolean>; +// use never so you don't have to cast everywhere +export type InputConfirmValueDict = Record<string, never>; + +export type GeneralInputErrorDict = { + [key: string]: Text | null | undefined; +}; + +type MakeInputInfo<I extends Input> = Omit<I, "value" | "error" | "disabled">; + +export type InputInfo = { + [I in Input as I["type"]]: MakeInputInfo<I>; +}[Input["type"]]; + +export type Validator = ( + values: InputValueDict, + errors: GeneralInputErrorDict, + inputs: InputInfo[], +) => void; + +export type InputScheme = { + inputs: InputInfo[]; + validator?: Validator; +}; + +export type InputData = { + values: InputValueDict; + errors: InputErrorDict; + disabled: InputDisabledDict; + dirties: InputDirtyDict; +}; + +export type State = { + scheme: InputScheme; + data: InputData; +}; + +export type DataInitialization = { + values?: InputValueDict; + errors?: GeneralInputErrorDict; + disabled?: InputDisabledDict; + dirties?: InputDirtyDict; +}; + +export type Initialization = { + scheme: InputScheme; + dataInit?: DataInitialization; +}; + +export type GeneralInitialization = Initialization | InputScheme | InputInfo[]; + +export type Initializer = GeneralInitialization | (() => GeneralInitialization); + +export interface InputGroupProps { + color?: ThemeColor; + containerClassName?: string; + containerRef?: Ref<HTMLDivElement>; + + inputs: Input[]; + onChange: (index: number, value: Input["value"]) => void; +} + +function cleanObject<V>(o: Record<string, V>): Record<string, NonNullable<V>> { + const result = { ...o }; + for (const key of Object.keys(result)) { + if (result[key] == null) { + delete result[key]; + } + } + return result as never; +} + +export type ConfirmResult = + | { + type: "ok"; + values: InputConfirmValueDict; + } + | { + type: "error"; + errors: InputErrorDict; + }; + +function validate( + validator: Validator | null | undefined, + values: InputValueDict, + inputs: InputInfo[], +): InputErrorDict { + const errors: GeneralInputErrorDict = {}; + validator?.(values, errors, inputs); + return cleanObject(errors); +} + +export function useInputs(options: { init: Initializer }): { + inputGroupProps: InputGroupProps; + hasError: boolean; + hasErrorAndDirty: boolean; + confirm: () => ConfirmResult; + setAllDisabled: (disabled: boolean) => void; +} { + function initializeValue( + input: InputInfo, + value?: InputValue | null, + ): InputValue { + if (input.type === "text") { + return value ?? ""; + } else if (input.type === "bool") { + return value ?? false; + } else if (input.type === "select") { + return value ?? input.options[0].value; + } + throw new Error("Unknown input type"); + } + + function initialize(generalInitialization: GeneralInitialization): State { + const initialization: Initialization = Array.isArray(generalInitialization) + ? { scheme: { inputs: generalInitialization } } + : "scheme" in generalInitialization + ? generalInitialization + : { scheme: generalInitialization }; + + const { scheme, dataInit } = initialization; + const { inputs, validator } = scheme; + const keys = inputs.map((input) => input.key); + + if (process.env.NODE_ENV === "development") { + const checkKeys = (dict: Record<string, unknown> | undefined) => { + if (dict != null) { + for (const key of Object.keys(dict)) { + if (!keys.includes(key)) { + console.warn(""); + } + } + } + }; + + checkKeys(dataInit?.values); + checkKeys(dataInit?.errors ?? {}); + checkKeys(dataInit?.disabled); + checkKeys(dataInit?.dirties); + } + + function clean<V>( + dict: Record<string, V> | null | undefined, + ): Record<string, NonNullable<V>> { + return dict != null ? cleanObject(dict) : {}; + } + + const values: InputValueDict = {}; + const disabled: InputDisabledDict = clean(dataInit?.disabled); + const dirties: InputDirtyDict = clean(dataInit?.dirties); + const isErrorSet = dataInit?.errors != null; + let errors: InputErrorDict = clean(dataInit?.errors); + + for (let i = 0; i < inputs.length; i++) { + const input = inputs[i]; + const { key } = input; + + values[key] = initializeValue(input, dataInit?.values?.[key]); + } + + if (isErrorSet) { + if (process.env.NODE_ENV === "development") { + console.log( + "You explicitly set errors (not undefined) in initializer, so validator won't run.", + ); + } + } else { + errors = validate(validator, values, inputs); + } + + return { + scheme, + data: { + values, + errors, + disabled, + dirties, + }, + }; + } + + const { init } = options; + const initializer = typeof init === "function" ? init : () => init; + + const [state, setState] = useState<State>(() => initialize(initializer())); + + const { scheme, data } = state; + const { validator } = scheme; + + function createAllBooleanDict(value: boolean): Record<string, boolean> { + const result: InputDirtyDict = {}; + for (const key of scheme.inputs.map((input) => input.key)) { + result[key] = value; + } + return result; + } + + const createAllDirties = () => createAllBooleanDict(true); + + const componentInputs: Input[] = []; + + for (let i = 0; i < scheme.inputs.length; i++) { + const input = scheme.inputs[i]; + const value = data.values[input.key]; + const error = data.errors[input.key]; + const disabled = data.disabled[input.key] ?? false; + const dirty = data.dirties[input.key] ?? false; + const componentInput: Input = { + ...input, + value: value as never, + disabled, + error: dirty ? error : undefined, + }; + componentInputs.push(componentInput); + } + + const hasError = Object.keys(data.errors).length > 0; + const hasDirty = Object.keys(data.dirties).some((key) => data.dirties[key]); + + return { + inputGroupProps: { + inputs: componentInputs, + onChange: (index, value) => { + const input = scheme.inputs[index]; + const { key } = input; + const newValues = { ...data.values, [key]: value }; + const newDirties = { ...data.dirties, [key]: true }; + const newErrors = validate(validator, newValues, scheme.inputs); + setState({ + scheme, + data: { + ...data, + values: newValues, + errors: newErrors, + dirties: newDirties, + }, + }); + }, + }, + hasError, + hasErrorAndDirty: hasError && hasDirty, + confirm() { + const newDirties = createAllDirties(); + const newErrors = validate(validator, data.values, scheme.inputs); + + setState({ + scheme, + data: { + ...data, + dirties: newDirties, + errors: newErrors, + }, + }); + + if (Object.keys(newErrors).length !== 0) { + return { + type: "error", + errors: newErrors, + }; + } else { + return { + type: "ok", + values: data.values as InputConfirmValueDict, + }; + } + }, + setAllDisabled(disabled: boolean) { + setState({ + scheme, + data: { + ...data, + disabled: createAllBooleanDict(disabled), + }, + }); + }, + }; +} + +export function InputGroup({ + color, + inputs, + onChange, + containerRef, + containerClassName, +}: InputGroupProps) { + const c = useC(); + + const id = useId(); + + return ( + <div + ref={containerRef} + className={classNames( + "cru-input-group", + `cru-clickable-${color ?? "primary"}`, + containerClassName, + )} + > + {inputs.map((item, index) => { + const { key, type, value, label, error, helper, disabled } = item; + + const getContainerClassName = ( + ...additionalClassNames: classNames.ArgumentArray + ) => + classNames( + `cru-input-container cru-input-type-${type}`, + error && "error", + ...additionalClassNames, + ); + + const changeValue = (value: InputValue) => { + onChange(index, value); + }; + + const inputId = `${id}-${key}`; + + if (type === "text") { + const { password } = item; + return ( + <div + key={key} + className={getContainerClassName(password && "password")} + > + {label && ( + <label className="cru-input-label" htmlFor={inputId}> + {c(label)} + </label> + )} + <input + id={inputId} + type={password ? "password" : "text"} + value={value} + onChange={(event) => { + const v = event.target.value; + changeValue(v); + }} + disabled={disabled} + /> + {error && <div className="cru-input-error">{c(error)}</div>} + {helper && <div className="cru-input-helper">{c(helper)}</div>} + </div> + ); + } else if (type === "bool") { + return ( + <div key={key} className={getContainerClassName()}> + <input + id={inputId} + type="checkbox" + checked={value} + onChange={(event) => { + const v = event.currentTarget.checked; + changeValue(v); + }} + disabled={disabled} + /> + <label className="cru-input-label-inline" htmlFor={inputId}> + {c(label)} + </label> + {error && <div className="cru-input-error">{c(error)}</div>} + {helper && <div className="cru-input-helper">{c(helper)}</div>} + </div> + ); + } else if (type === "select") { + return ( + <div key={key} className={getContainerClassName()}> + <label className="cru-input-label" htmlFor={inputId}> + {c(label)} + </label> + <select + id={inputId} + value={value} + onChange={(event) => { + const e = event.target.value; + changeValue(e); + }} + disabled={disabled} + > + {item.options.map((option) => { + return ( + <option value={option.value} key={option.value}> + {option.icon} + {c(option.label)} + </option> + ); + })} + </select> + </div> + ); + } + })} + </div> + ); +} diff --git a/FrontEnd/src/components/input/index.ts b/FrontEnd/src/components/input/index.ts new file mode 100644 index 00000000..ca183089 --- /dev/null +++ b/FrontEnd/src/components/input/index.ts @@ -0,0 +1,11 @@ +export { useInputs, InputGroup } from "./InputGroup"; + +export type { + InputValueDict, + InputErrorDict, + InputDirtyDict, + InputDisabledDict, + InputConfirmValueDict, + Validator, + Initializer, +} from "./InputGroup"; diff --git a/FrontEnd/src/components/list/ListContainer.css b/FrontEnd/src/components/list/ListContainer.css new file mode 100644 index 00000000..53781834 --- /dev/null +++ b/FrontEnd/src/components/list/ListContainer.css @@ -0,0 +1,4 @@ +.cru-list-container { + border: 1px solid var(--cru-clickable-primary-normal-color); + border-radius: 5px; +} diff --git a/FrontEnd/src/components/list/ListContainer.tsx b/FrontEnd/src/components/list/ListContainer.tsx new file mode 100644 index 00000000..c27e67d4 --- /dev/null +++ b/FrontEnd/src/components/list/ListContainer.tsx @@ -0,0 +1,23 @@ +import { ComponentPropsWithoutRef, forwardRef, Ref } from "react"; +import classNames from "classnames"; + +import "./ListContainer.css"; + +function _ListContainer( + { className, children, ...otherProps }: ComponentPropsWithoutRef<"div">, + ref: Ref<HTMLDivElement>, +) { + return ( + <div + ref={ref} + className={classNames("cru-list-container", className)} + {...otherProps} + > + {children} + </div> + ); +} + +const ListContainer = forwardRef(_ListContainer); + +export default ListContainer; diff --git a/FrontEnd/src/components/list/ListItemContainer.css b/FrontEnd/src/components/list/ListItemContainer.css new file mode 100644 index 00000000..49468bc2 --- /dev/null +++ b/FrontEnd/src/components/list/ListItemContainer.css @@ -0,0 +1,7 @@ +.cru-list-item-container { + border-bottom: 1px solid var(--cru-clickable-primary-normal-color); +} + +.cru-list-item-container:last-child { + border-bottom: none; +} diff --git a/FrontEnd/src/components/list/ListItemContainer.tsx b/FrontEnd/src/components/list/ListItemContainer.tsx new file mode 100644 index 00000000..315cbd6e --- /dev/null +++ b/FrontEnd/src/components/list/ListItemContainer.tsx @@ -0,0 +1,23 @@ +import { ComponentPropsWithoutRef, forwardRef, Ref } from "react"; +import classNames from "classnames"; + +import "./ListItemContainer.css"; + +function _ListItemContainer( + { className, children, ...otherProps }: ComponentPropsWithoutRef<"div">, + ref: Ref<HTMLDivElement>, +) { + return ( + <div + ref={ref} + className={classNames("cru-list-item-container", className)} + {...otherProps} + > + {children} + </div> + ); +} + +const ListItemContainer = forwardRef(_ListItemContainer); + +export default ListItemContainer; diff --git a/FrontEnd/src/components/list/index.ts b/FrontEnd/src/components/list/index.ts new file mode 100644 index 00000000..e183f7da --- /dev/null +++ b/FrontEnd/src/components/list/index.ts @@ -0,0 +1,4 @@ +import ListContainer from "./ListContainer"; +import ListItemContainer from "./ListItemContainer"; + +export { ListContainer, ListItemContainer }; diff --git a/FrontEnd/src/components/menu/Menu.css b/FrontEnd/src/components/menu/Menu.css new file mode 100644 index 00000000..75734533 --- /dev/null +++ b/FrontEnd/src/components/menu/Menu.css @@ -0,0 +1,36 @@ +.cru-menu {
+ min-width: 200px;
+}
+
+.cru-menu-item {
+ display: block;
+ font-size: 1em;
+ width: 100%;
+ padding: 0.5em 1.5em;
+ transition: all 0.5s;
+ color: var(--cru-clickable-normal-color);
+ background-color: var(--cru-clickable-grayscale-normal-color);
+ border: none;
+ cursor: pointer;
+}
+
+.cru-menu-item:hover {
+ background-color: var(--cru-clickable-grayscale-hover-color);
+}
+
+.cru-menu-item:focus {
+ background-color: var(--cru-clickable-grayscale-focus-color);
+}
+
+.cru-menu-item:active {
+ background-color: var(--cru-clickable-grayscale-active-color);
+}
+
+.cru-menu-item-icon {
+ margin-right: 1em;
+}
+
+.cru-menu-divider {
+ border-width: 0;
+ border-top: 1px solid var(--cru-primary-color);
+}
\ No newline at end of file diff --git a/FrontEnd/src/components/menu/Menu.tsx b/FrontEnd/src/components/menu/Menu.tsx new file mode 100644 index 00000000..1a196a69 --- /dev/null +++ b/FrontEnd/src/components/menu/Menu.tsx @@ -0,0 +1,62 @@ +import { MouseEvent, CSSProperties } from "react"; +import classNames from "classnames"; + +import { useC, Text, ThemeColor } from "../common"; +import Icon from "../Icon"; + +import "./Menu.css"; + +export type MenuItem = + | { + type: "divider"; + } + | { + type: "button"; + text: Text; + icon?: string; + color?: ThemeColor; + onClick?: (e: MouseEvent<HTMLButtonElement>) => void; + }; + +export type MenuItems = MenuItem[]; + +export type MenuProps = { + items: MenuItems; + onItemClick?: (e: MouseEvent<HTMLButtonElement>) => void; + className?: string; + style?: CSSProperties; +}; + +export default function Menu({ + items, + onItemClick, + className, + style, +}: MenuProps) { + const c = useC(); + + return ( + <div className={classNames("cru-menu", className)} style={style}> + {items.map((item, index) => { + if (item.type === "divider") { + return <hr key={index} className="cru-menu-divider" />; + } else { + const { text, color, icon, onClick } = item; + return ( + <button + key={index} + className={`cru-menu-item cru-clickable-${color ?? "primary"}`} + onClick={(e) => { + onClick?.(e); + onItemClick?.(e); + }} + > + {icon != null && <Icon color={color} icon={icon} />} + {c(text)} + </button> + ); + } + })} + </div> + ); +} diff --git a/FrontEnd/src/components/menu/PopupMenu.css b/FrontEnd/src/components/menu/PopupMenu.css new file mode 100644 index 00000000..149e0699 --- /dev/null +++ b/FrontEnd/src/components/menu/PopupMenu.css @@ -0,0 +1,7 @@ +.cru-popup-menu-menu-container {
+ z-index: 1040;
+ border-radius: 3px;
+ border: var(--cru-clickable-normal-color) 1.5px solid;
+ background-color: var(--cru-background-color);
+ overflow: hidden;
+}
diff --git a/FrontEnd/src/components/menu/PopupMenu.tsx b/FrontEnd/src/components/menu/PopupMenu.tsx new file mode 100644 index 00000000..7ac2abfe --- /dev/null +++ b/FrontEnd/src/components/menu/PopupMenu.tsx @@ -0,0 +1,72 @@ +import { useState, CSSProperties, ReactNode } from "react"; +import classNames from "classnames"; +import { createPortal } from "react-dom"; +import { usePopper } from "react-popper"; + +import { ThemeColor } from "../common"; +import { useClickOutside } from "../hooks"; +import Menu, { MenuItems } from "./Menu"; + +import "./PopupMenu.css"; + +export interface PopupMenuProps { + color?: ThemeColor; + items: MenuItems; + children?: ReactNode; + containerClassName?: string; + containerStyle?: CSSProperties; +} + +export default function PopupMenu({ + color, + items, + children, + containerClassName, + containerStyle, +}: PopupMenuProps) { + const [show, setShow] = useState<boolean>(false); + + const [referenceElement, setReferenceElement] = + useState<HTMLDivElement | null>(null); + const [popperElement, setPopperElement] = useState<HTMLDivElement | null>( + null, + ); + const { styles, attributes } = usePopper(referenceElement, popperElement); + + useClickOutside(popperElement, () => setShow(false), true); + + return ( + <div + ref={setReferenceElement} + className={classNames( + "cru-popup-menu-trigger-container", + containerClassName, + )} + style={containerStyle} + onClick={() => setShow(true)} + > + {children} + {show && + createPortal( + <div + ref={setPopperElement} + className={`cru-popup-menu-menu-container cru-clickable-${ + color ?? "primary" + }`} + style={styles.popper} + {...attributes.popper} + > + <Menu + items={items} + onItemClick={(e) => { + setShow(false); + e.stopPropagation(); + }} + /> + </div>, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + document.getElementById("portal")!, + )} + </div> + ); +} diff --git a/FrontEnd/src/components/tab/TabBar.css b/FrontEnd/src/components/tab/TabBar.css new file mode 100644 index 00000000..dc6970c7 --- /dev/null +++ b/FrontEnd/src/components/tab/TabBar.css @@ -0,0 +1,32 @@ +.cru-tab-bar {
+ display: flex;
+}
+
+.cru-tab-bar-tab-area {
+ display: flex;
+ align-items: center;
+ border: var(--cru-clickable-normal-color) 1.6px solid;
+ border-radius: 3px;
+ overflow: hidden;
+}
+
+.cru-tab-bar-item {
+ color: var(--cru-text-minor-color);
+ transition: all 0.2s;
+ cursor: pointer;
+ padding: 0.3em 1em;
+}
+
+.cru-tab-bar-item:hover {
+ color: var(--cru-clickable-normal-color);
+}
+
+.cru-tab-bar-item.active {
+ color: var(--cru-push-button-text-color);
+ background-color: var(--cru-clickable-normal-color);
+ border-color: var(--cru-primary-color);
+}
+
+.cru-tab-bar-action-area {
+ margin-left: auto;
+}
diff --git a/FrontEnd/src/components/tab/TabBar.tsx b/FrontEnd/src/components/tab/TabBar.tsx new file mode 100644 index 00000000..601f664d --- /dev/null +++ b/FrontEnd/src/components/tab/TabBar.tsx @@ -0,0 +1,69 @@ +import { ReactNode } from "react"; +import { Link } from "react-router-dom"; +import classNames from "classnames"; + +import { Text, ThemeColor, useC } from "../common"; + +import "./TabBar.css"; + +export interface Tab { + name: string; + text: Text; + link?: string; + onClick?: () => void; +} + +export interface TabsProps { + activeTabName?: string; + tabs: Tab[]; + color?: ThemeColor; + actions?: ReactNode; + dense?: boolean; + className?: string; +} + +export default function TabBar(props: TabsProps) { + const { tabs, color, activeTabName, className, dense, actions } = props; + + const c = useC(); + + return ( + <div + className={classNames( + "cru-tab-bar", + dense && "dense", + `cru-clickable-${color ?? "primary"}`, + className, + )} + > + <div className="cru-tab-bar-tab-area"> + {tabs.map((tab) => { + const { name, text, link, onClick } = tab; + + const active = activeTabName === name; + const className = classNames("cru-tab-bar-item", active && "active"); + + if (link != null) { + return ( + <Link + key={name} + to={link} + onClick={onClick} + className={className} + > + {c(text)} + </Link> + ); + } else { + return ( + <span key={name} onClick={onClick} className={className}> + {c(text)} + </span> + ); + } + })} + </div> + <div className="cru-tab-bar-action-area">{actions}</div> + </div> + ); +} diff --git a/FrontEnd/src/components/tab/TabPages.css b/FrontEnd/src/components/tab/TabPages.css new file mode 100644 index 00000000..c07d042e --- /dev/null +++ b/FrontEnd/src/components/tab/TabPages.css @@ -0,0 +1,3 @@ +.cru-tab-page-container { + padding-top: 0.5em; +} diff --git a/FrontEnd/src/components/tab/TabPages.tsx b/FrontEnd/src/components/tab/TabPages.tsx new file mode 100644 index 00000000..ab45ffdf --- /dev/null +++ b/FrontEnd/src/components/tab/TabPages.tsx @@ -0,0 +1,61 @@ +import { ReactNode, useState } from "react"; +import classNames from "classnames"; + +import { Text, UiLogicError } from "../common"; + +import Tabs from "./TabBar"; + +import "./TabPages.css"; + +interface TabPage { + name: string; + text: Text; + page: ReactNode; +} + +interface TabPagesProps { + pages: TabPage[]; + actions?: ReactNode; + dense?: boolean; + className?: string; + tabBarClassName?: string; + pageContainerClassName?: string; +} + +export default function TabPages({ + pages, + actions, + dense, + className, + tabBarClassName, + pageContainerClassName, +}: TabPagesProps) { + const [tab, setTab] = useState<string>(pages[0].name); + + const currentPage = pages.find((p) => p.name === tab); + + if (currentPage == null) throw new UiLogicError(); + + return ( + <div className={className}> + <Tabs + tabs={pages.map((page) => ({ + name: page.name, + text: page.text, + onClick: () => { + setTab(page.name); + }, + }))} + dense={dense} + activeTabName={tab} + className={tabBarClassName} + actions={actions} + /> + <div + className={classNames("cru-tab-page-container", pageContainerClassName)} + > + {currentPage.page} + </div> + </div> + ); +} diff --git a/FrontEnd/src/components/tab/index.ts b/FrontEnd/src/components/tab/index.ts new file mode 100644 index 00000000..43d545cc --- /dev/null +++ b/FrontEnd/src/components/tab/index.ts @@ -0,0 +1,2 @@ +export { default as TabBar } from "./TabBar"; +export { default as TabPages } from "./TabPages"; diff --git a/FrontEnd/src/components/theme.css b/FrontEnd/src/components/theme.css new file mode 100644 index 00000000..68dd780f --- /dev/null +++ b/FrontEnd/src/components/theme.css @@ -0,0 +1,201 @@ +:root { + --cru-default-font-family: 'Segoe UI', 'DengXian', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; + --cru-page-padding: 1em 2em; + + --cru-border-radius: 4px; + --cru-card-border-radius: 4px; +} + +/* theme colors */ +:root { + --cru-primary-color: hsl(210 100% 50%); + --cru-secondary-color: hsl(30 100% 50%); + --cru-create-color: hsl(120 100% 25%); + --cru-danger-color: hsl(0 100% 50%); + --cru-warn-color: #e4a700; +} + +.cru-theme-primary { + --cru-theme-color: var(--cru-primary-color); +} + +.cru-theme-secondary { + --cru-theme-color: var(--cru-secondary-color); +} + +.cru-theme-create { + --cru-theme-color: var(--cru-create-color); +} + +.cru-theme-danger { + --cru-theme-color: var(--cru-danger-color); +} + +/* common colors */ +:root { + --cru-background-color: hsl(0 0% 100%); + --cru-container-background-color: hsl(0 0% 97%); + --cru-text-major-color: hsl(0 0% 0%); + --cru-text-minor-color: hsl(0 0% 38%); +} + +@media (prefers-color-scheme: dark) { + :root { + --cru-background-color: hsl(0 0% 0%); + --cru-container-background-color: hsl(0 0% 2%); + --cru-text-major-color: hsl(0 0% 100%); + --cru-text-minor-color: hsl(0 0% 85%); + } +} + +:root { + --cru-body-background-color: var(--cru-background-color); +} + +/* dialog color */ + +:root { + --cru-dialog-overlay-color: hsl(0 0% 100%); + --cru-dialog-container-background-color: hsl(0 0% 100%); +} + +@media (prefers-color-scheme: dark) { + :root { + --cru-dialog-overlay-color: hsl(0 0% 0%); + --cru-dialog-container-background-color: hsl(0 0% 0%); + } +} + +/* clickable color */ +:root { + --cru-clickable-primary-normal-color: var(--cru-primary-color); + --cru-clickable-primary-hover-color: hsl(210 100% 60%); + --cru-clickable-primary-focus-color: hsl(210 100% 60%); + --cru-clickable-primary-active-color: hsl(210 100% 70%); + --cru-clickable-secondary-normal-color: var(--cru-secondary-color); + --cru-clickable-secondary-hover-color: hsl(30 100% 60%); + --cru-clickable-secondary-focus-color: hsl(30 100% 60%); + --cru-clickable-secondary-active-color: hsl(30 100% 70%); + --cru-clickable-create-normal-color: var(--cru-create-color); + --cru-clickable-create-hover-color: hsl(120 100% 35%); + --cru-clickable-create-focus-color: hsl(120 100% 35%); + --cru-clickable-create-active-color: hsl(120 100% 35%); + --cru-clickable-danger-normal-color: var(--cru-danger-color); + --cru-clickable-danger-hover-color: hsl(0 100% 60%); + --cru-clickable-danger-focus-color: hsl(0 100% 60%); + --cru-clickable-danger-active-color: hsl(0 100% 70%); + --cru-clickable-grayscale-normal-color: hsl(0 0% 100%); + --cru-clickable-grayscale-hover-color: hsl(0 0% 92%); + --cru-clickable-grayscale-focus-color: hsl(0 0% 92%); + --cru-clickable-grayscale-active-color: hsl(0 0% 88%); + --cru-clickable-light-normal-color: hsl(0 0% 100%); + --cru-clickable-light-hover-color: hsl(0 0% 92%); + --cru-clickable-light-focus-color: hsl(0 0% 92%); + --cru-clickable-light-active-color: hsl(0 0% 88%); + --cru-clickable-minor-normal-color: hsl(0 0% 30%); + --cru-clickable-minor-hover-color: hsl(0 0% 40%); + --cru-clickable-minor-focus-color: hsl(0 0% 40%); + --cru-clickable-minor-active-color: hsl(0 0% 45%); + --cru-clickable-disabled-color: hsl(0 0% 50%); +} + +@media (prefers-color-scheme: dark) { + :root { + --cru-clickable-minor-normal-color: hsl(0 0% 74%); + --cru-clickable-minor-hover-color: hsl(0 0% 82%); + --cru-clickable-minor-focus-color: hsl(0 0% 82%); + --cru-clickable-minor-active-color: hsl(0 0% 90%); + --cru-clickable-grayscale-normal-color: hsl(0 0% 0%); + --cru-clickable-grayscale-hover-color: hsl(0 0% 10%); + --cru-clickable-grayscale-focus-color: hsl(0 0% 10%); + --cru-clickable-grayscale-active-color: hsl(0 0% 20%); + } +} + +.cru-clickable-primary { + --cru-clickable-normal-color: var(--cru-clickable-primary-normal-color); + --cru-clickable-hover-color: var(--cru-clickable-primary-hover-color); + --cru-clickable-focus-color: var(--cru-clickable-primary-focus-color); + --cru-clickable-active-color: var(--cru-clickable-primary-active-color); +} + +.cru-clickable-secondary { + --cru-clickable-normal-color: var(--cru-clickable-secondary-normal-color); + --cru-clickable-hover-color: var(--cru-clickable-secondary-hover-color); + --cru-clickable-focus-color: var(--cru-clickable-secondary-focus-color); + --cru-clickable-active-color: var(--cru-clickable-secondary-active-color); +} + +.cru-clickable-create { + --cru-clickable-normal-color: var(--cru-clickable-create-normal-color); + --cru-clickable-hover-color: var(--cru-clickable-create-hover-color); + --cru-clickable-focus-color: var(--cru-clickable-create-focus-color); + --cru-clickable-active-color: var(--cru-clickable-create-active-color); +} + +.cru-clickable-danger { + --cru-clickable-normal-color: var(--cru-clickable-danger-normal-color); + --cru-clickable-hover-color: var(--cru-clickable-danger-hover-color); + --cru-clickable-focus-color: var(--cru-clickable-danger-focus-color); + --cru-clickable-active-color: var(--cru-clickable-danger-active-color); +} + +.cru-clickable-grayscale { + --cru-clickable-normal-color: var(--cru-clickable-grayscale-normal-color); + --cru-clickable-hover-color: var(--cru-clickable-grayscale-hover-color); + --cru-clickable-focus-color: var(--cru-clickable-grayscale-focus-color); + --cru-clickable-active-color: var(--cru-clickable-grayscale-active-color); +} + +.cru-clickable-light { + --cru-clickable-normal-color: var(--cru-clickable-light-normal-color); + --cru-clickable-hover-color: var(--cru-clickable-light-hover-color); + --cru-clickable-focus-color: var(--cru-clickable-light-focus-color); + --cru-clickable-active-color: var(--cru-clickable-light-active-color); +} + +.cru-clickable-minor { + --cru-clickable-normal-color: var(--cru-clickable-minor-normal-color); + --cru-clickable-hover-color: var(--cru-clickable-minor-hover-color); + --cru-clickable-focus-color: var(--cru-clickable-minor-focus-color); + --cru-clickable-active-color: var(--cru-clickable-minor-active-color); +} + +/* button colors */ +:root { + /* push button colors */ + --cru-push-button-text-color: #ffffff; + --cru-push-button-disabled-text-color: hsl(0 0% 80%); +} + +/* Card colors */ +:root { + --cru-card-background-primary-color: hsl(210 100% 50%); + --cru-card-border-primary-color: hsl(210 100% 50%); + --cru-card-background-secondary-color: hsl(30 100% 50%); + --cru-card-border-secondary-color: hsl(30 100% 50%); + --cru-card-background-create-color: hsl(120 100% 25%); + --cru-card-border-create-color: hsl(120 100% 25%); + --cru-card-background-danger-color: hsl(0 100% 50%); + --cru-card-border-danger-color: hsl(0 100% 50%); +} + +.cru-card-primary { + --cru-card-background-color: var(--cru-card-background-primary-color); + --cru-card-border-color: var(--cru-card-border-primary-color) +} + +.cru-card-secondary { + --cru-card-background-color: var(--cru-card-background-secondary-color); + --cru-card-border-color: var(--cru-card-border-secondary-color) +} + +.cru-card-create { + --cru-card-background-color: var(--cru-card-background-create-color); + --cru-card-border-color: var(--cru-card-border-create-color) +} + +.cru-card-danger { + --cru-card-background-color: var(--cru-card-background-danger-color); + --cru-card-border-color: var(--cru-card-border-danger-color) +} diff --git a/FrontEnd/src/components/user/UserAvatar.tsx b/FrontEnd/src/components/user/UserAvatar.tsx new file mode 100644 index 00000000..8671f2d8 --- /dev/null +++ b/FrontEnd/src/components/user/UserAvatar.tsx @@ -0,0 +1,22 @@ +import { Ref, ComponentPropsWithoutRef } from "react"; + +import { getHttpUserClient } from "~src/http/user"; + +export interface UserAvatarProps extends ComponentPropsWithoutRef<"img"> { + username: string; + imgRef?: Ref<HTMLImageElement> | null; +} + +export default function UserAvatar({ + username, + imgRef, + ...otherProps +}: UserAvatarProps) { + return ( + <img + ref={imgRef} + src={getHttpUserClient().generateAvatarUrl(username)} + {...otherProps} + /> + ); +} diff --git a/FrontEnd/src/http/bookmark.ts b/FrontEnd/src/http/bookmark.ts index 40e121cc..311f9a0f 100644 --- a/FrontEnd/src/http/bookmark.ts +++ b/FrontEnd/src/http/bookmark.ts @@ -1,4 +1,4 @@ -import { withQuery } from "@/utilities/url"; +import { withQuery } from "~src/utilities/url"; import { axios, apiBaseUrl, extractResponseData, Page } from "./common"; diff --git a/FrontEnd/src/http/timeline.ts b/FrontEnd/src/http/timeline.ts index 401ae116..255c786e 100644 --- a/FrontEnd/src/http/timeline.ts +++ b/FrontEnd/src/http/timeline.ts @@ -1,4 +1,4 @@ -import { withQuery } from "@/utilities/url"; +import { withQuery } from "~src/utilities/url"; import { axios, diff --git a/FrontEnd/src/index.css b/FrontEnd/src/index.css index 419ccb8c..f779297b 100644 --- a/FrontEnd/src/index.css +++ b/FrontEnd/src/index.css @@ -1,13 +1,5 @@ -@import "npm:bootstrap/dist/css/bootstrap-reboot.css";
-@import "npm:bootstrap/dist/css/bootstrap-grid.css";
@import "npm:bootstrap-icons/font/bootstrap-icons.css";
-@import "./views/common/index.css";
-
-body {
- background: var(--cru-background-color);
-}
-
small {
line-height: 1.2;
}
@@ -24,7 +16,7 @@ small { textarea {
resize: none;
outline: none;
- border-color: var(--cru-background-2-color);
+ border-color: var(--cru-bg-2-color);
}
textarea:hover {
@@ -35,22 +27,6 @@ textarea:focus { border-color: var(--cru-primary-color);
}
-input:not([type="checkbox"]):not([type="radio"]) {
- resize: none;
- outline: none;
- border: 1px solid;
- transition: all 0.5s;
- border-color: var(--cru-background-2-color);
-}
-
-input:hover:not([type="checkbox"]):not([type="radio"]) {
- border-color: var(--cru-primary-r2-color);
-}
-
-input:focus:not([type="checkbox"]):not([type="radio"]) {
- border-color: var(--cru-primary-color);
-}
-
.white-space-no-wrap {
white-space: nowrap;
}
@@ -75,6 +51,7 @@ i { .markdown-container {
white-space: initial;
}
+
.markdown-container img {
max-height: 200px;
max-width: 100%;
@@ -82,4 +59,4 @@ i { a {
text-decoration: none;
-}
+}
\ No newline at end of file diff --git a/FrontEnd/src/index.tsx b/FrontEnd/src/index.tsx index ba61d357..64d39cd5 100644 --- a/FrontEnd/src/index.tsx +++ b/FrontEnd/src/index.tsx @@ -1,14 +1,9 @@ -import "regenerator-runtime"; -import "core-js/modules/es.promise"; -import "core-js/modules/es.array.iterator"; - import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import "./index.css"; import "./i18n"; -import "./palette"; import App from "./App"; @@ -18,5 +13,5 @@ const root = createRoot(container!); root.render( <StrictMode> <App /> - </StrictMode> + </StrictMode>, ); diff --git a/FrontEnd/src/locales/en/translation.json b/FrontEnd/src/locales/en/translation.json index 21c826bd..a7e4efe5 100644 --- a/FrontEnd/src/locales/en/translation.json +++ b/FrontEnd/src/locales/en/translation.json @@ -86,7 +86,7 @@ "ok": "OK!", "processing": "Processing...", "success": "Success!", - "error": "An error occured." + "error": "An error occurred." }, "timeline": { "messageCantSee": "Sorry, you are not allowed to see this timeline.😅", @@ -176,7 +176,7 @@ "noAccount": "If you don't have an account and know a register code, then click <1>here</1> to register." }, "settings": { - "subheaders": { + "subheader": { "account": "Account", "customization": "Customization" }, @@ -186,7 +186,6 @@ "logout": "Log out this account", "changeAvatar": "Change avatar", "changeNickname": "Change nickname", - "changeBookmarkVisibility": "Change bookmark visibility", "myRegisterCode": "My register code:", "myRegisterCodeDesc": "Click to create a new register code.", "renewRegisterCode": "Renew Register Code", @@ -224,23 +223,11 @@ } }, "about": { - "author": { - "title": "Site Developer", - "name": "Name: ", - "introduction": "Introduction: ", - "introductionContent": "A programmer coding based on coincidence", - "links": "Links: " - }, - "site": { - "title": "Site Information", - "content": "The name of this site is <1>Timeline</1>, which is a Web App with <3>timeline</3> as its core concept. Its frontend and backend are both developed by <5>me</5>, and open source on GitHub. It is relatively easy to deploy it on your own server, which is also one of my goals. Welcome to comment anything in GitHub repository.", - "repo": "GitHub Repo" - }, "credits": { "title": "Credits", - "content": "Timeline is works standing on shoulders of gaints. Special appreciation for many open source projects listed below or not. Related licenses could be found in GitHub repository.", - "frontend": "Frontend: ", - "backend": "Backend: " + "content": "Timeline stands on shoulders of giants. Special appreciation for many open source projects listed below or not. Related licenses could be found in GitHub repository.", + "frontend": "Frontend", + "backend": "Backend" } }, "admin": { diff --git a/FrontEnd/src/locales/zh/translation.json b/FrontEnd/src/locales/zh/translation.json index b7212128..8a2f628f 100644 --- a/FrontEnd/src/locales/zh/translation.json +++ b/FrontEnd/src/locales/zh/translation.json @@ -176,7 +176,7 @@ "noAccount": "如果你没有账号但有一个注册码,请点击<1>这里</1>注册账号。" }, "settings": { - "subheaders": { + "subheader": { "account": "账户", "customization": "个性化" }, diff --git a/FrontEnd/src/views/admin/Admin.tsx b/FrontEnd/src/migrating/admin/Admin.tsx index 986c36b4..986c36b4 100644 --- a/FrontEnd/src/views/admin/Admin.tsx +++ b/FrontEnd/src/migrating/admin/Admin.tsx diff --git a/FrontEnd/src/views/admin/AdminNav.tsx b/FrontEnd/src/migrating/admin/AdminNav.tsx index b7385e5c..b7385e5c 100644 --- a/FrontEnd/src/views/admin/AdminNav.tsx +++ b/FrontEnd/src/migrating/admin/AdminNav.tsx diff --git a/FrontEnd/src/views/admin/MoreAdmin.tsx b/FrontEnd/src/migrating/admin/MoreAdmin.tsx index d49d211f..d49d211f 100644 --- a/FrontEnd/src/views/admin/MoreAdmin.tsx +++ b/FrontEnd/src/migrating/admin/MoreAdmin.tsx diff --git a/FrontEnd/src/views/admin/UserAdmin.tsx b/FrontEnd/src/migrating/admin/UserAdmin.tsx index d5179bf5..08560c87 100644 --- a/FrontEnd/src/views/admin/UserAdmin.tsx +++ b/FrontEnd/src/migrating/admin/UserAdmin.tsx @@ -1,3 +1,6 @@ +// eslint-disable +// @ts-nocheck + import { useState, useEffect } from "react"; import * as React from "react"; import { Trans, useTranslation } from "react-i18next"; @@ -5,9 +8,7 @@ import classnames from "classnames"; import { getHttpUserClient, HttpUser, kUserPermissionList } from "@/http/user"; -import OperationDialog, { - OperationDialogBoolInput, -} from "../common/dialog/OperationDialog"; +import OperationDialog from "../common/dialog/OperationDialog"; import Button from "../common/button/Button"; import Spinner from "../common/Spinner"; import FlatButton from "../common/button/FlatButton"; @@ -21,18 +22,15 @@ const CreateUserDialog: React.FC<{ return ( <OperationDialog title="admin:user.dialog.create.title" - themeColor="success" inputPrompt="admin:user.dialog.create.prompt" - inputScheme={ - [ - { type: "text", label: "admin:user.username" }, - { type: "text", label: "admin:user.password" }, - ] as const - } - onProcess={([username, password]) => + inputs={[ + { key: "username", type: "text", label: "admin:user.username" }, + { key: "password", type: "text", label: "admin:user.password" }, + ]} + onProcess={({ username, password }) => getHttpUserClient().post({ - username, - password, + username: username as string, + password: password as string, }) } onClose={close} @@ -80,13 +78,12 @@ const UserModifyDialog: React.FC<{ open={open} onClose={close} title="admin:user.dialog.modify.title" - themeColor="danger" - inputPrompt={() => ( + inputPromptNode={ <Trans i18nKey="admin:user.dialog.modify.prompt"> 0<UsernameLabel>{user.username}</UsernameLabel>2 </Trans> - )} - inputScheme={ + } + inputs={ [ { type: "text", @@ -120,7 +117,7 @@ const UserPermissionModifyDialog: React.FC<{ onSuccess: () => void; }> = ({ open, close, user, onSuccess }) => { const oldPermissionBoolList: boolean[] = kUserPermissionList.map( - (permission) => user.permissions.includes(permission) + (permission) => user.permissions.includes(permission), ); return ( @@ -139,7 +136,7 @@ const UserPermissionModifyDialog: React.FC<{ type: "bool", label: { type: "custom", value: permission }, initValue: oldPermissionBoolList[index], - }) + }), )} onProcess={async (newPermissionBoolList): Promise<boolean[]> => { for (let index = 0; index < kUserPermissionList.length; index++) { @@ -150,12 +147,12 @@ const UserPermissionModifyDialog: React.FC<{ if (newValue) { await getHttpUserClient().putUserPermission( user.username, - permission + permission, ); } else { await getHttpUserClient().deleteUserPermission( user.username, - permission + permission, ); } } diff --git a/FrontEnd/src/views/admin/index.css b/FrontEnd/src/migrating/admin/index.css index 17e24586..17e24586 100644 --- a/FrontEnd/src/views/admin/index.css +++ b/FrontEnd/src/migrating/admin/index.css diff --git a/FrontEnd/src/views/admin/index.tsx b/FrontEnd/src/migrating/admin/index.tsx index 0467711d..0467711d 100644 --- a/FrontEnd/src/views/admin/index.tsx +++ b/FrontEnd/src/migrating/admin/index.tsx diff --git a/FrontEnd/src/views/center/CenterBoards.tsx b/FrontEnd/src/migrating/center/CenterBoards.tsx index a8be2c29..f1c3fc6a 100644 --- a/FrontEnd/src/views/center/CenterBoards.tsx +++ b/FrontEnd/src/migrating/center/CenterBoards.tsx @@ -1,13 +1,13 @@ import * as React from "react"; import { useTranslation } from "react-i18next"; -import { highlightTimelineUsername } from "@/common"; +import { highlightTimelineUsername } from "~src/common"; -import { pushAlert } from "@/services/alert"; -import { useUserLoggedIn } from "@/services/user"; +import { pushAlert } from "~src/services/alert"; +import { useUserLoggedIn } from "~src/services/user"; -import { getHttpTimelineClient } from "@/http/timeline"; -import { getHttpBookmarkClient } from "@/http/bookmark"; +import { getHttpTimelineClient } from "~src/http/timeline"; +import { getHttpBookmarkClient } from "~src/http/bookmark"; import TimelineBoard from "./TimelineBoard"; diff --git a/FrontEnd/src/views/center/TimelineBoard.tsx b/FrontEnd/src/migrating/center/TimelineBoard.tsx index b3ccdf8c..8f4401bc 100644 --- a/FrontEnd/src/views/center/TimelineBoard.tsx +++ b/FrontEnd/src/migrating/center/TimelineBoard.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import classnames from "classnames"; import { Link } from "react-router-dom"; -import { TimelineBookmark } from "@/http/bookmark"; +import { TimelineBookmark } from "~src/http/bookmark"; import TimelineLogo from "../common/TimelineLogo"; import LoadFailReload from "../common/LoadFailReload"; diff --git a/FrontEnd/src/views/center/TimelineCreateDialog.tsx b/FrontEnd/src/migrating/center/TimelineCreateDialog.tsx index 63742936..340a08fe 100644 --- a/FrontEnd/src/views/center/TimelineCreateDialog.tsx +++ b/FrontEnd/src/migrating/center/TimelineCreateDialog.tsx @@ -1,11 +1,11 @@ import * as React from "react"; import { useNavigate } from "react-router-dom"; -import { validateTimelineName } from "@/services/timeline"; -import { getHttpTimelineClient, HttpTimelineInfo } from "@/http/timeline"; +import { validateTimelineName } from "~src/services/timeline"; +import { getHttpTimelineClient, HttpTimelineInfo } from "~src/http/timeline"; import OperationDialog from "../common/dialog/OperationDialog"; -import { useUserLoggedIn } from "@/services/user"; +import { useUserLoggedIn } from "~src/services/user"; interface TimelineCreateDialogProps { open: boolean; diff --git a/FrontEnd/src/views/center/index.css b/FrontEnd/src/migrating/center/index.css index a779ff90..a779ff90 100644 --- a/FrontEnd/src/views/center/index.css +++ b/FrontEnd/src/migrating/center/index.css diff --git a/FrontEnd/src/views/center/index.tsx b/FrontEnd/src/migrating/center/index.tsx index 77af2c20..11502517 100644 --- a/FrontEnd/src/views/center/index.tsx +++ b/FrontEnd/src/migrating/center/index.tsx @@ -1,7 +1,7 @@ import * as React from "react"; import { useNavigate } from "react-router-dom"; -import { useUserLoggedIn } from "@/services/user"; +import { useUserLoggedIn } from "~src/services/user"; import SearchInput from "../common/SearchInput"; import Button from "../common/button/Button"; 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/views/register/index.css b/FrontEnd/src/pages/register/index.css index c0078b28..c0078b28 100644 --- a/FrontEnd/src/views/register/index.css +++ b/FrontEnd/src/pages/register/index.css 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/views/timeline/ConnectionStatusBadge.css b/FrontEnd/src/pages/timeline/ConnectionStatusBadge.css index 7fe83b9b..0a6979cb 100644 --- a/FrontEnd/src/views/timeline/ConnectionStatusBadge.css +++ b/FrontEnd/src/pages/timeline/ConnectionStatusBadge.css @@ -2,8 +2,8 @@ font-size: 0.8em;
border-radius: 5px;
padding: 0.1em 1em;
- background-color: #eaf2ff;
}
+
.connection-status-badge::before {
width: 10px;
height: 10px;
@@ -12,25 +12,27 @@ content: "";
margin-right: 0.6em;
}
+
.connection-status-badge.success {
- color: #006100;
+ color: var(--cru-create-color);
}
+
.connection-status-badge.success::before {
- background-color: #006100;
+ background-color: var(--cru-create-color);
}
.connection-status-badge.warning {
- color: #e4a700;
+ color: var(--cru-warn-color);
}
.connection-status-badge.warning::before {
- background-color: #e4a700;
+ background-color: var(--cru-warn-color);
}
.connection-status-badge.danger {
- color: #fd1616;
+ color: var(--cru-danger-color);
}
.connection-status-badge.danger::before {
- background-color: #fd1616;
+ background-color: var(--cru-danger-color);
}
diff --git a/FrontEnd/src/views/timeline/ConnectionStatusBadge.tsx b/FrontEnd/src/pages/timeline/ConnectionStatusBadge.tsx index 2b820454..63990878 100644 --- a/FrontEnd/src/views/timeline/ConnectionStatusBadge.tsx +++ b/FrontEnd/src/pages/timeline/ConnectionStatusBadge.tsx @@ -1,14 +1,13 @@ -import * as React from "react"; -import classnames from "classnames"; +import classNames from "classnames"; import { HubConnectionState } from "@microsoft/signalr"; -import { useTranslation } from "react-i18next"; + +import { useC }from '~/src/components/common'; import "./ConnectionStatusBadge.css"; -export interface ConnectionStatusBadgeProps { +interface ConnectionStatusBadgeProps { status: HubConnectionState; className?: string; - style?: React.CSSProperties; } const classNameMap: Record<HubConnectionState, string> = { @@ -19,23 +18,19 @@ const classNameMap: Record<HubConnectionState, string> = { Reconnecting: "warning", }; -const ConnectionStatusBadge: React.FC<ConnectionStatusBadgeProps> = (props) => { - const { status, className, style } = props; - - const { t } = useTranslation(); +export default function ConnectionStatusBadge({status, className}: ConnectionStatusBadgeProps) { + const c = useC(); return ( <div - className={classnames( + className={classNames( "connection-status-badge", classNameMap[status], className )} - style={style} > - {t(`connectionState.${status}`)} + {c(`connectionState.${status}`)} </div> ); }; -export default ConnectionStatusBadge; 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/views/timeline/Timeline.tsx b/FrontEnd/src/pages/timeline/Timeline.tsx index 3a7fbd00..32cbf8c8 100644 --- a/FrontEnd/src/views/timeline/Timeline.tsx +++ b/FrontEnd/src/pages/timeline/Timeline.tsx @@ -1,69 +1,63 @@ -import * as React from "react"; -import classnames from "classnames"; -import { useScrollToBottom } from "@/utilities/hooks"; +import { useState, useEffect } from "react"; +import classNames from "classnames"; import { HubConnectionState } from "@microsoft/signalr"; import { HttpForbiddenError, HttpNetworkError, HttpNotFoundError, -} from "@/http/common"; +} from "~src/http/common"; import { getHttpTimelineClient, HttpTimelineInfo, HttpTimelinePostInfo, -} from "@/http/timeline"; +} from "~src/http/timeline"; -import { useUser } from "@/services/user"; -import { getTimelinePostUpdate$ } from "@/services/timeline"; +import { getTimelinePostUpdate$ } from "~src/services/timeline"; -import TimelinePostListView from "./TimelinePostListView"; -import TimelineEmptyItem from "./TimelineEmptyItem"; -import TimelineLoading from "./TimelineLoading"; -import TimelinePostEdit from "./TimelinePostEdit"; -import TimelinePostEditNoLogin from "./TimelinePostEditNoLogin"; -import TimelineCard from "./TimelineCard"; +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; - style?: React.CSSProperties; timelineOwner: string; timelineName: string; } -const Timeline: React.FC<TimelineProps> = (props) => { - const { timelineOwner, timelineName, className, style } = props; - - const user = useUser(); +export function Timeline(props: TimelineProps) { + const { timelineOwner, timelineName, className } = props; - const [timeline, setTimeline] = React.useState<HttpTimelineInfo | null>(null); - const [posts, setPosts] = React.useState<HttpTimelinePostInfo[] | null>(null); - const [signalrState, setSignalrState] = React.useState<HubConnectionState>( - HubConnectionState.Connecting + const [timeline, setTimeline] = useState<HttpTimelineInfo | null>(null); + const [posts, setPosts] = useState<HttpTimelinePostInfo[] | null>(null); + const [signalrState, setSignalrState] = useState<HubConnectionState>( + HubConnectionState.Connecting, ); - const [error, setError] = React.useState< + const [error, setError] = useState< "offline" | "forbid" | "notfound" | "error" | null >(null); - const [currentPage, setCurrentPage] = React.useState(1); - const [totalPage, setTotalPage] = React.useState(0); + const [currentPage, setCurrentPage] = useState(1); + const [totalPage, setTotalPage] = useState(0); - const [timelineReloadKey, setTimelineReloadKey] = React.useState(0); - const [postsReloadKey, setPostsReloadKey] = React.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); - React.useEffect(() => { + useEffect(() => { setTimeline(null); setPosts(null); setError(null); setSignalrState(HubConnectionState.Connecting); }, [timelineOwner, timelineName]); - React.useEffect(() => { + useEffect(() => { getHttpTimelineClient() .getTimeline(timelineOwner, timelineName) .then( @@ -81,17 +75,17 @@ const Timeline: React.FC<TimelineProps> = (props) => { console.error(error); setError("error"); } - } + }, ); }, [timelineOwner, timelineName, timelineReloadKey]); - React.useEffect(() => { + useEffect(() => { getHttpTimelineClient() .listPost(timelineOwner, timelineName, 1) .then( (page) => { setPosts( - page.items.filter((p): p is HttpTimelinePostInfo => !p.deleted) + page.items.filter((p): p is HttpTimelinePostInfo => !p.deleted), ); setTotalPage(page.totalPageCount); }, @@ -106,14 +100,14 @@ const Timeline: React.FC<TimelineProps> = (props) => { console.error(error); setError("error"); } - } + }, ); }, [timelineOwner, timelineName, postsReloadKey]); - React.useEffect(() => { + useEffect(() => { const timelinePostUpdate$ = getTimelinePostUpdate$( timelineOwner, - timelineName + timelineName, ); const subscription = timelinePostUpdate$.subscribe(({ update, state }) => { if (update) { @@ -134,7 +128,7 @@ const Timeline: React.FC<TimelineProps> = (props) => { .then( (page) => { const ps = page.items.filter( - (p): p is HttpTimelinePostInfo => !p.deleted + (p): p is HttpTimelinePostInfo => !p.deleted, ); setPosts((old) => [...(old ?? []), ...ps]); }, @@ -149,59 +143,38 @@ const Timeline: React.FC<TimelineProps> = (props) => { console.error(error); setError("error"); } - } + }, ); }, currentPage < totalPage); if (error === "offline") { - return ( - <div className={className} style={style}> - Offline. - </div> - ); + return <div className={className}>Offline.</div>; } else if (error === "notfound") { - return ( - <div className={className} style={style}> - Not exist. - </div> - ); + return <div className={className}>Not exist.</div>; } else if (error === "forbid") { - return ( - <div className={className} style={style}> - Forbid. - </div> - ); + return <div className={className}>Forbid.</div>; } else if (error === "error") { - return ( - <div className={className} style={style}> - Error. - </div> - ); + return <div className={className}>Error.</div>; } return ( - <> - {timeline == null && posts == null && <TimelineLoading />} + <div className="timeline-container"> {timeline && ( - <TimelineCard - className="timeline-card" + <TimelineInfoCard timeline={timeline} connectionStatus={signalrState} onReload={updateTimeline} /> )} {posts && ( - <div style={style} className={classnames("timeline", className)}> - <TimelineEmptyItem className="timeline-top" height={50} /> - {timeline?.postable ? ( + <div className={classNames("timeline", className)}> + {timeline?.postable && ( <TimelinePostEdit timeline={timeline} onPosted={updatePosts} /> - ) : user == null ? ( - <TimelinePostEditNoLogin /> - ) : null} - <TimelinePostListView posts={posts} onReload={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/views/timeline/TimelineMember.tsx b/FrontEnd/src/pages/timeline/TimelineMember.tsx index aaafd173..0812016f 100644 --- a/FrontEnd/src/views/timeline/TimelineMember.tsx +++ b/FrontEnd/src/pages/timeline/TimelineMember.tsx @@ -1,55 +1,59 @@ import { useState } from "react"; -import * as React from "react"; import { useTranslation } from "react-i18next"; -import { convertI18nText, I18nText } from "@/common"; +import { convertI18nText, I18nText } from "~src/common"; -import { HttpUser } from "@/http/user"; -import { getHttpSearchClient } from "@/http/search"; -import { getHttpTimelineClient, HttpTimelineInfo } from "@/http/timeline"; +import { HttpUser } from "~src/http/user"; +import { getHttpSearchClient } from "~src/http/search"; +import { getHttpTimelineClient, HttpTimelineInfo } from "~src/http/timeline"; -import SearchInput from "../common/SearchInput"; -import UserAvatar from "../common/user/UserAvatar"; -import Button from "../common/button/Button"; -import Dialog from "../common/dialog/Dialog"; +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"; -const TimelineMemberItem: React.FC<{ +function TimelineMemberItem({ + user, + add, + onAction, +}: { user: HttpUser; add?: boolean; onAction?: (username: string) => void; -}> = ({ user, add, onAction }) => { +}) { return ( - <div className="container timeline-member-item"> - <div className="row"> - <div className="col col-auto"> - <UserAvatar username={user.username} className="cru-avatar small" /> - </div> - <div className="col"> - <div className="row">{user.nickname}</div> - <small className="row">{"@" + user.username}</small> - </div> - {onAction ? ( - <div className="col col-auto"> - <Button - text={`timeline.member.${add ? "add" : "remove"}`} - color={add ? "success" : "danger"} - onClick={() => { - onAction(user.username); - }} - /> - </div> - ) : null} + <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> - </div> + {onAction ? ( + <div className="timeline-member-action"> + <IconButton + icon={add ? "plus-lg" : "trash"} + color={add ? "create" : "danger"} + onClick={() => { + onAction(user.username); + }} + /> + </div> + ) : null} + </ListItemContainer> ); -}; +} -const TimelineMemberUserSearch: React.FC<{ +function TimelineMemberUserSearch({ + timeline, + onChange, +}: { timeline: HttpTimelineInfo; onChange: () => void; -}> = ({ timeline, onChange }) => { +}) { const { t } = useTranslation(); const [userSearchText, setUserSearchText] = useState<string>(""); @@ -64,9 +68,9 @@ const TimelineMemberUserSearch: React.FC<{ >({ type: "init" }); return ( - <> + <div className="timeline-member-user-search"> <SearchInput - className="mt-3" + className="" value={userSearchText} onChange={(v) => { setUserSearchText(v); @@ -88,8 +92,8 @@ const TimelineMemberUserSearch: React.FC<{ users = users.filter( (user) => timeline.members.findIndex( - (m) => m.username === user.username - ) === -1 && timeline.owner.username !== user.username + (m) => m.username === user.username, + ) === -1 && timeline.owner.username !== user.username, ); setUserSearchState({ type: "users", data: users }); }, @@ -98,7 +102,7 @@ const TimelineMemberUserSearch: React.FC<{ type: "error", data: { type: "custom", value: String(e) }, }); - } + }, ); }} /> @@ -109,7 +113,7 @@ const TimelineMemberUserSearch: React.FC<{ return <div>{t("timeline.member.noUserAvailableToAdd")}</div>; } else { return ( - <div className="mt-2"> + <div className=""> {users.map((user) => ( <TimelineMemberItem key={user.username} @@ -120,7 +124,7 @@ const TimelineMemberUserSearch: React.FC<{ .memberPut( timeline.owner.username, timeline.nameV2, - user.username + user.username, ) .then(() => { setUserSearchText(""); @@ -141,22 +145,22 @@ const TimelineMemberUserSearch: React.FC<{ ); } })()} - </> + </div> ); -}; +} -export interface TimelineMemberProps { +interface TimelineMemberProps { timeline: HttpTimelineInfo; onChange: () => void; } -const TimelineMember: React.FC<TimelineMemberProps> = (props) => { +export default function TimelineMember(props: TimelineMemberProps) { const { timeline, onChange } = props; const members = [timeline.owner, ...timeline.members]; return ( <div className="container px-4 py-3"> - <div> + <ListContainer> {members.map((member, index) => ( <TimelineMemberItem key={member.username} @@ -168,7 +172,7 @@ const TimelineMember: React.FC<TimelineMemberProps> = (props) => { .memberDelete( timeline.owner.username, timeline.nameV2, - member.username + member.username, ) .then(onChange); } @@ -176,27 +180,10 @@ const TimelineMember: React.FC<TimelineMemberProps> = (props) => { } /> ))} - </div> + </ListContainer> {timeline.manageable ? ( <TimelineMemberUserSearch timeline={timeline} onChange={onChange} /> ) : null} </div> ); -}; - -export default TimelineMember; - -export interface TimelineMemberDialogProps extends TimelineMemberProps { - open: boolean; - onClose: () => void; } - -export const TimelineMemberDialog: React.FC<TimelineMemberDialogProps> = ( - props -) => { - return ( - <Dialog open={props.open} onClose={props.onClose}> - <TimelineMember {...props} /> - </Dialog> - ); -}; 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/views/timeline/TimelinePostListView.tsx b/FrontEnd/src/pages/timeline/TimelinePostList.tsx index f878b004..66262ccd 100644 --- a/FrontEnd/src/views/timeline/TimelinePostListView.tsx +++ b/FrontEnd/src/pages/timeline/TimelinePostList.tsx @@ -1,11 +1,12 @@ -import { Fragment } from "react"; -import * as React from "react"; +import { useMemo, Fragment } from "react"; -import { HttpTimelinePostInfo } from "@/http/timeline"; +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() && @@ -14,15 +15,15 @@ function dateEqual(left: Date, right: Date): boolean { ); } -export interface TimelinePostListViewProps { +interface TimelinePostListViewProps { posts: HttpTimelinePostInfo[]; onReload: () => void; } -const TimelinePostListView: React.FC<TimelinePostListViewProps> = (props) => { +export default function TimelinePostList(props: TimelinePostListViewProps) { const { posts, onReload } = props; - const groupedPosts = React.useMemo< + const groupedPosts = useMemo< { date: Date; posts: (HttpTimelinePostInfo & { index: number })[]; @@ -51,7 +52,7 @@ const TimelinePostListView: React.FC<TimelinePostListViewProps> = (props) => { }, [posts]); return ( - <> + <div> {groupedPosts.map((group) => { return ( <Fragment key={group.date.toDateString()}> @@ -69,8 +70,6 @@ const TimelinePostListView: React.FC<TimelinePostListViewProps> = (props) => { </Fragment> ); })} - </> + </div> ); -}; - -export default TimelinePostListView; +} 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/views/timeline/index.tsx b/FrontEnd/src/pages/timeline/index.tsx index 1dffdcc1..6cd1ded0 100644 --- a/FrontEnd/src/views/timeline/index.tsx +++ b/FrontEnd/src/pages/timeline/index.tsx @@ -1,11 +1,10 @@ -import * as React from "react"; import { useParams } from "react-router-dom"; -import { UiLogicError } from "@/common"; +import { UiLogicError } from "~src/common"; import Timeline from "./Timeline"; -const TimelinePage: React.FC = () => { +export default function TimelinePage() { const { owner, timeline: timelineNameParam } = useParams(); if (owner == null || owner == "") @@ -13,11 +12,5 @@ const TimelinePage: React.FC = () => { const timeline = timelineNameParam || "self"; - return ( - <div className="container"> - <Timeline timelineOwner={owner} timelineName={timeline} /> - </div> - ); + return <Timeline timelineOwner={owner} timelineName={timeline} />; }; - -export default TimelinePage; 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>; +} diff --git a/FrontEnd/src/palette.ts b/FrontEnd/src/palette.ts deleted file mode 100644 index d06f9b19..00000000 --- a/FrontEnd/src/palette.ts +++ /dev/null @@ -1,167 +0,0 @@ -import Color from "color"; -import { BehaviorSubject, Observable } from "rxjs"; - -import refreshAnimation from "./utilities/refreshAnimation"; - -function lightenBy(color: Color, ratio: number): Color { - const lightness = color.lightness(); - return color.lightness(lightness + (100 - lightness) * ratio); -} - -function darkenBy(color: Color, ratio: number): Color { - const lightness = color.lightness(); - return color.lightness(lightness - lightness * ratio); -} - -export interface PaletteColor { - color: string; - l1: string; - l2: string; - l3: string; - d1: string; - d2: string; - d3: string; - f1: string; - f2: string; - f3: string; - r1: string; - r2: string; - r3: string; - t: string; - t1: string; - t2: string; - t3: string; - [key: string]: string; -} - -const paletteColorList = [ - "primary", - "primary-enhance", - "secondary", - "danger", - "success", -] as const; - -export type PaletteColorType = (typeof paletteColorList)[number]; - -export type Palette = Record<PaletteColorType, PaletteColor>; - -export function generatePaletteColor(color: string): PaletteColor { - const c = Color(color); - const light = c.lightness() > 60; - const l1 = lightenBy(c, 0.1).rgb().toString(); - const l2 = lightenBy(c, 0.2).rgb().toString(); - const l3 = lightenBy(c, 0.3).rgb().toString(); - const d1 = darkenBy(c, 0.1).rgb().toString(); - const d2 = darkenBy(c, 0.2).rgb().toString(); - const d3 = darkenBy(c, 0.3).rgb().toString(); - const f1 = light ? l1 : d1; - const f2 = light ? l2 : d2; - const f3 = light ? l3 : d3; - const r1 = light ? d1 : l1; - const r2 = light ? d2 : l2; - const r3 = light ? d3 : l3; - const _t = light ? Color("black") : Color("white"); - const t = _t.rgb().toString(); - const _b = light ? lightenBy : darkenBy; - const t1 = _b(_t, 0.1).rgb().toString(); - const t2 = _b(_t, 0.2).rgb().toString(); - const t3 = _b(_t, 0.3).rgb().toString(); - - return { - color: c.rgb().toString(), - l1, - l2, - l3, - d1, - d2, - d3, - f1, - f2, - f3, - r1, - r2, - r3, - t, - t1, - t2, - t3, - }; -} - -export function generatePalette(options: { - primary: string; - primaryEnhance?: string; - secondary?: string; -}): Palette { - const { primary, primaryEnhance, secondary } = options; - const p = Color(primary); - const pe = - primaryEnhance == null - ? lightenBy(p, 0.3).saturate(0.3) - : Color(primaryEnhance); - const s = secondary == null ? Color("gray") : Color(secondary); - - return { - primary: generatePaletteColor(p.toString()), - "primary-enhance": generatePaletteColor(pe.toString()), - secondary: generatePaletteColor(s.toString()), - danger: generatePaletteColor("red"), - success: generatePaletteColor("green"), - }; -} - -export function generatePaletteCSS(palette: Palette): string { - const colors: [string, string][] = []; - for (const colorType of paletteColorList) { - const paletteColor = palette[colorType]; - for (const variant in paletteColor) { - let key = `--cru-${colorType}`; - if (variant !== "color") key += `-${variant}`; - key += "-color"; - colors.push([key, paletteColor[variant]]); - } - } - - return `:root {${colors - .map(([key, color]) => `${key} : ${color};`) - .join("")}}`; -} - -const paletteSubject: BehaviorSubject<Palette | null> = - new BehaviorSubject<Palette | null>( - generatePalette({ primary: "rgb(0, 123, 255)" }) - ); - -export const palette$: Observable<Palette | null> = - paletteSubject.asObservable(); - -palette$.subscribe((palette) => { - const styleTagId = "timeline-palette-css"; - if (palette != null) { - let styleTag = document.getElementById(styleTagId); - if (styleTag == null) { - styleTag = document.createElement("style"); - styleTag.id = styleTagId; - document.head.append(styleTag); - } - styleTag.innerHTML = generatePaletteCSS(palette); - } else { - const styleTag = document.getElementById(styleTagId); - if (styleTag != null) { - styleTag.parentElement?.removeChild(styleTag); - } - } - - refreshAnimation(); -}); - -export function setPalette(palette: Palette): () => void { - const old = paletteSubject.value; - - paletteSubject.next(palette); - - return () => { - paletteSubject.next(old); - }; -} diff --git a/FrontEnd/src/services/TimelinePostBuilder.ts b/FrontEnd/src/services/TimelinePostBuilder.ts deleted file mode 100644 index 83d63abe..00000000 --- a/FrontEnd/src/services/TimelinePostBuilder.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { marked } from "marked"; - -import { UiLogicError } from "@/common"; - -import base64 from "@/utilities/base64"; - -import { HttpTimelinePostPostRequest } from "@/http/timeline"; - -class TimelinePostMarkedRenderer extends marked.Renderer { - constructor(private _images: { file: File; url: string }[]) { - super(); - } - - image(href: string | null, title: string | null, text: string): string { - if (href != null) { - const i = parseInt(href); - if (!isNaN(i) && i > 0 && i <= this._images.length) { - href = this._images[i - 1].url; - } - } - return this.image(href, title, text); - } -} - -export default class TimelinePostBuilder { - private _onChange: () => void; - private _text = ""; - private _images: { file: File; url: string }[] = []; - private _markedOptions: marked.MarkedOptions; - - constructor(onChange: () => void) { - this._onChange = onChange; - this._markedOptions = { - renderer: new TimelinePostMarkedRenderer(this._images), - }; - } - - setMarkdownText(text: string): void { - this._text = text; - this._onChange(); - } - - appendImage(file: File): void { - this._images = this._images.slice(); - this._images.push({ - file, - url: URL.createObjectURL(file), - }); - this._onChange(); - } - - moveImage(oldIndex: number, newIndex: number): void { - if (oldIndex < 0 || oldIndex >= this._images.length) { - throw new UiLogicError("Old index out of range."); - } - - if (newIndex < 0) { - newIndex = 0; - } - - if (newIndex >= this._images.length) { - newIndex = this._images.length - 1; - } - - this._images = this._images.slice(); - - const [old] = this._images.splice(oldIndex, 1); - this._images.splice(newIndex, 0, old); - - this._onChange(); - } - - deleteImage(index: number): void { - if (index < 0 || index >= this._images.length) { - throw new UiLogicError("Old index out of range."); - } - - this._images = this._images.slice(); - - URL.revokeObjectURL(this._images[index].url); - this._images.splice(index, 1); - - this._onChange(); - } - - get text(): string { - return this._text; - } - - get images(): { file: File; url: string }[] { - return this._images; - } - - get isEmpty(): boolean { - return this._text.length === 0 && this._images.length === 0; - } - - renderHtml(): string { - return marked.parse(this._text); - } - - dispose(): void { - for (const image of this._images) { - URL.revokeObjectURL(image.url); - } - this._images = []; - } - - async build(): Promise<HttpTimelinePostPostRequest["dataList"]> { - return [ - { - contentType: "text/markdown", - data: await base64(this._text), - }, - ...(await Promise.all( - this._images.map((image) => - base64(image.file).then((data) => ({ - contentType: image.file.type, - data, - })), - ), - )), - ]; - } -} diff --git a/FrontEnd/src/services/alert.ts b/FrontEnd/src/services/alert.ts index 42b14451..0fa37848 100644 --- a/FrontEnd/src/services/alert.ts +++ b/FrontEnd/src/services/alert.ts @@ -1,10 +1,10 @@ import pull from "lodash/pull"; -import { I18nText } from "@/common"; -import { PaletteColorType } from "@/palette"; +import { I18nText } from "~src/common"; +import { ThemeColor } from "~src/components/common"; export interface AlertInfo { - type?: PaletteColorType; + type?: ThemeColor; message?: I18nText; customMessage?: React.ReactElement; dismissTime?: number | "never"; diff --git a/FrontEnd/src/services/timeline.ts b/FrontEnd/src/services/timeline.ts index 707c956f..41a7bff0 100644 --- a/FrontEnd/src/services/timeline.ts +++ b/FrontEnd/src/services/timeline.ts @@ -1,9 +1,22 @@ -import { TimelineVisibility } from "@/http/timeline"; import XRegExp from "xregexp"; -import { Observable } from "rxjs"; -import { HubConnectionBuilder, HubConnectionState } from "@microsoft/signalr"; - -import { getHttpToken } from "@/http/common"; +import { + Observable, + BehaviorSubject, + switchMap, + filter, + first, + distinctUntilChanged, +} from "rxjs"; +import { + HubConnection, + HubConnectionBuilder, + HubConnectionState, +} from "@microsoft/signalr"; + +import { TimelineVisibility } from "~src/http/timeline"; +import { token$ } from "~src/http/common"; + +// cSpell:ignore onreconnected onreconnecting const timelineNameReg = XRegExp("^[-_\\p{L}]*$", "u"); @@ -20,17 +33,23 @@ export const timelineVisibilityTooltipTranslationMap: Record< Private: "timeline.visibilityTooltip.private", }; -export function getTimelinePostUpdate$( - owner: string, - timeline: string, -): Observable<{ update: boolean; state: HubConnectionState }> { - return new Observable((subscriber) => { - subscriber.next({ - update: false, - state: HubConnectionState.Connecting, - }); +type ConnectionState = + | "Connecting" + | "Reconnecting" + | "Disconnected" + | "Connected"; + +type Connection = { + connection: HubConnection; + state$: Observable<ConnectionState>; +}; + +function createConnection$(token: string | null): Observable<Connection> { + return new Observable<Connection>((subscriber) => { + const connectionStateSubject = new BehaviorSubject<ConnectionState>( + "Connecting", + ); - const token = getHttpToken(); const connection = new HubConnectionBuilder() .withUrl("/api/hub/timeline", { accessTokenFactory: token == null ? undefined : () => token, @@ -38,56 +57,138 @@ export function getTimelinePostUpdate$( .withAutomaticReconnect() .build(); - const o = owner; - const t = timeline; + connection.onclose = () => { + connectionStateSubject.next("Disconnected"); + }; + + connection.onreconnecting = () => { + connectionStateSubject.next("Reconnecting"); + }; + + connection.onreconnected = () => { + connectionStateSubject.next("Connected"); + }; + + let requestStopped = false; + + void connection.start().then( + () => { + connectionStateSubject.next("Connected"); + }, + (e) => { + if (!requestStopped) { + throw e; + } + }, + ); + + subscriber.next({ + connection, + state$: connectionStateSubject.asObservable(), + }); + + return () => { + void connection.stop(); + requestStopped = true; + }; + }); +} + +const connectionSubject = new BehaviorSubject<Connection | null>(null); + +token$ + .pipe(distinctUntilChanged(), switchMap(createConnection$)) + .subscribe(connectionSubject); + +const connection$ = connectionSubject + .asObservable() + .pipe(filter((c): c is Connection => c != null)); + +function createTimelinePostUpdateCount$( + connection: Connection, + owner: string, + timeline: string, +): Observable<number> { + const [o, t] = [owner, timeline]; + return new Observable<number>((subscriber) => { + const hubConnection = connection.connection; + let count = 0; const handler = (owner: string, timeline: string): void => { if (owner === o && timeline === t) { - subscriber.next({ update: true, state: connection.state }); + subscriber.next(count++); } }; - connection.onclose(() => { - subscriber.next({ - update: false, - state: HubConnectionState.Disconnected, + let hubOn = false; + + const subscription = connection.state$ + .pipe(first((state) => state === "Connected")) + .subscribe(() => { + hubConnection.on("OnTimelinePostChangedV2", handler); + void hubConnection.invoke( + "SubscribeTimelinePostChangeV2", + owner, + timeline, + ); + hubOn = true; }); - }); - connection.onreconnecting(() => { - subscriber.next({ - update: false, - state: HubConnectionState.Reconnecting, - }); - }); + return () => { + if (hubOn) { + void hubConnection.invoke( + "UnsubscribeTimelinePostChangeV2", + owner, + timeline, + ); + hubConnection.off("OnTimelinePostChangedV2", handler); + } + + subscription.unsubscribe(); + }; + }); +} + +type OldUpdateInfo = { update: boolean; state: HubConnectionState }; - connection.onreconnected(() => { +function createTimelinePostOldUpdateInfo$( + connection: Connection, + owner: string, + timeline: string, +): Observable<OldUpdateInfo> { + return new Observable<OldUpdateInfo>((subscriber) => { + let savedState: ConnectionState = "Connecting"; + + const postUpdateSubscription = createTimelinePostUpdateCount$( + connection, + owner, + timeline, + ).subscribe(() => { subscriber.next({ - update: false, - state: HubConnectionState.Connected, + update: true, + state: savedState as HubConnectionState, }); }); - connection.on("OnTimelinePostChangedV2", handler); - - void connection.start().then(() => { - subscriber.next({ update: false, state: HubConnectionState.Connected }); - - return connection.invoke( - "SubscribeTimelinePostChangeV2", - owner, - timeline, - ); + const stateSubscription = connection.state$.subscribe((state) => { + savedState = state; + subscriber.next({ update: false, state: state as HubConnectionState }); }); return () => { - connection.off("OnTimelinePostChangedV2", handler); - - if (connection.state === HubConnectionState.Connected) { - void connection - .invoke("UnsubscribeTimelinePostChangeV2", owner, timeline) - .then(() => connection.stop()); - } + stateSubscription.unsubscribe(); + postUpdateSubscription.unsubscribe(); }; }); } + +export function getTimelinePostUpdate$( + owner: string, + timeline: string, +): Observable<OldUpdateInfo> { + return connection$.pipe( + switchMap((connection) => + createTimelinePostOldUpdateInfo$(connection, owner, timeline), + ), + ); +} diff --git a/FrontEnd/src/services/user.ts b/FrontEnd/src/services/user.ts index c89ca893..5f682a36 100644 --- a/FrontEnd/src/services/user.ts +++ b/FrontEnd/src/services/user.ts @@ -2,20 +2,23 @@ import { useState, useEffect } from "react"; import { BehaviorSubject, Observable } from "rxjs"; import { AxiosError } from "axios"; -import { UiLogicError } from "@/common"; +import { UiLogicError } from "~src/common"; -import { setHttpToken, axios, HttpBadRequestError } from "@/http/common"; -import { getHttpTokenClient } from "@/http/token"; -import { getHttpUserClient, HttpUser, UserPermission } from "@/http/user"; +import { setHttpToken, axios, HttpBadRequestError } from "~src/http/common"; +import { getHttpTokenClient } from "~src/http/token"; +import { getHttpUserClient, HttpUser, UserPermission } from "~src/http/user"; -import { pushAlert } from "./alert"; +import { pushAlert } from "~src/components/alert"; interface IAuthUser extends HttpUser { token: string; } export class AuthUser implements IAuthUser { - constructor(user: HttpUser, public token: string) { + constructor( + user: HttpUser, + public token: string, + ) { this.uniqueId = user.uniqueId; this.username = user.username; this.permissions = user.permissions; @@ -61,7 +64,7 @@ export class UserService { if (e.isAxiosError && e.response && e.response.status === 401) { this.userSubject.next(null); pushAlert({ - type: "danger", + color: "danger", message: "user.tokenInvalid", }); } else { @@ -97,11 +100,11 @@ export class UserService { localStorage.removeItem(USER_STORAGE_KEY); this.userSubject.next(null); pushAlert({ - type: "danger", + color: "danger", message: "user.tokenInvalid", }); } - } + }, ); } } @@ -118,7 +121,7 @@ export class UserService { async login( credentials: LoginCredentials, - rememberMe: boolean + rememberMe: boolean, ): Promise<void> { if (this.currentUser) { throw new UiLogicError("Already login."); diff --git a/FrontEnd/src/utilities/array.ts b/FrontEnd/src/utilities/array.ts new file mode 100644 index 00000000..838e8744 --- /dev/null +++ b/FrontEnd/src/utilities/array.ts @@ -0,0 +1,41 @@ +export function copy_move<T>( + array: T[], + oldIndex: number, + newIndex: number, +): T[] { + if (oldIndex < 0 || oldIndex >= array.length) { + throw new Error("Old index out of range."); + } + + if (newIndex < 0) { + newIndex = 0; + } + + if (newIndex >= array.length) { + newIndex = array.length - 1; + } + + const result = array.slice(); + const [element] = result.splice(oldIndex, 1); + result.splice(newIndex, 0, element); + + return result; +} + +export function copy_insert<T>(array: T[], index: number, element: T): T[] { + const result = array.slice(); + result.splice(index, 0, element); + return result; +} + +export function copy_push<T>(array: T[], element: T): T[] { + const result = array.slice(); + result.push(element); + return result; +} + +export function copy_delete<T>(array: T[], index: number): T[] { + const result = array.slice(); + result.splice(index, 1); + return array; +} diff --git a/FrontEnd/src/utilities/base64.ts b/FrontEnd/src/utilities/base64.ts index 59de7512..6eece979 100644 --- a/FrontEnd/src/utilities/base64.ts +++ b/FrontEnd/src/utilities/base64.ts @@ -1,8 +1,19 @@ -import { Base64 } from "js-base64"; +function bytesToBase64(bytes: Uint8Array): string { + const binString = Array.from(bytes, (x) => String.fromCodePoint(x)).join(""); + return btoa(binString); +} + +export default function base64( + data: Blob | Uint8Array | string, +): Promise<string> { + if (typeof data === "string") { + // From https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem + const binString = new TextEncoder().encode(data); + return Promise.resolve(bytesToBase64(binString)); + } -export default function base64(blob: Blob | string): Promise<string> { - if (typeof blob === "string") { - return Promise.resolve(Base64.encode(blob)); + if (data instanceof Uint8Array) { + return Promise.resolve(bytesToBase64(data)); } return new Promise<string>((resolve) => { @@ -10,6 +21,6 @@ export default function base64(blob: Blob | string): Promise<string> { reader.onload = function () { resolve((reader.result as string).replace(/^data:.*;base64,/, "")); }; - reader.readAsDataURL(blob); + reader.readAsDataURL(data); }); } diff --git a/FrontEnd/src/utilities/geometry.ts b/FrontEnd/src/utilities/geometry.ts new file mode 100644 index 00000000..60a8d3d4 --- /dev/null +++ b/FrontEnd/src/utilities/geometry.ts @@ -0,0 +1,292 @@ +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + +export interface Point { + x: number; + y: number; +} + +export type Movement = Point; + +export interface Size { + width: number; + height: number; +} + +export class Rect { + static empty = new Rect(0, 0, 0, 0); + static max = new Rect( + Number.MIN_VALUE, + Number.MIN_VALUE, + Number.MAX_VALUE, + Number.MAX_VALUE, + ); + + static from({ + left, + top, + width, + height, + }: { + left: number; + top: number; + width: number; + height: number; + }): Rect { + return new Rect(left, top, width, height); + } + + constructor( + public left: number, + public top: number, + public width: number, + public height: number, + ) {} + + get right(): number { + return this.left + this.width; + } + + set right(value: number) { + this.width = value - this.left; + } + + get bottom(): number { + return this.top + this.height; + } + + set bottom(value: number) { + this.height = value - this.top; + } + + get ratio(): number { + return this.height / this.width; + } + + get position(): Point { + return { + x: this.left, + y: this.top, + }; + } + + set position(value: Point) { + this.left = value.x; + this.top = value.y; + } + + get size(): Size { + return { + width: this.width, + height: this.height, + }; + } + + set size(value: Size) { + this.width = value.width; + this.height = value.height; + } + + get normalizedLeft(): number { + return this.width >= 0 ? this.left : this.right; + } + + get normalizedTop(): number { + return this.height >= 0 ? this.top : this.bottom; + } + + get normalizedRight(): number { + return this.width >= 0 ? this.right : this.left; + } + + get normalizedBottom(): number { + return this.height >= 0 ? this.bottom : this.top; + } + + get normalizedWidth(): number { + return Math.abs(this.width); + } + + get normalizedHeight(): number { + return Math.abs(this.height); + } + + get normalizedSize(): Size { + return { + width: this.normalizedWidth, + height: this.normalizedHeight, + }; + } + + get normalizedRatio(): number { + return Math.abs(this.ratio); + } + + normalize(): Rect { + if (this.width < 0) { + this.width = -this.width; + this.left -= this.width; + } + if (this.height < 0) { + this.height = -this.height; + this.top -= this.height; + } + return this; + } + + move(movement: Movement): Rect { + this.left += movement.x; + this.top += movement.y; + return this; + } + + expand(size: Size | Point): Rect { + if ("x" in size) { + this.width += size.x; + this.height += size.y; + } else { + this.width += size.width; + this.height += size.height; + } + return this; + } + + copy(): Rect { + return new Rect(this.left, this.top, this.width, this.height); + } +} + +export function adjustRectToContainer( + rect: Rect, + container: Rect, + mode: "move" | "resize" | "both", + options?: { + targetRatio?: number; + resizeNoFlip?: boolean; + ratioCorrectBasedOn?: "bigger" | "smaller" | "width" | "height"; + }, +): Rect { + rect = rect.copy(); + container = container.copy().normalize(); + + if (process.env.NODE_ENV === "development") { + if (mode === "move") { + if (rect.normalizedWidth > container.width) { + console.warn( + "adjust rect (move): rect.normalizedWidth > container.normalizedWidth", + ); + } + if (rect.normalizedHeight > container.height) { + console.warn( + "adjust rect (move): rect.normalizedHeight > container.normalizedHeight", + ); + } + } + if (mode === "resize") { + if (rect.left < container.left) { + console.warn( + "adjust rect (resize): rect.left < container.normalizedLeft", + ); + } + if (rect.left > container.right) { + console.warn( + "adjust rect (resize): rect.left > container.normalizedRight", + ); + } + if (rect.top < container.top) { + console.warn( + "adjust rect (resize): rect.top < container.normalizedTop", + ); + } + if (rect.top > container.bottom) { + console.warn( + "adjust rect (resize): rect.top > container.normalizedBottom", + ); + } + } + } + + if (mode === "move") { + rect.left = + rect.width >= 0 + ? clamp(rect.left, container.left, container.right - rect.width) + : clamp(rect.left, container.left - rect.width, container.right); + rect.top = + rect.height >= 0 + ? clamp(rect.top, container.top, container.bottom - rect.height) + : clamp(rect.top, container.top - rect.height, container.bottom); + } else if (mode === "resize") { + const noFlip = options?.resizeNoFlip; + const newRight = clamp( + rect.right, + rect.width > 0 && noFlip ? rect.left : container.left, + rect.width < 0 && noFlip ? rect.left : container.right, + ); + rect.right = newRight; + rect.bottom = clamp( + rect.bottom, + rect.height > 0 && noFlip ? rect.top : container.top, + rect.height < 0 && noFlip ? rect.top : container.bottom, + ); + } else { + rect.left = clamp(rect.left, container.left, container.right); + rect.top = clamp(rect.top, container.top, container.bottom); + rect.right = clamp(rect.right, container.left, container.right); + rect.bottom = clamp(rect.bottom, container.top, container.bottom); + } + + // Now correct ratio + const currentRatio = rect.normalizedRatio; + let targetRatio = options?.targetRatio; + if (targetRatio != null) targetRatio = Math.abs(targetRatio); + if (targetRatio != null && currentRatio !== targetRatio) { + const { ratioCorrectBasedOn } = options ?? {}; + + const newWidth = + (Math.sign(rect.width) * rect.normalizedHeight) / targetRatio; + const newHeight = + Math.sign(rect.height) * rect.normalizedWidth * targetRatio; + + const newBottom = rect.top + newHeight; + const newRight = rect.left + newWidth; + + if (ratioCorrectBasedOn === "width") { + if (newBottom >= container.top && newBottom <= container.bottom) { + rect.height = newHeight; + } else { + rect.bottom = clamp(newBottom, container.top, container.bottom); + rect.width = + (Math.sign(rect.width) * rect.normalizedHeight) / targetRatio; + } + } else if (ratioCorrectBasedOn === "height") { + if (newRight >= container.left && newRight <= container.right) { + rect.width = newWidth; + } else { + rect.right = clamp(newRight, container.left, container.right); + rect.height = + Math.sign(rect.height) * rect.normalizedWidth * targetRatio; + } + } else if (ratioCorrectBasedOn === "smaller") { + if (currentRatio > targetRatio) { + // too tall + rect.width = + (Math.sign(rect.width) * rect.normalizedHeight) / targetRatio; + } else { + rect.height = + Math.sign(rect.height) * rect.normalizedWidth * targetRatio; + } + } else { + if (currentRatio < targetRatio) { + // too wide + rect.width = + (Math.sign(rect.width) * rect.normalizedHeight) / targetRatio; + } else { + rect.height = + Math.sign(rect.height) * rect.normalizedWidth * targetRatio; + } + } + } + + return rect; +} diff --git a/FrontEnd/src/utilities/hooks.ts b/FrontEnd/src/utilities/hooks.ts deleted file mode 100644 index a59f7167..00000000 --- a/FrontEnd/src/utilities/hooks.ts +++ /dev/null @@ -1,5 +0,0 @@ -import useClickOutside from "./hooks/useClickOutside"; -import useScrollToBottom from "./hooks/useScrollToBottom"; -import { useIsSmallScreen } from "./hooks/mediaQuery"; - -export { useClickOutside, useScrollToBottom, useIsSmallScreen }; diff --git a/FrontEnd/src/utilities/hooks/mediaQuery.ts b/FrontEnd/src/utilities/hooks/mediaQuery.ts deleted file mode 100644 index ad55c3c0..00000000 --- a/FrontEnd/src/utilities/hooks/mediaQuery.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { useMediaQuery } from "react-responsive"; - -export function useIsSmallScreen(): boolean { - return useMediaQuery({ maxWidth: 576 }); -} diff --git a/FrontEnd/src/utilities/index.ts b/FrontEnd/src/utilities/index.ts new file mode 100644 index 00000000..7659a8aa --- /dev/null +++ b/FrontEnd/src/utilities/index.ts @@ -0,0 +1,27 @@ +export { default as base64 } from "./base64"; +export { withQuery } from "./url"; + +export function delay(milliseconds: number): Promise<void> { + return new Promise<void>((resolve) => { + setTimeout(() => { + resolve(); + }, milliseconds); + }); +} + +export function range(stop: number): number[]; +export function range(start: number, stop: number, step?: number): number[]; +export function range(start: number, stop?: number, step?: number): number[] { + if (stop == undefined) { + stop = start; + start = 0; + } + if (step == undefined) { + step = 1; + } + const result: number[] = []; + for (let i = start; i < stop; i += step) { + result.push(i); + } + return result; +} diff --git a/FrontEnd/src/views/about/author-avatar.png b/FrontEnd/src/views/about/author-avatar.png Binary files differdeleted file mode 100644 index d890d8d0..00000000 --- a/FrontEnd/src/views/about/author-avatar.png +++ /dev/null diff --git a/FrontEnd/src/views/about/github.png b/FrontEnd/src/views/about/github.png Binary files differdeleted file mode 100644 index ea6ff545..00000000 --- a/FrontEnd/src/views/about/github.png +++ /dev/null diff --git a/FrontEnd/src/views/about/index.css b/FrontEnd/src/views/about/index.css deleted file mode 100644 index 2574f4b7..00000000 --- a/FrontEnd/src/views/about/index.css +++ /dev/null @@ -1,4 +0,0 @@ -.about-link-icon {
- width: 1.2em;
- height: 1.2em;
-}
diff --git a/FrontEnd/src/views/about/index.tsx b/FrontEnd/src/views/about/index.tsx deleted file mode 100644 index 093da894..00000000 --- a/FrontEnd/src/views/about/index.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import { useTranslation, Trans } from "react-i18next"; - -import authorAvatarUrl from "./author-avatar.png"; -import githubLogoUrl from "./github.png"; - -import Card from "../common/Card"; - -import "./index.css"; - -const frontendCredits: { - name: string; - url: string; -}[] = [ - { - name: "reactjs", - url: "https://reactjs.org", - }, - { - name: "typescript", - url: "https://www.typescriptlang.org", - }, - { - name: "bootstrap", - url: "https://getbootstrap.com", - }, - { - name: "vite", - url: "https://vitejs.dev", - }, - { - name: "eslint", - url: "https://eslint.org", - }, - { - name: "prettier", - url: "https://prettier.io", - }, - { - name: "pepjs", - url: "https://github.com/jquery/PEP", - }, -]; - -const backendCredits: { - name: string; - url: string; -}[] = [ - { - 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 { t } = useTranslation(); - - return ( - <div className="px-2 mb-4"> - <Card className="container mt-4 py-3"> - <h4 id="author-info">{t("about.author.title")}</h4> - <div> - <div className="d-block"> - <img - src={authorAvatarUrl} - className="cru-avatar large cru-round cru-float-left" - /> - <p> - <small>{t("about.author.name")}</small> - <span className="cru-color-primary">crupest</span> - </p> - <p> - <small>{t("about.author.introduction")}</small> - {t("about.author.introductionContent")} - </p> - </div> - <p> - <small>{t("about.author.links")}</small> - <a - href="https://github.com/crupest" - target="_blank" - rel="noopener noreferrer" - > - <img src={githubLogoUrl} className="about-link-icon" /> - </a> - </p> - </div> - </Card> - <Card className="container mt-4 py-3"> - <h4>{t("about.site.title")}</h4> - <p> - <Trans i18nKey="about.site.content"> - 0<span className="cru-color-primary">1</span>2<b>3</b>4 - <a href="#author-info">5</a>6 - </Trans> - </p> - <p> - <a - href="https://github.com/crupest/Timeline" - target="_blank" - rel="noopener noreferrer" - > - {t("about.site.repo")} - </a> - </p> - </Card> - <Card className="container mt-4 py-3"> - <h4>{t("about.credits.title")}</h4> - <p>{t("about.credits.content")}</p> - <p>{t("about.credits.frontend")}</p> - <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> - <p>{t("about.credits.backend")}</p> - <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> - </Card> - </div> - ); -} diff --git a/FrontEnd/src/views/common/AppBar.css b/FrontEnd/src/views/common/AppBar.css deleted file mode 100644 index 3ec4fa36..00000000 --- a/FrontEnd/src/views/common/AppBar.css +++ /dev/null @@ -1,95 +0,0 @@ -.app-bar {
- display: flex;
- align-items: center;
- height: 56px;
- position: fixed;
- z-index: 1030;
- top: 0;
- left: 0;
- right: 0;
- background-color: var(--cru-primary-color);
- transition: background-color 1s;
-}
-
-.app-bar .cru-avatar {
- background-color: white;
-}
-
-.app-bar a {
- color: var(--cru-primary-t1-color);
- text-decoration: none;
- margin: 0 1em;
- transition: color 1s;
-}
-.app-bar a:hover {
- color: var(--cru-primary-t-color);
-}
-.app-bar a.active {
- color: var(--cru-primary-t-color);
-}
-
-.app-bar-brand {
- display: flex;
- align-items: center;
-}
-
-.app-bar-brand-icon {
- height: 2em;
-}
-
-.app-bar-main-area {
- display: flex;
- flex-grow: 1;
-}
-
-.app-bar-link-area {
- display: flex;
- align-items: center;
- flex-shrink: 0;
-}
-
-.app-bar-user-area {
- display: flex;
- align-items: center;
- flex-shrink: 0;
- margin-left: auto;
-}
-
-.small-screen .app-bar-main-area {
- position: absolute;
- top: 56px;
- left: 0;
- right: 0;
- transform-origin: top;
- transition: transform 0.6s, background-color 1s;
- background-color: var(--cru-primary-color);
- flex-direction: column;
-}
-.small-screen .app-bar-main-area.app-bar-collapse {
- transform: scale(1, 0);
-}
-.small-screen .app-bar-main-area a {
- text-align: left;
- padding: 0.5em 0.5em;
-}
-.small-screen .app-bar-link-area {
- flex-direction: column;
- align-items: stretch;
-}
-.small-screen .app-bar-user-area {
- flex-direction: column;
- align-items: stretch;
- margin-left: unset;
-}
-.small-screen .app-bar-avatar {
- align-self: flex-end;
-}
-
-.app-bar-toggler {
- margin-left: auto;
- font-size: 2em;
- margin-right: 1em;
- color: var(--cru-primary-t-color);
- cursor: pointer;
- user-select: none;
-}
diff --git a/FrontEnd/src/views/common/AppBar.tsx b/FrontEnd/src/views/common/AppBar.tsx deleted file mode 100644 index 278c70fd..00000000 --- a/FrontEnd/src/views/common/AppBar.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import * as React from "react"; -import classnames from "classnames"; -import { useTranslation } from "react-i18next"; -import { Link, NavLink } from "react-router-dom"; -import { useMediaQuery } from "react-responsive"; - -import { useUser } from "@/services/user"; - -import TimelineLogo from "./TimelineLogo"; -import UserAvatar from "./user/UserAvatar"; - -import "./AppBar.css"; - -const AppBar: React.FC = () => { - const { t } = useTranslation(); - - const user = useUser(); - const hasAdministrationPermission = user && user.hasAdministrationPermission; - - const isSmallScreen = useMediaQuery({ maxWidth: 576 }); - - const [expand, setExpand] = React.useState<boolean>(false); - const collapse = (): void => setExpand(false); - const toggleExpand = (): void => setExpand(!expand); - - const createLink = ( - link: string, - label: React.ReactNode, - className?: string - ): React.ReactNode => ( - <NavLink - to={link} - onClick={collapse} - className={({ isActive }) => classnames(className, isActive && "active")} - > - {label} - </NavLink> - ); - - return ( - <nav className={classnames("app-bar", isSmallScreen && "small-screen")}> - <Link to="/" className="app-bar-brand active"> - <TimelineLogo className="app-bar-brand-icon" /> - Timeline - </Link> - - {isSmallScreen && ( - <i className="bi-list app-bar-toggler" onClick={toggleExpand} /> - )} - - <div - className={classnames( - "app-bar-main-area", - !expand && "app-bar-collapse" - )} - > - <div className="app-bar-link-area"> - {createLink("/settings", t("nav.settings"))} - {createLink("/about", t("nav.about"))} - {hasAdministrationPermission && - createLink("/admin", t("nav.administration"))} - </div> - - <div className="app-bar-user-area"> - {user != null - ? createLink( - "/", - <UserAvatar - username={user.username} - className="cru-avatar small cru-round cursor-pointer ml-auto" - />, - "app-bar-avatar" - ) - : createLink("/login", t("nav.login"))} - </div> - </div> - </nav> - ); -}; - -export default AppBar; diff --git a/FrontEnd/src/views/common/BlobImage.tsx b/FrontEnd/src/views/common/BlobImage.tsx deleted file mode 100644 index 5e050ebe..00000000 --- a/FrontEnd/src/views/common/BlobImage.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import * as React from "react"; - -const BlobImage: React.FC< - Omit<React.ImgHTMLAttributes<HTMLImageElement>, "src"> & { - blob?: Blob | unknown; - } -> = (props) => { - const { blob, ...otherProps } = props; - - const [url, setUrl] = React.useState<string | undefined>(undefined); - - React.useEffect(() => { - if (blob instanceof Blob) { - const url = URL.createObjectURL(blob); - setUrl(url); - return () => { - URL.revokeObjectURL(url); - }; - } else { - setUrl(undefined); - } - }, [blob]); - - return <img {...otherProps} src={url} />; -}; - -export default BlobImage; diff --git a/FrontEnd/src/views/common/Card.css b/FrontEnd/src/views/common/Card.css deleted file mode 100644 index 6de0dd8e..00000000 --- a/FrontEnd/src/views/common/Card.css +++ /dev/null @@ -1,15 +0,0 @@ -:root {
- --cru-card-border-radius: 8px;
-}
-
-.cru-card {
- border: 1px solid;
- border-color: #e9ecef;
- border-radius: var(--cru-card-border-radius);
- background: #fefeff;
- transition: all 0.3s;
-}
-
-.cru-card:hover {
- border-color: var(--cru-primary-color);
-}
diff --git a/FrontEnd/src/views/common/Card.tsx b/FrontEnd/src/views/common/Card.tsx deleted file mode 100644 index ebbce77e..00000000 --- a/FrontEnd/src/views/common/Card.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import classNames from "classnames"; -import * as React from "react"; - -import "./Card.css"; - -function _Card( - { - className, - children, - ...otherProps - }: React.PropsWithChildren<React.HTMLAttributes<HTMLDivElement>>, - ref: React.ForwardedRef<HTMLDivElement> -): React.ReactElement | null { - return ( - <div - ref={ref} - className={classNames("cru-card", className)} - {...otherProps} - > - {children} - </div> - ); -} - -const Card = React.forwardRef(_Card); - -export default Card; diff --git a/FrontEnd/src/views/common/ImageCropper.tsx b/FrontEnd/src/views/common/ImageCropper.tsx deleted file mode 100644 index 04e17415..00000000 --- a/FrontEnd/src/views/common/ImageCropper.tsx +++ /dev/null @@ -1,306 +0,0 @@ -import * as React from "react"; -import classnames from "classnames"; - -import { UiLogicError } from "@/common"; - -import "./ImageCropper.css"; - -export interface Clip { - left: number; - top: number; - width: number; -} - -interface NormailizedClip extends Clip { - height: number; -} - -interface ImageInfo { - width: number; - height: number; - landscape: boolean; - ratio: number; - maxClipWidth: number; - maxClipHeight: number; -} - -interface ImageCropperSavedState { - clip: NormailizedClip; - x: number; - y: number; - pointerId: number; -} - -export interface ImageCropperProps { - clip: Clip | null; - imageUrl: string; - onChange: (clip: Clip) => void; - imageElementCallback?: (element: HTMLImageElement | null) => void; - className?: string; -} - -const ImageCropper = (props: ImageCropperProps): React.ReactElement => { - const { clip, imageUrl, onChange, imageElementCallback, className } = props; - - const [oldState, setOldState] = React.useState<ImageCropperSavedState | null>( - null - ); - const [imageInfo, setImageInfo] = React.useState<ImageInfo | null>(null); - - const normalizeClip = (c: Clip | null | undefined): NormailizedClip => { - if (c == null) { - return { left: 0, top: 0, width: 0, height: 0 }; - } - - return { - left: c.left || 0, - top: c.top || 0, - width: c.width || 0, - height: imageInfo != null ? (c.width || 0) / imageInfo.ratio : 0, - }; - }; - - const c = normalizeClip(clip); - - const imgElementRef = React.useRef<HTMLImageElement | null>(null); - - const onImageRef = React.useCallback( - (e: HTMLImageElement | null) => { - imgElementRef.current = e; - if (imageElementCallback != null && e == null) { - imageElementCallback(null); - } - }, - [imageElementCallback] - ); - - const onImageLoad = React.useCallback( - (e: React.SyntheticEvent<HTMLImageElement>) => { - const img = e.currentTarget; - const landscape = img.naturalWidth >= img.naturalHeight; - - const info = { - width: img.naturalWidth, - height: img.naturalHeight, - landscape, - ratio: img.naturalHeight / img.naturalWidth, - maxClipWidth: landscape ? img.naturalHeight / img.naturalWidth : 1, - maxClipHeight: landscape ? 1 : img.naturalWidth / img.naturalHeight, - }; - setImageInfo(info); - onChange({ left: 0, top: 0, width: info.maxClipWidth }); - if (imageElementCallback != null) { - imageElementCallback(img); - } - }, - [onChange, imageElementCallback] - ); - - const onPointerDown = React.useCallback( - (e: React.PointerEvent) => { - if (oldState != null) return; - e.currentTarget.setPointerCapture(e.pointerId); - setOldState({ - x: e.clientX, - y: e.clientY, - clip: c, - pointerId: e.pointerId, - }); - }, - [oldState, c] - ); - - const onPointerUp = React.useCallback( - (e: React.PointerEvent) => { - if (oldState == null || oldState.pointerId !== e.pointerId) return; - e.currentTarget.releasePointerCapture(e.pointerId); - setOldState(null); - }, - [oldState] - ); - - const onPointerMove = React.useCallback( - (e: React.PointerEvent) => { - if (oldState == null) return; - - const oldClip = oldState.clip; - - const movement = { x: e.clientX - oldState.x, y: e.clientY - oldState.y }; - - const { current: imgElement } = imgElementRef; - - if (imgElement == null) throw new UiLogicError("Image element is null."); - - const moveRatio = { - x: movement.x / imgElement.width, - y: movement.y / imgElement.height, - }; - - const newRatio = { - x: oldClip.left + moveRatio.x, - y: oldClip.top + moveRatio.y, - }; - if (newRatio.x < 0) { - newRatio.x = 0; - } else if (newRatio.x > 1 - oldClip.width) { - newRatio.x = 1 - oldClip.width; - } - if (newRatio.y < 0) { - newRatio.y = 0; - } else if (newRatio.y > 1 - oldClip.height) { - newRatio.y = 1 - oldClip.height; - } - - onChange({ left: newRatio.x, top: newRatio.y, width: oldClip.width }); - }, - [oldState, onChange] - ); - - const onHandlerPointerMove = React.useCallback( - (e: React.PointerEvent) => { - if (oldState == null) return; - - const oldClip = oldState.clip; - - const movement = { x: e.clientX - oldState.x, y: e.clientY - oldState.y }; - - const ratio = imageInfo == null ? 1 : imageInfo.ratio; - - const { current: imgElement } = imgElementRef; - - if (imgElement == null) throw new UiLogicError("Image element is null."); - - const moveRatio = { - x: movement.x / imgElement.width, - y: movement.x / imgElement.width / ratio, - }; - - const newRatio = { - x: oldClip.width + moveRatio.x, - y: oldClip.height + moveRatio.y, - }; - - const maxRatio = { - x: Math.min(1 - oldClip.left, newRatio.x), - y: Math.min(1 - oldClip.top, newRatio.y), - }; - - const maxWidthRatio = Math.min(maxRatio.x, maxRatio.y * ratio); - - let newWidth; - if (newRatio.x < 0) { - newWidth = 0; - } else if (newRatio.x > maxWidthRatio) { - newWidth = maxWidthRatio; - } else { - newWidth = newRatio.x; - } - - onChange({ left: oldClip.left, top: oldClip.top, width: newWidth }); - }, - [imageInfo, oldState, onChange] - ); - - const toPercentage = (n: number): string => `${n}%`; - - // fuck!!! I just can't find a better way to implement this in pure css - const containerStyle: React.CSSProperties = (() => { - if (imageInfo == null) { - return { width: "100%", paddingTop: "100%", height: 0 }; - } else { - if (imageInfo.ratio > 1) { - return { - width: toPercentage(100 / imageInfo.ratio), - paddingTop: "100%", - height: 0, - }; - } else { - return { - width: "100%", - paddingTop: toPercentage(100 * imageInfo.ratio), - height: 0, - }; - } - } - })(); - - return ( - <div - className={classnames("image-cropper-container", className)} - style={containerStyle} - > - <img ref={onImageRef} src={imageUrl} onLoad={onImageLoad} alt="to crop" /> - <div className="image-cropper-mask-container"> - <div - className="image-cropper-mask" - style={{ - left: toPercentage(c.left * 100), - top: toPercentage(c.top * 100), - width: toPercentage(c.width * 100), - height: toPercentage(c.height * 100), - }} - onPointerMove={onPointerMove} - onPointerDown={onPointerDown} - onPointerUp={onPointerUp} - /> - </div> - <div - className="image-cropper-handler" - style={{ - left: `calc(${(c.left + c.width) * 100}% - 15px)`, - top: `calc(${(c.top + c.height) * 100}% - 15px)`, - }} - onPointerMove={onHandlerPointerMove} - onPointerDown={onPointerDown} - onPointerUp={onPointerUp} - /> - </div> - ); -}; - -export default ImageCropper; - -export function applyClipToImage( - image: HTMLImageElement, - clip: Clip, - mimeType: string -): Promise<Blob> { - return new Promise((resolve, reject) => { - const naturalSize = { - width: image.naturalWidth, - height: image.naturalHeight, - }; - const clipArea = { - x: naturalSize.width * clip.left, - y: naturalSize.height * clip.top, - length: naturalSize.width * clip.width, - }; - - const canvas = document.createElement("canvas"); - canvas.width = clipArea.length; - canvas.height = clipArea.length; - const context = canvas.getContext("2d"); - - if (context == null) throw new Error("Failed to create context."); - - context.drawImage( - image, - clipArea.x, - clipArea.y, - clipArea.length, - clipArea.length, - 0, - 0, - clipArea.length, - clipArea.length - ); - - canvas.toBlob((blob) => { - if (blob == null) { - reject(new Error("canvas.toBlob returns null")); - } else { - resolve(blob); - } - }, mimeType); - }); -} diff --git a/FrontEnd/src/views/common/LoadingPage.tsx b/FrontEnd/src/views/common/LoadingPage.tsx deleted file mode 100644 index 35ee1aa8..00000000 --- a/FrontEnd/src/views/common/LoadingPage.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import * as React from "react"; - -import Spinner from "./Spinner"; - -const LoadingPage: React.FC = () => { - return ( - <div className="position-fixed w-100 h-100 d-flex justify-content-center align-items-center"> - <Spinner /> - </div> - ); -}; - -export default LoadingPage; diff --git a/FrontEnd/src/views/common/SearchInput.tsx b/FrontEnd/src/views/common/SearchInput.tsx deleted file mode 100644 index 9d644ab7..00000000 --- a/FrontEnd/src/views/common/SearchInput.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { useCallback } from "react"; -import * as React from "react"; -import classnames from "classnames"; -import { useTranslation } from "react-i18next"; - -import LoadingButton from "./button/LoadingButton"; - -import "./SearchInput.css"; - -export interface SearchInputProps { - value: string; - onChange: (value: string) => void; - onButtonClick: () => void; - className?: string; - loading?: boolean; - buttonText?: string; - placeholder?: string; - additionalButton?: React.ReactNode; - alwaysOneline?: boolean; -} - -const SearchInput: React.FC<SearchInputProps> = (props) => { - const { onChange, onButtonClick, alwaysOneline } = props; - - const { t } = useTranslation(); - - const onInputChange = useCallback( - (event: React.ChangeEvent<HTMLInputElement>): void => { - onChange(event.currentTarget.value); - }, - [onChange] - ); - - const onInputKeyPress = useCallback( - (event: React.KeyboardEvent<HTMLInputElement>): void => { - if (event.key === "Enter") { - onButtonClick(); - event.preventDefault(); - } - }, - [onButtonClick] - ); - - return ( - <div - className={classnames( - "cru-search-input", - alwaysOneline ? "flex-nowrap" : "flex-sm-nowrap", - props.className - )} - > - <input - type="text" - className="cru-search-input-input me-sm-2 flex-grow-1" - value={props.value} - onChange={onInputChange} - onKeyPress={onInputKeyPress} - placeholder={props.placeholder} - /> - {props.additionalButton ? ( - <div className="mt-2 mt-sm-0 flex-shrink-0 order-sm-last ms-sm-2"> - {props.additionalButton} - </div> - ) : null} - <div - className={classnames( - alwaysOneline ? "mt-0 ms-2" : "mt-2 mt-sm-0 ms-auto ms-sm-0", - "flex-shrink-0" - )} - > - <LoadingButton loading={props.loading} onClick={props.onButtonClick}> - {props.buttonText ?? t("search")} - </LoadingButton> - </div> - </div> - ); -}; - -export default SearchInput; diff --git a/FrontEnd/src/views/common/Skeleton.css b/FrontEnd/src/views/common/Skeleton.css deleted file mode 100644 index db1a1c34..00000000 --- a/FrontEnd/src/views/common/Skeleton.css +++ /dev/null @@ -1,14 +0,0 @@ -.cru-skeleton {
- padding: 0 1em;
-}
-
-.cru-skeleton-line {
- height: 1em;
- background-color: #e6e6e6;
- margin: 0.7em 0;
- border-radius: 0.2em;
-}
-
-.cru-skeleton-line.last {
- width: 50%;
-}
diff --git a/FrontEnd/src/views/common/Skeleton.tsx b/FrontEnd/src/views/common/Skeleton.tsx deleted file mode 100644 index 3b149db9..00000000 --- a/FrontEnd/src/views/common/Skeleton.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import * as React from "react"; -import classnames from "classnames"; -import range from "lodash/range"; - -import "./Skeleton.css"; - -export interface SkeletonProps { - lineNumber?: number; - className?: string; - style?: React.CSSProperties; -} - -const Skeleton: React.FC<SkeletonProps> = (props) => { - const { lineNumber: lineNumberProps, className, style } = props; - const lineNumber = lineNumberProps ?? 3; - - return ( - <div className={classnames(className, "cru-skeleton")} style={style}> - {range(lineNumber).map((i) => ( - <div - key={i} - className={classnames( - "cru-skeleton-line", - i === lineNumber - 1 && "last" - )} - /> - ))} - </div> - ); -}; - -export default Skeleton; diff --git a/FrontEnd/src/views/common/Spinner.tsx b/FrontEnd/src/views/common/Spinner.tsx deleted file mode 100644 index e99a9d1b..00000000 --- a/FrontEnd/src/views/common/Spinner.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import * as React from "react"; -import classnames from "classnames"; - -import { PaletteColorType } from "@/palette"; - -import "./Spinner.css"; - -export interface SpinnerProps { - size?: "sm" | "md" | "lg" | number | string; - color?: PaletteColorType; - className?: string; - style?: React.CSSProperties; -} - -export default function Spinner( - props: SpinnerProps -): React.ReactElement | null { - const { size, color, className, style } = props; - const calculatedSize = - size === "sm" - ? "18px" - : size === "md" - ? "30px" - : size === "lg" - ? "42px" - : typeof size === "number" - ? size - : size == null - ? "20px" - : size; - const calculatedColor = color ?? "primary"; - - return ( - <span - className={classnames( - "cru-spinner", - `cru-color-${calculatedColor}`, - className - )} - style={{ width: calculatedSize, height: calculatedSize, ...style }} - /> - ); -} diff --git a/FrontEnd/src/views/common/alert/AlertHost.tsx b/FrontEnd/src/views/common/alert/AlertHost.tsx deleted file mode 100644 index 42074781..00000000 --- a/FrontEnd/src/views/common/alert/AlertHost.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import * as React from "react"; -import without from "lodash/without"; -import { useTranslation } from "react-i18next"; -import classNames from "classnames"; - -import { alertService, AlertInfoEx, AlertInfo } from "@/services/alert"; -import { convertI18nText } from "@/common"; - -import IconButton from "../button/IconButton"; - -import "./alert.css"; - -interface AutoCloseAlertProps { - alert: AlertInfo; - close: () => void; -} - -export const AutoCloseAlert: React.FC<AutoCloseAlertProps> = (props) => { - const { alert, close } = props; - const { dismissTime } = alert; - - const { t } = useTranslation(); - - const timerTag = React.useRef<number | null>(null); - const closeHandler = React.useRef<(() => void) | null>(null); - - React.useEffect(() => { - closeHandler.current = close; - }, [close]); - - React.useEffect(() => { - const tag = - dismissTime === "never" - ? null - : typeof dismissTime === "number" - ? window.setTimeout(() => closeHandler.current?.(), dismissTime) - : window.setTimeout(() => closeHandler.current?.(), 5000); - timerTag.current = tag; - return () => { - if (tag != null) { - window.clearTimeout(tag); - } - }; - }, [dismissTime]); - - const cancelTimer = (): void => { - const { current: tag } = timerTag; - if (tag != null) { - window.clearTimeout(tag); - } - }; - - return ( - <div - className={classNames( - "m-3 cru-alert", - "cru-" + (alert.type ?? "primary") - )} - onClick={cancelTimer} - > - <div className="cru-alert-content"> - {(() => { - const { message, customMessage } = alert; - if (customMessage != null) { - return customMessage; - } else { - return convertI18nText(message, t); - } - })()} - </div> - <div className="cru-alert-close-button-container"> - <IconButton - icon="x" - className="cru-alert-close-button" - onClick={close} - /> - </div> - </div> - ); -}; - -const AlertHost: React.FC = () => { - const [alerts, setAlerts] = React.useState<AlertInfoEx[]>([]); - - React.useEffect(() => { - const consume = (alert: AlertInfoEx): void => { - setAlerts((old) => [...old, alert]); - }; - - alertService.registerConsumer(consume); - return () => { - alertService.unregisterConsumer(consume); - }; - }, []); - - return ( - <div className="alert-container"> - {alerts.map((alert) => { - return ( - <AutoCloseAlert - key={alert.id} - alert={alert} - close={() => { - setAlerts((old) => without(old, alert)); - }} - /> - ); - })} - </div> - ); -}; - -export default AlertHost; diff --git a/FrontEnd/src/views/common/alert/alert.css b/FrontEnd/src/views/common/alert/alert.css deleted file mode 100644 index fc15e3cb..00000000 --- a/FrontEnd/src/views/common/alert/alert.css +++ /dev/null @@ -1,33 +0,0 @@ -.alert-container {
- position: fixed;
- z-index: 1040;
-}
-
-.cru-alert {
- border-radius: 5px;
- border: var(--cru-theme-color) 1px solid;
- color: var(--cru-theme-t-color);
- background-color: var(--cru-theme-r1-color);
-
- display: flex;
- overflow: hidden;
-}
-
-.cru-alert-content {
- padding: 0.5em 2em;
-}
-
-.cru-alert-close-button-container {
- flex-shrink: 0;
- margin-left: auto;
- width: 2em;
- text-align: center;
- display: flex;
- align-items: center;
- justify-content: center;
- background-color: var(--cru-theme-t-color);
-}
-
-.cru-alert-close-button {
- color: var(--cru-theme-color);
-}
diff --git a/FrontEnd/src/views/common/button/Button.css b/FrontEnd/src/views/common/button/Button.css deleted file mode 100644 index c34176f6..00000000 --- a/FrontEnd/src/views/common/button/Button.css +++ /dev/null @@ -1,51 +0,0 @@ -.cru-button:not(.outline) {
- color: var(--cru-theme-t-color);
- cursor: pointer;
- padding: 0.2em 0.5em;
- border-radius: 0.2em;
- border: none;
- transition: all 0.5s;
- background-color: var(--cru-theme-color);
-}
-
-.cru-button:not(.outline):hover {
- background-color: var(--cru-theme-f1-color);
-}
-
-.cru-button:not(.outline):active {
- background-color: var(--cru-theme-f2-color);
-}
-
-.cru-button:not(.outline):disabled {
- background-color: var(--cru-disable-color);
- cursor: auto;
-}
-
-.cru-button.outline {
- color: var(--cru-theme-color);
- border: var(--cru-theme-color) 1px solid;
- cursor: pointer;
- padding: 0.2em 0.5em;
- border-radius: 0.2em;
- transition: all 0.6s;
- background-color: white;
-}
-
-.cru-button.outline:hover {
- color: var(--cru-theme-f1-color);
- border-color: var(--cru-theme-f1-color);
- background-color: var(--cru-background-color);
-}
-
-.cru-button.outline:active {
- color: var(--cru-theme-f2-color);
- border-color: var(--cru-theme-f2-color);
- background-color: var(--cru-background-1-color);
-}
-
-.cru-button.outline:disabled {
- color: var(--cru-disable-color);
- border-color: var(--cru-disable-color);
- background-color: white;
- cursor: auto;
-}
diff --git a/FrontEnd/src/views/common/button/FlatButton.css b/FrontEnd/src/views/common/button/FlatButton.css deleted file mode 100644 index f0d33153..00000000 --- a/FrontEnd/src/views/common/button/FlatButton.css +++ /dev/null @@ -1,18 +0,0 @@ -.cru-flat-button {
- cursor: pointer;
- padding: 0.2em 0.5em;
- border-radius: 0.2em;
- border: none;
- background-color: transparent;
- transition: all 0.6s;
- color: var(--cru-theme-color);
-}
-
-.cru-flat-button.disabled {
- color: var(--cru-theme-l1-color);
- cursor: default;
-}
-
-.cru-flat-button:hover:not(.disabled) {
- background-color: #e9ecef;
-}
diff --git a/FrontEnd/src/views/common/button/IconButton.css b/FrontEnd/src/views/common/button/IconButton.css deleted file mode 100644 index 45fb103c..00000000 --- a/FrontEnd/src/views/common/button/IconButton.css +++ /dev/null @@ -1,10 +0,0 @@ -.cru-icon-button { - color: var(--cru-theme-color); - font-size: 1.4rem; - background: none; - border: none; -} - -.cru-icon-button.large { - font-size: 1.6rem; -} diff --git a/FrontEnd/src/views/common/button/LoadingButton.tsx b/FrontEnd/src/views/common/button/LoadingButton.tsx deleted file mode 100644 index fceaec27..00000000 --- a/FrontEnd/src/views/common/button/LoadingButton.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import * as React from "react"; -import classNames from "classnames"; -import { useTranslation } from "react-i18next"; - -import { convertI18nText, I18nText } from "@/common"; -import { PaletteColorType } from "@/palette"; - -import Spinner from "../Spinner"; - -interface LoadingButtonProps extends React.ComponentPropsWithoutRef<"button"> { - color?: PaletteColorType; - text?: I18nText; - loading?: boolean; -} - -function LoadingButton(props: LoadingButtonProps): JSX.Element { - const { t } = useTranslation(); - - const { color, text, loading, className, children, ...otherProps } = props; - - if (text != null && children != null) { - console.warn("You can't set both text and children props."); - } - - return ( - <button - className={classNames( - "cru-" + (color ?? "primary"), - "cru-button outline", - className, - )} - {...otherProps} - > - {text != null ? convertI18nText(text, t) : children} - {loading && <Spinner />} - </button> - ); -} - -export default LoadingButton; diff --git a/FrontEnd/src/views/common/button/index.tsx b/FrontEnd/src/views/common/button/index.tsx deleted file mode 100644 index cff5ba3f..00000000 --- a/FrontEnd/src/views/common/button/index.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import Button from "./Button"; -import FlatButton from "./FlatButton"; -import IconButton from "./IconButton"; -import LoadingButton from "./LoadingButton"; - -export { Button, FlatButton, IconButton, LoadingButton }; diff --git a/FrontEnd/src/views/common/dialog/ConfirmDialog.tsx b/FrontEnd/src/views/common/dialog/ConfirmDialog.tsx deleted file mode 100644 index 8c2cea5a..00000000 --- a/FrontEnd/src/views/common/dialog/ConfirmDialog.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { convertI18nText, I18nText } from "@/common"; -import * as React from "react"; -import { useTranslation } from "react-i18next"; - -import Button from "../button/Button"; -import Dialog from "./Dialog"; - -const ConfirmDialog: React.FC<{ - open: boolean; - onClose: () => void; - onConfirm: () => void; - title: I18nText; - body: I18nText; -}> = ({ open, onClose, onConfirm, title, body }) => { - const { t } = useTranslation(); - - return ( - <Dialog onClose={onClose} open={open}> - <h3 className="cru-color-danger">{convertI18nText(title, t)}</h3> - <hr /> - <p>{convertI18nText(body, t)}</p> - <hr /> - <div className="cru-dialog-bottom-area"> - <Button - text="operationDialog.cancel" - color="secondary" - outline - onClick={onClose} - /> - <Button - text="operationDialog.confirm" - color="danger" - onClick={() => { - onConfirm(); - onClose(); - }} - /> - </div> - </Dialog> - ); -}; - -export default ConfirmDialog; diff --git a/FrontEnd/src/views/common/dialog/Dialog.css b/FrontEnd/src/views/common/dialog/Dialog.css deleted file mode 100644 index 21ea52fc..00000000 --- a/FrontEnd/src/views/common/dialog/Dialog.css +++ /dev/null @@ -1,55 +0,0 @@ -.cru-dialog-overlay {
- position: fixed;
- z-index: 1040;
- left: 0;
- top: 0;
- right: 0;
- bottom: 0;
- background-color: rgba(255, 255, 255, 0.92);
-
- display: flex;
- padding: 2em;
-
- overflow: auto;
-}
-
-.cru-dialog-container {
- max-width: 100%;
- min-width: 30vw;
-
- margin: auto;
-
- border: var(--cru-primary-color) 1px solid;
- border-radius: 5px;
- padding: 1.5em;
- background-color: white;
-}
-
-.cru-dialog-bottom-area {
- display: flex;
- justify-content: flex-end;
-}
-
-.cru-dialog-bottom-area > * {
- margin: 0 0.5em;
-}
-
-.cru-dialog-enter .cru-dialog-container {
- transform: scale(0, 0);
- opacity: 0;
- transform-origin: center;
-}
-
-.cru-dialog-enter-active .cru-dialog-container {
- transform: scale(1, 1);
- opacity: 1;
- transition: transform 0.3s, opacity 0.3s;
- transform-origin: center;
-}
-
-.cru-dialog-exit-active .cru-dialog-container {
- transition: transform 0.3s, opacity 0.3s;
- transform: scale(0, 0);
- opacity: 0;
- transform-origin: center;
-}
diff --git a/FrontEnd/src/views/common/dialog/Dialog.tsx b/FrontEnd/src/views/common/dialog/Dialog.tsx deleted file mode 100644 index 923c636b..00000000 --- a/FrontEnd/src/views/common/dialog/Dialog.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { ReactNode } from "react"; -import ReactDOM from "react-dom"; -import { CSSTransition } from "react-transition-group"; - -import "./Dialog.css"; - -const optionalPortalElement = document.getElementById("portal"); -if (optionalPortalElement == null) { - throw new Error("Portal element not found"); -} -const portalElement = optionalPortalElement; - -interface DialogProps { - onClose: () => void; - open: boolean; - children?: ReactNode; - disableCloseOnClickOnOverlay?: boolean; -} - -export default function Dialog(props: DialogProps) { - const { open, onClose, children, disableCloseOnClickOnOverlay } = props; - - return ReactDOM.createPortal( - <CSSTransition - mountOnEnter - unmountOnExit - in={open} - timeout={300} - classNames="cru-dialog" - > - <div - className="cru-dialog-overlay" - onPointerDown={ - disableCloseOnClickOnOverlay - ? undefined - : () => { - onClose(); - } - } - > - <div - className="cru-dialog-container" - onPointerDown={(e) => e.stopPropagation()} - > - {children} - </div> - </div> - </CSSTransition>, - portalElement, - ); -} diff --git a/FrontEnd/src/views/common/dialog/FullPageDialog.css b/FrontEnd/src/views/common/dialog/FullPageDialog.css deleted file mode 100644 index 2f1fc636..00000000 --- a/FrontEnd/src/views/common/dialog/FullPageDialog.css +++ /dev/null @@ -1,44 +0,0 @@ -.cru-full-page {
- position: fixed;
- z-index: 1030;
- left: 0;
- top: 0;
- right: 0;
- bottom: 0;
- background-color: white;
- padding-top: 56px;
-}
-
-.cru-full-page-top-bar {
- height: 56px;
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- z-index: 1;
- background-color: var(--cru-primary-color);
- display: flex;
- align-items: center;
-}
-
-.cru-full-page-content-container {
- overflow: scroll;
-}
-
-.cru-full-page-back-button {
- color: var(--cru-primary-t-color);
-}
-
-.cru-full-page-enter {
- transform: translate(100%, 0);
-}
-
-.cru-full-page-enter-active {
- transform: none;
- transition: transform 0.3s;
-}
-
-.cru-full-page-exit-active {
- transition: transform 0.3s;
- transform: translate(100%, 0);
-}
diff --git a/FrontEnd/src/views/common/dialog/FullPageDialog.tsx b/FrontEnd/src/views/common/dialog/FullPageDialog.tsx deleted file mode 100644 index 6368fc0a..00000000 --- a/FrontEnd/src/views/common/dialog/FullPageDialog.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import * as React from "react"; -import { createPortal } from "react-dom"; -import classnames from "classnames"; -import { CSSTransition } from "react-transition-group"; - -import "./FullPageDialog.css"; -import IconButton from "../button/IconButton"; - -export interface FullPageDialogProps { - show: boolean; - onBack: () => void; - contentContainerClassName?: string; - children: React.ReactNode; -} - -const FullPageDialog: React.FC<FullPageDialogProps> = ({ - show, - onBack, - children, - contentContainerClassName, -}) => { - return createPortal( - <CSSTransition - mountOnEnter - unmountOnExit - in={show} - timeout={300} - classNames="cru-full-page" - > - <div className="cru-full-page"> - <div className="cru-full-page-top-bar"> - <IconButton - icon="arrow-left" - className="ms-3 cru-full-page-back-button" - onClick={onBack} - /> - </div> - <div - className={classnames( - "cru-full-page-content-container", - contentContainerClassName - )} - > - {children} - </div> - </div> - </CSSTransition>, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - document.getElementById("portal")! - ); -}; - -export default FullPageDialog; diff --git a/FrontEnd/src/views/common/dialog/OperationDialog.css b/FrontEnd/src/views/common/dialog/OperationDialog.css deleted file mode 100644 index 2f7617d0..00000000 --- a/FrontEnd/src/views/common/dialog/OperationDialog.css +++ /dev/null @@ -1,25 +0,0 @@ -.cru-operation-dialog-group {
- display: block;
- margin: 0.4em 0;
-}
-
-.cru-operation-dialog-label {
- display: block;
- color: var(--cru-primary-color);
-}
-
-.cru-operation-dialog-inline-label {
- margin-inline-start: 0.5em;
-}
-
-.cru-operation-dialog-error-text {
- display: block;
- font-size: 0.8em;
- color: var(--cru-danger-color);
-}
-
-.cru-operation-dialog-helper-text {
- display: block;
- font-size: 0.8em;
- color: var(--cru-primary-color);
-}
diff --git a/FrontEnd/src/views/common/dialog/OperationDialog.tsx b/FrontEnd/src/views/common/dialog/OperationDialog.tsx deleted file mode 100644 index 71be030a..00000000 --- a/FrontEnd/src/views/common/dialog/OperationDialog.tsx +++ /dev/null @@ -1,531 +0,0 @@ -import { useState } from "react"; -import * as React from "react"; -import { useTranslation } from "react-i18next"; -import { TwitterPicker } from "react-color"; -import classNames from "classnames"; -import moment from "moment"; - -import { convertI18nText, I18nText, UiLogicError } from "@/common"; - -import { PaletteColorType } from "@/palette"; - -import Button from "../button/Button"; -import LoadingButton from "../button/LoadingButton"; -import Dialog from "./Dialog"; - -import "./OperationDialog.css"; - -interface DefaultErrorPromptProps { - error?: string; -} - -const DefaultErrorPrompt: React.FC<DefaultErrorPromptProps> = (props) => { - const { t } = useTranslation(); - - let result = <p className="cru-color-danger">{t("operationDialog.error")}</p>; - - if (props.error != null) { - result = ( - <> - {result} - <p className="cru-color-danger">{props.error}</p> - </> - ); - } - - return result; -}; - -export interface OperationDialogTextInput { - type: "text"; - label?: I18nText; - password?: boolean; - initValue?: string; - textFieldProps?: Omit< - React.InputHTMLAttributes<HTMLInputElement>, - "type" | "value" | "onChange" - >; - helperText?: string; -} - -export interface OperationDialogBoolInput { - type: "bool"; - label: I18nText; - initValue?: boolean; - helperText?: string; -} - -export interface OperationDialogSelectInputOption { - value: string; - label: I18nText; - icon?: React.ReactElement; -} - -export interface OperationDialogSelectInput { - type: "select"; - label: I18nText; - options: OperationDialogSelectInputOption[]; - initValue?: string; -} - -export interface OperationDialogColorInput { - type: "color"; - label?: I18nText; - initValue?: string | null; - canBeNull?: boolean; -} - -export interface OperationDialogDateTimeInput { - type: "datetime"; - label?: I18nText; - initValue?: string; - helperText?: string; -} - -export type OperationDialogInput = - | OperationDialogTextInput - | OperationDialogBoolInput - | OperationDialogSelectInput - | OperationDialogColorInput - | OperationDialogDateTimeInput; - -interface OperationInputTypeStringToValueTypeMap { - text: string; - bool: boolean; - select: string; - color: string | null; - datetime: string; -} - -type MapOperationInputTypeStringToValueType<Type> = - Type extends keyof OperationInputTypeStringToValueTypeMap - ? OperationInputTypeStringToValueTypeMap[Type] - : never; - -type MapOperationInputInfoValueType<T> = T extends OperationDialogInput - ? MapOperationInputTypeStringToValueType<T["type"]> - : T; - -const initValueMapperMap: { - [T in OperationDialogInput as T["type"]]: ( - item: T - ) => MapOperationInputInfoValueType<T>; -} = { - bool: (item) => item.initValue ?? false, - color: (item) => item.initValue ?? null, - datetime: (item) => { - if (item.initValue != null) { - return moment(item.initValue).format("YYYY-MM-DDTHH:mm:ss"); - } else { - return ""; - } - }, - select: (item) => item.initValue ?? item.options[0].value, - text: (item) => item.initValue ?? "", -}; - -type MapOperationInputInfoValueTypeList< - Tuple extends readonly OperationDialogInput[] -> = { - [Index in keyof Tuple]: MapOperationInputInfoValueType<Tuple[Index]>; -} & { length: Tuple["length"] }; - -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< - TData, - OperationInputInfoList extends readonly OperationDialogInput[] -> { - open: boolean; - onClose: () => void; - title: I18nText | (() => React.ReactNode); - themeColor?: PaletteColorType; - onProcess: ( - inputs: MapOperationInputInfoValueTypeList<OperationInputInfoList> - ) => Promise<TData>; - inputScheme?: OperationInputInfoList; - inputValidator?: ( - inputs: MapOperationInputInfoValueTypeList<OperationInputInfoList> - ) => OperationInputError; - inputPrompt?: I18nText | (() => React.ReactNode); - processPrompt?: () => React.ReactNode; - successPrompt?: (data: TData) => React.ReactNode; - failurePrompt?: (error: unknown) => React.ReactNode; - onSuccessAndClose?: (data: TData) => void; -} - -const OperationDialog = < - TData, - OperationInputInfoList extends readonly OperationDialogInput[] ->( - props: OperationDialogProps<TData, OperationInputInfoList> -): React.ReactElement => { - const inputScheme = (props.inputScheme ?? - []) as readonly OperationDialogInput[]; - - const { t } = useTranslation(); - - type Step = - | { type: "input" } - | { type: "process" } - | { - type: "success"; - data: TData; - } - | { - type: "failure"; - data: unknown; - }; - const [step, setStep] = useState<Step>({ type: "input" }); - - type ValueType = boolean | string | null | undefined; - - const [values, setValues] = useState<ValueType[]>( - inputScheme.map((item) => { - if (item.type in initValueMapperMap) { - return ( - initValueMapperMap[item.type] as ( - i: OperationDialogInput - ) => ValueType - )(item); - } else { - throw new UiLogicError("Unknown input scheme."); - } - }) - ); - const [dirtyList, setDirtyList] = useState<boolean[]>(() => - inputScheme.map(() => false) - ); - const [inputError, setInputError] = useState<OperationInputError>(); - - const close = (): void => { - if (step.type !== "process") { - props.onClose(); - if (step.type === "success" && props.onSuccessAndClose) { - props.onSuccessAndClose(step.data); - } - } else { - console.log("Attempt to close modal when processing."); - } - }; - - const onConfirm = (): void => { - setStep({ type: "process" }); - props - .onProcess( - values.map((v, index) => { - if (inputScheme[index].type === "datetime" && v !== "") - return new Date(v as string).toISOString(); - else return v; - }) as unknown as MapOperationInputInfoValueTypeList<OperationInputInfoList> - ) - .then( - (d) => { - setStep({ - type: "success", - data: d, - }); - }, - (e: unknown) => { - setStep({ - type: "failure", - data: e, - }); - } - ); - }; - - let body: React.ReactNode; - if (step.type === "input" || step.type === "process") { - const process = step.type === "process"; - - let inputPrompt = - typeof props.inputPrompt === "function" - ? props.inputPrompt() - : convertI18nText(props.inputPrompt, t); - inputPrompt = <h6>{inputPrompt}</h6>; - - const validate = (values: ValueType[]): 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: ValueType): void => { - const oldValues = values; - const newValues = oldValues.slice(); - newValues[index] = newValue; - setValues(newValues); - if (dirtyList[index] === false) { - const newDirtyList = dirtyList.slice(); - newDirtyList[index] = true; - setDirtyList(newDirtyList); - } - validate(newValues); - }; - - const canProcess = isNoError(inputError); - - body = ( - <> - <div> - {inputPrompt} - {inputScheme.map((item, index) => { - const value = values[index]; - const error: string | null = - dirtyList[index] && inputError != null - ? convertI18nText(inputError[index], t) - : null; - - if (item.type === "text") { - return ( - <div - key={index} - className={classNames( - "cru-operation-dialog-group", - error != null ? "error" : null - )} - > - {item.label && ( - <label className="cru-operation-dialog-label"> - {convertI18nText(item.label, t)} - </label> - )} - <input - type={item.password === true ? "password" : "text"} - value={value as string} - onChange={(e) => { - const v = e.target.value; - updateValue(index, v); - }} - disabled={process} - /> - {error != null && ( - <div className="cru-operation-dialog-error-text"> - {error} - </div> - )} - {item.helperText && ( - <div className="cru-operation-dialog-helper-text"> - {t(item.helperText)} - </div> - )} - </div> - ); - } else if (item.type === "bool") { - return ( - <div - key={index} - className={classNames( - "cru-operation-dialog-group", - error != null ? "error" : null - )} - > - <input - type="checkbox" - checked={value as boolean} - onChange={(event) => { - updateValue(index, event.currentTarget.checked); - }} - disabled={process} - /> - <label className="cru-operation-dialog-inline-label"> - {convertI18nText(item.label, t)} - </label> - {error != null && ( - <div className="cru-operation-dialog-error-text"> - {error} - </div> - )} - {item.helperText && ( - <div className="cru-operation-dialog-helper-text"> - {t(item.helperText)} - </div> - )} - </div> - ); - } else if (item.type === "select") { - return ( - <div - key={index} - className={classNames( - "cru-operation-dialog-group", - error != null ? "error" : null - )} - > - <label className="cru-operation-dialog-label"> - {convertI18nText(item.label, t)} - </label> - <select - value={value as string} - onChange={(event) => { - updateValue(index, event.target.value); - }} - disabled={process} - > - {item.options.map((option, i) => { - return ( - <option value={option.value} key={i}> - {option.icon} - {convertI18nText(option.label, t)} - </option> - ); - })} - </select> - </div> - ); - } else if (item.type === "color") { - return ( - <div - key={index} - className={classNames( - "cru-operation-dialog-group", - error != null ? "error" : null - )} - > - {item.canBeNull ? ( - <input - type="checkbox" - checked={value !== null} - onChange={(event) => { - if (event.currentTarget.checked) { - updateValue(index, "#007bff"); - } else { - updateValue(index, null); - } - }} - disabled={process} - /> - ) : null} - <label className="cru-operation-dialog-inline-label"> - {convertI18nText(item.label, t)} - </label> - {value !== null && ( - <TwitterPicker - color={value as string} - triangle="hide" - onChange={(result) => updateValue(index, result.hex)} - /> - )} - </div> - ); - } else if (item.type === "datetime") { - return ( - <div - key={index} - className={classNames( - "cru-operation-dialog-group", - error != null ? "error" : null - )} - > - {item.label && ( - <label className="cru-operation-dialog-label"> - {convertI18nText(item.label, t)} - </label> - )} - <input - type="datetime-local" - value={value as string} - onChange={(e) => { - const v = e.target.value; - updateValue(index, v); - }} - disabled={process} - /> - {error != null && <div>{error}</div>} - </div> - ); - } - })} - </div> - <hr /> - <div className="cru-dialog-bottom-area"> - <Button - text="operationDialog.cancel" - color="secondary" - outline - onClick={close} - disabled={process} - /> - <LoadingButton - color={props.themeColor} - loading={process} - disabled={!canProcess} - onClick={() => { - setDirtyList(inputScheme.map(() => true)); - if (validate(values)) { - onConfirm(); - } - }} - > - {t("operationDialog.confirm")} - </LoadingButton> - </div> - </> - ); - } else { - let content: React.ReactNode; - const result = step; - if (result.type === "success") { - content = - props.successPrompt?.(result.data) ?? t("operationDialog.success"); - if (typeof content === "string") - content = <p className="cru-color-success">{content}</p>; - } else { - content = props.failurePrompt?.(result.data) ?? <DefaultErrorPrompt />; - if (typeof content === "string") - content = <DefaultErrorPrompt error={content} />; - } - body = ( - <> - <div>{content}</div> - <hr /> - <div className="cru-dialog-bottom-area"> - <Button text="operationDialog.ok" color="primary" onClick={close} /> - </div> - </> - ); - } - - const title = - typeof props.title === "function" - ? props.title() - : convertI18nText(props.title, t); - - return ( - <Dialog open={props.open} onClose={close}> - <h3 - className={ - props.themeColor != null - ? "cru-color-" + props.themeColor - : "cru-color-primary" - } - > - {title} - </h3> - <hr /> - {body} - </Dialog> - ); -}; - -export default OperationDialog; diff --git a/FrontEnd/src/views/common/index.css b/FrontEnd/src/views/common/index.css deleted file mode 100644 index 111a3ec0..00000000 --- a/FrontEnd/src/views/common/index.css +++ /dev/null @@ -1,293 +0,0 @@ -:root {
- --cru-background-color: #f8f9fa;
- --cru-background-1-color: #e9ecef;
- --cru-background-2-color: #dee2e6;
-
- --cru-disable-color: #ced4da;
-
- /*
- --cru-primary-color: rgb(0, 123, 255);
- --cru-primary-l1-color: rgb(26, 136, 255);
- --cru-primary-l2-color: rgb(51, 149, 255);
- --cru-primary-l3-color: rgb(77, 163, 255);
- --cru-primary-d1-color: rgb(0, 111, 230);
- --cru-primary-d2-color: rgb(0, 98, 204);
- --cru-primary-d3-color: rgb(0, 86, 179);
- --cru-primary-f1-color: rgb(0, 111, 230);
- --cru-primary-f2-color: rgb(0, 98, 204);
- --cru-primary-f3-color: rgb(0, 86, 179);
- --cru-primary-r1-color: rgb(26, 136, 255);
- --cru-primary-r2-color: rgb(51, 149, 255);
- --cru-primary-r3-color: rgb(77, 163, 255);
- --cru-primary-t-color: rgb(255, 255, 255);
- --cru-primary-t1-color: rgb(230, 230, 230);
- --cru-primary-t2-color: rgb(204, 204, 204);
- --cru-primary-t3-color: rgb(179, 179, 179);
- --cru-primary-enhance-color: rgb(77, 163, 255);
- --cru-primary-enhance-l1-color: rgb(94, 172, 255);
- --cru-primary-enhance-l2-color: rgb(112, 181, 255);
- --cru-primary-enhance-l3-color: rgb(130, 190, 255);
- --cru-primary-enhance-d1-color: rgb(43, 145, 255);
- --cru-primary-enhance-d2-color: rgb(10, 128, 255);
- --cru-primary-enhance-d3-color: rgb(0, 112, 232);
- --cru-primary-enhance-f1-color: rgb(94, 172, 255);
- --cru-primary-enhance-f2-color: rgb(112, 181, 255);
- --cru-primary-enhance-f3-color: rgb(130, 190, 255);
- --cru-primary-enhance-r1-color: rgb(43, 145, 255);
- --cru-primary-enhance-r2-color: rgb(10, 128, 255);
- --cru-primary-enhance-r3-color: rgb(0, 112, 232);
- --cru-primary-enhance-t-color: rgb(0, 0, 0);
- --cru-primary-enhance-t1-color: rgb(26, 26, 26);
- --cru-primary-enhance-t2-color: rgb(51, 51, 51);
- --cru-primary-enhance-t3-color: rgb(77, 77, 77);
- --cru-secondary-color: rgb(128, 128, 128);
- --cru-secondary-l1-color: rgb(141, 141, 141);
- --cru-secondary-l2-color: rgb(153, 153, 153);
- --cru-secondary-l3-color: rgb(166, 166, 166);
- --cru-secondary-d1-color: rgb(115, 115, 115);
- --cru-secondary-d2-color: rgb(102, 102, 102);
- --cru-secondary-d3-color: rgb(90, 90, 90);
- --cru-secondary-f1-color: rgb(115, 115, 115);
- --cru-secondary-f2-color: rgb(102, 102, 102);
- --cru-secondary-f3-color: rgb(90, 90, 90);
- --cru-secondary-r1-color: rgb(141, 141, 141);
- --cru-secondary-r2-color: rgb(153, 153, 153);
- --cru-secondary-r3-color: rgb(166, 166, 166);
- --cru-secondary-t-color: rgb(255, 255, 255);
- --cru-secondary-t1-color: rgb(230, 230, 230);
- --cru-secondary-t2-color: rgb(204, 204, 204);
- --cru-secondary-t3-color: rgb(179, 179, 179);
- --cru-danger-color: rgb(255, 0, 0);
- --cru-danger-l1-color: rgb(255, 26, 26);
- --cru-danger-l2-color: rgb(255, 51, 51);
- --cru-danger-l3-color: rgb(255, 77, 77);
- --cru-danger-d1-color: rgb(230, 0, 0);
- --cru-danger-d2-color: rgb(204, 0, 0);
- --cru-danger-d3-color: rgb(179, 0, 0);
- --cru-danger-f1-color: rgb(230, 0, 0);
- --cru-danger-f2-color: rgb(204, 0, 0);
- --cru-danger-f3-color: rgb(179, 0, 0);
- --cru-danger-r1-color: rgb(255, 26, 26);
- --cru-danger-r2-color: rgb(255, 51, 51);
- --cru-danger-r3-color: rgb(255, 77, 77);
- --cru-danger-t-color: rgb(255, 255, 255);
- --cru-danger-t1-color: rgb(230, 230, 230);
- --cru-danger-t2-color: rgb(204, 204, 204);
- --cru-danger-t3-color: rgb(179, 179, 179);
- --cru-success-color: rgb(0, 128, 0);
- --cru-success-l1-color: rgb(0, 166, 0);
- --cru-success-l2-color: rgb(0, 204, 0);
- --cru-success-l3-color: rgb(0, 243, 0);
- --cru-success-d1-color: rgb(0, 115, 0);
- --cru-success-d2-color: rgb(0, 102, 0);
- --cru-success-d3-color: rgb(0, 90, 0);
- --cru-success-f1-color: rgb(0, 115, 0);
- --cru-success-f2-color: rgb(0, 102, 0);
- --cru-success-f3-color: rgb(0, 90, 0);
- --cru-success-r1-color: rgb(0, 166, 0);
- --cru-success-r2-color: rgb(0, 204, 0);
- --cru-success-r3-color: rgb(0, 243, 0);
- --cru-success-t-color: rgb(255, 255, 255);
- --cru-success-t1-color: rgb(230, 230, 230);
- --cru-success-t2-color: rgb(204, 204, 204);
- --cru-success-t3-color: rgb(179, 179, 179);
- */
-}
-
-.cru-primary {
- --cru-theme-color: var(--cru-primary-color);
- --cru-theme-l1-color: var(--cru-primary-l1-color);
- --cru-theme-l2-color: var(--cru-primary-l2-color);
- --cru-theme-l3-color: var(--cru-primary-l3-color);
- --cru-theme-d1-color: var(--cru-primary-d1-color);
- --cru-theme-d2-color: var(--cru-primary-d2-color);
- --cru-theme-d3-color: var(--cru-primary-d3-color);
- --cru-theme-f1-color: var(--cru-primary-f1-color);
- --cru-theme-f2-color: var(--cru-primary-f2-color);
- --cru-theme-f3-color: var(--cru-primary-f3-color);
- --cru-theme-r1-color: var(--cru-primary-r1-color);
- --cru-theme-r2-color: var(--cru-primary-r2-color);
- --cru-theme-r3-color: var(--cru-primary-r3-color);
- --cru-theme-t-color: var(--cru-primary-t-color);
- --cru-theme-t1-color: var(--cru-primary-t1-color);
- --cru-theme-t2-color: var(--cru-primary-t2-color);
- --cru-theme-t3-color: var(--cru-primary-t3-color);
-}
-
-.cru-primary-enhance {
- --cru-theme-color: var(--cru-primary-enhance-color);
- --cru-theme-l1-color: var(--cru-primary-enhance-l1-color);
- --cru-theme-l2-color: var(--cru-primary-enhance-l2-color);
- --cru-theme-l3-color: var(--cru-primary-enhance-l3-color);
- --cru-theme-d1-color: var(--cru-primary-enhance-d1-color);
- --cru-theme-d2-color: var(--cru-primary-enhance-d2-color);
- --cru-theme-d3-color: var(--cru-primary-enhance-d3-color);
- --cru-theme-f1-color: var(--cru-primary-enhance-f1-color);
- --cru-theme-f2-color: var(--cru-primary-enhance-f2-color);
- --cru-theme-f3-color: var(--cru-primary-enhance-f3-color);
- --cru-theme-r1-color: var(--cru-primary-enhance-r1-color);
- --cru-theme-r2-color: var(--cru-primary-enhance-r2-color);
- --cru-theme-r3-color: var(--cru-primary-enhance-r3-color);
- --cru-theme-t-color: var(--cru-primary-enhance-t-color);
- --cru-theme-t1-color: var(--cru-primary-enhance-t1-color);
- --cru-theme-t2-color: var(--cru-primary-enhance-t2-color);
- --cru-theme-t3-color: var(--cru-primary-enhance-t3-color);
-}
-
-.cru-secondary {
- --cru-theme-color: var(--cru-secondary-color);
- --cru-theme-l1-color: var(--cru-secondary-l1-color);
- --cru-theme-l2-color: var(--cru-secondary-l2-color);
- --cru-theme-l3-color: var(--cru-secondary-l3-color);
- --cru-theme-d1-color: var(--cru-secondary-d1-color);
- --cru-theme-d2-color: var(--cru-secondary-d2-color);
- --cru-theme-d3-color: var(--cru-secondary-d3-color);
- --cru-theme-f1-color: var(--cru-secondary-f1-color);
- --cru-theme-f2-color: var(--cru-secondary-f2-color);
- --cru-theme-f3-color: var(--cru-secondary-f3-color);
- --cru-theme-r1-color: var(--cru-secondary-r1-color);
- --cru-theme-r2-color: var(--cru-secondary-r2-color);
- --cru-theme-r3-color: var(--cru-secondary-r3-color);
- --cru-theme-t-color: var(--cru-secondary-t-color);
- --cru-theme-t1-color: var(--cru-secondary-t1-color);
- --cru-theme-t2-color: var(--cru-secondary-t2-color);
- --cru-theme-t3-color: var(--cru-secondary-t3-color);
-}
-
-.cru-success {
- --cru-theme-color: var(--cru-success-color);
- --cru-theme-l1-color: var(--cru-success-l1-color);
- --cru-theme-l2-color: var(--cru-success-l2-color);
- --cru-theme-l3-color: var(--cru-success-l3-color);
- --cru-theme-d1-color: var(--cru-success-d1-color);
- --cru-theme-d2-color: var(--cru-success-d2-color);
- --cru-theme-d3-color: var(--cru-success-d3-color);
- --cru-theme-f1-color: var(--cru-success-f1-color);
- --cru-theme-f2-color: var(--cru-success-f2-color);
- --cru-theme-f3-color: var(--cru-success-f3-color);
- --cru-theme-r1-color: var(--cru-success-r1-color);
- --cru-theme-r2-color: var(--cru-success-r2-color);
- --cru-theme-r3-color: var(--cru-success-r3-color);
- --cru-theme-t-color: var(--cru-success-t-color);
- --cru-theme-t1-color: var(--cru-success-t1-color);
- --cru-theme-t2-color: var(--cru-success-t2-color);
- --cru-theme-t3-color: var(--cru-success-t3-color);
-}
-
-.cru-danger {
- --cru-theme-color: var(--cru-danger-color);
- --cru-theme-l1-color: var(--cru-danger-l1-color);
- --cru-theme-l2-color: var(--cru-danger-l2-color);
- --cru-theme-l3-color: var(--cru-danger-l3-color);
- --cru-theme-d1-color: var(--cru-danger-d1-color);
- --cru-theme-d2-color: var(--cru-danger-d2-color);
- --cru-theme-d3-color: var(--cru-danger-d3-color);
- --cru-theme-f1-color: var(--cru-danger-f1-color);
- --cru-theme-f2-color: var(--cru-danger-f2-color);
- --cru-theme-f3-color: var(--cru-danger-f3-color);
- --cru-theme-r1-color: var(--cru-danger-r1-color);
- --cru-theme-r2-color: var(--cru-danger-r2-color);
- --cru-theme-r3-color: var(--cru-danger-r3-color);
- --cru-theme-t-color: var(--cru-danger-t-color);
- --cru-theme-t1-color: var(--cru-danger-t1-color);
- --cru-theme-t2-color: var(--cru-danger-t2-color);
- --cru-theme-t3-color: var(--cru-danger-t3-color);
-}
-
-.cru-color-primary {
- color: var(--cru-primary-color);
-}
-
-.cru-color-primary-enhance {
- color: var(--cru-primary-enhance-color);
-}
-
-.cru-color-secondary {
- color: var(--cru-secondary-color);
-}
-
-.cru-color-success {
- color: var(--cru-success-color);
-}
-
-.cru-color-danger {
- color: var(--cru-danger-color);
-}
-
-.cru-text-center {
- text-align: center;
-}
-
-.cru-text-end {
- text-align: end;
-}
-
-.cru-float-left {
- float: left;
-}
-
-.cru-float-right {
- float: right;
-}
-
-.cru-align-text-bottom {
- vertical-align: text-bottom;
-}
-
-.cru-align-middle {
- vertical-align: middle;
-}
-
-.cru-clearfix::after {
- clear: both;
-}
-
-.cru-fill-parent {
- width: 100%;
- height: 100%;
-}
-
-.cru-avatar {
- width: 60px;
- height: 60px;
-}
-
-.cru-avatar.large {
- width: 100px;
- height: 100px;
-}
-
-.cru-avatar.small {
- width: 40px;
- height: 40px;
-}
-
-.cru-round {
- border-radius: 50%;
-}
-
-.cru-tab-pages-action-area {
- display: flex;
- align-items: center;
-}
-
-.alert-container {
- position: fixed;
- z-index: 1070;
-}
-
-@media (min-width: 576px) {
- .alert-container {
- bottom: 0;
- right: 0;
- }
-}
-
-@media (max-width: 575.98px) {
- .alert-container {
- bottom: 0;
- right: 0;
- left: 0;
- text-align: center;
- }
-}
\ No newline at end of file diff --git a/FrontEnd/src/views/common/input/InputPanel.css b/FrontEnd/src/views/common/input/InputPanel.css deleted file mode 100644 index f9d6ac8b..00000000 --- a/FrontEnd/src/views/common/input/InputPanel.css +++ /dev/null @@ -1,25 +0,0 @@ -.cru-input-panel-group { - display: block; - margin: 0.4em 0; -} - -.cru-input-panel-label { - display: block; - color: var(--cru-primary-color); -} - -.cru-input-panel-inline-label { - margin-inline-start: 0.5em; -} - -.cru-input-panel-error-text { - display: block; - font-size: 0.8em; - color: var(--cru-danger-color); -} - -.cru-input-panel-helper-text { - display: block; - font-size: 0.8em; - color: var(--cru-primary-color); -} diff --git a/FrontEnd/src/views/common/input/InputPanel.tsx b/FrontEnd/src/views/common/input/InputPanel.tsx deleted file mode 100644 index 234ed267..00000000 --- a/FrontEnd/src/views/common/input/InputPanel.tsx +++ /dev/null @@ -1,257 +0,0 @@ -import * as React from "react"; -import classNames from "classnames"; -import { useTranslation } from "react-i18next"; -import { TwitterPicker } from "react-color"; - -import { convertI18nText, I18nText } from "@/common"; - -import "./InputPanel.css"; - -export interface TextInput { - type: "text"; - label?: I18nText; - helper?: I18nText; - password?: boolean; -} - -export interface BoolInput { - type: "bool"; - label: I18nText; - helper?: I18nText; -} - -export interface SelectInputOption { - value: string; - label: I18nText; - icon?: React.ReactElement; -} - -export interface SelectInput { - type: "select"; - label: I18nText; - options: SelectInputOption[]; -} - -export interface ColorInput { - type: "color"; - label?: I18nText; -} - -export interface DateTimeInput { - type: "datetime"; - label?: I18nText; - helper?: I18nText; -} - -export type Input = - | TextInput - | BoolInput - | SelectInput - | ColorInput - | DateTimeInput; - -interface InputTypeToValueTypeMap { - text: string; - bool: boolean; - select: string; - color: string; - datetime: string; -} - -type ValueTypes = InputTypeToValueTypeMap[keyof InputTypeToValueTypeMap]; - -type MapInputTypeToValueType<Type> = Type extends keyof InputTypeToValueTypeMap - ? InputTypeToValueTypeMap[Type] - : never; - -type MapInputToValueType<T> = T extends Input - ? MapInputTypeToValueType<T["type"]> - : T; - -type MapInputListToValueTypeList<Tuple extends readonly Input[]> = { - [Index in keyof Tuple]: MapInputToValueType<Tuple[Index]>; -} & { length: Tuple["length"] }; - -export type InputPanelError = { - [index: number]: I18nText | null | undefined; -}; - -export function hasError(e: InputPanelError | null | undefined): boolean { - if (e == null) return false; - for (const key of Object.keys(e)) { - if (e[key as unknown as number] != null) return true; - } - return false; -} - -export interface InputPanelProps<InputList extends readonly Input[]> { - scheme: InputList; - values: MapInputListToValueTypeList<InputList>; - onChange: ( - values: MapInputListToValueTypeList<InputList>, - index: number - ) => void; - error?: InputPanelError; - disable?: boolean; -} - -const InputPanel = <InputList extends readonly Input[]>( - props: InputPanelProps<InputList> -): React.ReactElement => { - const { values, onChange, scheme, error, disable } = props; - - const { t } = useTranslation(); - - const updateValue = (index: number, newValue: ValueTypes): void => { - const oldValues = values; - const newValues = oldValues.slice(); - newValues[index] = newValue; - onChange( - newValues as unknown as MapInputListToValueTypeList<InputList>, - index - ); - }; - - return ( - <div> - {scheme.map((item, index) => { - const v = values[index]; - const e: string | null = convertI18nText(error?.[index], t); - - if (item.type === "text") { - return ( - <div - key={index} - className={classNames("cru-input-panel-group", e && "error")} - > - {item.label && ( - <label className="cru-input-panel-label"> - {convertI18nText(item.label, t)} - </label> - )} - <input - type={item.password === true ? "password" : "text"} - value={v as string} - onChange={(e) => { - const v = e.target.value; - updateValue(index, v); - }} - disabled={disable} - /> - {e && <div className="cru-input-panel-error-text">{e}</div>} - {item.helper && ( - <div className="cru-input-panel-helper-text"> - {convertI18nText(item.helper, t)} - </div> - )} - </div> - ); - } else if (item.type === "bool") { - return ( - <div - key={index} - className={classNames("cru-input-panel-group", e && "error")} - > - <input - type="checkbox" - checked={v as boolean} - onChange={(event) => { - const value = event.currentTarget.checked; - updateValue(index, value); - }} - disabled={disable} - /> - <label className="cru-input-panel-inline-label"> - {convertI18nText(item.label, t)} - </label> - {e != null && ( - <div className="cru-input-panel-error-text">{e}</div> - )} - {item.helper && ( - <div className="cru-input-panel-helper-text"> - {convertI18nText(item.helper, t)} - </div> - )} - </div> - ); - } else if (item.type === "select") { - return ( - <div - key={index} - className={classNames("cru-input-panel-group", e && "error")} - > - <label className="cru-input-panel-label"> - {convertI18nText(item.label, t)} - </label> - <select - value={v as string} - onChange={(event) => { - const value = event.target.value; - updateValue(index, value); - }} - disabled={disable} - > - {item.options.map((option, i) => { - return ( - <option value={option.value} key={i}> - {option.icon} - {convertI18nText(option.label, t)} - </option> - ); - })} - </select> - </div> - ); - } else if (item.type === "color") { - return ( - <div - key={index} - className={classNames("cru-input-panel-group", e && "error")} - > - <label className="cru-input-panel-inline-label"> - {convertI18nText(item.label, t)} - </label> - <TwitterPicker - color={v as string} - triangle="hide" - onChange={(result) => updateValue(index, result.hex)} - /> - </div> - ); - } else if (item.type === "datetime") { - return ( - <div - key={index} - className={classNames("cru-input-panel-group", e && "error")} - > - {item.label && ( - <label className="cru-input-panel-label"> - {convertI18nText(item.label, t)} - </label> - )} - <input - type="datetime-local" - value={v as string} - onChange={(e) => { - const v = e.target.value; - updateValue(index, v); - }} - disabled={disable} - /> - {e != null && ( - <div className="cru-input-panel-error-text">{e}</div> - )} - {item.helper && ( - <div className="cru-input-panel-helper-text"> - {convertI18nText(item.helper, t)} - </div> - )} - </div> - ); - } - })} - </div> - ); -}; - -export default InputPanel; diff --git a/FrontEnd/src/views/common/menu/Menu.css b/FrontEnd/src/views/common/menu/Menu.css deleted file mode 100644 index c3fa82c4..00000000 --- a/FrontEnd/src/views/common/menu/Menu.css +++ /dev/null @@ -1,24 +0,0 @@ -.cru-menu {
- min-width: 200px;
-}
-
-.cru-menu-item {
- font-size: 1em;
- padding: 0.5em 1.5em;
- cursor: pointer;
- transition: all 0.5s;
- color: var(--cru-theme-color);
-}
-
-.cru-menu-item:hover {
- color: var(--cru-theme-t-color);
- background-color: var(--cru-theme-color);
-}
-
-.cru-menu-item-icon {
- margin-right: 1em;
-}
-
-.cru-menu-divider {
- border-top: 1px solid #e9ecef;
-}
diff --git a/FrontEnd/src/views/common/menu/Menu.tsx b/FrontEnd/src/views/common/menu/Menu.tsx deleted file mode 100644 index de3b1664..00000000 --- a/FrontEnd/src/views/common/menu/Menu.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import * as React from "react"; -import classnames from "classnames"; -import { useTranslation } from "react-i18next"; - -import { convertI18nText, I18nText } from "@/common"; -import { PaletteColorType } from "@/palette"; - -import "./Menu.css"; - -export type MenuItem = - | { - type: "divider"; - } - | { - type: "button"; - text: I18nText; - iconClassName?: string; - color?: PaletteColorType; - onClick: () => void; - }; - -export type MenuItems = MenuItem[]; - -export type MenuProps = { - items: MenuItems; - onItemClicked?: () => void; - className?: string; - style?: React.CSSProperties; -}; - -export default function _Menu({ - items, - onItemClicked, - className, - style, -}: MenuProps): React.ReactElement | null { - const { t } = useTranslation(); - - return ( - <div className={classnames("cru-menu", className)} style={style}> - {items.map((item, index) => { - if (item.type === "divider") { - return <div key={index} className="cru-menu-divider" />; - } else { - return ( - <div - key={index} - className={classnames( - "cru-menu-item", - `cru-${item.color ?? "primary"}` - )} - onClick={() => { - item.onClick(); - onItemClicked?.(); - }} - > - {item.iconClassName != null ? ( - <i - className={classnames( - item.iconClassName, - "cru-menu-item-icon" - )} - /> - ) : null} - {convertI18nText(item.text, t)} - </div> - ); - } - })} - </div> - ); -} diff --git a/FrontEnd/src/views/common/menu/PopupMenu.css b/FrontEnd/src/views/common/menu/PopupMenu.css deleted file mode 100644 index f6654f68..00000000 --- a/FrontEnd/src/views/common/menu/PopupMenu.css +++ /dev/null @@ -1,6 +0,0 @@ -.cru-popup-menu-menu-container {
- z-index: 1040;
- border-radius: 5px;
- border: var(--cru-primary-color) 1px solid;
- background-color: white;
-}
diff --git a/FrontEnd/src/views/common/menu/PopupMenu.tsx b/FrontEnd/src/views/common/menu/PopupMenu.tsx deleted file mode 100644 index 74ca7aba..00000000 --- a/FrontEnd/src/views/common/menu/PopupMenu.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import classNames from "classnames"; -import * as React from "react"; -import { createPortal } from "react-dom"; -import { usePopper } from "react-popper"; - -import { useClickOutside } from "@/utilities/hooks"; - -import Menu, { MenuItems } from "./Menu"; - -import "./PopupMenu.css"; - -export interface PopupMenuProps { - items: MenuItems; - children?: React.ReactNode; - containerClassName?: string; - containerStyle?: React.CSSProperties; -} - -const PopupMenu: React.FC<PopupMenuProps> = ({ - items, - children, - containerClassName, - containerStyle, -}) => { - const [show, setShow] = React.useState<boolean>(false); - - const [referenceElement, setReferenceElement] = - React.useState<HTMLDivElement | null>(null); - const [popperElement, setPopperElement] = - React.useState<HTMLDivElement | null>(null); - const { styles, attributes } = usePopper(referenceElement, popperElement); - - useClickOutside(popperElement, () => setShow(false), true); - - return ( - <> - <div - ref={setReferenceElement} - className={classNames( - "cru-popup-menu-trigger-container", - containerClassName - )} - style={containerStyle} - onClick={() => setShow(true)} - > - {children} - </div> - {show - ? createPortal( - <div - ref={setPopperElement} - className="cru-popup-menu-menu-container" - style={styles.popper} - {...attributes.popper} - > - <Menu - items={items} - onItemClicked={() => { - setShow(false); - }} - /> - </div>, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - document.getElementById("portal")! - ) - : null} - </> - ); -}; - -export default PopupMenu; diff --git a/FrontEnd/src/views/common/tab/TabPages.tsx b/FrontEnd/src/views/common/tab/TabPages.tsx deleted file mode 100644 index cdb988e0..00000000 --- a/FrontEnd/src/views/common/tab/TabPages.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import * as React from "react"; - -import { I18nText, UiLogicError } from "@/common"; - -import Tabs from "./Tabs"; - -export interface TabPage { - name: string; - text: I18nText; - page: React.ReactNode; -} - -export interface TabPagesProps { - pages: TabPage[]; - actions?: React.ReactNode; - dense?: boolean; - className?: string; - style?: React.CSSProperties; - navClassName?: string; - navStyle?: React.CSSProperties; - pageContainerClassName?: string; - pageContainerStyle?: React.CSSProperties; -} - -const TabPages: React.FC<TabPagesProps> = ({ - pages, - actions, - dense, - className, - style, - navClassName, - navStyle, - pageContainerClassName, - pageContainerStyle, -}) => { - if (pages.length === 0) { - throw new UiLogicError("Page list can't be empty."); - } - - const [tab, setTab] = React.useState<string>(pages[0].name); - - const currentPage = pages.find((p) => p.name === tab); - - if (currentPage == null) { - throw new UiLogicError("Current tab value is bad."); - } - - return ( - <div className={className} style={style}> - <Tabs - tabs={pages.map((page) => ({ - name: page.name, - text: page.text, - onClick: () => { - setTab(page.name); - }, - }))} - dense={dense} - activeTabName={tab} - className={navClassName} - style={navStyle} - actions={actions} - /> - <div className={pageContainerClassName} style={pageContainerStyle}> - {currentPage.page} - </div> - </div> - ); -}; - -export default TabPages; diff --git a/FrontEnd/src/views/common/tab/Tabs.css b/FrontEnd/src/views/common/tab/Tabs.css deleted file mode 100644 index 395d16a7..00000000 --- a/FrontEnd/src/views/common/tab/Tabs.css +++ /dev/null @@ -1,33 +0,0 @@ -.cru-nav {
- border-bottom: var(--cru-primary-color) 1px solid;
- display: flex;
-}
-
-.cru-nav-item {
- color: var(--cru-primary-color);
- border: var(--cru-background-2-color) 0.5px solid;
- border-bottom: none;
- padding: 0.5em 1.5em;
- border-top-left-radius: 5px;
- border-top-right-radius: 5px;
- transition: all 0.5s;
- cursor: pointer;
-}
-
-.cru-nav.dense .cru-nav-item {
- padding: 0.2em 1em;
-}
-
-.cru-nav-item:hover {
- background-color: var(--cru-background-1-color);
-}
-
-.cru-nav-item.active {
- color: var(--cru-primary-t-color);
- background-color: var(--cru-primary-color);
- border-color: var(--cru-primary-color);
-}
-
-.cru-nav-action-area {
- margin-left: auto;
-}
diff --git a/FrontEnd/src/views/common/tab/Tabs.tsx b/FrontEnd/src/views/common/tab/Tabs.tsx deleted file mode 100644 index 3e3ef6fa..00000000 --- a/FrontEnd/src/views/common/tab/Tabs.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import * as React from "react"; -import { Link } from "react-router-dom"; -import { useTranslation } from "react-i18next"; -import classnames from "classnames"; - -import { convertI18nText, I18nText } from "@/common"; - -import "./Tabs.css"; - -export interface Tab { - name: string; - text: I18nText; - link?: string; - onClick?: () => void; -} - -export interface TabsProps { - activeTabName?: string; - actions?: React.ReactNode; - dense?: boolean; - tabs: Tab[]; - className?: string; - style?: React.CSSProperties; -} - -export default function Tabs(props: TabsProps): React.ReactElement | null { - const { tabs, activeTabName, className, style, dense, actions } = props; - - const { t } = useTranslation(); - - return ( - <div - className={classnames("cru-nav", dense && "dense", className)} - style={style} - > - {tabs.map((tab) => { - const active = activeTabName === tab.name; - const className = classnames("cru-nav-item", active && "active"); - - if (tab.link != null) { - return ( - <Link - key={tab.name} - to={tab.link} - onClick={tab.onClick} - className={className} - > - {convertI18nText(tab.text, t)} - </Link> - ); - } else { - return ( - <span key={tab.name} onClick={tab.onClick} className={className}> - {convertI18nText(tab.text, t)} - </span> - ); - } - })} - <div className="cru-nav-action-area">{actions}</div> - </div> - ); -} diff --git a/FrontEnd/src/views/common/user/UserAvatar.tsx b/FrontEnd/src/views/common/user/UserAvatar.tsx deleted file mode 100644 index fcff8c69..00000000 --- a/FrontEnd/src/views/common/user/UserAvatar.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import * as React from "react"; - -import { getHttpUserClient } from "@/http/user"; - -export interface UserAvatarProps - extends React.ImgHTMLAttributes<HTMLImageElement> { - username: string; -} - -const UserAvatar: React.FC<UserAvatarProps> = ({ username, ...otherProps }) => { - return ( - <img - src={getHttpUserClient().generateAvatarUrl(username)} - {...otherProps} - /> - ); -}; - -export default UserAvatar; diff --git a/FrontEnd/src/views/home/TimelineListView.tsx b/FrontEnd/src/views/home/TimelineListView.tsx deleted file mode 100644 index fbcdc9b0..00000000 --- a/FrontEnd/src/views/home/TimelineListView.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import * as React from "react"; -import { useTranslation } from "react-i18next"; -import { Link } from "react-router-dom"; - -import { convertI18nText, I18nText } from "@/common"; - -import { TimelineBookmark } from "@/http/bookmark"; - -import IconButton from "../common/button/IconButton"; - -interface TimelineListItemProps { - timeline: TimelineBookmark; -} - -const TimelineListItem: React.FC<TimelineListItemProps> = ({ timeline }) => { - return ( - <div className="home-timeline-list-item home-timeline-list-item-timeline"> - <svg className="home-timeline-list-item-line" viewBox="0 0 120 100"> - <path - d="M 80,50 m 0,-12 a 12 12 180 1 1 0,24 12 12 180 1 1 0,-24 z M 60,0 h 40 v 100 h -40 z" - fillRule="evenodd" - fill="#007bff" - /> - </svg> - <div> - {timeline.timelineOwner}/{timeline.timelineName} - </div> - <Link to={`${timeline.timelineOwner}/${timeline.timelineName}`}> - <IconButton icon="arrow-right" className="ms-3" /> - </Link> - </div> - ); -}; - -const TimelineListArrow: React.FC = () => { - return ( - <div> - <div className="home-timeline-list-item"> - <svg className="home-timeline-list-item-line" viewBox="0 0 120 60"> - <path d="M 60,0 h 40 v 20 l -20,20 l -20,-20 z" fill="#007bff" /> - </svg> - </div> - <div className="home-timeline-list-item"> - <svg - className="home-timeline-list-item-line home-timeline-list-loading-head" - viewBox="0 0 120 40" - > - <path - d="M 60,10 l 20,20 l 20,-20" - fill="none" - stroke="#007bff" - strokeWidth="5" - /> - </svg> - </div> - </div> - ); -}; - -interface TimelineListViewProps { - headerText?: I18nText; - timelines?: TimelineBookmark[]; -} - -const TimelineListView: React.FC<TimelineListViewProps> = ({ - headerText, - timelines, -}) => { - const { t } = useTranslation(); - - return ( - <div className="home-timeline-list"> - <div className="home-timeline-list-item"> - <svg className="home-timeline-list-item-line" viewBox="0 0 120 120"> - <path - d="M 0,20 Q 80,20 80,80 l 0,40" - stroke="#007bff" - strokeWidth="40" - fill="none" - /> - </svg> - <h3>{convertI18nText(headerText, t)}</h3> - </div> - {timelines != null - ? timelines.map((t) => ( - <TimelineListItem - key={`${t.timelineOwner}/${t.timelineName}`} - timeline={t} - /> - )) - : null} - <TimelineListArrow /> - </div> - ); -}; - -export default TimelineListView; diff --git a/FrontEnd/src/views/home/WebsiteIntroduction.tsx b/FrontEnd/src/views/home/WebsiteIntroduction.tsx deleted file mode 100644 index e843c325..00000000 --- a/FrontEnd/src/views/home/WebsiteIntroduction.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import * as React from "react"; -import { useTranslation } from "react-i18next"; -import { Link } from "react-router-dom"; - -const WebsiteIntroduction: React.FC<{ - className?: string; - style?: React.CSSProperties; -}> = ({ className, style }) => { - const { i18n } = useTranslation(); - - if (i18n.language.startsWith("zh")) { - return ( - <div className={className} style={style}> - <h2> - 欢迎来到<strong>时间线</strong>!🎉🎉🎉 - </h2> - <p> - 本网站由无数个独立的时间线构成,每一个时间线都是一个消息列表,类似于一个聊天软件(比如QQ)。 - </p> - <p> - 如果你拥有一个账号,<Link to="/login">登陆</Link> - 后你可以自由地在属于你的时间线中发送内容,支持markdown和上传图片哦!你可以创建一个新的时间线来开启一个新的话题。你也可以设置相关权限,只让一部分人能看到时间线的内容。 - </p> - <p> - 如果你没有账号,那么你可以去浏览一下公开的时间线,比如下面这些站长设置的高光时间线。 - </p> - <p> - 鉴于这个网站在我的小型服务器上部署,所以没有开放注册。如果你也想把这个服务部署到自己的服务器上,你可以在 - <Link to="/about">关于</Link>页面找到一些信息。 - </p> - <p> - <small className="text-secondary"> - 这一段介绍是我的对象抱怨多次我的网站他根本看不明白之后加的,希望你能顺利看懂这个网站的逻辑!😅 - </small> - </p> - </div> - ); - } else { - return ( - <div className={className} style={style}> - <h2> - Welcome to <strong>Timeline</strong>!🎉🎉🎉 - </h2> - <p> - This website consists of many individual timelines. Each timeline is a - list of messages just like a chat app. - </p> - <p> - If you do have an account, you can <Link to="/login">login</Link> and - post messages, which supports Markdown and images, in your timelines. - You can also create a new timeline to open a new topic. You can set - the permission of a timeline to only allow specified people to see the - content of the timeline. - </p> - <p> - If you don't have an account, you can view some public timelines - like highlight timelines below set by website manager. - </p> - <p> - Since this website is hosted on my tiny server, so account registry is - not opened. If you want to host this service on your own server, you - can find some useful information on <Link to="/about">about</Link>{" "} - page. - </p> - <p> - <small className="text-secondary"> - This introduction is added after my lover complained a lot of times - about the obscuration of my website. May you understand the logic of - it!😅 - </small> - </p> - </div> - ); - } -}; - -export default WebsiteIntroduction; diff --git a/FrontEnd/src/views/home/index.css b/FrontEnd/src/views/home/index.css deleted file mode 100644 index 89d36f0d..00000000 --- a/FrontEnd/src/views/home/index.css +++ /dev/null @@ -1,42 +0,0 @@ -.home-timeline-list-item {
- display: flex;
- align-items: center;
-}
-
-.home-timeline-list-item-timeline {
- transition: background 0.8s;
- animation: 0.8s home-timeline-list-item-timeline-enter;
-}
-.home-timeline-list-item-timeline:hover {
- background: #e9ecef;
-}
-
-@keyframes home-timeline-list-item-timeline-enter {
- from {
- transform: translate(-100%, 0);
- opacity: 0;
- }
-}
-.home-timeline-list-item-line {
- width: 80px;
- flex-shrink: 0;
-}
-
-@keyframes home-timeline-list-loading-head-animation {
- from {
- transform: translate(0, -30px);
- opacity: 1;
- }
- to {
- opacity: 0;
- }
-}
-.home-timeline-list-loading-head {
- animation: 1s infinite home-timeline-list-loading-head-animation;
-}
-
-@media (min-width: 576px) {
- .home-search {
- float: right;
- }
-}
diff --git a/FrontEnd/src/views/home/index.tsx b/FrontEnd/src/views/home/index.tsx deleted file mode 100644 index 3c80fb0c..00000000 --- a/FrontEnd/src/views/home/index.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import * as React from "react"; -import { useNavigate } from "react-router-dom"; - -import { highlightTimelineUsername } from "@/common"; - -import { Page } from "@/http/common"; -import { getHttpBookmarkClient, TimelineBookmark } from "@/http/bookmark"; - -import SearchInput from "../common/SearchInput"; -import TimelineListView from "./TimelineListView"; -import WebsiteIntroduction from "./WebsiteIntroduction"; - -import "./index.css"; - -const highlightTimelineMessageMap = { - loading: "home.loadingHighlightTimelines", - done: "home.loadedHighlightTimelines", - error: "home.errorHighlightTimelines", -} as const; - -const HomeV2: React.FC = () => { - const navigate = useNavigate(); - - const [navText, setNavText] = React.useState<string>(""); - - const [highlightTimelineState, setHighlightTimelineState] = React.useState< - "loading" | "done" | "error" - >("loading"); - const [highlightTimelines, setHighlightTimelines] = React.useState< - Page<TimelineBookmark> | undefined - >(); - - React.useEffect(() => { - if (highlightTimelineState === "loading") { - let subscribe = true; - void getHttpBookmarkClient() - .list(highlightTimelineUsername) - .then( - (data) => { - if (subscribe) { - setHighlightTimelineState("done"); - setHighlightTimelines(data); - } - }, - () => { - if (subscribe) { - setHighlightTimelineState("error"); - setHighlightTimelines(undefined); - } - } - ); - return () => { - subscribe = false; - }; - } - }, [highlightTimelineState]); - - return ( - <> - <SearchInput - className="mx-2 my-3 home-search" - value={navText} - onChange={setNavText} - onButtonClick={() => { - navigate(`search?q=${navText}`); - }} - alwaysOneline - /> - <WebsiteIntroduction className="m-2" /> - <TimelineListView - headerText={highlightTimelineMessageMap[highlightTimelineState]} - timelines={highlightTimelines?.items} - /> - </> - ); -}; - -export default HomeV2; diff --git a/FrontEnd/src/views/login/index.css b/FrontEnd/src/views/login/index.css deleted file mode 100644 index aefe57e8..00000000 --- a/FrontEnd/src/views/login/index.css +++ /dev/null @@ -1,8 +0,0 @@ -.login-container {
- max-width: 25em;
-}
-
-.login-container input[type="text"],
-.login-container input[type="password"] {
- width: 100%;
-}
diff --git a/FrontEnd/src/views/login/index.tsx b/FrontEnd/src/views/login/index.tsx deleted file mode 100644 index cc1d9865..00000000 --- a/FrontEnd/src/views/login/index.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import * as React from "react"; -import { Link, useNavigate } from "react-router-dom"; -import { useTranslation, Trans } from "react-i18next"; - -import { useUser, userService } from "@/services/user"; - -import AppBar from "../common/AppBar"; -import LoadingButton from "../common/button/LoadingButton"; - -import "./index.css"; - -const LoginPage: React.FC = () => { - const { t } = useTranslation(); - - const navigate = useNavigate(); - - const [username, setUsername] = React.useState<string>(""); - const [usernameDirty, setUsernameDirty] = React.useState<boolean>(false); - const [password, setPassword] = React.useState<string>(""); - const [passwordDirty, setPasswordDirty] = React.useState<boolean>(false); - const [rememberMe, setRememberMe] = React.useState<boolean>(true); - const [process, setProcess] = React.useState<boolean>(false); - const [error, setError] = React.useState<string | null>(null); - - const user = useUser(); - - React.useEffect(() => { - if (user != null) { - const id = setTimeout(() => navigate("/"), 3000); - return () => { - clearTimeout(id); - }; - } - }, [navigate, user]); - - if (user != null) { - return ( - <> - <AppBar /> - <p>{t("login.alreadyLogin")}</p> - </> - ); - } - - const submit = (): void => { - if (username === "" || password === "") { - setUsernameDirty(true); - setPasswordDirty(true); - return; - } - - setProcess(true); - userService - .login( - { - username: username, - password: password, - }, - rememberMe - ) - .then( - () => { - if (history.length === 0) { - navigate("/"); - } else { - navigate(-1); - } - }, - (e: Error) => { - setProcess(false); - setError(e.message); - } - ); - }; - - const onEnterPressInPassword: React.KeyboardEventHandler = (e) => { - if (e.key === "Enter") { - submit(); - } - }; - - return ( - <div className="login-container container-fluid mt-2"> - <h1 className="cru-text-center cru-color-primary">{t("welcome")}</h1> - <div className="cru-operation-dialog-group"> - <label className="cru-operation-dialog-label" htmlFor="username"> - {t("user.username")} - </label> - <input - id="username" - type="text" - disabled={process} - onChange={(e) => { - setUsername(e.target.value); - setUsernameDirty(true); - }} - value={username} - /> - {usernameDirty && username === "" && ( - <div className="cru-operation-dialog-error-text"> - {t("login.emptyUsername")} - </div> - )} - </div> - <div className="cru-operation-dialog-group"> - <label className="cru-operation-dialog-label" htmlFor="password"> - {t("user.password")} - </label> - <input - id="password" - type="password" - disabled={process} - onChange={(e) => { - setPassword(e.target.value); - setPasswordDirty(true); - }} - value={password} - onKeyDown={onEnterPressInPassword} - /> - {passwordDirty && password === "" && ( - <div className="cru-operation-dialog-error-text"> - {t("login.emptyPassword")} - </div> - )} - </div> - <div className="cru-operation-dialog-group"> - <input - id="remember-me" - type="checkbox" - checked={rememberMe} - onChange={(e) => { - setRememberMe(e.currentTarget.checked); - }} - /> - <label className="cru-operation-dialog-inline-label"> - {t("user.rememberMe")} - </label> - </div> - {error ? <p className="cru-color-danger">{t(error)}</p> : null} - <div className="cru-text-end"> - <LoadingButton - loading={process} - onClick={(e) => { - submit(); - e.preventDefault(); - }} - disabled={username === "" || password === "" ? true : undefined} - > - {t("user.login")} - </LoadingButton> - </div> - <Trans i18nKey="login.noAccount"> - 0<Link to="/register">1</Link>2 - </Trans> - </div> - ); -}; - -export default LoginPage; diff --git a/FrontEnd/src/views/register/index.tsx b/FrontEnd/src/views/register/index.tsx deleted file mode 100644 index c1b95ff7..00000000 --- a/FrontEnd/src/views/register/index.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import * as React from "react"; -import { useTranslation } from "react-i18next"; -import { useNavigate } from "react-router-dom"; - -import { HttpBadRequestError } from "@/http/common"; -import { getHttpTokenClient } from "@/http/token"; -import { userService, useUser } from "@/services/user"; - -import { LoadingButton } from "../common/button"; -import InputPanel, { - hasError, - InputPanelError, -} from "../common/input/InputPanel"; - -import "./index.css"; - -const validate = (values: string[], dirties: boolean[]): InputPanelError => { - const e: InputPanelError = {}; - if (dirties[0] && values[0].length === 0) { - e[0] = "register.error.usernameEmpty"; - } - if (dirties[1] && values[1].length === 0) { - e[1] = "register.error.passwordEmpty"; - } - if (dirties[2] && values[2] !== values[1]) { - e[2] = "register.error.confirmPasswordWrong"; - } - if (dirties[3] && values[3].length === 0) { - e[3] = "register.error.registerCodeEmpty"; - } - return e; -}; - -const RegisterPage: React.FC = () => { - const navigate = useNavigate(); - - const { t } = useTranslation(); - - const [username, setUsername] = React.useState<string>(""); - const [password, setPassword] = React.useState<string>(""); - const [confirmPassword, setConfirmPassword] = React.useState<string>(""); - const [registerCode, setRegisterCode] = React.useState<string>(""); - - const [dirty, setDirty] = React.useState<boolean[]>(new Array(4).fill(false)); - - const [process, setProcess] = React.useState<boolean>(false); - - const [inputError, setInputError] = React.useState<InputPanelError>(); - const [resultError, setResultError] = React.useState<string | null>(null); - - const user = useUser(); - - React.useEffect(() => { - if (user != null) { - navigate("/"); - } - }); - - return ( - <div className="container register-page"> - <InputPanel - scheme={[ - { - type: "text", - label: "register.username", - }, - { - type: "text", - label: "register.password", - password: true, - }, - { - type: "text", - label: "register.confirmPassword", - password: true, - }, - { type: "text", label: "register.registerCode" }, - ]} - values={[username, password, confirmPassword, registerCode]} - onChange={(values, index) => { - setUsername(values[0]); - setPassword(values[1]); - setConfirmPassword(values[2]); - setRegisterCode(values[3]); - const newDirty = dirty.slice(); - newDirty[index] = true; - setDirty(newDirty); - - setInputError(validate(values, newDirty)); - }} - error={inputError} - disable={process} - /> - {resultError && <div className="cru-color-danger">{t(resultError)}</div>} - <LoadingButton - text="register.register" - loading={process} - disabled={hasError(inputError)} - onClick={() => { - const newDirty = dirty.slice().fill(true); - setDirty(newDirty); - const e = validate( - [username, password, confirmPassword, registerCode], - newDirty - ); - if (hasError(e)) { - setInputError(e); - } else { - setProcess(true); - void getHttpTokenClient() - .register({ - username, - password, - registerCode, - }) - .then( - () => { - void userService - .login({ username, password }, true) - .then(() => { - navigate("/"); - }); - }, - (error) => { - if (error instanceof HttpBadRequestError) { - setResultError("register.error.registerCodeInvalid"); - } else { - setResultError("error.network"); - } - setProcess(false); - } - ); - } - }} - /> - </div> - ); -}; - -export default RegisterPage; diff --git a/FrontEnd/src/views/search/index.css b/FrontEnd/src/views/search/index.css deleted file mode 100644 index 6ff4d9fa..00000000 --- a/FrontEnd/src/views/search/index.css +++ /dev/null @@ -1,15 +0,0 @@ -.timeline-search-result-item {
- border: 1px solid;
- border-color: #e9ecef;
- background: #f8f9fa;
- transition: all 0.3s;
-}
-.timeline-search-result-item:hover {
- border-color: #0d6efd;
-}
-
-.timeline-search-result-item-avatar {
- width: 2em;
- height: 2em;
- border-radius: 50%;
-}
diff --git a/FrontEnd/src/views/search/index.tsx b/FrontEnd/src/views/search/index.tsx deleted file mode 100644 index 58257465..00000000 --- a/FrontEnd/src/views/search/index.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import * as React from "react"; -import { useTranslation } from "react-i18next"; -import { useNavigate, useLocation } from "react-router-dom"; -import { Link } from "react-router-dom"; - -import { HttpNetworkError } from "@/http/common"; -import { getHttpSearchClient } from "@/http/search"; -import { HttpTimelineInfo } from "@/http/timeline"; - -import SearchInput from "../common/SearchInput"; -import UserAvatar from "../common/user/UserAvatar"; - -import "./index.css"; - -const TimelineSearchResultItemView: React.FC<{ - timeline: HttpTimelineInfo; -}> = ({ timeline }) => { - return ( - <div className="timeline-search-result-item my-2 p-3"> - <h4> - <Link - to={`/${timeline.owner.username}/${timeline.nameV2}`} - className="mb-2 text-primary" - > - {timeline.title} - <small className="ms-3 text-secondary">{timeline.nameV2}</small> - </Link> - </h4> - <div> - <UserAvatar - username={timeline.owner.username} - className="timeline-search-result-item-avatar me-2" - /> - {timeline.owner.nickname} - <small className="ms-3 text-secondary"> - @{timeline.owner.username} - </small> - </div> - </div> - ); -}; - -const SearchPage: React.FC = () => { - const { t } = useTranslation(); - - const navigate = useNavigate(); - const location = useLocation(); - const searchParams = new URLSearchParams(location.search); - const queryParam = searchParams.get("q"); - - const [searchText, setSearchText] = React.useState<string>(""); - const [state, setState] = React.useState< - HttpTimelineInfo[] | "init" | "loading" | "network-error" | "error" - >("init"); - - const [forceResearchKey, setForceResearchKey] = React.useState<number>(0); - - React.useEffect(() => { - setState("init"); - if (queryParam != null && queryParam.length > 0) { - setSearchText(queryParam); - setState("loading"); - void getHttpSearchClient() - .searchTimelines(queryParam) - .then( - (ts) => { - setState(ts); - }, - (e) => { - if (e instanceof HttpNetworkError) { - setState("network-error"); - } else { - setState("error"); - } - } - ); - } - }, [queryParam, forceResearchKey]); - - return ( - <div className="container my-3"> - <div className="row justify-content-center"> - <SearchInput - className="col-12 col-sm-9 col-md-6" - value={searchText} - onChange={setSearchText} - loading={state === "loading"} - onButtonClick={() => { - if (queryParam === searchText) { - setForceResearchKey((old) => old + 1); - } else { - navigate(`/search?q=${searchText}`); - } - }} - /> - </div> - {(() => { - switch (state) { - case "init": { - if (queryParam == null || queryParam.length === 0) { - return <div>{t("searchPage.input")}</div>; - } - break; - } - case "loading": { - return <div>{t("searchPage.loading")}</div>; - } - case "network-error": { - return <div className="text-danger">{t("error.network")}</div>; - } - case "error": { - return <div className="text-danger">{t("error.unknown")}</div>; - } - default: { - if (state.length === 0) { - return <div>{t("searchPage.noResult")}</div>; - } - return state.map((t) => ( - <TimelineSearchResultItemView - key={`${t.owner.username}/${t.nameV2}`} - timeline={t} - /> - )); - } - } - })()} - </div> - ); -}; - -export default SearchPage; diff --git a/FrontEnd/src/views/settings/ChangeAvatarDialog.tsx b/FrontEnd/src/views/settings/ChangeAvatarDialog.tsx deleted file mode 100644 index 44bd2c68..00000000 --- a/FrontEnd/src/views/settings/ChangeAvatarDialog.tsx +++ /dev/null @@ -1,354 +0,0 @@ -import { useState, useEffect } from "react"; -import * as React from "react"; -import { useTranslation } from "react-i18next"; -import { AxiosError } from "axios"; - -import { convertI18nText, I18nText, UiLogicError } from "@/common"; - -import { useUser } from "@/services/user"; - -import { getHttpUserClient } from "@/http/user"; - -import ImageCropper, { Clip, applyClipToImage } from "../common/ImageCropper"; -import Button from "../common/button/Button"; -import Dialog from "../common/dialog/Dialog"; - -export interface ChangeAvatarDialogProps { - open: boolean; - close: () => void; -} - -const ChangeAvatarDialog: React.FC<ChangeAvatarDialogProps> = (props) => { - const { t } = useTranslation(); - - const user = useUser(); - - const [file, setFile] = React.useState<File | null>(null); - const [fileUrl, setFileUrl] = React.useState<string | null>(null); - const [clip, setClip] = React.useState<Clip | null>(null); - const [cropImgElement, setCropImgElement] = - React.useState<HTMLImageElement | null>(null); - const [resultBlob, setResultBlob] = React.useState<Blob | null>(null); - const [resultUrl, setResultUrl] = React.useState<string | null>(null); - - const [state, setState] = React.useState< - | "select" - | "crop" - | "processcrop" - | "preview" - | "uploading" - | "success" - | "error" - >("select"); - - const [message, setMessage] = useState<I18nText>( - "settings.dialogChangeAvatar.prompt.select" - ); - - const trueMessage = convertI18nText(message, t); - - const closeDialog = props.close; - - const close = React.useCallback((): void => { - if (!(state === "uploading")) { - closeDialog(); - } - }, [state, closeDialog]); - - useEffect(() => { - if (file != null) { - const url = URL.createObjectURL(file); - setClip(null); - setFileUrl(url); - setState("crop"); - return () => { - URL.revokeObjectURL(url); - }; - } else { - setFileUrl(null); - setState("select"); - } - }, [file]); - - React.useEffect(() => { - if (resultBlob != null) { - const url = URL.createObjectURL(resultBlob); - setResultUrl(url); - setState("preview"); - return () => { - URL.revokeObjectURL(url); - }; - } else { - setResultUrl(null); - } - }, [resultBlob]); - - const onSelectFile = React.useCallback( - (e: React.ChangeEvent<HTMLInputElement>): void => { - const files = e.target.files; - if (files == null || files.length === 0) { - setFile(null); - } else { - setFile(files[0]); - } - }, - [] - ); - - const onCropNext = React.useCallback(() => { - if ( - cropImgElement == null || - clip == null || - clip.width === 0 || - file == null - ) { - throw new UiLogicError(); - } - - setState("processcrop"); - void applyClipToImage(cropImgElement, clip, file.type).then((b) => { - setResultBlob(b); - }); - }, [cropImgElement, clip, file]); - - const onCropPrevious = React.useCallback(() => { - setFile(null); - setState("select"); - }, []); - - const onPreviewPrevious = React.useCallback(() => { - setResultBlob(null); - setState("crop"); - }, []); - - const upload = React.useCallback(() => { - if (resultBlob == null) { - throw new UiLogicError(); - } - - if (user == null) { - throw new UiLogicError(); - } - - setState("uploading"); - getHttpUserClient() - .putAvatar(user.username, resultBlob) - .then( - () => { - setState("success"); - }, - (e: unknown) => { - setState("error"); - setMessage({ type: "custom", value: (e as AxiosError).message }); - } - ); - }, [user, resultBlob]); - - const createPreviewRow = (): React.ReactElement => { - if (resultUrl == null) { - throw new UiLogicError(); - } - return ( - <div className="row justify-content-center"> - <div className="col col-auto"> - <img - className="change-avatar-img" - src={resultUrl} - alt={t("settings.dialogChangeAvatar.previewImgAlt") ?? undefined} - /> - </div> - </div> - ); - }; - - return ( - <Dialog open={props.open} onClose={close}> - <h3 className="cru-color-primary"> - {t("settings.dialogChangeAvatar.title")} - </h3> - <hr /> - {(() => { - if (state === "select") { - return ( - <> - <div className="container"> - <div className="row"> - {t("settings.dialogChangeAvatar.prompt.select")} - </div> - <div className="row"> - <input - className="px-0" - type="file" - accept="image/*" - onChange={onSelectFile} - /> - </div> - </div> - <hr /> - <div className="cru-dialog-bottom-area"> - <Button - text="operationDialog.cancel" - color="secondary" - onClick={close} - /> - </div> - </> - ); - } else if (state === "crop") { - if (fileUrl == null) { - throw new UiLogicError(); - } - return ( - <> - <div className="container"> - <div className="row justify-content-center"> - <ImageCropper - clip={clip} - onChange={setClip} - imageUrl={fileUrl} - imageElementCallback={setCropImgElement} - /> - </div> - <div className="row"> - {t("settings.dialogChangeAvatar.prompt.crop")} - </div> - </div> - <hr /> - <div className="cru-dialog-bottom-area"> - <Button - text="operationDialog.cancel" - color="secondary" - outline - onClick={close} - /> - <Button - text="operationDialog.previousStep" - color="secondary" - outline - onClick={onCropPrevious} - /> - <Button - text="operationDialog.nextStep" - color="primary" - onClick={onCropNext} - disabled={ - cropImgElement == null || clip == null || clip.width === 0 - } - /> - </div> - </> - ); - } else if (state === "processcrop") { - return ( - <> - <div className="container"> - <div className="row"> - {t("settings.dialogChangeAvatar.prompt.processingCrop")} - </div> - </div> - <hr /> - <div className="cru-dialog-bottom-area"> - <Button - text="operationDialog.cancel" - color="secondary" - onClick={close} - outline - /> - <Button - text="operationDialog.previousStep" - color="secondary" - onClick={onPreviewPrevious} - outline - /> - </div> - </> - ); - } else if (state === "preview") { - return ( - <> - <div className="container"> - {createPreviewRow()} - <div className="row"> - {t("settings.dialogChangeAvatar.prompt.preview")} - </div> - </div> - <hr /> - <div className="cru-dialog-bottom-area"> - <Button - text="operationDialog.cancel" - color="secondary" - outline - onClick={close} - /> - <Button - text="operationDialog.previousStep" - color="secondary" - outline - onClick={onPreviewPrevious} - /> - <Button - text="settings.dialogChangeAvatar.upload" - color="primary" - onClick={upload} - /> - </div> - </> - ); - } else if (state === "uploading") { - return ( - <> - <div className="container"> - {createPreviewRow()} - <div className="row"> - {t("settings.dialogChangeAvatar.prompt.uploading")} - </div> - </div> - </> - ); - } else if (state === "success") { - return ( - <> - <div className="container"> - <div className="row p-4 text-success"> - {t("operationDialog.success")} - </div> - </div> - <hr /> - <div className="cru-dialog-bottom-area"> - <Button - text="operationDialog.ok" - color="success" - onClick={close} - /> - </div> - </> - ); - } else { - return ( - <> - <div className="container"> - {createPreviewRow()} - <div className="row text-danger">{trueMessage}</div> - </div> - <hr /> - <div> - <Button - text="operationDialog.cancel" - color="secondary" - onClick={close} - /> - <Button - text="operationDialog.retry" - color="primary" - onClick={upload} - /> - </div> - </> - ); - } - })()} - </Dialog> - ); -}; - -export default ChangeAvatarDialog; diff --git a/FrontEnd/src/views/settings/ChangeNicknameDialog.tsx b/FrontEnd/src/views/settings/ChangeNicknameDialog.tsx deleted file mode 100644 index 7ba12de8..00000000 --- a/FrontEnd/src/views/settings/ChangeNicknameDialog.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { getHttpUserClient } from "@/http/user"; -import { useUser } from "@/services/user"; -import * as React from "react"; - -import OperationDialog from "../common/dialog/OperationDialog"; - -export interface ChangeNicknameDialogProps { - open: boolean; - close: () => void; -} - -const ChangeNicknameDialog: React.FC<ChangeNicknameDialogProps> = (props) => { - const user = useUser(); - - if (user == null) return null; - - return ( - <OperationDialog - open={props.open} - title="settings.dialogChangeNickname.title" - inputScheme={[ - { type: "text", label: "settings.dialogChangeNickname.inputLabel" }, - ]} - onProcess={([newNickname]) => { - return getHttpUserClient().patch(user.username, { - nickname: newNickname, - }); - }} - onClose={props.close} - /> - ); -}; - -export default ChangeNicknameDialog; diff --git a/FrontEnd/src/views/settings/ChangePasswordDialog.tsx b/FrontEnd/src/views/settings/ChangePasswordDialog.tsx deleted file mode 100644 index a34ca4a7..00000000 --- a/FrontEnd/src/views/settings/ChangePasswordDialog.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { useState } from "react"; -import * as React from "react"; -import { useNavigate } from "react-router-dom"; - -import { userService } from "@/services/user"; - -import OperationDialog from "../common/dialog/OperationDialog"; - -export interface ChangePasswordDialogProps { - open: boolean; - close: () => void; -} - -const ChangePasswordDialog: React.FC<ChangePasswordDialogProps> = (props) => { - const navigate = useNavigate(); - - const [redirect, setRedirect] = useState<boolean>(false); - - return ( - <OperationDialog - open={props.open} - title="settings.dialogChangePassword.title" - themeColor="danger" - inputPrompt="settings.dialogChangePassword.prompt" - inputScheme={[ - { - type: "text", - label: "settings.dialogChangePassword.inputOldPassword", - password: true, - }, - { - type: "text", - label: "settings.dialogChangePassword.inputNewPassword", - password: true, - }, - { - type: "text", - label: "settings.dialogChangePassword.inputRetypeNewPassword", - password: true, - }, - ]} - 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, newPassword); - setRedirect(true); - }} - onClose={() => { - props.close(); - if (redirect) { - navigate("/login"); - } - }} - /> - ); -}; - -export default ChangePasswordDialog; diff --git a/FrontEnd/src/views/settings/index.css b/FrontEnd/src/views/settings/index.css deleted file mode 100644 index ccf7a97a..00000000 --- a/FrontEnd/src/views/settings/index.css +++ /dev/null @@ -1,31 +0,0 @@ -.change-avatar-cropper-row {
- max-height: 400px;
-}
-
-.change-avatar-img {
- min-width: 50%;
- max-width: 100%;
- max-height: 400px;
-}
-
-.settings-item {
- padding: 0.5em 1em;
- transition: background 0.3s;
- border-bottom: 1px solid #e9ecef;
- align-items: center;
-}
-.settings-item.first {
- border-top: 1px solid #e9ecef;
-}
-.settings-item.clickable {
- cursor: pointer;
-}
-.settings-item:hover {
- background: #dee2e6;
-}
-
-.register-code {
- border: 1px solid black;
- border-radius: 3px;
- padding: 0.2em;
-}
\ No newline at end of file diff --git a/FrontEnd/src/views/settings/index.tsx b/FrontEnd/src/views/settings/index.tsx deleted file mode 100644 index 6647826f..00000000 --- a/FrontEnd/src/views/settings/index.tsx +++ /dev/null @@ -1,338 +0,0 @@ -import { useState } from "react"; -import * as React from "react"; -import { useNavigate } from "react-router-dom"; -import { useTranslation } from "react-i18next"; -import classNames from "classnames"; - -import { convertI18nText, I18nText, UiLogicError } from "@/common"; -import { useUser, userService } from "@/services/user"; -import { getHttpUserClient } from "@/http/user"; -import { TimelineVisibility } from "@/http/timeline"; - -import ConfirmDialog from "../common/dialog/ConfirmDialog"; -import Card from "../common/Card"; -import Spinner from "../common/Spinner"; -import ChangePasswordDialog from "./ChangePasswordDialog"; -import ChangeAvatarDialog from "./ChangeAvatarDialog"; -import ChangeNicknameDialog from "./ChangeNicknameDialog"; - -import "./index.css"; -import { pushAlert } from "@/services/alert"; - -interface SettingSectionProps { - title: I18nText; - children: React.ReactNode; -} - -const SettingSection: React.FC<SettingSectionProps> = ({ title, children }) => { - const { t } = useTranslation(); - - return ( - <Card className="my-3 py-3"> - <h3 className="px-3 mb-3 cru-color-primary"> - {convertI18nText(title, t)} - </h3> - {children} - </Card> - ); -}; - -interface SettingItemContainerWithoutChildrenProps { - title: I18nText; - subtext?: I18nText; - first?: boolean; - danger?: boolean; - style?: React.CSSProperties; - className?: string; - onClick?: () => void; -} - -interface SettingItemContainerProps - extends SettingItemContainerWithoutChildrenProps { - children?: React.ReactNode; -} - -function SettingItemContainer({ - title, - subtext, - first, - danger, - children, - style, - className, - onClick, -}: SettingItemContainerProps): JSX.Element { - const { t } = useTranslation(); - - return ( - <div - style={style} - className={classNames( - "row settings-item mx-0", - first && "first", - onClick && "clickable", - className, - )} - onClick={onClick} - > - <div className="px-0 col col-auto"> - <div className={classNames(danger && "cru-color-danger")}> - {convertI18nText(title, t)} - </div> - <small className="d-block cru-color-secondary"> - {convertI18nText(subtext, t)} - </small> - </div> - <div className="col col-auto">{children}</div> - </div> - ); -} - -type ButtonSettingItemProps = SettingItemContainerWithoutChildrenProps; - -const ButtonSettingItem: React.FC<ButtonSettingItemProps> = ({ ...props }) => { - return <SettingItemContainer {...props} />; -}; - -interface SelectSettingItemProps - extends SettingItemContainerWithoutChildrenProps { - options: { - value: string; - label: I18nText; - }[]; - value?: string; - onSelect: (value: string) => void; -} - -const SelectSettingsItem: React.FC<SelectSettingItemProps> = ({ - options, - value, - onSelect, - ...props -}) => { - const { t } = useTranslation(); - - return ( - <SettingItemContainer {...props}> - {value == null ? ( - <Spinner /> - ) : ( - <select - value={value} - onChange={(e) => { - onSelect(e.target.value); - }} - > - {options.map(({ value, label }) => ( - <option key={value} value={value}> - {convertI18nText(label, t)} - </option> - ))} - </select> - )} - </SettingItemContainer> - ); -}; - -const SettingsPage: React.FC = () => { - const { i18n } = useTranslation(); - const user = useUser(); - const navigate = useNavigate(); - - const [dialog, setDialog] = useState< - | null - | "changepassword" - | "changeavatar" - | "changenickname" - | "logout" - | "renewregistercode" - >(null); - - const [registerCode, setRegisterCode] = useState<undefined | null | string>( - undefined, - ); - - const [bookmarkVisibility, setBookmarkVisibility] = - useState<TimelineVisibility>(); - - React.useEffect(() => { - if (user != null) { - void getHttpUserClient() - .getBookmarkVisibility(user.username) - .then(({ visibility }) => { - setBookmarkVisibility(visibility); - }); - } else { - setBookmarkVisibility(undefined); - } - }, [user]); - - React.useEffect(() => { - setRegisterCode(undefined); - }, [user]); - - React.useEffect(() => { - if (user != null && registerCode === undefined) { - void getHttpUserClient() - .getRegisterCode(user.username) - .then((code) => { - setRegisterCode(code.registerCode ?? null); - }); - } - }, [user, registerCode]); - - const language = i18n.language.slice(0, 2); - - return ( - <> - <div className="container"> - {user ? ( - <SettingSection title="settings.subheaders.account"> - <SettingItemContainer - title="settings.myRegisterCode" - subtext="settings.myRegisterCodeDesc" - onClick={() => setDialog("renewregistercode")} - > - {registerCode === undefined ? ( - <Spinner /> - ) : registerCode === null ? ( - <span>Noop</span> - ) : ( - <code - className="register-code" - onClick={(event) => { - void navigator.clipboard - .writeText(registerCode) - .then(() => { - pushAlert({ - type: "success", - message: "settings.myRegisterCodeCopied", - }); - }); - event.stopPropagation(); - }} - > - {registerCode} - </code> - )} - </SettingItemContainer> - <ButtonSettingItem - title="settings.changeAvatar" - onClick={() => setDialog("changeavatar")} - first - /> - <ButtonSettingItem - title="settings.changeNickname" - onClick={() => setDialog("changenickname")} - /> - <SelectSettingsItem - title="settings.changeBookmarkVisibility" - options={[ - { - value: "Private", - label: "visibility.private", - }, - { - value: "Register", - label: "visibility.register", - }, - { - value: "Public", - label: "visibility.public", - }, - ]} - value={bookmarkVisibility} - onSelect={(value) => { - void getHttpUserClient() - .putBookmarkVisibility(user.username, { - visibility: value as TimelineVisibility, - }) - .then(() => { - setBookmarkVisibility(value as TimelineVisibility); - }); - }} - /> - <ButtonSettingItem - title="settings.changePassword" - onClick={() => setDialog("changepassword")} - danger - /> - <ButtonSettingItem - title="settings.logout" - onClick={() => { - setDialog("logout"); - }} - danger - /> - </SettingSection> - ) : null} - <SettingSection title="settings.subheaders.customization"> - <SelectSettingsItem - title="settings.languagePrimary" - subtext="settings.languageSecondary" - options={[ - { - value: "zh", - label: { - type: "custom", - value: "中文", - }, - }, - { - value: "en", - label: { - type: "custom", - value: "English", - }, - }, - ]} - value={language} - onSelect={(value) => { - void i18n.changeLanguage(value); - }} - first - /> - </SettingSection> - </div> - <ChangePasswordDialog - open={dialog === "changepassword"} - close={() => setDialog(null)} - /> - <ConfirmDialog - title="settings.dialogConfirmLogout.title" - body="settings.dialogConfirmLogout.prompt" - onClose={() => setDialog(null)} - open={dialog === "logout"} - onConfirm={() => { - void userService.logout().then(() => { - navigate("/"); - }); - }} - /> - <ConfirmDialog - title="settings.renewRegisterCode" - body="settings.renewRegisterCodeDesc" - onClose={() => setDialog(null)} - open={dialog === "renewregistercode"} - onConfirm={() => { - if (user == null) throw new UiLogicError(); - void getHttpUserClient() - .renewRegisterCode(user.username) - .then(() => { - setRegisterCode(undefined); - }); - }} - /> - <ChangeAvatarDialog - open={dialog === "changeavatar"} - close={() => setDialog(null)} - /> - <ChangeNicknameDialog - open={dialog === "changenickname"} - close={() => setDialog(null)} - /> - </> - ); -}; - -export default SettingsPage; diff --git a/FrontEnd/src/views/timeline/CollapseButton.tsx b/FrontEnd/src/views/timeline/CollapseButton.tsx deleted file mode 100644 index 374ccc2e..00000000 --- a/FrontEnd/src/views/timeline/CollapseButton.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import * as React from "react"; - -import IconButton from "../common/button/IconButton"; - -const CollapseButton: React.FC<{ - collapse: boolean; - onClick: () => void; - className?: string; - style?: React.CSSProperties; -}> = ({ collapse, onClick, className, style }) => { - return ( - <IconButton - icon={collapse ? "arrows-angle-expand" : "arrows-angle-contract"} - onClick={onClick} - className={className} - style={style} - /> - ); -}; - -export default CollapseButton; diff --git a/FrontEnd/src/views/timeline/MarkdownPostEdit.css b/FrontEnd/src/views/timeline/MarkdownPostEdit.css deleted file mode 100644 index e36be992..00000000 --- a/FrontEnd/src/views/timeline/MarkdownPostEdit.css +++ /dev/null @@ -1,21 +0,0 @@ -.timeline-markdown-post-edit-page {
- overflow: auto;
- max-height: 300px;
-}
-
-.timeline-markdown-post-edit-image-container {
- position: relative;
- text-align: center;
- margin-bottom: 1em;
-}
-
-.timeline-markdown-post-edit-image {
- max-width: 100%;
- max-height: 200px;
-}
-
-.timeline-markdown-post-edit-image-delete-button {
- position: absolute;
- right: 10px;
- top: 2px;
-}
diff --git a/FrontEnd/src/views/timeline/MarkdownPostEdit.tsx b/FrontEnd/src/views/timeline/MarkdownPostEdit.tsx deleted file mode 100644 index 6401cfaa..00000000 --- a/FrontEnd/src/views/timeline/MarkdownPostEdit.tsx +++ /dev/null @@ -1,215 +0,0 @@ -import * as React from "react"; -import classnames from "classnames"; -import { useTranslation } from "react-i18next"; - -import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline"; - -import TimelinePostBuilder from "@/services/TimelinePostBuilder"; - -import FlatButton from "../common/button/FlatButton"; -import TabPages from "../common/tab/TabPages"; -import ConfirmDialog from "../common/dialog/ConfirmDialog"; -import Spinner from "../common/Spinner"; -import IconButton from "../common/button/IconButton"; - -import "./MarkdownPostEdit.css"; - -export interface MarkdownPostEditProps { - owner: string; - timeline: string; - onPosted: (post: HttpTimelinePostInfo) => void; - onPostError: () => void; - onClose: () => void; - className?: string; - style?: React.CSSProperties; -} - -const MarkdownPostEdit: React.FC<MarkdownPostEditProps> = ({ - owner: ownerUsername, - timeline: timelineName, - onPosted, - onClose, - onPostError, - className, - style, -}) => { - const { t } = useTranslation(); - - const [canLeave, setCanLeave] = React.useState<boolean>(true); - - const [process, setProcess] = React.useState<boolean>(false); - - const [showLeaveConfirmDialog, setShowLeaveConfirmDialog] = - React.useState<boolean>(false); - - const [text, _setText] = React.useState<string>(""); - const [images, _setImages] = React.useState<{ file: File; url: string }[]>( - [] - ); - const [previewHtml, _setPreviewHtml] = React.useState<string>(""); - - const _builder = React.useRef<TimelinePostBuilder | null>(null); - - const getBuilder = (): TimelinePostBuilder => { - if (_builder.current == null) { - const builder = new TimelinePostBuilder(() => { - setCanLeave(builder.isEmpty); - _setText(builder.text); - _setImages(builder.images); - _setPreviewHtml(builder.renderHtml()); - }); - _builder.current = builder; - } - return _builder.current; - }; - - const canSend = text.length > 0; - - React.useEffect(() => { - return () => { - getBuilder().dispose(); - }; - }, []); - - React.useEffect(() => { - window.onbeforeunload = (): unknown => { - if (!canLeave) { - return t("timeline.confirmLeave"); - } - }; - - return () => { - window.onbeforeunload = null; - }; - }, [canLeave, t]); - - const send = async (): Promise<void> => { - setProcess(true); - try { - const dataList = await getBuilder().build(); - const post = await getHttpTimelineClient().postPost( - ownerUsername, - timelineName, - { - dataList, - } - ); - onPosted(post); - onClose(); - } catch (e) { - setProcess(false); - onPostError(); - } - }; - - return ( - <> - <TabPages - className={className} - style={style} - pageContainerClassName="py-2" - dense - actions={ - process ? ( - <Spinner /> - ) : ( - <div> - <IconButton - icon="x" - color="danger" - large - className="cru-align-middle me-2" - onClick={() => { - if (canLeave) { - onClose(); - } else { - setShowLeaveConfirmDialog(true); - } - }} - /> - {canSend && ( - <FlatButton text="timeline.send" onClick={() => void send()} /> - )} - </div> - ) - } - pages={[ - { - name: "text", - text: "edit", - page: ( - <textarea - value={text} - disabled={process} - className="cru-fill-parent" - onChange={(event) => { - getBuilder().setMarkdownText(event.currentTarget.value); - }} - /> - ), - }, - { - name: "images", - text: "image", - page: ( - <div className="timeline-markdown-post-edit-page"> - {images.map((image, index) => ( - <div - key={image.url} - className="timeline-markdown-post-edit-image-container" - > - <img - src={image.url} - className="timeline-markdown-post-edit-image" - /> - <IconButton - icon="trash" - color="danger" - className={classnames( - "timeline-markdown-post-edit-image-delete-button", - process && "d-none" - )} - onClick={() => { - getBuilder().deleteImage(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) { - getBuilder().appendImage(files[0]); - } - }} - disabled={process} - /> - </div> - ), - }, - { - name: "preview", - text: "preview", - page: ( - <div - className="markdown-container timeline-markdown-post-edit-page" - dangerouslySetInnerHTML={{ __html: previewHtml }} - /> - ), - }, - ]} - /> - <ConfirmDialog - onClose={() => setShowLeaveConfirmDialog(false)} - onConfirm={onClose} - open={showLeaveConfirmDialog} - title="timeline.dropDraft" - body="timeline.confirmLeave" - /> - </> - ); -}; - -export default MarkdownPostEdit; diff --git a/FrontEnd/src/views/timeline/PostPropertyChangeDialog.tsx b/FrontEnd/src/views/timeline/PostPropertyChangeDialog.tsx deleted file mode 100644 index fc55185c..00000000 --- a/FrontEnd/src/views/timeline/PostPropertyChangeDialog.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import * as React from "react"; - -import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline"; - -import OperationDialog from "../common/dialog/OperationDialog"; - -function PostPropertyChangeDialog(props: { - open: boolean; - onClose: () => void; - post: HttpTimelinePostInfo; - onSuccess: (post: HttpTimelinePostInfo) => void; -}): React.ReactElement | null { - const { open, onClose, post, onSuccess } = props; - - return ( - <OperationDialog - title="timeline.changePostPropertyDialog.title" - onClose={onClose} - open={open} - inputScheme={[ - { - label: "timeline.changePostPropertyDialog.time", - type: "datetime", - initValue: post.time, - }, - ]} - onProcess={([time]) => { - return getHttpTimelineClient().patchPost( - post.timelineOwnerV2, - post.timelineNameV2, - post.id, - { - time: time === "" ? undefined : new Date(time).toISOString(), - } - ); - }} - onSuccessAndClose={onSuccess} - /> - ); -} - -export default PostPropertyChangeDialog; diff --git a/FrontEnd/src/views/timeline/Timeline.css b/FrontEnd/src/views/timeline/Timeline.css deleted file mode 100644 index 4dd4fdcc..00000000 --- a/FrontEnd/src/views/timeline/Timeline.css +++ /dev/null @@ -1,244 +0,0 @@ -.timeline { - z-index: 0; - position: relative; - width: 100%; -} - -@keyframes timeline-line-node { - to { - box-shadow: 0 0 20px 3px var(--cru-primary-l1-color); - } -} - -@keyframes timeline-line-node-current { - to { - box-shadow: 0 0 20px 3px var(--cru-primary-enhance-l1-color); - } -} - -@keyframes timeline-line-node-loading { - to { - box-shadow: 0 0 20px 3px var(--cru-primary-l1-color); - } -} - -@keyframes timeline-line-node-loading-edge { - from { - transform: rotate(0turn); - } - to { - transform: rotate(1turn); - } -} - -@keyframes timeline-top-loading-enter { - from { - transform: translate(0, -100%); - } -} - -@keyframes timeline-post-enter { - from { - transform: translate(0, 100%); - opacity: 0; - } - to { - opacity: 1; - } -} - -.timeline-top-loading-enter { - animation: 1s timeline-top-loading-enter; -} - -.timeline-line { - display: flex; - flex-direction: column; - align-items: center; - width: 30px; - position: absolute; - z-index: 1; - left: 2em; - top: 0; - bottom: 0; - transition: left 0.5s; -} - -@media (max-width: 575.98px) { - .timeline-line { - left: 1em; - } -} - -.timeline-line .segment { - width: 7px; - background: var(--cru-primary-color); -} -.timeline-line .segment.start { - height: 1.8em; - flex: 0 0 auto; -} -.timeline-line .segment.end { - flex: 1 1 auto; -} -.timeline-line .segment.current-end { - height: 2em; - flex: 0 0 auto; - background: linear-gradient(var(--cru-primary-enhance-color), white); -} -.timeline-line .node-container { - flex: 0 0 auto; - position: relative; - width: 18px; - height: 18px; -} -.timeline-line .node { - width: 20px; - height: 20px; - position: absolute; - background: var(--cru-primary-color); - left: -1px; - top: -1px; - border-radius: 50%; - box-sizing: border-box; - z-index: 1; - animation: 1s infinite alternate; - animation-name: timeline-line-node; -} -.timeline-line .node-loading-edge { - color: var(--cru-primary-color); - width: 38px; - height: 38px; - position: absolute; - left: -10px; - top: -10px; - box-sizing: border-box; - z-index: 2; - animation: 1.5s linear infinite timeline-line-node-loading-edge; -} -.timeline-line.current .segment.start { - background: linear-gradient( - var(--cru-primary-color), - var(--cru-primary-enhance-color) - ); -} - -.timeline-line.current .segment.end { - background: var(--cru-primary-enhance-color); -} - -.timeline-line.current .node { - background: var(--cru-primary-enhance-color); - animation-name: timeline-line-node-current; -} - -.timeline-line.loading .node { - background: var(--cru-primary-color); - animation-name: timeline-line-node-loading; -} - -.timeline-item { - position: relative; - padding: 0.5em; -} - -.timeline-item-card { - position: relative; - padding: 0.5em 0.5em 0.5em 4em; -} - -.timeline-item-card.enter-animation { - animation: 0.6s forwards; - opacity: 0; -} - -@media (max-width: 575.98px) { - .timeline-item-card { - padding-left: 3em; - } -} - -.timeline-item-header { - display: flex; - align-items: center; -} - -.timeline-avatar { - border-radius: 50%; - width: 2em; - height: 2em; -} - -.timeline-item-delete-button { - position: absolute; - right: 0; - bottom: 0; -} - -.timeline-content { - white-space: pre-line; -} - -.timeline-content-image { - max-width: 80%; - max-height: 200px; -} - -.timeline-date-item { - position: relative; - padding: 0.3em 0 0.3em 4em; -} - -.timeline-date-item-badge { - display: inline-block; - padding: 0.1em 0.4em; - border-radius: 0.4em; - background: #7c7c7c; - color: white; - font-size: 0.8em; -} - -.timeline-post-item-options-mask { - background: rgba(255, 255, 255, 0.85); - z-index: 100; - position: absolute; - left: 0; - right: 0; - top: 0; - bottom: 0; - - display: flex; - justify-content: space-around; - align-items: center; - - border-radius: var(--cru-card-border-radius); -} - -.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; -} - -.timeline-card { - position: fixed; - z-index: 1029; - top: 56px; - right: 0; - margin: 0.5em; -} - -.timeline-top { - position: sticky; - top: 56px; -} diff --git a/FrontEnd/src/views/timeline/TimelineCard.tsx b/FrontEnd/src/views/timeline/TimelineCard.tsx deleted file mode 100644 index fdf7f0a0..00000000 --- a/FrontEnd/src/views/timeline/TimelineCard.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import * as React from "react"; -import { useTranslation } from "react-i18next"; -import classnames from "classnames"; -import { HubConnectionState } from "@microsoft/signalr"; - -import { useIsSmallScreen } from "@/utilities/hooks"; -import { timelineVisibilityTooltipTranslationMap } from "@/services/timeline"; -import { useUser } from "@/services/user"; -import { pushAlert } from "@/services/alert"; -import { HttpTimelineInfo } from "@/http/timeline"; -import { getHttpBookmarkClient } from "@/http/bookmark"; - -import UserAvatar from "../common/user/UserAvatar"; -import PopupMenu from "../common/menu/PopupMenu"; -import FullPageDialog from "../common/dialog/FullPageDialog"; -import Card from "../common/Card"; -import TimelineDeleteDialog from "./TimelineDeleteDialog"; -import ConnectionStatusBadge from "./ConnectionStatusBadge"; -import CollapseButton from "./CollapseButton"; -import { TimelineMemberDialog } from "./TimelineMember"; -import TimelinePropertyChangeDialog from "./TimelinePropertyChangeDialog"; -import IconButton from "../common/button/IconButton"; - -export interface TimelinePageCardProps { - timeline: HttpTimelineInfo; - connectionStatus: HubConnectionState; - className?: string; - onReload: () => void; -} - -const TimelineCard: React.FC<TimelinePageCardProps> = (props) => { - const { timeline, connectionStatus, onReload, className } = props; - - const { t } = useTranslation(); - - const [dialog, setDialog] = React.useState< - "member" | "property" | "delete" | null - >(null); - - const [collapse, setCollapse] = React.useState(true); - const toggleCollapse = (): void => { - setCollapse((o) => !o); - }; - - const isSmallScreen = useIsSmallScreen(); - - const user = useUser(); - - const content = ( - <> - <h3 className="cru-color-primary d-inline-block align-middle"> - {timeline.title} - <small className="ms-3 cru-color-secondary">{timeline.nameV2}</small> - </h3> - <div> - <UserAvatar - username={timeline.owner.username} - className="cru-avatar small cru-round me-3" - /> - {timeline.owner.nickname} - <small className="ms-3 cru-color-secondary"> - @{timeline.owner.username} - </small> - </div> - <p className="mb-0">{timeline.description}</p> - <small className="mt-1 d-block"> - {t(timelineVisibilityTooltipTranslationMap[timeline.visibility])} - </small> - <div className="mt-2 cru-text-end"> - {user != null ? ( - <IconButton - icon={timeline.isBookmark ? "bookmark-fill" : "bookmark"} - className="me-3" - onClick={() => { - getHttpBookmarkClient() - [timeline.isBookmark ? "delete" : "post"]( - user.username, - timeline.owner.username, - timeline.nameV2 - ) - .then(onReload, () => { - pushAlert({ - message: timeline.isBookmark - ? "timeline.removeBookmarkFail" - : "timeline.addBookmarkFail", - type: "danger", - }); - }); - }} - /> - ) : null} - <IconButton - icon="people" - className="me-3" - onClick={() => setDialog("member")} - /> - {timeline.manageable ? ( - <PopupMenu - items={[ - { - type: "button", - text: "timeline.manageItem.property", - onClick: () => setDialog("property"), - }, - { type: "divider" }, - { - type: "button", - onClick: () => setDialog("delete"), - color: "danger", - text: "timeline.manageItem.delete", - }, - ]} - containerClassName="d-inline" - > - <IconButton icon="three-dots-vertical" /> - </PopupMenu> - ) : null} - </div> - </> - ); - - return ( - <> - <Card className={classnames("p-2 cru-clearfix", className)}> - <div - className={classnames( - "cru-float-right d-flex align-items-center", - !collapse && "ms-3" - )} - > - <ConnectionStatusBadge status={connectionStatus} className="me-2" /> - <CollapseButton collapse={collapse} onClick={toggleCollapse} /> - </div> - {isSmallScreen ? ( - <FullPageDialog - onBack={toggleCollapse} - show={!collapse} - contentContainerClassName="p-2" - > - {content} - </FullPageDialog> - ) : ( - <div style={{ display: collapse ? "none" : "inline" }}>{content}</div> - )} - </Card> - <TimelineMemberDialog - timeline={timeline} - onClose={() => setDialog(null)} - open={dialog === "member"} - onChange={onReload} - /> - <TimelinePropertyChangeDialog - timeline={timeline} - close={() => setDialog(null)} - open={dialog === "property"} - onChange={onReload} - /> - <TimelineDeleteDialog - timeline={timeline} - open={dialog === "delete"} - close={() => setDialog(null)} - /> - </> - ); -}; - -export default TimelineCard; diff --git a/FrontEnd/src/views/timeline/TimelineDateLabel.tsx b/FrontEnd/src/views/timeline/TimelineDateLabel.tsx deleted file mode 100644 index 5f4ac706..00000000 --- a/FrontEnd/src/views/timeline/TimelineDateLabel.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import * as React from "react"; -import TimelineLine from "./TimelineLine"; - -export interface TimelineDateItemProps { - date: Date; -} - -const TimelineDateLabel: React.FC<TimelineDateItemProps> = ({ date }) => { - return ( - <div className="timeline-date-item"> - <TimelineLine center="none" /> - <div className="timeline-date-item-badge"> - {date.toLocaleDateString()} - </div> - </div> - ); -}; - -export default TimelineDateLabel; diff --git a/FrontEnd/src/views/timeline/TimelineDeleteDialog.tsx b/FrontEnd/src/views/timeline/TimelineDeleteDialog.tsx deleted file mode 100644 index c960b3c2..00000000 --- a/FrontEnd/src/views/timeline/TimelineDeleteDialog.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import * as React from "react"; -import { useNavigate } from "react-router-dom"; -import { Trans } from "react-i18next"; - -import { getHttpTimelineClient, HttpTimelineInfo } from "@/http/timeline"; - -import OperationDialog from "../common/dialog/OperationDialog"; - -interface TimelineDeleteDialog { - timeline: HttpTimelineInfo; - open: boolean; - close: () => void; -} - -const TimelineDeleteDialog: React.FC<TimelineDeleteDialog> = (props) => { - const navigate = useNavigate(); - - const { timeline } = props; - - return ( - <OperationDialog - open={props.open} - onClose={props.close} - title="timeline.deleteDialog.title" - themeColor="danger" - inputPrompt={() => { - return ( - <Trans - i18nKey="timeline.deleteDialog.inputPrompt" - values={{ name: timeline.nameV2 }} - > - 0<code className="mx-2">1</code>2 - </Trans> - ); - }} - inputScheme={[ - { - type: "text", - }, - ]} - inputValidator={([value]) => { - if (value !== timeline.nameV2) { - return { 0: "timeline.deleteDialog.notMatch" }; - } else { - return null; - } - }} - onProcess={() => { - return getHttpTimelineClient().deleteTimeline( - timeline.owner.username, - timeline.nameV2 - ); - }} - onSuccessAndClose={() => { - navigate("/", { replace: true }); - }} - /> - ); -}; - -export default TimelineDeleteDialog; diff --git a/FrontEnd/src/views/timeline/TimelineEmptyItem.tsx b/FrontEnd/src/views/timeline/TimelineEmptyItem.tsx deleted file mode 100644 index 5e0728d4..00000000 --- a/FrontEnd/src/views/timeline/TimelineEmptyItem.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import * as React from "react"; -import classnames from "classnames"; - -import TimelineLine, { TimelineLineProps } from "./TimelineLine"; - -export interface TimelineEmptyItemProps extends Partial<TimelineLineProps> { - height?: number | string; - className?: string; - style?: React.CSSProperties; -} - -const TimelineEmptyItem: React.FC<TimelineEmptyItemProps> = (props) => { - const { height, style, className, center, ...lineProps } = props; - - return ( - <div - style={{ ...style, height: height }} - className={classnames("timeline-item", className)} - > - <TimelineLine center={center ?? "none"} {...lineProps} /> - </div> - ); -}; - -export default TimelineEmptyItem; diff --git a/FrontEnd/src/views/timeline/TimelineLine.tsx b/FrontEnd/src/views/timeline/TimelineLine.tsx deleted file mode 100644 index 4a87e6e0..00000000 --- a/FrontEnd/src/views/timeline/TimelineLine.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import * as React from "react"; -import classnames from "classnames"; - -export interface TimelineLineProps { - current?: boolean; - startSegmentLength?: string | number; - center: "node" | "loading" | "none"; - className?: string; - style?: React.CSSProperties; -} - -const TimelineLine: React.FC<TimelineLineProps> = ({ - startSegmentLength, - center, - current, - className, - style, -}) => { - return ( - <div - className={classnames( - "timeline-line", - current && "current", - center === "loading" && "loading", - className - )} - style={style} - > - <div className="segment start" style={{ height: startSegmentLength }} /> - {center !== "none" ? ( - <div className="node-container"> - <div className="node"></div> - {center === "loading" ? ( - <svg className="node-loading-edge" viewBox="0 0 100 100"> - <path - d="M 50,10 A 40 40 45 0 1 78.28,21.72" - stroke="currentcolor" - strokeLinecap="square" - strokeWidth="8" - /> - </svg> - ) : null} - </div> - ) : null} - {center !== "loading" ? <div className="segment end"></div> : null} - {current && <div className="segment current-end" />} - </div> - ); -}; - -export default TimelineLine; diff --git a/FrontEnd/src/views/timeline/TimelineLoading.tsx b/FrontEnd/src/views/timeline/TimelineLoading.tsx deleted file mode 100644 index f876cba9..00000000 --- a/FrontEnd/src/views/timeline/TimelineLoading.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import * as React from "react"; - -import TimelineEmptyItem from "./TimelineEmptyItem"; - -const TimelineLoading: React.FC = () => { - return ( - <TimelineEmptyItem - className="timeline-top-loading-enter" - height={100} - center="loading" - startSegmentLength={56} - /> - ); -}; - -export default TimelineLoading; diff --git a/FrontEnd/src/views/timeline/TimelineMember.css b/FrontEnd/src/views/timeline/TimelineMember.css deleted file mode 100644 index adb78764..00000000 --- a/FrontEnd/src/views/timeline/TimelineMember.css +++ /dev/null @@ -1,8 +0,0 @@ -.timeline-member-item {
- border: var(--cru-background-1-color) solid;
- border-width: 0.5px 1px;
-}
-
-.timeline-member-item > div {
- padding: 0.5em;
-}
diff --git a/FrontEnd/src/views/timeline/TimelinePostContentView.tsx b/FrontEnd/src/views/timeline/TimelinePostContentView.tsx deleted file mode 100644 index 9ed192e5..00000000 --- a/FrontEnd/src/views/timeline/TimelinePostContentView.tsx +++ /dev/null @@ -1,187 +0,0 @@ -import * as React from "react"; -import classnames from "classnames"; -import { marked } from "marked"; - -import { UiLogicError } from "@/common"; - -import { HttpNetworkError } from "@/http/common"; -import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline"; - -import { useUser } from "@/services/user"; - -import Skeleton from "../common/Skeleton"; -import LoadFailReload from "../common/LoadFailReload"; - -const TextView: React.FC<TimelinePostContentViewProps> = (props) => { - const { post, className, style } = props; - - const [text, setText] = React.useState<string | null>(null); - const [error, setError] = React.useState<"offline" | "error" | null>(null); - - const [reloadKey, setReloadKey] = React.useState<number>(0); - - React.useEffect(() => { - let subscribe = true; - - setText(null); - setError(null); - - void getHttpTimelineClient() - .getPostDataAsString(post.timelineOwnerV2, post.timelineNameV2, post.id) - .then( - (data) => { - if (subscribe) setText(data); - }, - (error) => { - if (subscribe) { - if (error instanceof HttpNetworkError) { - setError("offline"); - } else { - setError("error"); - } - } - } - ); - - return () => { - subscribe = false; - }; - }, [post.timelineOwnerV2, post.timelineNameV2, post.id, reloadKey]); - - if (error != null) { - return ( - <LoadFailReload - className={className} - style={style} - onReload={() => setReloadKey(reloadKey + 1)} - /> - ); - } else if (text == null) { - return <Skeleton />; - } else { - return ( - <div className={className} style={style}> - {text} - </div> - ); - } -}; - -const ImageView: React.FC<TimelinePostContentViewProps> = (props) => { - const { post, className, style } = props; - - useUser(); - - return ( - <img - src={getHttpTimelineClient().generatePostDataUrl( - post.timelineOwnerV2, - post.timelineNameV2, - post.id - )} - className={classnames(className, "timeline-content-image")} - style={style} - /> - ); -}; - -const MarkdownView: React.FC<TimelinePostContentViewProps> = (props) => { - const { post, className, style } = props; - - const [markdown, setMarkdown] = React.useState<string | null>(null); - const [error, setError] = React.useState<"offline" | "error" | null>(null); - - const [reloadKey, setReloadKey] = React.useState<number>(0); - - React.useEffect(() => { - let subscribe = true; - - setMarkdown(null); - setError(null); - - void getHttpTimelineClient() - .getPostDataAsString(post.timelineOwnerV2, post.timelineNameV2, post.id) - .then( - (data) => { - if (subscribe) setMarkdown(data); - }, - (error) => { - if (subscribe) { - if (error instanceof HttpNetworkError) { - setError("offline"); - } else { - setError("error"); - } - } - } - ); - - return () => { - subscribe = false; - }; - }, [post.timelineOwnerV2, post.timelineNameV2, post.id, reloadKey]); - - const markdownHtml = React.useMemo<string | null>(() => { - if (markdown == null) return null; - return marked.parse(markdown); - }, [markdown]); - - if (error != null) { - return ( - <LoadFailReload - className={className} - style={style} - onReload={() => setReloadKey(reloadKey + 1)} - /> - ); - } else if (markdown == null) { - return <Skeleton />; - } else { - if (markdownHtml == null) { - throw new UiLogicError("Markdown is not null but markdown html is."); - } - return ( - <div - className={classnames(className, "markdown-container")} - style={style} - dangerouslySetInnerHTML={{ - __html: markdownHtml, - }} - /> - ); - } -}; - -export interface TimelinePostContentViewProps { - post: HttpTimelinePostInfo; - className?: string; - style?: React.CSSProperties; -} - -const viewMap: Record<string, React.FC<TimelinePostContentViewProps>> = { - "text/plain": TextView, - "text/markdown": MarkdownView, - "image/png": ImageView, - "image/jpeg": ImageView, - "image/gif": ImageView, - "image/webp": ImageView, -}; - -const TimelinePostContentView: React.FC<TimelinePostContentViewProps> = ( - props -) => { - const { post, className, style } = props; - - const type = post.dataList[0].kind; - - if (type in viewMap) { - const View = viewMap[type]; - return <View post={post} className={className} style={style} />; - } else { - // TODO: i18n - console.error("Unknown post type", post); - return <div>Error, unknown post type!</div>; - } -}; - -export default TimelinePostContentView; diff --git a/FrontEnd/src/views/timeline/TimelinePostEdit.css b/FrontEnd/src/views/timeline/TimelinePostEdit.css deleted file mode 100644 index 9b7629e2..00000000 --- a/FrontEnd/src/views/timeline/TimelinePostEdit.css +++ /dev/null @@ -1,10 +0,0 @@ -.timeline-post-edit {
- position: sticky !important;
- top: 106px;
- z-index: 100;
-}
-
-.timeline-post-edit-image {
- max-width: 100px;
- max-height: 100px;
-}
diff --git a/FrontEnd/src/views/timeline/TimelinePostEdit.tsx b/FrontEnd/src/views/timeline/TimelinePostEdit.tsx deleted file mode 100644 index 38e72264..00000000 --- a/FrontEnd/src/views/timeline/TimelinePostEdit.tsx +++ /dev/null @@ -1,267 +0,0 @@ -import * as React from "react"; -import { useTranslation } from "react-i18next"; - -import { UiLogicError } from "@/common"; - -import { - getHttpTimelineClient, - HttpTimelineInfo, - HttpTimelinePostInfo, - HttpTimelinePostPostRequestData, -} from "@/http/timeline"; - -import { pushAlert } from "@/services/alert"; - -import base64 from "@/utilities/base64"; - -import BlobImage from "../common/BlobImage"; -import LoadingButton from "../common/button/LoadingButton"; -import PopupMenu from "../common/menu/PopupMenu"; -import MarkdownPostEdit from "./MarkdownPostEdit"; -import TimelinePostEditCard from "./TimelinePostEditCard"; -import IconButton from "../common/button/IconButton"; - -import "./TimelinePostEdit.css"; - -interface TimelinePostEditTextProps { - text: string; - disabled: boolean; - onChange: (text: string) => void; - className?: string; - style?: React.CSSProperties; -} - -const TimelinePostEditText: React.FC<TimelinePostEditTextProps> = (props) => { - const { text, disabled, onChange, className, style } = props; - - return ( - <textarea - value={text} - disabled={disabled} - onChange={(event) => { - onChange(event.target.value); - }} - className={className} - style={style} - /> - ); -}; - -interface TimelinePostEditImageProps { - onSelect: (file: File | null) => void; - disabled: boolean; -} - -const TimelinePostEditImage: React.FC<TimelinePostEditImageProps> = (props) => { - const { onSelect, disabled } = props; - - const { t } = useTranslation(); - - const [file, setFile] = React.useState<File | null>(null); - const [error, setError] = React.useState<boolean>(false); - - const onInputChange: React.ChangeEventHandler<HTMLInputElement> = (e) => { - setError(false); - const files = e.target.files; - if (files == null || files.length === 0) { - setFile(null); - onSelect(null); - } else { - setFile(files[0]); - } - }; - - React.useEffect(() => { - return () => { - onSelect(null); - }; - }, [onSelect]); - - return ( - <> - <input - type="file" - onChange={onInputChange} - accept="image/*" - disabled={disabled} - className="mx-3 my-1" - /> - {file != null && !error && ( - <BlobImage - blob={file} - className="timeline-post-edit-image" - onLoad={() => onSelect(file)} - onError={() => { - onSelect(null); - setError(true); - }} - /> - )} - {error ? <div className="text-danger">{t("loadImageError")}</div> : null} - </> - ); -}; - -type PostKind = "text" | "markdown" | "image"; - -const postKindIconMap: Record<PostKind, string> = { - text: "fonts", - markdown: "markdown", - image: "image", -}; - -export interface TimelinePostEditProps { - className?: string; - style?: React.CSSProperties; - timeline: HttpTimelineInfo; - onPosted: (newPost: HttpTimelinePostInfo) => void; -} - -const TimelinePostEdit: React.FC<TimelinePostEditProps> = (props) => { - const { timeline, style, className, onPosted } = props; - - const { t } = useTranslation(); - - const [process, setProcess] = React.useState<boolean>(false); - - const [kind, setKind] = React.useState<Exclude<PostKind, "markdown">>("text"); - const [showMarkdown, setShowMarkdown] = React.useState<boolean>(false); - - const [text, setText] = React.useState<string>(""); - const [image, setImage] = React.useState<File | null>(null); - - const draftTextLocalStorageKey = `timeline.${timeline.owner.username}.${timeline.nameV2}.postDraft.text`; - - React.useEffect(() => { - setText(window.localStorage.getItem(draftTextLocalStorageKey) ?? ""); - }, [draftTextLocalStorageKey]); - - const canSend = - (kind === "text" && text.length !== 0) || - (kind === "image" && image != null); - - const onPostError = (): void => { - pushAlert({ - type: "danger", - message: "timeline.sendPostFailed", - }); - }; - - const onSend = async (): Promise<void> => { - setProcess(true); - - let requestData: HttpTimelinePostPostRequestData; - switch (kind) { - case "text": - requestData = { - contentType: "text/plain", - data: await base64(text), - }; - break; - case "image": - if (image == null) { - throw new UiLogicError( - "Content type is image but image blob is null.", - ); - } - requestData = { - contentType: image.type, - data: await base64(image), - }; - break; - default: - throw new UiLogicError("Unknown content type."); - } - - getHttpTimelineClient() - .postPost(timeline.owner.username, timeline.nameV2, { - dataList: [requestData], - }) - .then( - (data) => { - if (kind === "text") { - setText(""); - window.localStorage.removeItem(draftTextLocalStorageKey); - } - setProcess(false); - setKind("text"); - onPosted(data); - }, - () => { - setProcess(false); - onPostError(); - }, - ); - }; - - return ( - <TimelinePostEditCard className={className} style={style}> - {showMarkdown ? ( - <MarkdownPostEdit - className="cru-fill-parent" - onClose={() => setShowMarkdown(false)} - owner={timeline.owner.username} - timeline={timeline.nameV2} - onPosted={onPosted} - onPostError={onPostError} - /> - ) : ( - <div className="row"> - <div className="col px-1 py-1"> - {(() => { - if (kind === "text") { - return ( - <TimelinePostEditText - className="cru-fill-parent timeline-post-edit" - text={text} - disabled={process} - onChange={(t) => { - setText(t); - window.localStorage.setItem(draftTextLocalStorageKey, t); - }} - /> - ); - } else if (kind === "image") { - return ( - <TimelinePostEditImage - onSelect={setImage} - disabled={process} - /> - ); - } - })()} - </div> - <div className="col col-auto align-self-end m-1"> - <div className="d-block cru-text-center mt-1 mb-2"> - <PopupMenu - items={(["text", "image", "markdown"] as const).map((kind) => ({ - type: "button", - text: `timeline.post.type.${kind}`, - iconClassName: postKindIconMap[kind], - onClick: () => { - if (kind === "markdown") { - setShowMarkdown(true); - } else { - setKind(kind); - } - }, - }))} - > - <IconButton large icon={postKindIconMap[kind]} /> - </PopupMenu> - </div> - <LoadingButton - onClick={() => void onSend()} - disabled={!canSend} - loading={process} - > - {t("timeline.send")} - </LoadingButton> - </div> - </div> - )} - </TimelinePostEditCard> - ); -}; - -export default TimelinePostEdit; diff --git a/FrontEnd/src/views/timeline/TimelinePostEditCard.tsx b/FrontEnd/src/views/timeline/TimelinePostEditCard.tsx deleted file mode 100644 index d2f7bd72..00000000 --- a/FrontEnd/src/views/timeline/TimelinePostEditCard.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import * as React from "react"; -import classnames from "classnames"; - -import Card from "../common/Card"; -import TimelineLine from "./TimelineLine"; - -import "./TimelinePostEdit.css"; - -export interface TimelinePostEditCardProps { - className?: string; - style?: React.CSSProperties; - children?: React.ReactNode; -} - -const TimelinePostEdit: React.FC<TimelinePostEditCardProps> = ({ - className, - style, - children, -}) => { - return ( - <div - className={classnames("timeline-item timeline-post-edit", className)} - style={style} - > - <TimelineLine center="node" /> - <Card className="timeline-item-card">{children}</Card> - </div> - ); -}; - -export default TimelinePostEdit; diff --git a/FrontEnd/src/views/timeline/TimelinePostEditNoLogin.tsx b/FrontEnd/src/views/timeline/TimelinePostEditNoLogin.tsx deleted file mode 100644 index 1ef0a287..00000000 --- a/FrontEnd/src/views/timeline/TimelinePostEditNoLogin.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import * as React from "react"; -import { Trans } from "react-i18next"; -import { Link } from "react-router-dom"; - -import TimelinePostEditCard from "./TimelinePostEditCard"; - -export default function TimelinePostEditNoLogin(): React.ReactElement | null { - return ( - <TimelinePostEditCard> - <div className="mt-3 mb-4"> - <Trans - i18nKey="timeline.postNoLogin" - components={{ l: <Link to="/login" /> }} - /> - </div> - </TimelinePostEditCard> - ); -} diff --git a/FrontEnd/src/views/timeline/TimelinePostView.tsx b/FrontEnd/src/views/timeline/TimelinePostView.tsx deleted file mode 100644 index e3eac0f4..00000000 --- a/FrontEnd/src/views/timeline/TimelinePostView.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import * as React from "react"; -import classnames from "classnames"; - -import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline"; - -import { pushAlert } from "@/services/alert"; - -import { useClickOutside } from "@/utilities/hooks"; - -import UserAvatar from "../common/user/UserAvatar"; -import Card from "../common/Card"; -import FlatButton from "../common/button/FlatButton"; -import ConfirmDialog from "../common/dialog/ConfirmDialog"; -import TimelineLine from "./TimelineLine"; -import TimelinePostContentView from "./TimelinePostContentView"; -import PostPropertyChangeDialog from "./PostPropertyChangeDialog"; -import IconButton from "../common/button/IconButton"; - -export interface TimelinePostViewProps { - post: HttpTimelinePostInfo; - className?: string; - style?: React.CSSProperties; - cardStyle?: React.CSSProperties; - onChanged: (post: HttpTimelinePostInfo) => void; - onDeleted: () => void; -} - -const TimelinePostView: React.FC<TimelinePostViewProps> = (props) => { - const { post, className, style, cardStyle, onChanged, onDeleted } = props; - - const [operationMaskVisible, setOperationMaskVisible] = - React.useState<boolean>(false); - const [dialog, setDialog] = React.useState< - "delete" | "changeproperty" | null - >(null); - - const [maskElement, setMaskElement] = React.useState<HTMLElement | null>( - null - ); - - useClickOutside(maskElement, () => setOperationMaskVisible(false)); - - const cardRef = React.useRef<HTMLDivElement>(null); - React.useEffect(() => { - const cardIntersectionObserver = new IntersectionObserver(([e]) => { - if (e.intersectionRatio > 0) { - if (cardRef.current != null) { - cardRef.current.style.animationName = "timeline-post-enter"; - } - } - }); - if (cardRef.current) { - cardIntersectionObserver.observe(cardRef.current); - } - - return () => { - cardIntersectionObserver.disconnect(); - }; - }, []); - - return ( - <div - id={`timeline-post-${post.id}`} - className={classnames("timeline-item", className)} - style={style} - > - <TimelineLine center="node" /> - <Card - ref={cardRef} - className="timeline-item-card enter-animation" - style={cardStyle} - > - {post.editable ? ( - <IconButton - icon="chevron-down" - color="primary-enhance" - className="cru-float-right" - onClick={(e) => { - setOperationMaskVisible(true); - e.stopPropagation(); - }} - /> - ) : null} - <div className="timeline-item-header"> - <span className="me-2"> - <span> - <UserAvatar - username={post.author.username} - className="timeline-avatar me-1" - /> - <small className="text-dark me-2">{post.author.nickname}</small> - <small className="text-secondary white-space-no-wrap"> - {new Date(post.time).toLocaleTimeString()} - </small> - </span> - </span> - </div> - <div className="timeline-content"> - <TimelinePostContentView post={post} /> - </div> - {operationMaskVisible ? ( - <div - ref={setMaskElement} - className="timeline-post-item-options-mask" - onClick={() => { - setOperationMaskVisible(false); - }} - > - <FlatButton - text="changeProperty" - onClick={(e) => { - setDialog("changeproperty"); - e.stopPropagation(); - }} - /> - <FlatButton - text="delete" - color="danger" - onClick={(e) => { - setDialog("delete"); - e.stopPropagation(); - }} - /> - </div> - ) : null} - </Card> - <ConfirmDialog - title="timeline.post.deleteDialog.title" - body="timeline.post.deleteDialog.prompt" - open={dialog === "delete"} - onClose={() => { - setDialog(null); - setOperationMaskVisible(false); - }} - onConfirm={() => { - void getHttpTimelineClient() - .deletePost(post.timelineOwnerV2, post.timelineNameV2, post.id) - .then(onDeleted, () => { - pushAlert({ - type: "danger", - message: "timeline.deletePostFailed", - }); - }); - }} - /> - <PostPropertyChangeDialog - open={dialog === "changeproperty"} - onClose={() => { - setDialog(null); - setOperationMaskVisible(false); - }} - post={post} - onSuccess={onChanged} - /> - </div> - ); -}; - -export default TimelinePostView; diff --git a/FrontEnd/src/views/timeline/TimelinePropertyChangeDialog.tsx b/FrontEnd/src/views/timeline/TimelinePropertyChangeDialog.tsx deleted file mode 100644 index 63750445..00000000 --- a/FrontEnd/src/views/timeline/TimelinePropertyChangeDialog.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import * as React from "react"; - -import { - getHttpTimelineClient, - HttpTimelineInfo, - HttpTimelinePatchRequest, - kTimelineVisibilities, - TimelineVisibility, -} from "@/http/timeline"; - -import OperationDialog from "../common/dialog/OperationDialog"; - -export interface TimelinePropertyChangeDialogProps { - open: boolean; - close: () => void; - timeline: HttpTimelineInfo; - onChange: () => void; -} - -const labelMap: { [key in TimelineVisibility]: string } = { - Private: "timeline.visibility.private", - Public: "timeline.visibility.public", - Register: "timeline.visibility.register", -}; - -const TimelinePropertyChangeDialog: React.FC< - TimelinePropertyChangeDialogProps -> = (props) => { - const { timeline, onChange } = props; - - return ( - <OperationDialog - title={"timeline.dialogChangeProperty.title"} - inputScheme={ - [ - { - type: "text", - label: "timeline.dialogChangeProperty.titleField", - initValue: timeline.title, - }, - { - type: "select", - label: "timeline.dialogChangeProperty.visibility", - options: kTimelineVisibilities.map((v) => ({ - label: labelMap[v], - value: v, - })), - initValue: timeline.visibility, - }, - { - type: "text", - label: "timeline.dialogChangeProperty.description", - initValue: timeline.description, - }, - { - type: "color", - label: "timeline.dialogChangeProperty.color", - initValue: timeline.color ?? null, - canBeNull: true, - }, - ] as const - } - open={props.open} - onClose={props.close} - onProcess={([newTitle, newVisibility, newDescription, newColor]) => { - const req: HttpTimelinePatchRequest = {}; - if (newTitle !== timeline.title) { - req.title = newTitle; - } - if (newVisibility !== timeline.visibility) { - req.visibility = newVisibility as TimelineVisibility; - } - if (newDescription !== timeline.description) { - req.description = newDescription; - } - const nc = newColor ?? ""; - if (nc !== timeline.color) { - req.color = nc; - } - return getHttpTimelineClient() - .patchTimeline(timeline.owner.username, timeline.nameV2, req) - .then(onChange); - }} - /> - ); -}; - -export default TimelinePropertyChangeDialog; |