aboutsummaryrefslogtreecommitdiff
path: root/FrontEnd/src/app
diff options
context:
space:
mode:
Diffstat (limited to 'FrontEnd/src/app')
-rw-r--r--FrontEnd/src/app/App.tsx84
-rw-r--r--FrontEnd/src/app/common.ts44
-rw-r--r--FrontEnd/src/app/http/common.ts161
-rw-r--r--FrontEnd/src/app/http/timeline.ts544
-rw-r--r--FrontEnd/src/app/http/token.ts72
-rw-r--r--FrontEnd/src/app/http/user.ts134
-rw-r--r--FrontEnd/src/app/i18n.ts79
-rw-r--r--FrontEnd/src/app/index.ejs29
-rw-r--r--FrontEnd/src/app/index.sass66
-rw-r--r--FrontEnd/src/app/index.tsx15
-rw-r--r--FrontEnd/src/app/locales/en/translation.ts202
-rw-r--r--FrontEnd/src/app/locales/scheme.ts182
-rw-r--r--FrontEnd/src/app/locales/zh/translation.ts195
-rw-r--r--FrontEnd/src/app/service-worker.tsx113
-rw-r--r--FrontEnd/src/app/services/DataHub.ts225
-rw-r--r--FrontEnd/src/app/services/alert.ts61
-rw-r--r--FrontEnd/src/app/services/common.ts23
-rw-r--r--FrontEnd/src/app/services/timeline.ts702
-rw-r--r--FrontEnd/src/app/services/user.ts393
-rw-r--r--FrontEnd/src/app/tsconfig.json13
-rw-r--r--FrontEnd/src/app/typings.d.ts24
-rw-r--r--FrontEnd/src/app/utilities/rxjs.ts14
-rw-r--r--FrontEnd/src/app/utilities/url.ts52
-rw-r--r--FrontEnd/src/app/views/about/about.sass4
-rw-r--r--FrontEnd/src/app/views/about/author-avatar.pngbin0 -> 12038 bytes
-rw-r--r--FrontEnd/src/app/views/about/github.pngbin0 -> 4268 bytes
-rw-r--r--FrontEnd/src/app/views/about/index.tsx164
-rw-r--r--FrontEnd/src/app/views/admin/Admin.tsx75
-rw-r--r--FrontEnd/src/app/views/admin/UserAdmin.tsx460
-rw-r--r--FrontEnd/src/app/views/common/AppBar.tsx64
-rw-r--r--FrontEnd/src/app/views/common/BlobImage.tsx27
-rw-r--r--FrontEnd/src/app/views/common/ImageCropper.tsx306
-rw-r--r--FrontEnd/src/app/views/common/LoadingButton.tsx29
-rw-r--r--FrontEnd/src/app/views/common/LoadingPage.tsx12
-rw-r--r--FrontEnd/src/app/views/common/OperationDialog.tsx364
-rw-r--r--FrontEnd/src/app/views/common/SearchInput.tsx63
-rw-r--r--FrontEnd/src/app/views/common/TimelineLogo.tsx26
-rw-r--r--FrontEnd/src/app/views/common/UserTimelineLogo.tsx26
-rw-r--r--FrontEnd/src/app/views/common/alert/AlertHost.tsx101
-rw-r--r--FrontEnd/src/app/views/common/alert/alert.sass15
-rw-r--r--FrontEnd/src/app/views/common/common.sass33
-rw-r--r--FrontEnd/src/app/views/home/BoardWithUser.tsx101
-rw-r--r--FrontEnd/src/app/views/home/BoardWithoutUser.tsx60
-rw-r--r--FrontEnd/src/app/views/home/OfflineBoard.tsx61
-rw-r--r--FrontEnd/src/app/views/home/TimelineBoard.tsx73
-rw-r--r--FrontEnd/src/app/views/home/TimelineCreateDialog.tsx53
-rw-r--r--FrontEnd/src/app/views/home/home.sass13
-rw-r--r--FrontEnd/src/app/views/home/index.tsx99
-rw-r--r--FrontEnd/src/app/views/login/index.tsx151
-rw-r--r--FrontEnd/src/app/views/login/login.sass2
-rw-r--r--FrontEnd/src/app/views/settings/index.tsx209
-rw-r--r--FrontEnd/src/app/views/timeline-common/CollapseButton.tsx23
-rw-r--r--FrontEnd/src/app/views/timeline-common/InfoCardTemplate.tsx26
-rw-r--r--FrontEnd/src/app/views/timeline-common/SyncStatusBadge.tsx58
-rw-r--r--FrontEnd/src/app/views/timeline-common/Timeline.tsx84
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelineItem.tsx172
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelineMember.tsx211
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx185
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx243
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelinePostEdit.tsx241
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx72
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelineTop.tsx21
-rw-r--r--FrontEnd/src/app/views/timeline-common/timeline-common.sass146
-rw-r--r--FrontEnd/src/app/views/timeline/TimelineDeleteDialog.tsx55
-rw-r--r--FrontEnd/src/app/views/timeline/TimelineInfoCard.tsx85
-rw-r--r--FrontEnd/src/app/views/timeline/TimelinePageUI.tsx20
-rw-r--r--FrontEnd/src/app/views/timeline/index.tsx37
-rw-r--r--FrontEnd/src/app/views/timeline/timeline.sass0
-rw-r--r--FrontEnd/src/app/views/user/ChangeAvatarDialog.tsx302
-rw-r--r--FrontEnd/src/app/views/user/ChangeNicknameDialog.tsx28
-rw-r--r--FrontEnd/src/app/views/user/UserInfoCard.tsx80
-rw-r--r--FrontEnd/src/app/views/user/UserPageUI.tsx18
-rw-r--r--FrontEnd/src/app/views/user/index.tsx72
-rw-r--r--FrontEnd/src/app/views/user/user.sass7
74 files changed, 8204 insertions, 0 deletions
diff --git a/FrontEnd/src/app/App.tsx b/FrontEnd/src/app/App.tsx
new file mode 100644
index 00000000..b68eddb6
--- /dev/null
+++ b/FrontEnd/src/app/App.tsx
@@ -0,0 +1,84 @@
+import React from "react";
+import { BrowserRouter as Router, Route, Switch } from "react-router-dom";
+import { hot } from "react-hot-loader/root";
+
+import AppBar from "./views/common/AppBar";
+import LoadingPage from "./views/common/LoadingPage";
+import Home from "./views/home";
+import Login from "./views/login";
+import Settings from "./views/settings";
+import About from "./views/about";
+import User from "./views/user";
+import TimelinePage from "./views/timeline";
+import AlertHost from "./views/common/alert/AlertHost";
+
+import { dataStorage } from "./services/common";
+import { userService, useRawUser } from "./services/user";
+
+const NoMatch: React.FC = () => {
+ return (
+ <>
+ <AppBar />
+ <div style={{ height: 56 }} />
+ <div>Ah-oh, 404!</div>
+ </>
+ );
+};
+
+const LazyAdmin = React.lazy(
+ () => import(/* webpackChunkName: "admin" */ "./views/admin/Admin")
+);
+
+const App: React.FC = () => {
+ const [loading, setLoading] = React.useState<boolean>(true);
+
+ const user = useRawUser();
+
+ React.useEffect(() => {
+ void userService.checkLoginState();
+ void dataStorage.ready().then(() => setLoading(false));
+ }, []);
+
+ if (user === undefined || loading) {
+ return <LoadingPage />;
+ } else {
+ return (
+ <React.Suspense fallback={<LoadingPage />}>
+ <Router>
+ <AppBar />
+ <Switch>
+ <Route exact path="/">
+ <Home />
+ </Route>
+ <Route exact path="/login">
+ <Login />
+ </Route>
+ <Route path="/settings">
+ <Settings />
+ </Route>
+ <Route path="/about">
+ <About />
+ </Route>
+ <Route path="/timelines/:name">
+ <TimelinePage />
+ </Route>
+ <Route path="/users/:username">
+ <User />
+ </Route>
+ {user && user.administrator && (
+ <Route path="/admin">
+ <LazyAdmin user={user} />
+ </Route>
+ )}
+ <Route>
+ <NoMatch />
+ </Route>
+ </Switch>
+ <AlertHost />
+ </Router>
+ </React.Suspense>
+ );
+ }
+};
+
+export default hot(App);
diff --git a/FrontEnd/src/app/common.ts b/FrontEnd/src/app/common.ts
new file mode 100644
index 00000000..0a2d345f
--- /dev/null
+++ b/FrontEnd/src/app/common.ts
@@ -0,0 +1,44 @@
+import React from "react";
+import { Observable, Subject } from "rxjs";
+
+// This error is thrown when ui goes wrong with bad logic.
+// Such as a variable should not be null, but it does.
+// This error should never occur. If it does, it indicates there is some logic bug in codes.
+export class UiLogicError extends Error {}
+
+export function useEventEmiiter(): [() => Observable<null>, () => void] {
+ const ref = React.useRef<Subject<null> | null>(null);
+
+ return React.useMemo(() => {
+ const getter = (): Subject<null> => {
+ if (ref.current == null) {
+ ref.current = new Subject<null>();
+ }
+ return ref.current;
+ };
+ const trigger = (): void => {
+ getter().next(null);
+ };
+ return [getter, trigger];
+ }, []);
+}
+
+export function useValueEventEmiiter<T>(): [
+ () => Observable<T>,
+ (value: T) => void
+] {
+ const ref = React.useRef<Subject<T> | null>(null);
+
+ return React.useMemo(() => {
+ const getter = (): Subject<T> => {
+ if (ref.current == null) {
+ ref.current = new Subject<T>();
+ }
+ return ref.current;
+ };
+ const trigger = (value: T): void => {
+ getter().next(value);
+ };
+ return [getter, trigger];
+ }, []);
+}
diff --git a/FrontEnd/src/app/http/common.ts b/FrontEnd/src/app/http/common.ts
new file mode 100644
index 00000000..54203d1a
--- /dev/null
+++ b/FrontEnd/src/app/http/common.ts
@@ -0,0 +1,161 @@
+import { AxiosError, AxiosResponse } from "axios";
+
+export const apiBaseUrl = "/api";
+
+export function base64(blob: Blob): Promise<string> {
+ return new Promise<string>((resolve) => {
+ const reader = new FileReader();
+ reader.onload = function () {
+ resolve((reader.result as string).replace(/^data:.+;base64,/, ""));
+ };
+ reader.readAsDataURL(blob);
+ });
+}
+
+export function extractStatusCode(error: AxiosError): number | null {
+ if (error.isAxiosError) {
+ const code = error?.response?.status;
+ if (typeof code === "number") {
+ return code;
+ }
+ }
+ return null;
+}
+
+export interface CommonErrorResponse {
+ code: number;
+ message: string;
+}
+
+export function extractErrorCode(
+ error: AxiosError<CommonErrorResponse>
+): number | null {
+ if (error.isAxiosError) {
+ const code = error.response?.data?.code;
+ if (typeof code === "number") {
+ return code;
+ }
+ }
+ return null;
+}
+
+export class HttpNetworkError extends Error {
+ constructor(public innerError?: AxiosError) {
+ super();
+ }
+}
+
+export class HttpForbiddenError extends Error {
+ constructor(public innerError?: AxiosError) {
+ super();
+ }
+}
+
+export class NotModified {}
+
+export interface BlobWithEtag {
+ data: Blob;
+ etag: string;
+}
+
+export function extractResponseData<T>(res: AxiosResponse<T>): T {
+ return res.data;
+}
+
+export function catchIfStatusCodeIs<
+ TResult,
+ TErrorHandlerResult extends TResult | PromiseLike<TResult> | null | undefined
+>(
+ statusCode: number,
+ errorHandler: (error: AxiosError<CommonErrorResponse>) => TErrorHandlerResult
+): (error: AxiosError<CommonErrorResponse>) => TErrorHandlerResult {
+ return (error: AxiosError<CommonErrorResponse>) => {
+ if (extractStatusCode(error) == statusCode) {
+ return errorHandler(error);
+ } else {
+ throw error;
+ }
+ };
+}
+
+export function convertToIfStatusCodeIs<NewError>(
+ statusCode: number,
+ newErrorType: {
+ new (innerError: AxiosError): NewError;
+ }
+): (error: AxiosError<CommonErrorResponse>) => never {
+ return catchIfStatusCodeIs(statusCode, (error) => {
+ throw new newErrorType(error);
+ });
+}
+
+export function catchIfErrorCodeIs<
+ TResult,
+ TErrorHandlerResult extends TResult | PromiseLike<TResult> | null | undefined
+>(
+ errorCode: number,
+ errorHandler: (error: AxiosError<CommonErrorResponse>) => TErrorHandlerResult
+): (error: AxiosError<CommonErrorResponse>) => TErrorHandlerResult {
+ return (error: AxiosError<CommonErrorResponse>) => {
+ if (extractErrorCode(error) == errorCode) {
+ return errorHandler(error);
+ } else {
+ throw error;
+ }
+ };
+}
+export function convertToIfErrorCodeIs<NewError>(
+ errorCode: number,
+ newErrorType: {
+ new (innerError: AxiosError): NewError;
+ }
+): (error: AxiosError<CommonErrorResponse>) => never {
+ return catchIfErrorCodeIs(errorCode, (error) => {
+ throw new newErrorType(error);
+ });
+}
+
+export function convertToNetworkError(
+ error: AxiosError<CommonErrorResponse>
+): never {
+ if (error.isAxiosError && error.response == null) {
+ throw new HttpNetworkError(error);
+ } else {
+ throw error;
+ }
+}
+
+export function convertToForbiddenError(
+ error: AxiosError<CommonErrorResponse>
+): never {
+ if (
+ error.isAxiosError &&
+ error.response != null &&
+ (error.response.status == 401 || error.response.status == 403)
+ ) {
+ throw new HttpForbiddenError(error);
+ } else {
+ throw error;
+ }
+}
+
+export function convertToNotModified(
+ error: AxiosError<CommonErrorResponse>
+): NotModified {
+ if (
+ error.isAxiosError &&
+ error.response != null &&
+ error.response.status == 304
+ ) {
+ return new NotModified();
+ } else {
+ throw error;
+ }
+}
+
+export function convertToBlobWithEtag(res: AxiosResponse<Blob>): BlobWithEtag {
+ return {
+ data: res.data,
+ etag: (res.headers as Record<"etag", string>)["etag"],
+ };
+}
diff --git a/FrontEnd/src/app/http/timeline.ts b/FrontEnd/src/app/http/timeline.ts
new file mode 100644
index 00000000..eb7d5065
--- /dev/null
+++ b/FrontEnd/src/app/http/timeline.ts
@@ -0,0 +1,544 @@
+import axios, { AxiosError } from "axios";
+
+import { updateQueryString, applyQueryParameters } from "../utilities/url";
+
+import {
+ apiBaseUrl,
+ extractResponseData,
+ convertToNetworkError,
+ base64,
+ convertToIfStatusCodeIs,
+ convertToIfErrorCodeIs,
+ BlobWithEtag,
+ NotModified,
+ convertToNotModified,
+ convertToForbiddenError,
+ convertToBlobWithEtag,
+} from "./common";
+import { HttpUser } from "./user";
+
+export const kTimelineVisibilities = ["Public", "Register", "Private"] as const;
+
+export type TimelineVisibility = typeof kTimelineVisibilities[number];
+
+export interface HttpTimelineInfo {
+ uniqueId: string;
+ name: string;
+ description: string;
+ owner: HttpUser;
+ visibility: TimelineVisibility;
+ lastModified: Date;
+ members: HttpUser[];
+}
+
+export interface HttpTimelineListQuery {
+ visibility?: TimelineVisibility;
+ relate?: string;
+ relateType?: "own" | "join";
+}
+
+export interface HttpTimelinePostRequest {
+ name: string;
+}
+
+export interface HttpTimelinePostTextContent {
+ type: "text";
+ text: string;
+}
+
+export interface HttpTimelinePostImageContent {
+ type: "image";
+}
+
+export type HttpTimelinePostContent =
+ | HttpTimelinePostTextContent
+ | HttpTimelinePostImageContent;
+
+export interface HttpTimelinePostInfo {
+ id: number;
+ content: HttpTimelinePostContent;
+ time: Date;
+ lastUpdated: Date;
+ author: HttpUser;
+ deleted: false;
+}
+
+export interface HttpTimelineDeletedPostInfo {
+ id: number;
+ time: Date;
+ lastUpdated: Date;
+ author?: HttpUser;
+ deleted: true;
+}
+
+export type HttpTimelineGenericPostInfo =
+ | HttpTimelinePostInfo
+ | HttpTimelineDeletedPostInfo;
+
+export interface HttpTimelinePostPostRequestTextContent {
+ type: "text";
+ text: string;
+}
+
+export interface HttpTimelinePostPostRequestImageContent {
+ type: "image";
+ data: Blob;
+}
+
+export type HttpTimelinePostPostRequestContent =
+ | HttpTimelinePostPostRequestTextContent
+ | HttpTimelinePostPostRequestImageContent;
+
+export interface HttpTimelinePostPostRequest {
+ content: HttpTimelinePostPostRequestContent;
+ time?: Date;
+}
+
+export interface HttpTimelinePatchRequest {
+ visibility?: TimelineVisibility;
+ description?: string;
+}
+
+export class HttpTimelineNotExistError extends Error {
+ constructor(public innerError?: AxiosError) {
+ super();
+ }
+}
+
+export class HttpTimelinePostNotExistError extends Error {
+ constructor(public innerError?: AxiosError) {
+ super();
+ }
+}
+
+export class HttpTimelineNameConflictError extends Error {
+ constructor(public innerError?: AxiosError) {
+ super();
+ }
+}
+
+//-------------------- begin: internal model --------------------
+
+interface RawTimelineInfo {
+ uniqueId: string;
+ name: string;
+ description: string;
+ owner: HttpUser;
+ visibility: TimelineVisibility;
+ lastModified: string;
+ members: HttpUser[];
+}
+
+interface RawTimelinePostTextContent {
+ type: "text";
+ text: string;
+}
+
+interface RawTimelinePostImageContent {
+ type: "image";
+ url: string;
+}
+
+type RawTimelinePostContent =
+ | RawTimelinePostTextContent
+ | RawTimelinePostImageContent;
+
+interface RawTimelinePostInfo {
+ id: number;
+ content: RawTimelinePostContent;
+ time: string;
+ lastUpdated: string;
+ author: HttpUser;
+ deleted: false;
+}
+
+interface RawTimelineDeletedPostInfo {
+ id: number;
+ time: string;
+ lastUpdated: string;
+ author: HttpUser;
+ deleted: true;
+}
+
+type RawTimelineGenericPostInfo =
+ | RawTimelinePostInfo
+ | RawTimelineDeletedPostInfo;
+
+interface RawTimelinePostPostRequestTextContent {
+ type: "text";
+ text: string;
+}
+
+interface RawTimelinePostPostRequestImageContent {
+ type: "image";
+ data: string;
+}
+
+type RawTimelinePostPostRequestContent =
+ | RawTimelinePostPostRequestTextContent
+ | RawTimelinePostPostRequestImageContent;
+
+interface RawTimelinePostPostRequest {
+ content: RawTimelinePostPostRequestContent;
+ time?: string;
+}
+
+//-------------------- end: internal model --------------------
+
+function processRawTimelineInfo(raw: RawTimelineInfo): HttpTimelineInfo {
+ return {
+ ...raw,
+ lastModified: new Date(raw.lastModified),
+ };
+}
+
+function processRawTimelinePostInfo(
+ raw: RawTimelinePostInfo
+): HttpTimelinePostInfo;
+function processRawTimelinePostInfo(
+ raw: RawTimelineGenericPostInfo
+): HttpTimelineGenericPostInfo;
+function processRawTimelinePostInfo(
+ raw: RawTimelineGenericPostInfo
+): HttpTimelineGenericPostInfo {
+ return {
+ ...raw,
+ time: new Date(raw.time),
+ lastUpdated: new Date(raw.lastUpdated),
+ };
+}
+
+export interface IHttpTimelineClient {
+ listTimeline(query: HttpTimelineListQuery): Promise<HttpTimelineInfo[]>;
+ getTimeline(timelineName: string): Promise<HttpTimelineInfo>;
+ getTimeline(
+ timelineName: string,
+ query: {
+ checkUniqueId?: string;
+ }
+ ): Promise<HttpTimelineInfo>;
+ getTimeline(
+ timelineName: string,
+ query: {
+ checkUniqueId?: string;
+ ifModifiedSince: Date;
+ }
+ ): Promise<HttpTimelineInfo | NotModified>;
+ postTimeline(
+ req: HttpTimelinePostRequest,
+ token: string
+ ): Promise<HttpTimelineInfo>;
+ patchTimeline(
+ timelineName: string,
+ req: HttpTimelinePatchRequest,
+ token: string
+ ): Promise<HttpTimelineInfo>;
+ deleteTimeline(timelineName: string, token: string): Promise<void>;
+ memberPut(
+ timelineName: string,
+ username: string,
+ token: string
+ ): Promise<void>;
+ memberDelete(
+ timelineName: string,
+ username: string,
+ token: string
+ ): Promise<void>;
+ listPost(
+ timelineName: string,
+ token?: string
+ ): Promise<HttpTimelinePostInfo[]>;
+ listPost(
+ timelineName: string,
+ token: string | undefined,
+ query: {
+ modifiedSince?: Date;
+ includeDeleted?: false;
+ }
+ ): Promise<HttpTimelinePostInfo[]>;
+ listPost(
+ timelineName: string,
+ token: string | undefined,
+ query: {
+ modifiedSince?: Date;
+ includeDeleted: true;
+ }
+ ): Promise<HttpTimelineGenericPostInfo[]>;
+ getPostData(
+ timelineName: string,
+ postId: number,
+ token?: string
+ ): Promise<BlobWithEtag>;
+ getPostData(
+ timelineName: string,
+ postId: number,
+ token: string | undefined,
+ etag: string
+ ): Promise<BlobWithEtag | NotModified>;
+ postPost(
+ timelineName: string,
+ req: HttpTimelinePostPostRequest,
+ token: string
+ ): Promise<HttpTimelinePostInfo>;
+ deletePost(
+ timelineName: string,
+ postId: number,
+ token: string
+ ): Promise<void>;
+}
+
+export class HttpTimelineClient implements IHttpTimelineClient {
+ listTimeline(query: HttpTimelineListQuery): Promise<HttpTimelineInfo[]> {
+ return axios
+ .get<RawTimelineInfo[]>(
+ applyQueryParameters(`${apiBaseUrl}/timelines`, query)
+ )
+ .then(extractResponseData)
+ .then((list) => list.map(processRawTimelineInfo))
+ .catch(convertToNetworkError);
+ }
+
+ getTimeline(timelineName: string): Promise<HttpTimelineInfo>;
+ getTimeline(
+ timelineName: string,
+ query: {
+ checkUniqueId?: string;
+ }
+ ): Promise<HttpTimelineInfo>;
+ getTimeline(
+ timelineName: string,
+ query: {
+ checkUniqueId?: string;
+ ifModifiedSince: Date;
+ }
+ ): Promise<HttpTimelineInfo | NotModified>;
+ getTimeline(
+ timelineName: string,
+ query?: {
+ checkUniqueId?: string;
+ ifModifiedSince?: Date;
+ }
+ ): Promise<HttpTimelineInfo | NotModified> {
+ return axios
+ .get<RawTimelineInfo>(
+ applyQueryParameters(`${apiBaseUrl}/timelines/${timelineName}`, query)
+ )
+ .then((res) => {
+ if (res.status === 304) {
+ return new NotModified();
+ } else {
+ return processRawTimelineInfo(res.data);
+ }
+ })
+ .catch(convertToIfStatusCodeIs(404, HttpTimelineNotExistError))
+ .catch(convertToNetworkError);
+ }
+
+ postTimeline(
+ req: HttpTimelinePostRequest,
+ token: string
+ ): Promise<HttpTimelineInfo> {
+ return axios
+ .post<RawTimelineInfo>(`${apiBaseUrl}/timelines?token=${token}`, req)
+ .then(extractResponseData)
+ .then(processRawTimelineInfo)
+ .catch(convertToIfErrorCodeIs(11040101, HttpTimelineNameConflictError))
+ .catch(convertToNetworkError);
+ }
+
+ patchTimeline(
+ timelineName: string,
+ req: HttpTimelinePatchRequest,
+ token: string
+ ): Promise<HttpTimelineInfo> {
+ return axios
+ .patch<RawTimelineInfo>(
+ `${apiBaseUrl}/timelines/${timelineName}?token=${token}`,
+ req
+ )
+ .then(extractResponseData)
+ .then(processRawTimelineInfo)
+ .catch(convertToNetworkError);
+ }
+
+ deleteTimeline(timelineName: string, token: string): Promise<void> {
+ return axios
+ .delete(`${apiBaseUrl}/timelines/${timelineName}?token=${token}`)
+ .catch(convertToNetworkError)
+ .then();
+ }
+
+ memberPut(
+ timelineName: string,
+ username: string,
+ token: string
+ ): Promise<void> {
+ return axios
+ .put(
+ `${apiBaseUrl}/timelines/${timelineName}/members/${username}?token=${token}`
+ )
+ .catch(convertToNetworkError)
+ .then();
+ }
+
+ memberDelete(
+ timelineName: string,
+ username: string,
+ token: string
+ ): Promise<void> {
+ return axios
+ .delete(
+ `${apiBaseUrl}/timelines/${timelineName}/members/${username}?token=${token}`
+ )
+ .catch(convertToNetworkError)
+ .then();
+ }
+
+ listPost(
+ timelineName: string,
+ token?: string
+ ): Promise<HttpTimelinePostInfo[]>;
+ listPost(
+ timelineName: string,
+ token: string | undefined,
+ query: {
+ modifiedSince?: Date;
+ includeDeleted?: false;
+ }
+ ): Promise<HttpTimelinePostInfo[]>;
+ listPost(
+ timelineName: string,
+ token: string | undefined,
+ query: {
+ modifiedSince?: Date;
+ includeDeleted: true;
+ }
+ ): Promise<HttpTimelineGenericPostInfo[]>;
+ listPost(
+ timelineName: string,
+ token?: string,
+ query?: {
+ modifiedSince?: Date;
+ includeDeleted?: boolean;
+ }
+ ): Promise<HttpTimelineGenericPostInfo[]> {
+ let url = `${apiBaseUrl}/timelines/${timelineName}/posts`;
+ url = updateQueryString("token", token, url);
+ if (query != null) {
+ if (query.modifiedSince != null) {
+ url = updateQueryString(
+ "modifiedSince",
+ query.modifiedSince.toISOString(),
+ url
+ );
+ }
+ if (query.includeDeleted != null) {
+ url = updateQueryString(
+ "includeDeleted",
+ query.includeDeleted ? "true" : "false",
+ url
+ );
+ }
+ }
+
+ return axios
+ .get<RawTimelineGenericPostInfo[]>(url)
+ .then(extractResponseData)
+ .catch(convertToIfStatusCodeIs(404, HttpTimelineNotExistError))
+ .catch(convertToForbiddenError)
+ .catch(convertToNetworkError)
+ .then((rawPosts) =>
+ rawPosts.map((raw) => processRawTimelinePostInfo(raw))
+ );
+ }
+
+ getPostData(
+ timelineName: string,
+ postId: number,
+ token: string
+ ): Promise<BlobWithEtag>;
+ getPostData(
+ timelineName: string,
+ postId: number,
+ token?: string,
+ etag?: string
+ ): Promise<BlobWithEtag | NotModified> {
+ const headers =
+ etag != null
+ ? {
+ "If-None-Match": etag,
+ }
+ : undefined;
+
+ let url = `${apiBaseUrl}/timelines/${timelineName}/posts/${postId}/data`;
+ url = updateQueryString("token", token, url);
+
+ return axios
+ .get(url, {
+ responseType: "blob",
+ headers,
+ })
+ .then(convertToBlobWithEtag)
+ .catch(convertToNotModified)
+ .catch(convertToIfStatusCodeIs(404, HttpTimelinePostNotExistError))
+ .catch(convertToNetworkError);
+ }
+
+ async postPost(
+ timelineName: string,
+ req: HttpTimelinePostPostRequest,
+ token: string
+ ): Promise<HttpTimelinePostInfo> {
+ let content: RawTimelinePostPostRequestContent;
+ if (req.content.type === "image") {
+ const base64Data = await base64(req.content.data);
+ content = {
+ ...req.content,
+ data: base64Data,
+ } as RawTimelinePostPostRequestImageContent;
+ } else {
+ content = req.content;
+ }
+ const rawReq: RawTimelinePostPostRequest = {
+ content,
+ };
+ if (req.time != null) {
+ rawReq.time = req.time.toISOString();
+ }
+ return await axios
+ .post<RawTimelinePostInfo>(
+ `${apiBaseUrl}/timelines/${timelineName}/posts?token=${token}`,
+ rawReq
+ )
+ .then(extractResponseData)
+ .catch(convertToNetworkError)
+ .then((rawPost) => processRawTimelinePostInfo(rawPost));
+ }
+
+ deletePost(
+ timelineName: string,
+ postId: number,
+ token: string
+ ): Promise<void> {
+ return axios
+ .delete(
+ `${apiBaseUrl}/timelines/${timelineName}/posts/${postId}?token=${token}`
+ )
+ .catch(convertToNetworkError)
+ .then();
+ }
+}
+
+let client: IHttpTimelineClient = new HttpTimelineClient();
+
+export function getHttpTimelineClient(): IHttpTimelineClient {
+ return client;
+}
+
+export function setHttpTimelineClient(
+ newClient: IHttpTimelineClient
+): IHttpTimelineClient {
+ const old = client;
+ client = newClient;
+ return old;
+}
diff --git a/FrontEnd/src/app/http/token.ts b/FrontEnd/src/app/http/token.ts
new file mode 100644
index 00000000..ae0cf3f6
--- /dev/null
+++ b/FrontEnd/src/app/http/token.ts
@@ -0,0 +1,72 @@
+import axios, { AxiosError } from "axios";
+
+import {
+ apiBaseUrl,
+ convertToNetworkError,
+ convertToIfErrorCodeIs,
+ extractResponseData,
+} from "./common";
+import { HttpUser } from "./user";
+
+export interface HttpCreateTokenRequest {
+ username: string;
+ password: string;
+ expire: number;
+}
+
+export interface HttpCreateTokenResponse {
+ token: string;
+ user: HttpUser;
+}
+
+export interface HttpVerifyTokenRequest {
+ token: string;
+}
+
+export interface HttpVerifyTokenResponse {
+ user: HttpUser;
+}
+
+export class HttpCreateTokenBadCredentialError extends Error {
+ constructor(public innerError?: AxiosError) {
+ super();
+ }
+}
+
+export interface IHttpTokenClient {
+ create(req: HttpCreateTokenRequest): Promise<HttpCreateTokenResponse>;
+ verify(req: HttpVerifyTokenRequest): Promise<HttpVerifyTokenResponse>;
+}
+
+export class HttpTokenClient implements IHttpTokenClient {
+ create(req: HttpCreateTokenRequest): Promise<HttpCreateTokenResponse> {
+ return axios
+ .post<HttpCreateTokenResponse>(`${apiBaseUrl}/token/create`, req)
+ .then(extractResponseData)
+ .catch(
+ convertToIfErrorCodeIs(11010101, HttpCreateTokenBadCredentialError)
+ )
+ .catch(convertToNetworkError);
+ }
+
+ verify(req: HttpVerifyTokenRequest): Promise<HttpVerifyTokenResponse> {
+ return axios
+ .post<HttpVerifyTokenResponse>(`${apiBaseUrl}/token/verify`, req)
+ .then(extractResponseData)
+ .catch(convertToNetworkError);
+ }
+}
+
+let client: IHttpTokenClient = new HttpTokenClient();
+
+export function getHttpTokenClient(): IHttpTokenClient {
+ return client;
+}
+
+export function setHttpTokenClient(
+ newClient: IHttpTokenClient
+): IHttpTokenClient {
+ const old = client;
+ client = newClient;
+ return old;
+}
diff --git a/FrontEnd/src/app/http/user.ts b/FrontEnd/src/app/http/user.ts
new file mode 100644
index 00000000..a0a02cce
--- /dev/null
+++ b/FrontEnd/src/app/http/user.ts
@@ -0,0 +1,134 @@
+import axios, { AxiosError } from "axios";
+
+import {
+ apiBaseUrl,
+ convertToNetworkError,
+ extractResponseData,
+ convertToIfStatusCodeIs,
+ convertToIfErrorCodeIs,
+ NotModified,
+ BlobWithEtag,
+ convertToBlobWithEtag,
+ convertToNotModified,
+} from "./common";
+
+export interface HttpUser {
+ uniqueId: string;
+ username: string;
+ administrator: boolean;
+ nickname: string;
+}
+
+export interface HttpUserPatchRequest {
+ nickname?: string;
+}
+
+export interface HttpChangePasswordRequest {
+ oldPassword: string;
+ newPassword: string;
+}
+
+export class HttpUserNotExistError extends Error {
+ constructor(public innerError?: AxiosError) {
+ super();
+ }
+}
+
+export class HttpChangePasswordBadCredentialError extends Error {
+ constructor(public innerError?: AxiosError) {
+ super();
+ }
+}
+
+export interface IHttpUserClient {
+ get(username: string): Promise<HttpUser>;
+ patch(
+ username: string,
+ req: HttpUserPatchRequest,
+ token: string
+ ): Promise<HttpUser>;
+ getAvatar(username: string): Promise<BlobWithEtag>;
+ getAvatar(
+ username: string,
+ etag: string
+ ): Promise<BlobWithEtag | NotModified>;
+ putAvatar(username: string, data: Blob, token: string): Promise<void>;
+ changePassword(req: HttpChangePasswordRequest, token: string): Promise<void>;
+}
+
+export class HttpUserClient implements IHttpUserClient {
+ get(username: string): Promise<HttpUser> {
+ return axios
+ .get<HttpUser>(`${apiBaseUrl}/users/${username}`)
+ .then(extractResponseData)
+ .catch(convertToIfStatusCodeIs(404, HttpUserNotExistError))
+ .catch(convertToNetworkError);
+ }
+
+ patch(
+ username: string,
+ req: HttpUserPatchRequest,
+ token: string
+ ): Promise<HttpUser> {
+ return axios
+ .patch<HttpUser>(`${apiBaseUrl}/users/${username}?token=${token}`, req)
+ .then(extractResponseData)
+ .catch(convertToNetworkError);
+ }
+
+ getAvatar(username: string): Promise<BlobWithEtag>;
+ getAvatar(
+ username: string,
+ etag?: string
+ ): Promise<BlobWithEtag | NotModified> {
+ const headers =
+ etag != null
+ ? {
+ "If-None-Match": etag,
+ }
+ : undefined;
+
+ return axios
+ .get(`${apiBaseUrl}/users/${username}/avatar`, {
+ responseType: "blob",
+ headers,
+ })
+ .then(convertToBlobWithEtag)
+ .catch(convertToNotModified)
+ .catch(convertToIfStatusCodeIs(404, HttpUserNotExistError))
+ .catch(convertToNetworkError);
+ }
+
+ putAvatar(username: string, data: Blob, token: string): Promise<void> {
+ return axios
+ .put(`${apiBaseUrl}/users/${username}/avatar?token=${token}`, data, {
+ headers: {
+ "Content-Type": data.type,
+ },
+ })
+ .catch(convertToNetworkError)
+ .then();
+ }
+
+ changePassword(req: HttpChangePasswordRequest, token: string): Promise<void> {
+ return axios
+ .post(`${apiBaseUrl}/userop/changepassword?token=${token}`, req)
+ .catch(
+ convertToIfErrorCodeIs(11020201, HttpChangePasswordBadCredentialError)
+ )
+ .catch(convertToNetworkError)
+ .then();
+ }
+}
+
+let client: IHttpUserClient = new HttpUserClient();
+
+export function getHttpUserClient(): IHttpUserClient {
+ return client;
+}
+
+export function setHttpUserClient(newClient: IHttpUserClient): IHttpUserClient {
+ const old = client;
+ client = newClient;
+ return old;
+}
diff --git a/FrontEnd/src/app/i18n.ts b/FrontEnd/src/app/i18n.ts
new file mode 100644
index 00000000..cdced7bf
--- /dev/null
+++ b/FrontEnd/src/app/i18n.ts
@@ -0,0 +1,79 @@
+import i18n, { BackendModule, ResourceKey } from "i18next";
+import LanguageDetector from "i18next-browser-languagedetector";
+import { initReactI18next } from "react-i18next";
+
+const backend: BackendModule = {
+ type: "backend",
+ async read(language, namespace, callback) {
+ function error(message: string): void {
+ callback(new Error(message), false);
+ }
+
+ function success(result: ResourceKey): void {
+ callback(null, result);
+ }
+
+ if (namespace !== "translation") {
+ error("Namespace must be 'translation'.");
+ }
+
+ if (language === "en") {
+ const res = (
+ await import(
+ /* webpackChunkName: "locales-en" */ "./locales/en/translation"
+ )
+ ).default;
+ success(res);
+ } else if (language === "zh-cn" || language === "zh") {
+ const res = (
+ await import(
+ /* webpackChunkName: "locales-zh" */ "./locales/zh/translation"
+ )
+ ).default;
+ success(res);
+ } else {
+ error(`Language ${language} is not supported.`);
+ }
+ },
+ init() {}, // eslint-disable-line @typescript-eslint/no-empty-function
+ create() {}, // eslint-disable-line @typescript-eslint/no-empty-function
+};
+
+export const i18nPromise = i18n
+ .use(LanguageDetector)
+ .use(backend)
+ .use(initReactI18next) // bind react-i18next to the instance
+ .init({
+ fallbackLng: false,
+ lowerCaseLng: true,
+
+ debug: process.env.NODE_ENV === "development",
+
+ interpolation: {
+ escapeValue: false, // not needed for react!!
+ },
+
+ // react i18next special options (optional)
+ // override if needed - omit if ok with defaults
+ /*
+ react: {
+ bindI18n: 'languageChanged',
+ bindI18nStore: '',
+ transEmptyNodeValue: '',
+ transSupportBasicHtmlNodes: true,
+ transKeepBasicHtmlNodesFor: ['br', 'strong', 'i'],
+ useSuspense: true,
+ }
+ */
+ });
+
+if (module.hot) {
+ module.hot.accept(
+ ["./locales/en/translation", "./locales/zh/translation"],
+ () => {
+ void i18n.reloadResources();
+ }
+ );
+}
+
+export default i18n;
diff --git a/FrontEnd/src/app/index.ejs b/FrontEnd/src/app/index.ejs
new file mode 100644
index 00000000..c2ff4182
--- /dev/null
+++ b/FrontEnd/src/app/index.ejs
@@ -0,0 +1,29 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8" />
+ <meta http-equiv="X-UA-Compatible" content="IE=edge" />
+ <meta name="viewport" content="width=device-width,initial-scale=1.0" />
+
+ <link rel="icon" href="/favicon.ico" />
+ <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
+ <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
+ <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
+ <link rel="manifest" href="/site.webmanifest" />
+ <link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5" />
+ <meta name="msapplication-TileColor" content="#2d89ef" />
+ <meta name="theme-color" content="#ffffff" />
+
+ <title><%= htmlWebpackPlugin.options.title %></title>
+ </head>
+ <body>
+ <noscript>
+ <strong>
+ We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work
+ properly without JavaScript enabled. Please enable it to continue.
+ </strong>
+ </noscript>
+ <div id="app"></div>
+ <!-- built files will be auto injected -->
+ </body>
+</html>
diff --git a/FrontEnd/src/app/index.sass b/FrontEnd/src/app/index.sass
new file mode 100644
index 00000000..8e87e4ac
--- /dev/null
+++ b/FrontEnd/src/app/index.sass
@@ -0,0 +1,66 @@
+@import '~bootstrap/scss/bootstrap'
+
+@import './views/common/common'
+@import './views/common/alert/alert'
+@import './views/home/home'
+@import './views/about/about'
+@import './views/login/login'
+@import './views/timeline-common/timeline-common'
+@import './views/timeline/timeline'
+@import './views/user/user'
+
+body
+ margin: 0
+
+small
+ line-height: 1.2
+
+.flex-fix-length
+ flex-grow: 0
+ flex-shrink: 0
+
+.position-lt
+ left: 0
+ top: 0
+
+.avatar
+ width: 60px
+ &.large
+ width: 100px
+ &.small
+ width: 40px
+
+.mt-appbar
+ margin-top: 56px
+
+.icon-button
+ font-size: 1.4em
+ &.large
+ font-size: 1.6em
+
+.cursor-pointer
+ cursor: pointer
+
+textarea
+ resize: none
+
+.white-space-no-wrap
+ white-space: nowrap
+
+.cru-card
+ @extend .shadow
+ @extend .border
+ @extend .border-primary
+ @extend .rounded
+ @extend .bg-light
+
+.full-viewport-center-child
+ position: fixed
+ width: 100vw
+ height: 100vh
+ display: flex
+ justify-content: center
+ align-items: center
+
+.text-orange
+ color: $orange
diff --git a/FrontEnd/src/app/index.tsx b/FrontEnd/src/app/index.tsx
new file mode 100644
index 00000000..00a75a4a
--- /dev/null
+++ b/FrontEnd/src/app/index.tsx
@@ -0,0 +1,15 @@
+import "regenerator-runtime";
+import "core-js/modules/es.promise";
+import "core-js/modules/es.array.iterator";
+import "pepjs";
+
+import React from "react";
+import ReactDOM from "react-dom";
+
+import "./index.sass";
+
+import "./i18n";
+
+import App from "./App";
+
+ReactDOM.render(<App />, document.getElementById("app"));
diff --git a/FrontEnd/src/app/locales/en/translation.ts b/FrontEnd/src/app/locales/en/translation.ts
new file mode 100644
index 00000000..c7f33d1e
--- /dev/null
+++ b/FrontEnd/src/app/locales/en/translation.ts
@@ -0,0 +1,202 @@
+import TranslationResource from "../scheme";
+
+const translation: TranslationResource = {
+ welcome: "Welcome!",
+ search: "Search",
+ loadFailReload: "Load failed, click <1>here</1> to reload.",
+ serviceWorker: {
+ availableOffline:
+ "Timeline is now cached in your computer and you can use it offline. 🎉🎉🎉",
+ upgradePrompt: "App is getting a new version!",
+ upgradeNow: "Update Now",
+ upgradeSuccess:
+ "Congratulations! App update succeeded! Still you can use it offline. 🎉🎉🎉",
+ externalActivatedPrompt:
+ "A new version of app is activated. Please refresh the page. Or it may be broken.",
+ reloadNow: "Refresh Now",
+ },
+ nav: {
+ settings: "Settings",
+ login: "Login",
+ about: "About",
+ },
+ chooseImage: "Choose a image",
+ loadImageError: "Failed to load image.",
+ home: {
+ go: "Go!",
+ allTimeline: "All Timelines",
+ joinTimeline: "Joined Timelines",
+ ownTimeline: "Owned Timelines",
+ offlinePrompt:
+ "Oh oh, it seems you are offline. Here list some timelines cached locally. You can view them or click <1>here</1> to refresh.",
+ createButton: "Create Timeline",
+ createDialog: {
+ title: "Create Timeline!",
+ name: "Name",
+ nameFormat:
+ "Name must consist of only letter including non-English letter, digit, hyphen(-) and underline(_) and be no longer than 26.",
+ badFormat: "Bad format.",
+ noEmpty: "Empty is not allowed.",
+ tooLong: "Too long.",
+ },
+ },
+ operationDialog: {
+ retry: "Retry",
+ nextStep: "Next",
+ previousStep: "Previous",
+ confirm: "Confirm",
+ cancel: "Cancel",
+ ok: "OK!",
+ processing: "Processing...",
+ success: "Success!",
+ error: "An error occured.",
+ },
+ timeline: {
+ messageCantSee: "Sorry, you are not allowed to see this timeline.😅",
+ userNotExist: "The user does not exist!",
+ timelineNotExist: "The timeline does not exist!",
+ manage: "Manage",
+ memberButton: "Member",
+ send: "Send",
+ deletePostFailed: "Failed to delete post.",
+ sendPostFailed: "Failed to send post.",
+ visibility: {
+ public: "public to everyone",
+ register: "only registed people can see",
+ private: "only members can see",
+ },
+ visibilityTooltip: {
+ public:
+ "Everyone including those without accounts can see content of the timeline.",
+ register:
+ "Only those who have an account and logined can see content of the timeline.",
+ private: "Only members of this timeline can see content of the timeline.",
+ },
+ dialogChangeProperty: {
+ title: "Change Timeline Properties",
+ visibility: "Visibility",
+ description: "Description",
+ },
+ member: {
+ alreadyMember: "The user is already a member.",
+ add: "Add",
+ remove: "Remove",
+ },
+ manageItem: {
+ nickname: "Nickname",
+ avatar: "Avatar",
+ property: "Timeline Property",
+ member: "Timeline Member",
+ delete: "Delete Timeline",
+ },
+ deleteDialog: {
+ title: "Delete Timeline",
+ inputPrompt:
+ "This is a dangerous action. If you are sure to delete timeline<1>{{name}}</1>, please input its name below and click confirm button.",
+ notMatch: "Name does not match.",
+ },
+ postSyncState: {
+ syncing: "Syncing",
+ synced: "Synced",
+ offline: "Offline",
+ },
+ post: {
+ deleteDialog: {
+ title: "Confirm Delete",
+ prompt:
+ "Are you sure to delete the post? This operation is not recoverable.",
+ },
+ },
+ },
+ user: {
+ username: "username",
+ password: "password",
+ login: "login",
+ rememberMe: "Remember Me",
+ welcomeBack: "Welcome back!",
+ verifyTokenFailed: "User login info is expired. Please login again!",
+ verifyTokenFailedNetwork:
+ "Verifying user login info failed. Please check your network and refresh page!",
+ },
+ login: {
+ emptyUsername: "Username can't be empty.",
+ emptyPassword: "Password can't be empty.",
+ badCredential: "Username or password is invalid.",
+ alreadyLogin: "Already login! Redirect to home page in 3s!",
+ },
+ userPage: {
+ dialogChangeNickname: {
+ title: "Change Nickname",
+ inputLabel: "New nickname",
+ },
+ dialogChangeAvatar: {
+ title: "Change Avatar",
+ previewImgAlt: "preview",
+ prompt: {
+ select: "Please select a picture.",
+ crop: "Please crop the picture.",
+ processingCrop: "Cropping picture...",
+ uploading: "Uploading...",
+ preview: "Please preview avatar",
+ },
+ upload: "upload",
+ },
+ },
+ settings: {
+ subheaders: {
+ account: "Account",
+ customization: "Customization",
+ },
+ languagePrimary: "Choose display language.",
+ languageSecondary:
+ "You language preference will be saved locally. Next time you visit this page, last language option will be used.",
+ changePassword: "Change account's password.",
+ logout: "Log out this account.",
+ gotoSelf:
+ "Click here to go to timeline of myself to change nickname and avatar.",
+ dialogChangePassword: {
+ title: "Change Password",
+ prompt:
+ "You are changing your password. You need to input the correct old password. After change, you need to login again and all old login will be invalid.",
+ inputOldPassword: "Old password",
+ inputNewPassword: "New password",
+ inputRetypeNewPassword: "Retype new password",
+ errorEmptyOldPassword: "Old password can't be empty.",
+ errorEmptyNewPassword: "New password can't be empty.",
+ errorRetypeNotMatch: "Password retyped does not match.",
+ },
+ dialogConfirmLogout: {
+ title: "Confirm Logout",
+ prompt:
+ "Are you sure to log out? All cached data in the browser will be deleted.",
+ },
+ },
+ about: {
+ author: {
+ title: "Site Developer",
+ fullname: "Fullname: ",
+ nickname: "Nickname: ",
+ 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: ",
+ },
+ },
+ admin: {
+ title: "admin",
+ },
+};
+
+export default translation;
diff --git a/FrontEnd/src/app/locales/scheme.ts b/FrontEnd/src/app/locales/scheme.ts
new file mode 100644
index 00000000..9e3534ac
--- /dev/null
+++ b/FrontEnd/src/app/locales/scheme.ts
@@ -0,0 +1,182 @@
+export default interface TranslationResource {
+ welcome: string;
+ search: string;
+ chooseImage: string;
+ loadImageError: string;
+ loadFailReload: string;
+ serviceWorker: {
+ availableOffline: string;
+ upgradePrompt: string;
+ upgradeNow: string;
+ upgradeSuccess: string;
+ externalActivatedPrompt: string;
+ reloadNow: string;
+ };
+ nav: {
+ settings: string;
+ login: string;
+ about: string;
+ };
+ home: {
+ go: string;
+ allTimeline: string;
+ joinTimeline: string;
+ ownTimeline: string;
+ offlinePrompt: string;
+ createButton: string;
+ createDialog: {
+ title: string;
+ name: string;
+ nameFormat: string;
+ badFormat: string;
+ noEmpty: string;
+ tooLong: string;
+ };
+ };
+ operationDialog: {
+ retry: string;
+ nextStep: string;
+ previousStep: string;
+ confirm: string;
+ cancel: string;
+ ok: string;
+ processing: string;
+ success: string;
+ error: string;
+ };
+ timeline: {
+ messageCantSee: string;
+ userNotExist: string;
+ timelineNotExist: string;
+ manage: string;
+ memberButton: string;
+ send: string;
+ deletePostFailed: string;
+ sendPostFailed: string;
+ visibility: {
+ public: string;
+ register: string;
+ private: string;
+ };
+ visibilityTooltip: {
+ public: string;
+ register: string;
+ private: string;
+ };
+ dialogChangeProperty: {
+ title: string;
+ visibility: string;
+ description: string;
+ };
+ member: {
+ alreadyMember: string;
+ add: string;
+ remove: string;
+ };
+ manageItem: {
+ nickname: string;
+ avatar: string;
+ property: string;
+ member: string;
+ delete: string;
+ };
+ deleteDialog: {
+ title: string;
+ inputPrompt: string;
+ notMatch: string;
+ };
+ postSyncState: {
+ syncing: string;
+ synced: string;
+ offline: string;
+ };
+ post: {
+ deleteDialog: {
+ title: string;
+ prompt: string;
+ };
+ };
+ };
+ user: {
+ username: string;
+ password: string;
+ login: string;
+ rememberMe: string;
+ welcomeBack: string;
+ verifyTokenFailed: string;
+ verifyTokenFailedNetwork: string;
+ };
+ login: {
+ emptyUsername: string;
+ emptyPassword: string;
+ badCredential: string;
+ alreadyLogin: string;
+ };
+ userPage: {
+ dialogChangeNickname: {
+ title: string;
+ inputLabel: string;
+ };
+ dialogChangeAvatar: {
+ title: string;
+ previewImgAlt: string;
+ prompt: {
+ select: string;
+ crop: string;
+ processingCrop: string;
+ preview: string;
+ uploading: string;
+ };
+ upload: string;
+ };
+ };
+ settings: {
+ subheaders: {
+ account: string;
+ customization: string;
+ };
+ languagePrimary: string;
+ languageSecondary: string;
+ changePassword: string;
+ logout: string;
+ gotoSelf: string;
+ dialogChangePassword: {
+ title: string;
+ prompt: string;
+ inputOldPassword: string;
+ inputNewPassword: string;
+ inputRetypeNewPassword: string;
+ errorEmptyOldPassword: string;
+ errorEmptyNewPassword: string;
+ errorRetypeNotMatch: string;
+ };
+ dialogConfirmLogout: {
+ title: string;
+ prompt: string;
+ };
+ };
+ about: {
+ author: {
+ title: string;
+ fullname: string;
+ nickname: string;
+ introduction: string;
+ introductionContent: string;
+ links: string;
+ };
+ site: {
+ title: string;
+ content: string;
+ repo: string;
+ };
+ credits: {
+ title: string;
+ content: string;
+ frontend: string;
+ backend: string;
+ };
+ };
+ admin: {
+ title: string;
+ };
+}
diff --git a/FrontEnd/src/app/locales/zh/translation.ts b/FrontEnd/src/app/locales/zh/translation.ts
new file mode 100644
index 00000000..df316366
--- /dev/null
+++ b/FrontEnd/src/app/locales/zh/translation.ts
@@ -0,0 +1,195 @@
+import TranslationResource from "../scheme";
+
+const translation: TranslationResource = {
+ welcome: "欢迎!",
+ search: "搜索",
+ loadFailReload: "加载失败,<1>点击</1>重试。",
+ serviceWorker: {
+ availableOffline: "Timeline 已经缓存在本地,你可以离线使用它。🎉🎉🎉",
+ upgradePrompt: "App 有新版本!",
+ upgradeNow: "现在升级",
+ upgradeSuccess: "App 升级成功,当然,你仍可以离线使用它。 🎉🎉🎉",
+ externalActivatedPrompt:
+ "一个新的 App 版本已经激活,请刷新页面使用,否则页面可能会出现故障。",
+ reloadNow: "立刻刷新",
+ },
+ nav: {
+ settings: "设置",
+ login: "登陆",
+ about: "关于",
+ },
+ chooseImage: "选择一个图片",
+ loadImageError: "加载图片失败",
+ home: {
+ go: "冲!",
+ allTimeline: "所有的时间线",
+ joinTimeline: "加入的时间线",
+ ownTimeline: "拥有的时间线",
+ offlinePrompt:
+ "你好像处于离线状态。以下是一些缓存在本地的时间线。你可以查看它们或者<1>点击</1>重新获取在线信息。",
+ createButton: "创建时间线",
+ createDialog: {
+ title: "创建时间线!",
+ name: "名字",
+ nameFormat:
+ "名字只能由字母、汉字、数字、下划线(_)和连字符(-)构成,且长度不能超过26.",
+ badFormat: "格式错误",
+ noEmpty: "不能为空",
+ tooLong: "太长了",
+ },
+ },
+ operationDialog: {
+ retry: "重试",
+ nextStep: "下一步",
+ previousStep: "上一步",
+ confirm: "确定",
+ cancel: "取消",
+ ok: "好的!",
+ processing: "处理中...",
+ success: "成功!",
+ error: "出错啦!",
+ },
+ timeline: {
+ messageCantSee: "不好意思,你没有权限查看这个时间线。😅",
+ userNotExist: "该用户不存在!",
+ timelineNotExist: "该时间线不存在!",
+ manage: "管理",
+ memberButton: "成员",
+ send: "发送",
+ deletePostFailed: "删除消息失败。",
+ sendPostFailed: "发送消息失败。",
+ visibility: {
+ public: "对所有人公开",
+ register: "仅注册可见",
+ private: "仅成员可见",
+ },
+ visibilityTooltip: {
+ public: "所有人都可以看到这个时间线的内容,包括没有注册的人。",
+ register: "只有拥有本网站的账号且登陆了的人才能看到这个时间线的内容。",
+ private: "只有这个时间线的成员可以看到这个时间线的内容。",
+ },
+ dialogChangeProperty: {
+ title: "修改时间线属性",
+ visibility: "可见性",
+ description: "描述",
+ },
+ member: {
+ alreadyMember: "该用户已经是一个成员。",
+ add: "添加",
+ remove: "移除",
+ },
+ manageItem: {
+ nickname: "昵称",
+ avatar: "头像",
+ property: "时间线属性",
+ member: "时间线成员",
+ delete: "删除时间线",
+ },
+ deleteDialog: {
+ title: "删除时间线",
+ inputPrompt:
+ "这是一个危险的操作。如果您确认要删除时间线<1>{{name}}</1>,请在下面输入它的名字并点击确认。",
+ notMatch: "名字不匹配",
+ },
+ postSyncState: {
+ syncing: "同步中",
+ synced: "同步成功",
+ offline: "离线",
+ },
+ post: {
+ deleteDialog: {
+ title: "确认删除",
+ prompt: "确定删除这个消息?这个操作不可撤销。",
+ },
+ },
+ },
+ user: {
+ username: "用户名",
+ password: "密码",
+ login: "登录",
+ rememberMe: "记住我",
+ welcomeBack: "欢迎回来!",
+ verifyTokenFailed: "用户登录信息已过期,请重新登陆!",
+ verifyTokenFailedNetwork:
+ "验证用户登录信息失败,请检查网络连接并刷新页面!",
+ },
+ login: {
+ emptyUsername: "用户名不能为空。",
+ emptyPassword: "密码不能为空。",
+ badCredential: "用户名或密码错误。",
+ alreadyLogin: "已经登陆,三秒后导航到首页!",
+ },
+ userPage: {
+ dialogChangeNickname: {
+ title: "更改昵称",
+ inputLabel: "新昵称",
+ },
+ dialogChangeAvatar: {
+ title: "修改头像",
+ previewImgAlt: "预览",
+ prompt: {
+ select: "请选择一个图片",
+ crop: "请裁剪图片",
+ processingCrop: "正在裁剪图片",
+ uploading: "正在上传",
+ preview: "请预览图片",
+ },
+ upload: "上传",
+ },
+ },
+ settings: {
+ subheaders: {
+ account: "账户",
+ customization: "个性化",
+ },
+ languagePrimary: "选择显示的语言。",
+ languageSecondary:
+ "您的语言偏好将会存储在本地,下次浏览时将自动使用上次保存的语言选项。",
+ changePassword: "更改账号的密码。",
+ logout: "注销此账号。",
+ gotoSelf: "点击前往个人时间线修改昵称和头像!",
+ dialogChangePassword: {
+ title: "修改密码",
+ prompt:
+ "您正在修改密码,您需要输入正确的旧密码。成功修改后您需要重新登陆,而且以前所有的登录都会失效。",
+ inputOldPassword: "旧密码",
+ inputNewPassword: "新密码",
+ inputRetypeNewPassword: "再次输入新密码",
+ errorEmptyOldPassword: "旧密码不能为空。",
+ errorEmptyNewPassword: "新密码不能为空",
+ errorRetypeNotMatch: "两次输入的密码不一致",
+ },
+ dialogConfirmLogout: {
+ title: "确定注销",
+ prompt: "您确定注销此账号?这将删除所有已经缓存在浏览器的数据。",
+ },
+ },
+ about: {
+ author: {
+ title: "网站作者",
+ fullname: "姓名:",
+ nickname: "昵称:",
+ introduction: "简介:",
+ introductionContent: "一个基于巧合编程的代码爱好者。",
+ links: "链接:",
+ },
+ site: {
+ title: "网站信息",
+ content:
+ "这个网站的名字叫 <1>Timeline</1>,是一个以<3>时间线</3>为核心概念的 Web App . 它的前端和后端都是由<5>我</5>开发,并且在 GitHub 上开源。大家可以相对轻松的把它们部署在自己的服务器上,这也是我的目标之一。欢迎大家前往 GitHub 仓库提出任何意见。",
+ repo: "GitHub 仓库",
+ },
+ credits: {
+ title: "鸣谢",
+ content:
+ "Timeline 是站在巨人肩膀上的作品,感谢以下列出的和其他未列出的许多开源项目,相关 License 请在 GitHub 仓库中查看。",
+ frontend: "前端:",
+ backend: "后端:",
+ },
+ },
+ admin: {
+ title: "管理",
+ },
+};
+
+export default translation;
diff --git a/FrontEnd/src/app/service-worker.tsx b/FrontEnd/src/app/service-worker.tsx
new file mode 100644
index 00000000..3be54bc1
--- /dev/null
+++ b/FrontEnd/src/app/service-worker.tsx
@@ -0,0 +1,113 @@
+import React from "react";
+import { useTranslation } from "react-i18next";
+import { Button } from "react-bootstrap";
+
+import { pushAlert } from "./services/alert";
+
+if ("serviceWorker" in navigator) {
+ let isThisTriggerUpgrade = false;
+
+ const upgradeSuccessLocalStorageKey = "TIMELINE_UPGRADE_SUCCESS";
+
+ if (window.localStorage.getItem(upgradeSuccessLocalStorageKey)) {
+ pushAlert({
+ message: {
+ type: "i18n",
+ key: "serviceWorker.upgradeSuccess",
+ },
+ type: "success",
+ });
+ window.localStorage.removeItem(upgradeSuccessLocalStorageKey);
+ }
+
+ void import("workbox-window").then(({ Workbox, messageSW }) => {
+ const wb = new Workbox("/sw.js");
+ let registration: ServiceWorkerRegistration | undefined;
+
+ // externalactivated is not usable but I still use its name.
+ wb.addEventListener("controlling", () => {
+ const upgradeReload = (): void => {
+ window.localStorage.setItem(upgradeSuccessLocalStorageKey, "true");
+ window.location.reload();
+ };
+
+ if (isThisTriggerUpgrade) {
+ upgradeReload();
+ } else {
+ const Message: React.FC = () => {
+ const { t } = useTranslation();
+ return (
+ <>
+ {t("serviceWorker.externalActivatedPrompt")}
+ <Button
+ variant="outline-success"
+ size="sm"
+ onClick={upgradeReload}
+ >
+ {t("serviceWorker.reloadNow")}
+ </Button>
+ </>
+ );
+ };
+
+ pushAlert({
+ message: Message,
+ dismissTime: "never",
+ type: "warning",
+ });
+ }
+ });
+
+ wb.addEventListener("activated", (event) => {
+ if (!event.isUpdate) {
+ pushAlert({
+ message: {
+ type: "i18n",
+ key: "serviceWorker.availableOffline",
+ },
+ type: "success",
+ });
+ }
+ });
+
+ const showSkipWaitingPrompt = (): void => {
+ const upgrade = (): void => {
+ isThisTriggerUpgrade = true;
+ if (registration && registration.waiting) {
+ // Send a message to the waiting service worker,
+ // instructing it to activate.
+ // Note: for this to work, you have to add a message
+ // listener in your service worker. See below.
+ void messageSW(registration.waiting, { type: "SKIP_WAITING" });
+ }
+ };
+
+ const UpgradeMessage: React.FC = () => {
+ const { t } = useTranslation();
+ return (
+ <>
+ {t("serviceWorker.upgradePrompt")}
+ <Button variant="outline-success" size="sm" onClick={upgrade}>
+ {t("serviceWorker.upgradeNow")}
+ </Button>
+ </>
+ );
+ };
+
+ pushAlert({
+ message: UpgradeMessage,
+ dismissTime: "never",
+ type: "success",
+ });
+ };
+
+ // Add an event listener to detect when the registered
+ // service worker has installed but is waiting to activate.
+ wb.addEventListener("waiting", showSkipWaitingPrompt);
+ wb.addEventListener("externalwaiting", showSkipWaitingPrompt);
+
+ void wb.register().then((reg) => {
+ registration = reg;
+ });
+ });
+}
diff --git a/FrontEnd/src/app/services/DataHub.ts b/FrontEnd/src/app/services/DataHub.ts
new file mode 100644
index 00000000..93a9b41f
--- /dev/null
+++ b/FrontEnd/src/app/services/DataHub.ts
@@ -0,0 +1,225 @@
+import { pull } from "lodash";
+import { Observable, BehaviorSubject, combineLatest } from "rxjs";
+import { map } from "rxjs/operators";
+
+export type Subscriber<TData> = (data: TData) => void;
+
+export type WithSyncStatus<T> = T & { syncing: boolean };
+
+export class DataLine<TData> {
+ private _current: TData | undefined = undefined;
+
+ private _syncPromise: Promise<void> | null = null;
+ private _syncingSubject = new BehaviorSubject<boolean>(false);
+
+ private _observers: Subscriber<TData>[] = [];
+
+ constructor(
+ private config: {
+ sync: () => Promise<void>;
+ destroyable?: (value: TData | undefined) => boolean;
+ disableInitSync?: boolean;
+ }
+ ) {
+ if (config.disableInitSync !== true) {
+ setImmediate(() => void this.sync());
+ }
+ }
+
+ private subscribe(subscriber: Subscriber<TData>): void {
+ this._observers.push(subscriber);
+ if (this._current !== undefined) {
+ subscriber(this._current);
+ }
+ }
+
+ private unsubscribe(subscriber: Subscriber<TData>): void {
+ if (!this._observers.includes(subscriber)) return;
+ pull(this._observers, subscriber);
+ }
+
+ getObservable(): Observable<TData> {
+ return new Observable<TData>((observer) => {
+ const f = (data: TData): void => {
+ observer.next(data);
+ };
+ this.subscribe(f);
+
+ return () => {
+ this.unsubscribe(f);
+ };
+ });
+ }
+
+ getSyncStatusObservable(): Observable<boolean> {
+ return this._syncingSubject.asObservable();
+ }
+
+ getDataWithSyncStatusObservable(): Observable<WithSyncStatus<TData>> {
+ return combineLatest([
+ this.getObservable(),
+ this.getSyncStatusObservable(),
+ ]).pipe(
+ map(([data, syncing]) => ({
+ ...data,
+ syncing,
+ }))
+ );
+ }
+
+ get value(): TData | undefined {
+ return this._current;
+ }
+
+ next(value: TData): void {
+ this._current = value;
+ this._observers.forEach((observer) => observer(value));
+ }
+
+ get isSyncing(): boolean {
+ return this._syncPromise != null;
+ }
+
+ sync(): Promise<void> {
+ if (this._syncPromise == null) {
+ this._syncingSubject.next(true);
+ this._syncPromise = this.config.sync().then(() => {
+ this._syncingSubject.next(false);
+ this._syncPromise = null;
+ });
+ }
+
+ return this._syncPromise;
+ }
+
+ syncWithAction(
+ syncAction: (line: DataLine<TData>) => Promise<void>
+ ): Promise<void> {
+ if (this._syncPromise == null) {
+ this._syncingSubject.next(true);
+ this._syncPromise = syncAction(this).then(() => {
+ this._syncingSubject.next(false);
+ this._syncPromise = null;
+ });
+ }
+
+ return this._syncPromise;
+ }
+
+ get destroyable(): boolean {
+ const customDestroyable = this.config?.destroyable;
+
+ return (
+ this._observers.length === 0 &&
+ !this.isSyncing &&
+ (customDestroyable != null ? customDestroyable(this._current) : true)
+ );
+ }
+}
+
+export class DataHub<TKey, TData> {
+ private sync: (key: TKey, line: DataLine<TData>) => Promise<void>;
+ private keyToString: (key: TKey) => string;
+ private destroyable?: (key: TKey, value: TData | undefined) => boolean;
+
+ private readonly subscriptionLineMap = new Map<string, DataLine<TData>>();
+
+ private cleanTimerId = 0;
+
+ // setup is called after creating line and if it returns a function as destroyer, then when the line is destroyed the destroyer will be called.
+ constructor(config: {
+ sync: (key: TKey, line: DataLine<TData>) => Promise<void>;
+ keyToString?: (key: TKey) => string;
+ destroyable?: (key: TKey, value: TData | undefined) => boolean;
+ }) {
+ this.sync = config.sync;
+ this.keyToString =
+ config.keyToString ??
+ ((value): string => {
+ if (typeof value === "string") return value;
+ else
+ throw new Error(
+ "Default keyToString function only pass string value."
+ );
+ });
+
+ this.destroyable = config.destroyable;
+ }
+
+ private cleanLines(): void {
+ const toDelete: string[] = [];
+ for (const [key, line] of this.subscriptionLineMap.entries()) {
+ if (line.destroyable) {
+ toDelete.push(key);
+ }
+ }
+
+ if (toDelete.length === 0) return;
+
+ for (const key of toDelete) {
+ this.subscriptionLineMap.delete(key);
+ }
+
+ if (this.subscriptionLineMap.size === 0) {
+ window.clearInterval(this.cleanTimerId);
+ this.cleanTimerId = 0;
+ }
+ }
+
+ private createLine(key: TKey, disableInitSync = false): DataLine<TData> {
+ const keyString = this.keyToString(key);
+ const { destroyable } = this;
+ const newLine: DataLine<TData> = new DataLine<TData>({
+ sync: () => this.sync(key, newLine),
+ destroyable:
+ destroyable != null ? (value) => destroyable(key, value) : undefined,
+ disableInitSync: disableInitSync,
+ });
+ this.subscriptionLineMap.set(keyString, newLine);
+ if (this.subscriptionLineMap.size === 1) {
+ this.cleanTimerId = window.setInterval(this.cleanLines.bind(this), 20000);
+ }
+ return newLine;
+ }
+
+ getObservable(key: TKey): Observable<TData> {
+ return this.getLineOrCreate(key).getObservable();
+ }
+
+ getSyncStatusObservable(key: TKey): Observable<boolean> {
+ return this.getLineOrCreate(key).getSyncStatusObservable();
+ }
+
+ getDataWithSyncStatusObservable(
+ key: TKey
+ ): Observable<WithSyncStatus<TData>> {
+ return this.getLineOrCreate(key).getDataWithSyncStatusObservable();
+ }
+
+ getLine(key: TKey): DataLine<TData> | null {
+ const keyString = this.keyToString(key);
+ return this.subscriptionLineMap.get(keyString) ?? null;
+ }
+
+ getLineOrCreate(key: TKey): DataLine<TData> {
+ const keyString = this.keyToString(key);
+ return this.subscriptionLineMap.get(keyString) ?? this.createLine(key);
+ }
+
+ getLineOrCreateWithoutInitSync(key: TKey): DataLine<TData> {
+ const keyString = this.keyToString(key);
+ return (
+ this.subscriptionLineMap.get(keyString) ?? this.createLine(key, true)
+ );
+ }
+
+ optionalInitLineWithSyncAction(
+ key: TKey,
+ syncAction: (line: DataLine<TData>) => Promise<void>
+ ): Promise<void> {
+ const optionalLine = this.getLine(key);
+ if (optionalLine != null) return Promise.resolve();
+ const line = this.createLine(key, true);
+ return line.syncWithAction(syncAction);
+ }
+}
diff --git a/FrontEnd/src/app/services/alert.ts b/FrontEnd/src/app/services/alert.ts
new file mode 100644
index 00000000..e4c0e653
--- /dev/null
+++ b/FrontEnd/src/app/services/alert.ts
@@ -0,0 +1,61 @@
+import React from "react";
+import pull from "lodash/pull";
+
+export interface AlertInfo {
+ type?: "primary" | "secondary" | "success" | "danger" | "warning" | "info";
+ message: string | React.FC<unknown> | { type: "i18n"; key: string };
+ dismissTime?: number | "never";
+}
+
+export interface AlertInfoEx extends AlertInfo {
+ id: number;
+}
+
+export type AlertConsumer = (alerts: AlertInfoEx) => void;
+
+export class AlertService {
+ private consumers: AlertConsumer[] = [];
+ private savedAlerts: AlertInfoEx[] = [];
+ private currentId = 1;
+
+ private produce(alert: AlertInfoEx): void {
+ for (const consumer of this.consumers) {
+ consumer(alert);
+ }
+ }
+
+ registerConsumer(consumer: AlertConsumer): void {
+ this.consumers.push(consumer);
+ if (this.savedAlerts.length !== 0) {
+ for (const alert of this.savedAlerts) {
+ this.produce(alert);
+ }
+ this.savedAlerts = [];
+ }
+ }
+
+ unregisterConsumer(consumer: AlertConsumer): void {
+ pull(this.consumers, consumer);
+ }
+
+ push(alert: AlertInfo): void {
+ const newAlert: AlertInfoEx = { ...alert, id: this.currentId++ };
+ if (this.consumers.length === 0) {
+ this.savedAlerts.push(newAlert);
+ } else {
+ this.produce(newAlert);
+ }
+ }
+}
+
+export const alertService = new AlertService();
+
+export function pushAlert(alert: AlertInfo): void {
+ alertService.push(alert);
+}
+
+export const kAlertHostId = "alert-host";
+
+export function getAlertHost(): HTMLElement | null {
+ return document.getElementById(kAlertHostId);
+}
diff --git a/FrontEnd/src/app/services/common.ts b/FrontEnd/src/app/services/common.ts
new file mode 100644
index 00000000..3bb6b9d7
--- /dev/null
+++ b/FrontEnd/src/app/services/common.ts
@@ -0,0 +1,23 @@
+import localforage from "localforage";
+
+import { HttpNetworkError } from "@/http/common";
+
+export const dataStorage = localforage.createInstance({
+ name: "data",
+ description: "Database for offline data.",
+ driver: localforage.INDEXEDDB,
+});
+
+export class ForbiddenError extends Error {
+ constructor(message?: string) {
+ super(message);
+ }
+}
+
+export function throwIfNotNetworkError(e: unknown): void {
+ if (!(e instanceof HttpNetworkError)) {
+ throw e;
+ }
+}
+
+export type BlobOrStatus = Blob | "loading" | "error";
diff --git a/FrontEnd/src/app/services/timeline.ts b/FrontEnd/src/app/services/timeline.ts
new file mode 100644
index 00000000..9db76281
--- /dev/null
+++ b/FrontEnd/src/app/services/timeline.ts
@@ -0,0 +1,702 @@
+import React from "react";
+import XRegExp from "xregexp";
+import { Observable, from, combineLatest, of } from "rxjs";
+import { map, switchMap, startWith } from "rxjs/operators";
+import { uniqBy } from "lodash";
+
+import { convertError } from "@/utilities/rxjs";
+import {
+ TimelineVisibility,
+ HttpTimelineInfo,
+ HttpTimelinePatchRequest,
+ HttpTimelinePostPostRequest,
+ HttpTimelinePostPostRequestContent,
+ HttpTimelinePostPostRequestTextContent,
+ HttpTimelinePostPostRequestImageContent,
+ HttpTimelinePostInfo,
+ HttpTimelinePostTextContent,
+ getHttpTimelineClient,
+ HttpTimelineNotExistError,
+ HttpTimelineNameConflictError,
+} from "@/http/timeline";
+import { BlobWithEtag, NotModified, HttpForbiddenError } from "@/http/common";
+import { HttpUser } from "@/http/user";
+
+export { kTimelineVisibilities } from "@/http/timeline";
+
+export type { TimelineVisibility } from "@/http/timeline";
+
+import { dataStorage, throwIfNotNetworkError, BlobOrStatus } from "./common";
+import { DataHub, WithSyncStatus } from "./DataHub";
+import { UserAuthInfo, checkLogin, userService, userInfoService } from "./user";
+
+export type TimelineInfo = HttpTimelineInfo;
+export type TimelineChangePropertyRequest = HttpTimelinePatchRequest;
+export type TimelineCreatePostRequest = HttpTimelinePostPostRequest;
+export type TimelineCreatePostContent = HttpTimelinePostPostRequestContent;
+export type TimelineCreatePostTextContent = HttpTimelinePostPostRequestTextContent;
+export type TimelineCreatePostImageContent = HttpTimelinePostPostRequestImageContent;
+
+export type TimelinePostTextContent = HttpTimelinePostTextContent;
+
+export interface TimelinePostImageContent {
+ type: "image";
+ data: BlobOrStatus;
+}
+
+export type TimelinePostContent =
+ | TimelinePostTextContent
+ | TimelinePostImageContent;
+
+export interface TimelinePostInfo {
+ id: number;
+ content: TimelinePostContent;
+ time: Date;
+ lastUpdated: Date;
+ author: HttpUser;
+}
+
+export const timelineVisibilityTooltipTranslationMap: Record<
+ TimelineVisibility,
+ string
+> = {
+ Public: "timeline.visibilityTooltip.public",
+ Register: "timeline.visibilityTooltip.register",
+ Private: "timeline.visibilityTooltip.private",
+};
+
+export class TimelineNotExistError extends Error {}
+export class TimelineNameConflictError extends Error {}
+
+export type TimelineWithSyncStatus = WithSyncStatus<
+ | {
+ type: "cache";
+ timeline: TimelineInfo;
+ }
+ | {
+ type: "offline" | "synced";
+ timeline: TimelineInfo | null;
+ }
+>;
+
+export type TimelinePostsWithSyncState = WithSyncStatus<{
+ type:
+ | "cache"
+ | "offline" // Sync failed and use cache.
+ | "synced" // Sync succeeded.
+ | "forbid" // The list is forbidden to see.
+ | "notexist"; // The timeline does not exist.
+ posts: TimelinePostInfo[];
+}>;
+
+type TimelineData = Omit<HttpTimelineInfo, "owner" | "members"> & {
+ owner: string;
+ members: string[];
+};
+
+type TimelinePostData = Omit<HttpTimelinePostInfo, "author"> & {
+ author: string;
+};
+
+export class TimelineService {
+ private getCachedTimeline(
+ timelineName: string
+ ): Promise<TimelineData | null> {
+ return dataStorage.getItem<TimelineData | null>(`timeline.${timelineName}`);
+ }
+
+ private saveTimeline(
+ timelineName: string,
+ data: TimelineData
+ ): Promise<void> {
+ return dataStorage
+ .setItem<TimelineData>(`timeline.${timelineName}`, data)
+ .then();
+ }
+
+ private async clearTimelineData(timelineName: string): Promise<void> {
+ const keys = (await dataStorage.keys()).filter((k) =>
+ k.startsWith(`timeline.${timelineName}`)
+ );
+ await Promise.all(keys.map((k) => dataStorage.removeItem(k)));
+ }
+
+ private convertHttpTimelineToData(timeline: HttpTimelineInfo): TimelineData {
+ return {
+ ...timeline,
+ owner: timeline.owner.username,
+ members: timeline.members.map((m) => m.username),
+ };
+ }
+
+ private _timelineHub = new DataHub<
+ string,
+ | {
+ type: "cache";
+ timeline: TimelineData;
+ }
+ | {
+ type: "offline" | "synced";
+ timeline: TimelineData | null;
+ }
+ >({
+ sync: async (key, line) => {
+ const cache = await this.getCachedTimeline(key);
+
+ if (line.value == undefined) {
+ if (cache != null) {
+ line.next({ type: "cache", timeline: cache });
+ }
+ }
+
+ try {
+ const httpTimeline = await getHttpTimelineClient().getTimeline(key);
+
+ userInfoService.saveUsers([
+ httpTimeline.owner,
+ ...httpTimeline.members,
+ ]);
+
+ const timeline = this.convertHttpTimelineToData(httpTimeline);
+
+ if (cache != null && timeline.uniqueId !== cache.uniqueId) {
+ console.log(
+ `Timeline with name ${key} has changed to a new one. Clear old data.`
+ );
+ await this.clearTimelineData(key); // If timeline has changed, clear all old data.
+ }
+
+ await this.saveTimeline(key, timeline);
+
+ line.next({ type: "synced", timeline });
+ } catch (e) {
+ if (e instanceof HttpTimelineNotExistError) {
+ line.next({ type: "synced", timeline: null });
+ } else {
+ if (cache == null) {
+ line.next({ type: "offline", timeline: null });
+ } else {
+ line.next({ type: "offline", timeline: cache });
+ }
+ throwIfNotNetworkError(e);
+ }
+ }
+ },
+ });
+
+ syncTimeline(timelineName: string): Promise<void> {
+ return this._timelineHub.getLineOrCreate(timelineName).sync();
+ }
+
+ getTimeline$(timelineName: string): Observable<TimelineWithSyncStatus> {
+ return this._timelineHub.getDataWithSyncStatusObservable(timelineName).pipe(
+ switchMap((state) => {
+ const { timeline } = state;
+ if (timeline != null) {
+ return combineLatest(
+ [timeline.owner, ...timeline.members].map((u) =>
+ userInfoService.getUser$(u)
+ )
+ ).pipe(
+ map((users) => {
+ return {
+ ...state,
+ timeline: {
+ ...timeline,
+ owner: users[0],
+ members: users.slice(1),
+ },
+ };
+ })
+ );
+ } else {
+ return of(state as TimelineWithSyncStatus);
+ }
+ })
+ );
+ }
+
+ createTimeline(timelineName: string): Observable<TimelineInfo> {
+ const user = checkLogin();
+ return from(
+ getHttpTimelineClient().postTimeline(
+ {
+ name: timelineName,
+ },
+ user.token
+ )
+ ).pipe(
+ convertError(HttpTimelineNameConflictError, TimelineNameConflictError)
+ );
+ }
+
+ changeTimelineProperty(
+ timelineName: string,
+ req: TimelineChangePropertyRequest
+ ): Observable<TimelineInfo> {
+ const user = checkLogin();
+ return from(
+ getHttpTimelineClient()
+ .patchTimeline(timelineName, req, user.token)
+ .then((timeline) => {
+ void this.syncTimeline(timelineName);
+ return timeline;
+ })
+ );
+ }
+
+ deleteTimeline(timelineName: string): Observable<unknown> {
+ const user = checkLogin();
+ return from(
+ getHttpTimelineClient().deleteTimeline(timelineName, user.token)
+ );
+ }
+
+ addMember(timelineName: string, username: string): Observable<unknown> {
+ const user = checkLogin();
+ return from(
+ getHttpTimelineClient()
+ .memberPut(timelineName, username, user.token)
+ .then(() => {
+ void this.syncTimeline(timelineName);
+ })
+ );
+ }
+
+ removeMember(timelineName: string, username: string): Observable<unknown> {
+ const user = checkLogin();
+ return from(
+ getHttpTimelineClient()
+ .memberDelete(timelineName, username, user.token)
+ .then(() => {
+ void this.syncTimeline(timelineName);
+ })
+ );
+ }
+
+ private convertHttpPostToData(post: HttpTimelinePostInfo): TimelinePostData {
+ return {
+ ...post,
+ author: post.author.username,
+ };
+ }
+
+ private convertHttpPostToDataList(
+ posts: HttpTimelinePostInfo[]
+ ): TimelinePostData[] {
+ return posts.map((post) => this.convertHttpPostToData(post));
+ }
+
+ private getCachedPosts(
+ timelineName: string
+ ): Promise<TimelinePostData[] | null> {
+ return dataStorage.getItem<TimelinePostData[] | null>(
+ `timeline.${timelineName}.posts`
+ );
+ }
+
+ private savePosts(
+ timelineName: string,
+ data: TimelinePostData[]
+ ): Promise<void> {
+ return dataStorage
+ .setItem<TimelinePostData[]>(`timeline.${timelineName}.posts`, data)
+ .then();
+ }
+
+ private syncPosts(timelineName: string): Promise<void> {
+ return this._postsHub.getLineOrCreate(timelineName).sync();
+ }
+
+ private _postsHub = new DataHub<
+ string,
+ {
+ type: "cache" | "offline" | "synced" | "forbid" | "notexist";
+ posts: TimelinePostData[];
+ }
+ >({
+ sync: async (key, line) => {
+ // Wait for timeline synced. In case the timeline has changed to another and old data has been cleaned.
+ await this.syncTimeline(key);
+
+ if (line.value == null) {
+ const cache = await this.getCachedPosts(key);
+ if (cache != null) {
+ line.next({ type: "cache", posts: cache });
+ }
+ }
+
+ const now = new Date();
+
+ const lastUpdatedTime = await dataStorage.getItem<Date | null>(
+ `timeline.${key}.lastUpdated`
+ );
+
+ try {
+ if (lastUpdatedTime == null) {
+ const httpPosts = await getHttpTimelineClient().listPost(
+ key,
+ userService.currentUser?.token
+ );
+
+ userInfoService.saveUsers(
+ uniqBy(
+ httpPosts.map((post) => post.author),
+ "username"
+ )
+ );
+
+ const posts = this.convertHttpPostToDataList(httpPosts);
+ await this.savePosts(key, posts);
+ await dataStorage.setItem<Date>(`timeline.${key}.lastUpdated`, now);
+
+ line.next({ type: "synced", posts });
+ } else {
+ const httpPosts = await getHttpTimelineClient().listPost(
+ key,
+ userService.currentUser?.token,
+ {
+ modifiedSince: lastUpdatedTime,
+ includeDeleted: true,
+ }
+ );
+
+ const deletedIds = httpPosts
+ .filter((p) => p.deleted)
+ .map((p) => p.id);
+ const changed = httpPosts.filter(
+ (p): p is HttpTimelinePostInfo => !p.deleted
+ );
+
+ userInfoService.saveUsers(
+ uniqBy(
+ httpPosts
+ .map((post) => post.author)
+ .filter((u): u is HttpUser => u != null),
+ "username"
+ )
+ );
+
+ const cache = (await this.getCachedPosts(key)) ?? [];
+
+ const posts = cache.filter((p) => !deletedIds.includes(p.id));
+
+ for (const changedPost of changed) {
+ const savedChangedPostIndex = posts.findIndex(
+ (p) => p.id === changedPost.id
+ );
+ if (savedChangedPostIndex === -1) {
+ posts.push(this.convertHttpPostToData(changedPost));
+ } else {
+ posts[savedChangedPostIndex] = this.convertHttpPostToData(
+ changedPost
+ );
+ }
+ }
+
+ await this.savePosts(key, posts);
+ await dataStorage.setItem<Date>(`timeline.${key}.lastUpdated`, now);
+ line.next({ type: "synced", posts });
+ }
+ } catch (e) {
+ if (e instanceof HttpTimelineNotExistError) {
+ line.next({ type: "notexist", posts: [] });
+ } else if (e instanceof HttpForbiddenError) {
+ line.next({ type: "forbid", posts: [] });
+ } else {
+ const cache = await this.getCachedPosts(key);
+ if (cache == null) {
+ line.next({ type: "offline", posts: [] });
+ } else {
+ line.next({ type: "offline", posts: cache });
+ }
+ throwIfNotNetworkError(e);
+ }
+ }
+ },
+ });
+
+ getPosts$(timelineName: string): Observable<TimelinePostsWithSyncState> {
+ return this._postsHub.getDataWithSyncStatusObservable(timelineName).pipe(
+ switchMap((state) => {
+ if (state.posts.length === 0) {
+ return of({
+ ...state,
+ posts: [],
+ });
+ }
+
+ return combineLatest([
+ combineLatest(
+ state.posts.map((post) => userInfoService.getUser$(post.author))
+ ),
+ combineLatest(
+ state.posts.map((post) => {
+ if (post.content.type === "image") {
+ return this.getPostData$(timelineName, post.id);
+ } else {
+ return of(null);
+ }
+ })
+ ),
+ ]).pipe(
+ map(([authors, datas]) => {
+ return {
+ ...state,
+ posts: state.posts.map((post, i) => {
+ const { content } = post;
+
+ return {
+ ...post,
+ author: authors[i],
+ content: (() => {
+ if (content.type === "text") return content;
+ else
+ return {
+ type: "image",
+ data: datas[i],
+ } as TimelinePostImageContent;
+ })(),
+ };
+ }),
+ };
+ })
+ );
+ })
+ );
+ }
+
+ private getCachedPostData(key: {
+ timelineName: string;
+ postId: number;
+ }): Promise<BlobWithEtag | null> {
+ return dataStorage.getItem<BlobWithEtag | null>(
+ `timeline.${key.timelineName}.post.${key.postId}.data`
+ );
+ }
+
+ private savePostData(
+ key: {
+ timelineName: string;
+ postId: number;
+ },
+ data: BlobWithEtag
+ ): Promise<void> {
+ return dataStorage
+ .setItem<BlobWithEtag>(
+ `timeline.${key.timelineName}.post.${key.postId}.data`,
+ data
+ )
+ .then();
+ }
+
+ private syncPostData(key: {
+ timelineName: string;
+ postId: number;
+ }): Promise<void> {
+ return this._postDataHub.getLineOrCreate(key).sync();
+ }
+
+ private _postDataHub = new DataHub<
+ { timelineName: string; postId: number },
+ | { data: Blob; type: "cache" | "synced" | "offline" }
+ | { data?: undefined; type: "notexist" | "offline" }
+ >({
+ keyToString: (key) => `${key.timelineName}.${key.postId}`,
+ sync: async (key, line) => {
+ const cache = await this.getCachedPostData(key);
+ if (line.value == null) {
+ if (cache != null) {
+ line.next({ type: "cache", data: cache.data });
+ }
+ }
+
+ if (cache == null) {
+ try {
+ const res = await getHttpTimelineClient().getPostData(
+ key.timelineName,
+ key.postId
+ );
+ await this.savePostData(key, res);
+ line.next({ data: res.data, type: "synced" });
+ } catch (e) {
+ line.next({ type: "offline" });
+ throwIfNotNetworkError(e);
+ }
+ } else {
+ try {
+ const res = await getHttpTimelineClient().getPostData(
+ key.timelineName,
+ key.postId,
+ cache.etag
+ );
+ if (res instanceof NotModified) {
+ line.next({ data: cache.data, type: "synced" });
+ } else {
+ await this.savePostData(key, res);
+ line.next({ data: res.data, type: "synced" });
+ }
+ } catch (e) {
+ line.next({ data: cache.data, type: "offline" });
+ throwIfNotNetworkError(e);
+ }
+ }
+ },
+ });
+
+ getPostData$(timelineName: string, postId: number): Observable<BlobOrStatus> {
+ return this._postDataHub.getObservable({ timelineName, postId }).pipe(
+ map((state): BlobOrStatus => state.data ?? "error"),
+ startWith("loading")
+ );
+ }
+
+ createPost(
+ timelineName: string,
+ request: TimelineCreatePostRequest
+ ): Observable<unknown> {
+ const user = checkLogin();
+ return from(
+ getHttpTimelineClient()
+ .postPost(timelineName, request, user.token)
+ .then(() => {
+ void this.syncPosts(timelineName);
+ })
+ );
+ }
+
+ deletePost(timelineName: string, postId: number): Observable<unknown> {
+ const user = checkLogin();
+ return from(
+ getHttpTimelineClient()
+ .deletePost(timelineName, postId, user.token)
+ .then(() => {
+ void this.syncPosts(timelineName);
+ })
+ );
+ }
+
+ isMemberOf(username: string, timeline: TimelineInfo): boolean {
+ return timeline.members.findIndex((m) => m.username == username) >= 0;
+ }
+
+ hasReadPermission(
+ user: UserAuthInfo | null | undefined,
+ timeline: TimelineInfo
+ ): boolean {
+ if (user != null && user.administrator) return true;
+
+ const { visibility } = timeline;
+ if (visibility === "Public") {
+ return true;
+ } else if (visibility === "Register") {
+ if (user != null) return true;
+ } else if (visibility === "Private") {
+ if (
+ user != null &&
+ (user.username === timeline.owner.username ||
+ this.isMemberOf(user.username, timeline))
+ ) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ hasPostPermission(
+ user: UserAuthInfo | null | undefined,
+ timeline: TimelineInfo
+ ): boolean {
+ if (user != null && user.administrator) return true;
+
+ return (
+ user != null &&
+ (timeline.owner.username === user.username ||
+ this.isMemberOf(user.username, timeline))
+ );
+ }
+
+ hasManagePermission(
+ user: UserAuthInfo | null | undefined,
+ timeline: TimelineInfo
+ ): boolean {
+ if (user != null && user.administrator) return true;
+
+ return user != null && user.username == timeline.owner.username;
+ }
+
+ hasModifyPostPermission(
+ user: UserAuthInfo | null | undefined,
+ timeline: TimelineInfo,
+ post: TimelinePostInfo
+ ): boolean {
+ if (user != null && user.administrator) return true;
+
+ return (
+ user != null &&
+ (user.username === timeline.owner.username ||
+ user.username === post.author.username)
+ );
+ }
+}
+
+export const timelineService = new TimelineService();
+
+const timelineNameReg = XRegExp("^[-_\\p{L}]*$", "u");
+
+export function validateTimelineName(name: string): boolean {
+ return timelineNameReg.test(name);
+}
+
+export function useTimelineInfo(
+ timelineName: string
+): TimelineWithSyncStatus | undefined {
+ const [state, setState] = React.useState<TimelineWithSyncStatus | undefined>(
+ undefined
+ );
+ React.useEffect(() => {
+ const subscription = timelineService
+ .getTimeline$(timelineName)
+ .subscribe((data) => {
+ setState(data);
+ });
+ return () => {
+ subscription.unsubscribe();
+ };
+ }, [timelineName]);
+ return state;
+}
+
+export function usePostList(
+ timelineName: string | null | undefined
+): TimelinePostsWithSyncState | undefined {
+ const [state, setState] = React.useState<
+ TimelinePostsWithSyncState | undefined
+ >(undefined);
+ React.useEffect(() => {
+ if (timelineName == null) {
+ setState(undefined);
+ return;
+ }
+
+ const subscription = timelineService
+ .getPosts$(timelineName)
+ .subscribe((data) => {
+ setState(data);
+ });
+ return () => {
+ subscription.unsubscribe();
+ };
+ }, [timelineName]);
+ return state;
+}
+
+export async function getAllCachedTimelineNames(): Promise<string[]> {
+ const keys = await dataStorage.keys();
+ return keys
+ .filter(
+ (key) =>
+ key.startsWith("timeline.") && (key.match(/\./g) ?? []).length === 1
+ )
+ .map((key) => key.substr("timeline.".length));
+}
diff --git a/FrontEnd/src/app/services/user.ts b/FrontEnd/src/app/services/user.ts
new file mode 100644
index 00000000..f253fc19
--- /dev/null
+++ b/FrontEnd/src/app/services/user.ts
@@ -0,0 +1,393 @@
+import React, { useState, useEffect } from "react";
+import { BehaviorSubject, Observable, from } from "rxjs";
+import { map, filter } from "rxjs/operators";
+
+import { UiLogicError } from "@/common";
+import { convertError } from "@/utilities/rxjs";
+
+import { HttpNetworkError, BlobWithEtag, NotModified } from "@/http/common";
+import {
+ getHttpTokenClient,
+ HttpCreateTokenBadCredentialError,
+} from "@/http/token";
+import {
+ getHttpUserClient,
+ HttpUserNotExistError,
+ HttpUser,
+} from "@/http/user";
+
+import { dataStorage, throwIfNotNetworkError } from "./common";
+import { DataHub } from "./DataHub";
+import { pushAlert } from "./alert";
+
+export type User = HttpUser;
+
+export interface UserAuthInfo {
+ username: string;
+ administrator: boolean;
+}
+
+export interface UserWithToken extends User {
+ token: string;
+}
+
+export interface LoginCredentials {
+ username: string;
+ password: string;
+}
+
+export class BadCredentialError {
+ message = "login.badCredential";
+}
+
+const USER_STORAGE_KEY = "currentuser";
+
+export class UserService {
+ private userSubject = new BehaviorSubject<UserWithToken | null | undefined>(
+ undefined
+ );
+
+ get user$(): Observable<UserWithToken | null | undefined> {
+ return this.userSubject;
+ }
+
+ get currentUser(): UserWithToken | null | undefined {
+ return this.userSubject.value;
+ }
+
+ async checkLoginState(): Promise<UserWithToken | null> {
+ if (this.currentUser !== undefined) {
+ console.warn("Already checked user. Can't check twice.");
+ }
+
+ const savedUser = await dataStorage.getItem<UserWithToken | null>(
+ USER_STORAGE_KEY
+ );
+
+ if (savedUser == null) {
+ this.userSubject.next(null);
+ return null;
+ }
+
+ this.userSubject.next(savedUser);
+
+ const savedToken = savedUser.token;
+ try {
+ const res = await getHttpTokenClient().verify({ token: savedToken });
+ const user: UserWithToken = { ...res.user, token: savedToken };
+ await dataStorage.setItem<UserWithToken>(USER_STORAGE_KEY, user);
+ this.userSubject.next(user);
+ pushAlert({
+ type: "success",
+ message: {
+ type: "i18n",
+ key: "user.welcomeBack",
+ },
+ });
+ return user;
+ } catch (error) {
+ if (error instanceof HttpNetworkError) {
+ pushAlert({
+ type: "danger",
+ message: { type: "i18n", key: "user.verifyTokenFailedNetwork" },
+ });
+ return savedUser;
+ } else {
+ await dataStorage.removeItem(USER_STORAGE_KEY);
+ this.userSubject.next(null);
+ pushAlert({
+ type: "danger",
+ message: { type: "i18n", key: "user.verifyTokenFailed" },
+ });
+ return null;
+ }
+ }
+ }
+
+ async login(
+ credentials: LoginCredentials,
+ rememberMe: boolean
+ ): Promise<void> {
+ if (this.currentUser) {
+ throw new UiLogicError("Already login.");
+ }
+ try {
+ const res = await getHttpTokenClient().create({
+ ...credentials,
+ expire: 30,
+ });
+ const user: UserWithToken = {
+ ...res.user,
+ token: res.token,
+ };
+ if (rememberMe) {
+ await dataStorage.setItem<UserWithToken>(USER_STORAGE_KEY, user);
+ }
+ this.userSubject.next(user);
+ } catch (e) {
+ if (e instanceof HttpCreateTokenBadCredentialError) {
+ throw new BadCredentialError();
+ } else {
+ throw e;
+ }
+ }
+ }
+
+ async logout(): Promise<void> {
+ if (this.currentUser === undefined) {
+ throw new UiLogicError("Please check user first.");
+ }
+ if (this.currentUser === null) {
+ throw new UiLogicError("No login.");
+ }
+ await dataStorage.removeItem(USER_STORAGE_KEY);
+ this.userSubject.next(null);
+ }
+
+ changePassword(
+ oldPassword: string,
+ newPassword: string
+ ): Observable<unknown> {
+ if (this.currentUser == undefined) {
+ throw new UiLogicError("Not login or checked now, can't log out.");
+ }
+ const $ = from(
+ getHttpUserClient().changePassword(
+ {
+ oldPassword,
+ newPassword,
+ },
+ this.currentUser.token
+ )
+ );
+ $.subscribe(() => {
+ void this.logout();
+ });
+ return $;
+ }
+}
+
+export const userService = new UserService();
+
+export function useRawUser(): UserWithToken | null | undefined {
+ const [user, setUser] = useState<UserWithToken | null | undefined>(
+ userService.currentUser
+ );
+ useEffect(() => {
+ const subscription = userService.user$.subscribe((u) => setUser(u));
+ return () => {
+ subscription.unsubscribe();
+ };
+ });
+ return user;
+}
+
+export function useUser(): UserWithToken | null {
+ const [user, setUser] = useState<UserWithToken | null>(() => {
+ const initUser = userService.currentUser;
+ if (initUser === undefined) {
+ throw new UiLogicError(
+ "This is a logic error in user module. Current user can't be undefined in useUser."
+ );
+ }
+ return initUser;
+ });
+ useEffect(() => {
+ const sub = userService.user$.subscribe((u) => {
+ if (u === undefined) {
+ throw new UiLogicError(
+ "This is a logic error in user module. User emitted can't be undefined later."
+ );
+ }
+ setUser(u);
+ });
+ return () => {
+ sub.unsubscribe();
+ };
+ });
+ return user;
+}
+
+export function useUserLoggedIn(): UserWithToken {
+ const user = useUser();
+ if (user == null) {
+ throw new UiLogicError("You assert user has logged in but actually not.");
+ }
+ return user;
+}
+
+export function checkLogin(): UserWithToken {
+ const user = userService.currentUser;
+ if (user == null) {
+ throw new UiLogicError("You must login to perform the operation.");
+ }
+ return user;
+}
+
+export class UserNotExistError extends Error {}
+
+export class UserInfoService {
+ saveUser(user: HttpUser): void {
+ const key = user.username;
+ void this._userHub.optionalInitLineWithSyncAction(key, async (line) => {
+ await this.doSaveUser(user);
+ line.next({ user, type: "synced" });
+ });
+ }
+
+ saveUsers(users: HttpUser[]): void {
+ return users.forEach((user) => this.saveUser(user));
+ }
+
+ private getCachedUser(username: string): Promise<User | null> {
+ return dataStorage.getItem<HttpUser | null>(`user.${username}`);
+ }
+
+ private doSaveUser(user: HttpUser): Promise<void> {
+ return dataStorage.setItem<HttpUser>(`user.${user.username}`, user).then();
+ }
+
+ syncUser(username: string): Promise<void> {
+ return this._userHub.getLineOrCreate(username).sync();
+ }
+
+ private _userHub = new DataHub<
+ string,
+ | { user: User; type: "cache" | "synced" | "offline" }
+ | { user?: undefined; type: "notexist" | "offline" }
+ >({
+ sync: async (key, line) => {
+ if (line.value == undefined) {
+ const cache = await this.getCachedUser(key);
+ if (cache != null) {
+ line.next({ user: cache, type: "cache" });
+ }
+ }
+
+ try {
+ const res = await getHttpUserClient().get(key);
+ await this.doSaveUser(res);
+ line.next({ user: res, type: "synced" });
+ } catch (e) {
+ if (e instanceof HttpUserNotExistError) {
+ line.next({ type: "notexist" });
+ } else {
+ const cache = await this.getCachedUser(key);
+ line.next({ user: cache ?? undefined, type: "offline" });
+ throwIfNotNetworkError(e);
+ }
+ }
+ },
+ });
+
+ getUser$(username: string): Observable<User> {
+ return this._userHub.getObservable(username).pipe(
+ map((state) => state?.user),
+ filter((user): user is User => user != null)
+ );
+ }
+
+ private getCachedAvatar(username: string): Promise<BlobWithEtag | null> {
+ return dataStorage.getItem<BlobWithEtag | null>(`user.${username}.avatar`);
+ }
+
+ private saveAvatar(username: string, data: BlobWithEtag): Promise<void> {
+ return dataStorage
+ .setItem<BlobWithEtag>(`user.${username}.avatar`, data)
+ .then();
+ }
+
+ syncAvatar(username: string): Promise<void> {
+ return this._avatarHub.getLineOrCreate(username).sync();
+ }
+
+ private _avatarHub = new DataHub<
+ string,
+ | { data: Blob; type: "cache" | "synced" | "offline" }
+ | { data?: undefined; type: "notexist" | "offline" }
+ >({
+ sync: async (key, line) => {
+ const cache = await this.getCachedAvatar(key);
+ if (line.value == null) {
+ if (cache != null) {
+ line.next({ data: cache.data, type: "cache" });
+ }
+ }
+
+ if (cache == null) {
+ try {
+ const avatar = await getHttpUserClient().getAvatar(key);
+ await this.saveAvatar(key, avatar);
+ line.next({ data: avatar.data, type: "synced" });
+ } catch (e) {
+ line.next({ type: "offline" });
+ throwIfNotNetworkError(e);
+ }
+ } else {
+ try {
+ const res = await getHttpUserClient().getAvatar(key, cache.etag);
+ if (res instanceof NotModified) {
+ line.next({ data: cache.data, type: "synced" });
+ } else {
+ const avatar = res;
+ await this.saveAvatar(key, avatar);
+ line.next({ data: avatar.data, type: "synced" });
+ }
+ } catch (e) {
+ line.next({ data: cache.data, type: "offline" });
+ throwIfNotNetworkError(e);
+ }
+ }
+ },
+ });
+
+ getAvatar$(username: string): Observable<Blob> {
+ return this._avatarHub.getObservable(username).pipe(
+ map((state) => state.data),
+ filter((blob): blob is Blob => blob != null)
+ );
+ }
+
+ getUserInfo(username: string): Observable<User> {
+ return from(getHttpUserClient().get(username)).pipe(
+ convertError(HttpUserNotExistError, UserNotExistError)
+ );
+ }
+
+ async setAvatar(username: string, blob: Blob): Promise<void> {
+ const user = checkLogin();
+ await getHttpUserClient().putAvatar(username, blob, user.token);
+ this._avatarHub.getLine(username)?.next({ data: blob, type: "synced" });
+ }
+
+ async setNickname(username: string, nickname: string): Promise<void> {
+ const user = checkLogin();
+ return getHttpUserClient()
+ .patch(username, { nickname }, user.token)
+ .then((user) => {
+ this.saveUser(user);
+ });
+ }
+}
+
+export const userInfoService = new UserInfoService();
+
+export function useAvatar(username?: string): Blob | undefined {
+ const [state, setState] = React.useState<Blob | undefined>(undefined);
+ React.useEffect(() => {
+ if (username == null) {
+ setState(undefined);
+ return;
+ }
+
+ const subscription = userInfoService
+ .getAvatar$(username)
+ .subscribe((blob) => {
+ setState(blob);
+ });
+ return () => {
+ subscription.unsubscribe();
+ };
+ }, [username]);
+ return state;
+}
diff --git a/FrontEnd/src/app/tsconfig.json b/FrontEnd/src/app/tsconfig.json
new file mode 100644
index 00000000..17ee69cb
--- /dev/null
+++ b/FrontEnd/src/app/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "extends": "../tsconfig.json",
+ "compilerOptions": {
+ "lib": [
+ "dom",
+ "dom.iterable",
+ "esnext"
+ ]
+ },
+ "include": [
+ "."
+ ]
+}
diff --git a/FrontEnd/src/app/typings.d.ts b/FrontEnd/src/app/typings.d.ts
new file mode 100644
index 00000000..34381682
--- /dev/null
+++ b/FrontEnd/src/app/typings.d.ts
@@ -0,0 +1,24 @@
+declare module "*.png" {
+ const content: string;
+ export default content;
+}
+
+declare module "*.jpeg" {
+ const content: string;
+ export default content;
+}
+
+declare module "*.jpg" {
+ const content: string;
+ export default content;
+}
+
+declare module "*.gif" {
+ const content: string;
+ export default content;
+}
+
+declare module "*.svg" {
+ const content: string;
+ export default content;
+}
diff --git a/FrontEnd/src/app/utilities/rxjs.ts b/FrontEnd/src/app/utilities/rxjs.ts
new file mode 100644
index 00000000..0730b899
--- /dev/null
+++ b/FrontEnd/src/app/utilities/rxjs.ts
@@ -0,0 +1,14 @@
+import { OperatorFunction } from "rxjs";
+import { catchError } from "rxjs/operators";
+
+export function convertError<T, NewError>(
+ oldErrorType: { new (...args: never[]): unknown },
+ newErrorType: { new (): NewError }
+): OperatorFunction<T, T> {
+ return catchError((error) => {
+ if (error instanceof oldErrorType) {
+ throw new newErrorType();
+ }
+ throw error;
+ });
+}
diff --git a/FrontEnd/src/app/utilities/url.ts b/FrontEnd/src/app/utilities/url.ts
new file mode 100644
index 00000000..17ead5b2
--- /dev/null
+++ b/FrontEnd/src/app/utilities/url.ts
@@ -0,0 +1,52 @@
+//copied from https://stackoverflow.com/questions/5999118/how-can-i-add-or-update-a-query-string-parameter
+export function updateQueryString(
+ key: string,
+ value: undefined | string | null,
+ url: string
+): string {
+ const re = new RegExp("([?&])" + key + "=.*?(&|#|$)(.*)", "gi");
+ let hash;
+
+ if (re.test(url)) {
+ if (typeof value !== "undefined" && value !== null) {
+ return url.replace(re, "$1" + key + "=" + value + "$2$3");
+ } else {
+ hash = url.split("#");
+ url = hash[0].replace(re, "$1$3").replace(/(&|\?)$/, "");
+ if (typeof hash[1] !== "undefined" && hash[1] !== null) {
+ url += "#" + hash[1];
+ }
+ return url;
+ }
+ } else {
+ if (typeof value !== "undefined" && value !== null) {
+ const separator = url.includes("?") ? "&" : "?";
+ hash = url.split("#");
+ url = hash[0] + separator + key + "=" + value;
+ if (typeof hash[1] !== "undefined" && hash[1] !== null) {
+ url += "#" + hash[1];
+ }
+ return url;
+ } else {
+ return url;
+ }
+ }
+}
+
+export function applyQueryParameters<T>(url: string, query: T): string {
+ if (query == null) return url;
+
+ for (const [key, value] of Object.entries(query)) {
+ if (typeof value === "string") url = updateQueryString(key, value, url);
+ else if (typeof value === "number")
+ url = updateQueryString(key, String(value), url);
+ else if (typeof value === "boolean")
+ url = updateQueryString(key, value ? "true" : "false", url);
+ else if (value instanceof Date)
+ url = updateQueryString(key, value.toISOString(), url);
+ else {
+ console.error("Unknown query parameter type. Param: ", value);
+ }
+ }
+ return url;
+}
diff --git a/FrontEnd/src/app/views/about/about.sass b/FrontEnd/src/app/views/about/about.sass
new file mode 100644
index 00000000..f4d00cae
--- /dev/null
+++ b/FrontEnd/src/app/views/about/about.sass
@@ -0,0 +1,4 @@
+.about-link-icon
+ @extend .mx-2
+ width: 1.2em
+ height: 1.2em
diff --git a/FrontEnd/src/app/views/about/author-avatar.png b/FrontEnd/src/app/views/about/author-avatar.png
new file mode 100644
index 00000000..d890d8d0
--- /dev/null
+++ b/FrontEnd/src/app/views/about/author-avatar.png
Binary files differ
diff --git a/FrontEnd/src/app/views/about/github.png b/FrontEnd/src/app/views/about/github.png
new file mode 100644
index 00000000..ea6ff545
--- /dev/null
+++ b/FrontEnd/src/app/views/about/github.png
Binary files differ
diff --git a/FrontEnd/src/app/views/about/index.tsx b/FrontEnd/src/app/views/about/index.tsx
new file mode 100644
index 00000000..e7771cec
--- /dev/null
+++ b/FrontEnd/src/app/views/about/index.tsx
@@ -0,0 +1,164 @@
+import React from "react";
+import { useTranslation, Trans } from "react-i18next";
+
+import authorAvatarUrl from "./author-avatar.png";
+import githubLogoUrl from "./github.png";
+
+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: "react-bootstrap",
+ url: "https://react-bootstrap.github.io",
+ },
+ {
+ name: "babeljs",
+ url: "https://babeljs.io",
+ },
+ {
+ name: "webpack",
+ url: "https://webpack.js.org",
+ },
+ {
+ name: "sass",
+ url: "https://sass-lang.com",
+ },
+ {
+ name: "eslint",
+ url: "https://eslint.org",
+ },
+ {
+ name: "prettier",
+ url: "https://prettier.io",
+ },
+ {
+ name: "pepjs",
+ url: "https://github.com/jquery/PEP",
+ },
+ {
+ name: "react-inlinesvg",
+ url: "https://github.com/gilbarbara/react-inlinesvg",
+ },
+];
+
+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",
+ },
+];
+
+const AboutPage: React.FC = () => {
+ const { t } = useTranslation();
+
+ return (
+ <div className="mt-appbar px-2 mb-4">
+ <div className="container mt-4 py-3 shadow border border-primary rounded bg-light">
+ <h4 id="author-info">{t("about.author.title")}</h4>
+ <div>
+ <div className="d-flex">
+ <img
+ src={authorAvatarUrl}
+ className="align-self-start avatar large rounded-circle"
+ />
+ <div>
+ <p>
+ <small>{t("about.author.fullname")}</small>
+ <span className="text-primary">杨宇千</span>
+ </p>
+ <p>
+ <small>{t("about.author.nickname")}</small>
+ <span className="text-primary">crupest</span>
+ </p>
+ <p>
+ <small>{t("about.author.introduction")}</small>
+ {t("about.author.introductionContent")}
+ </p>
+ </div>
+ </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 text-body" />
+ </a>
+ </p>
+ </div>
+ </div>
+ <div className="container mt-4 py-3 shadow border border-primary rounded bg-light">
+ <h4>{t("about.site.title")}</h4>
+ <p>
+ <Trans i18nKey="about.site.content">
+ 0<span className="text-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>
+ </div>
+ <div className="container mt-4 py-3 shadow border border-primary rounded bg-light">
+ <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>
+ </div>
+ </div>
+ );
+};
+
+export default AboutPage;
diff --git a/FrontEnd/src/app/views/admin/Admin.tsx b/FrontEnd/src/app/views/admin/Admin.tsx
new file mode 100644
index 00000000..9c0250e7
--- /dev/null
+++ b/FrontEnd/src/app/views/admin/Admin.tsx
@@ -0,0 +1,75 @@
+import React, { Fragment } from "react";
+import {
+ Redirect,
+ Route,
+ Switch,
+ useRouteMatch,
+ useHistory,
+} from "react-router";
+import { Nav } from "react-bootstrap";
+
+import { UserWithToken } from "@/services/user";
+
+import UserAdmin from "./UserAdmin";
+
+interface AdminProps {
+ user: UserWithToken;
+}
+
+const Admin: React.FC<AdminProps> = (props) => {
+ const match = useRouteMatch();
+ const history = useHistory();
+ type TabNames = "users" | "more";
+
+ const tabName = history.location.pathname.replace(match.path + "/", "");
+
+ function toggle(newTab: TabNames): void {
+ history.push(`${match.url}/${newTab}`);
+ }
+
+ const createRoute = (
+ name: string,
+ body: React.ReactNode
+ ): React.ReactNode => {
+ return (
+ <Route path={`${match.path}/${name}`}>
+ <div style={{ height: 56 }} className="flex-fix-length" />
+ <Nav variant="tabs">
+ <Nav.Item>
+ <Nav.Link
+ active={tabName === "users"}
+ onClick={() => {
+ toggle("users");
+ }}
+ >
+ Users
+ </Nav.Link>
+ </Nav.Item>
+ <Nav.Item>
+ <Nav.Link
+ active={tabName === "more"}
+ onClick={() => {
+ toggle("more");
+ }}
+ >
+ More
+ </Nav.Link>
+ </Nav.Item>
+ </Nav>
+ {body}
+ </Route>
+ );
+ };
+
+ return (
+ <Fragment>
+ <Switch>
+ <Redirect from={match.path} to={`${match.path}/users`} exact />
+ {createRoute("users", <UserAdmin user={props.user} />)}
+ {createRoute("more", <div>More Page Works</div>)}
+ </Switch>
+ </Fragment>
+ );
+};
+
+export default Admin;
diff --git a/FrontEnd/src/app/views/admin/UserAdmin.tsx b/FrontEnd/src/app/views/admin/UserAdmin.tsx
new file mode 100644
index 00000000..18b77ca8
--- /dev/null
+++ b/FrontEnd/src/app/views/admin/UserAdmin.tsx
@@ -0,0 +1,460 @@
+import React, { useState, useEffect } from "react";
+import axios from "axios";
+import {
+ ListGroup,
+ Row,
+ Col,
+ Dropdown,
+ Spinner,
+ Button,
+} from "react-bootstrap";
+
+import OperationDialog from "../common/OperationDialog";
+import { User, UserWithToken } from "@/services/user";
+
+const apiBaseUrl = "/api";
+
+async function fetchUserList(_token: string): Promise<User[]> {
+ const res = await axios.get<User[]>(`${apiBaseUrl}/users`);
+ return res.data;
+}
+
+interface CreateUserInfo {
+ username: string;
+ password: string;
+ administrator: boolean;
+}
+
+async function createUser(user: CreateUserInfo, token: string): Promise<User> {
+ const res = await axios.post<User>(
+ `${apiBaseUrl}/userop/createuser?token=${token}`,
+ user
+ );
+ return res.data;
+}
+
+function deleteUser(username: string, token: string): Promise<void> {
+ return axios.delete(`${apiBaseUrl}/users/${username}?token=${token}`);
+}
+
+function changeUsername(
+ oldUsername: string,
+ newUsername: string,
+ token: string
+): Promise<void> {
+ return axios.patch(`${apiBaseUrl}/users/${oldUsername}?token=${token}`, {
+ username: newUsername,
+ });
+}
+
+function changePassword(
+ username: string,
+ newPassword: string,
+ token: string
+): Promise<void> {
+ return axios.patch(`${apiBaseUrl}/users/${username}?token=${token}`, {
+ password: newPassword,
+ });
+}
+
+function changePermission(
+ username: string,
+ newPermission: boolean,
+ token: string
+): Promise<void> {
+ return axios.patch(`${apiBaseUrl}/users/${username}?token=${token}`, {
+ administrator: newPermission,
+ });
+}
+
+const kChangeUsername = "changeusername";
+const kChangePassword = "changepassword";
+const kChangePermission = "changepermission";
+const kDelete = "delete";
+
+type TChangeUsername = typeof kChangeUsername;
+type TChangePassword = typeof kChangePassword;
+type TChangePermission = typeof kChangePermission;
+type TDelete = typeof kDelete;
+
+type ContextMenuItem =
+ | TChangeUsername
+ | TChangePassword
+ | TChangePermission
+ | TDelete;
+
+interface UserCardProps {
+ onContextMenu: (item: ContextMenuItem) => void;
+ user: User;
+}
+
+const UserItem: React.FC<UserCardProps> = (props) => {
+ const user = props.user;
+
+ const createClickCallback = (item: ContextMenuItem): (() => void) => {
+ return () => {
+ props.onContextMenu(item);
+ };
+ };
+
+ return (
+ <ListGroup.Item className="container">
+ <Row className="align-items-center">
+ <Col>
+ <p className="mb-0 text-primary">{user.username}</p>
+ <small
+ className={user.administrator ? "text-danger" : "text-secondary"}
+ >
+ {user.administrator ? "administrator" : "user"}
+ </small>
+ </Col>
+ <Col className="col-auto">
+ <Dropdown>
+ <Dropdown.Toggle variant="warning" className="text-light">
+ Manage
+ </Dropdown.Toggle>
+ <Dropdown.Menu>
+ <Dropdown.Item onClick={createClickCallback(kChangeUsername)}>
+ Change Username
+ </Dropdown.Item>
+ <Dropdown.Item onClick={createClickCallback(kChangePassword)}>
+ Change Password
+ </Dropdown.Item>
+ <Dropdown.Item onClick={createClickCallback(kChangePermission)}>
+ Change Permission
+ </Dropdown.Item>
+ <Dropdown.Item
+ className="text-danger"
+ onClick={createClickCallback(kDelete)}
+ >
+ Delete
+ </Dropdown.Item>
+ </Dropdown.Menu>
+ </Dropdown>
+ </Col>
+ </Row>
+ </ListGroup.Item>
+ );
+};
+
+interface DialogProps {
+ open: boolean;
+ close: () => void;
+}
+
+interface CreateUserDialogProps extends DialogProps {
+ process: (user: CreateUserInfo) => Promise<void>;
+}
+
+const CreateUserDialog: React.FC<CreateUserDialogProps> = (props) => {
+ return (
+ <OperationDialog
+ title="Create"
+ titleColor="create"
+ inputPrompt="You are creating a new user."
+ inputScheme={[
+ { type: "text", label: "Username" },
+ { type: "text", label: "Password" },
+ { type: "bool", label: "Administrator" },
+ ]}
+ onProcess={([username, password, administrator]) =>
+ props.process({
+ username: username as string,
+ password: password as string,
+ administrator: administrator as boolean,
+ })
+ }
+ close={props.close}
+ open={props.open}
+ />
+ );
+};
+
+const UsernameLabel: React.FC = (props) => {
+ return <span style={{ color: "blue" }}>{props.children}</span>;
+};
+
+interface UserDeleteDialogProps extends DialogProps {
+ username: string;
+ process: () => Promise<void>;
+}
+
+const UserDeleteDialog: React.FC<UserDeleteDialogProps> = (props) => {
+ return (
+ <OperationDialog
+ open={props.open}
+ close={props.close}
+ title="Dangerous"
+ titleColor="dangerous"
+ inputPrompt={() => (
+ <>
+ {"You are deleting user "}
+ <UsernameLabel>{props.username}</UsernameLabel>
+ {" !"}
+ </>
+ )}
+ onProcess={props.process}
+ />
+ );
+};
+
+interface UserModifyDialogProps<T> extends DialogProps {
+ username: string;
+ process: (value: T) => Promise<void>;
+}
+
+const UserChangeUsernameDialog: React.FC<UserModifyDialogProps<string>> = (
+ props
+) => {
+ return (
+ <OperationDialog
+ open={props.open}
+ close={props.close}
+ title="Caution"
+ titleColor="dangerous"
+ inputPrompt={() => (
+ <>
+ {"You are change the username of user "}
+ <UsernameLabel>{props.username}</UsernameLabel>
+ {" !"}
+ </>
+ )}
+ inputScheme={[{ type: "text", label: "New Username" }]}
+ onProcess={([newUsername]) => {
+ return props.process(newUsername as string);
+ }}
+ />
+ );
+};
+
+const UserChangePasswordDialog: React.FC<UserModifyDialogProps<string>> = (
+ props
+) => {
+ return (
+ <OperationDialog
+ open={props.open}
+ close={props.close}
+ title="Caution"
+ titleColor="dangerous"
+ inputPrompt={() => (
+ <>
+ {"You are change the password of user "}
+ <UsernameLabel>{props.username}</UsernameLabel>
+ {" !"}
+ </>
+ )}
+ inputScheme={[{ type: "text", label: "New Password" }]}
+ onProcess={([newPassword]) => {
+ return props.process(newPassword as string);
+ }}
+ />
+ );
+};
+
+interface UserChangePermissionDialogProps extends DialogProps {
+ username: string;
+ newPermission: boolean;
+ process: () => Promise<void>;
+}
+
+const UserChangePermissionDialog: React.FC<UserChangePermissionDialogProps> = (
+ props
+) => {
+ return (
+ <OperationDialog
+ open={props.open}
+ close={props.close}
+ title="Caution"
+ titleColor="dangerous"
+ inputPrompt={() => (
+ <>
+ {"You are change user "}
+ <UsernameLabel>{props.username}</UsernameLabel>
+ {" to "}
+ <span style={{ color: "orange" }}>
+ {props.newPermission ? "administrator" : "normal user"}
+ </span>
+ {" !"}
+ </>
+ )}
+ onProcess={props.process}
+ />
+ );
+};
+
+interface UserAdminProps {
+ user: UserWithToken;
+}
+
+const UserAdmin: React.FC<UserAdminProps> = (props) => {
+ type DialogInfo =
+ | null
+ | {
+ type: "create";
+ }
+ | { type: TDelete; username: string }
+ | {
+ type: TChangeUsername;
+ username: string;
+ }
+ | {
+ type: TChangePassword;
+ username: string;
+ }
+ | {
+ type: TChangePermission;
+ username: string;
+ newPermission: boolean;
+ };
+
+ const [users, setUsers] = useState<User[] | null>(null);
+ const [dialog, setDialog] = useState<DialogInfo>(null);
+
+ const token = props.user.token;
+
+ useEffect(() => {
+ let subscribe = true;
+ void fetchUserList(props.user.token).then((us) => {
+ if (subscribe) {
+ setUsers(us);
+ }
+ });
+ return () => {
+ subscribe = false;
+ };
+ }, [props.user]);
+
+ let dialogNode: React.ReactNode;
+ if (dialog)
+ switch (dialog.type) {
+ case "create":
+ dialogNode = (
+ <CreateUserDialog
+ open
+ close={() => setDialog(null)}
+ process={async (user) => {
+ const u = await createUser(user, token);
+ setUsers((oldUsers) => [...(oldUsers ?? []), u]);
+ }}
+ />
+ );
+ break;
+ case "delete":
+ dialogNode = (
+ <UserDeleteDialog
+ open
+ close={() => setDialog(null)}
+ username={dialog.username}
+ process={async () => {
+ await deleteUser(dialog.username, token);
+ setUsers((oldUsers) =>
+ (oldUsers ?? []).filter((u) => u.username !== dialog.username)
+ );
+ }}
+ />
+ );
+ break;
+ case kChangeUsername:
+ dialogNode = (
+ <UserChangeUsernameDialog
+ open
+ close={() => setDialog(null)}
+ username={dialog.username}
+ process={async (newUsername) => {
+ await changeUsername(dialog.username, newUsername, token);
+ setUsers((oldUsers) => {
+ const users = (oldUsers ?? []).slice();
+ const findedUser = users.find(
+ (u) => u.username === dialog.username
+ );
+ if (findedUser) findedUser.username = newUsername;
+ return users;
+ });
+ }}
+ />
+ );
+ break;
+ case kChangePassword:
+ dialogNode = (
+ <UserChangePasswordDialog
+ open
+ close={() => setDialog(null)}
+ username={dialog.username}
+ process={async (newPassword) => {
+ await changePassword(dialog.username, newPassword, token);
+ }}
+ />
+ );
+ break;
+ case kChangePermission: {
+ const newPermission = dialog.newPermission;
+ dialogNode = (
+ <UserChangePermissionDialog
+ open
+ close={() => setDialog(null)}
+ username={dialog.username}
+ newPermission={newPermission}
+ process={async () => {
+ await changePermission(dialog.username, newPermission, token);
+ setUsers((oldUsers) => {
+ const users = (oldUsers ?? []).slice();
+ const findedUser = users.find(
+ (u) => u.username === dialog.username
+ );
+ if (findedUser) findedUser.administrator = newPermission;
+ return users;
+ });
+ }}
+ />
+ );
+ break;
+ }
+ }
+
+ if (users) {
+ const userComponents = users.map((user) => {
+ return (
+ <UserItem
+ key={user.username}
+ user={user}
+ onContextMenu={(item) => {
+ setDialog(
+ item === kChangePermission
+ ? {
+ type: kChangePermission,
+ username: user.username,
+ newPermission: !user.administrator,
+ }
+ : {
+ type: item,
+ username: user.username,
+ }
+ );
+ }}
+ />
+ );
+ });
+
+ return (
+ <>
+ <Button
+ variant="success"
+ onClick={() =>
+ setDialog({
+ type: "create",
+ })
+ }
+ className="align-self-end"
+ >
+ Create User
+ </Button>
+ {userComponents}
+ {dialogNode}
+ </>
+ );
+ } else {
+ return <Spinner animation="border" />;
+ }
+};
+
+export default UserAdmin;
diff --git a/FrontEnd/src/app/views/common/AppBar.tsx b/FrontEnd/src/app/views/common/AppBar.tsx
new file mode 100644
index 00000000..ee4ead8f
--- /dev/null
+++ b/FrontEnd/src/app/views/common/AppBar.tsx
@@ -0,0 +1,64 @@
+import React from "react";
+import { useTranslation } from "react-i18next";
+import { LinkContainer } from "react-router-bootstrap";
+import { Navbar, Nav } from "react-bootstrap";
+
+import { useUser, useAvatar } from "@/services/user";
+
+import TimelineLogo from "./TimelineLogo";
+import BlobImage from "./BlobImage";
+
+const AppBar: React.FC = (_) => {
+ const user = useUser();
+ const avatar = useAvatar(user?.username);
+
+ const { t } = useTranslation();
+
+ const isAdministrator = user && user.administrator;
+
+ return (
+ <Navbar bg="primary" variant="dark" expand="md" sticky="top">
+ <LinkContainer to="/">
+ <Navbar.Brand className="d-flex align-items-center">
+ <TimelineLogo style={{ height: "1em" }} />
+ Timeline
+ </Navbar.Brand>
+ </LinkContainer>
+
+ <Navbar.Toggle />
+ <Navbar.Collapse>
+ <Nav className="mr-auto">
+ <LinkContainer to="/settings">
+ <Nav.Link>{t("nav.settings")}</Nav.Link>
+ </LinkContainer>
+
+ <LinkContainer to="/about">
+ <Nav.Link>{t("nav.about")}</Nav.Link>
+ </LinkContainer>
+
+ {isAdministrator && (
+ <LinkContainer to="/admin">
+ <Nav.Link>Administration</Nav.Link>
+ </LinkContainer>
+ )}
+ </Nav>
+ <Nav className="ml-auto mr-2">
+ {user != null ? (
+ <LinkContainer to={`/users/${user.username}`}>
+ <BlobImage
+ className="avatar small rounded-circle bg-white"
+ blob={avatar}
+ />
+ </LinkContainer>
+ ) : (
+ <LinkContainer to="/login">
+ <Nav.Link>{t("nav.login")}</Nav.Link>
+ </LinkContainer>
+ )}
+ </Nav>
+ </Navbar.Collapse>
+ </Navbar>
+ );
+};
+
+export default AppBar;
diff --git a/FrontEnd/src/app/views/common/BlobImage.tsx b/FrontEnd/src/app/views/common/BlobImage.tsx
new file mode 100644
index 00000000..0dd25c52
--- /dev/null
+++ b/FrontEnd/src/app/views/common/BlobImage.tsx
@@ -0,0 +1,27 @@
+import 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/app/views/common/ImageCropper.tsx b/FrontEnd/src/app/views/common/ImageCropper.tsx
new file mode 100644
index 00000000..b9db8b99
--- /dev/null
+++ b/FrontEnd/src/app/views/common/ImageCropper.tsx
@@ -0,0 +1,306 @@
+import React from "react";
+import clsx from "clsx";
+
+import { UiLogicError } from "@/common";
+
+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={clsx("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"
+ touch-action="none"
+ 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"
+ touch-action="none"
+ 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/app/views/common/LoadingButton.tsx b/FrontEnd/src/app/views/common/LoadingButton.tsx
new file mode 100644
index 00000000..154334a7
--- /dev/null
+++ b/FrontEnd/src/app/views/common/LoadingButton.tsx
@@ -0,0 +1,29 @@
+import React from "react";
+import { Button, ButtonProps, Spinner } from "react-bootstrap";
+
+const LoadingButton: React.FC<{ loading?: boolean } & ButtonProps> = ({
+ loading,
+ variant,
+ disabled,
+ ...otherProps
+}) => {
+ return (
+ <Button
+ variant={variant != null ? `outline-${variant}` : "outline-primary"}
+ disabled={disabled || loading}
+ {...otherProps}
+ >
+ {otherProps.children}
+ {loading ? (
+ <Spinner
+ className="ml-1"
+ variant={variant}
+ animation="grow"
+ size="sm"
+ />
+ ) : null}
+ </Button>
+ );
+};
+
+export default LoadingButton;
diff --git a/FrontEnd/src/app/views/common/LoadingPage.tsx b/FrontEnd/src/app/views/common/LoadingPage.tsx
new file mode 100644
index 00000000..590fafa0
--- /dev/null
+++ b/FrontEnd/src/app/views/common/LoadingPage.tsx
@@ -0,0 +1,12 @@
+import React from "react";
+import { Spinner } from "react-bootstrap";
+
+const LoadingPage: React.FC = () => {
+ return (
+ <div className="position-fixed w-100 h-100 d-flex justify-content-center align-items-center">
+ <Spinner variant="primary" animation="border" />
+ </div>
+ );
+};
+
+export default LoadingPage;
diff --git a/FrontEnd/src/app/views/common/OperationDialog.tsx b/FrontEnd/src/app/views/common/OperationDialog.tsx
new file mode 100644
index 00000000..841392a6
--- /dev/null
+++ b/FrontEnd/src/app/views/common/OperationDialog.tsx
@@ -0,0 +1,364 @@
+import React, { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Form, Button, Modal } from "react-bootstrap";
+
+import { UiLogicError } from "@/common";
+
+import LoadingButton from "./LoadingButton";
+
+interface DefaultErrorPromptProps {
+ error?: string;
+}
+
+const DefaultErrorPrompt: React.FC<DefaultErrorPromptProps> = (props) => {
+ const { t } = useTranslation();
+
+ let result = <p className="text-danger">{t("operationDialog.error")}</p>;
+
+ if (props.error != null) {
+ result = (
+ <>
+ {result}
+ <p className="text-danger">{props.error}</p>
+ </>
+ );
+ }
+
+ return result;
+};
+
+export type OperationInputOptionalError = undefined | null | string;
+
+export interface OperationInputErrorInfo {
+ [index: number]: OperationInputOptionalError;
+}
+
+export type OperationInputValidator<TValue> = (
+ value: TValue,
+ values: (string | boolean)[]
+) => OperationInputOptionalError | OperationInputErrorInfo;
+
+export interface OperationTextInputInfo {
+ type: "text";
+ password?: boolean;
+ label?: string;
+ initValue?: string;
+ textFieldProps?: Omit<
+ React.InputHTMLAttributes<HTMLInputElement>,
+ "type" | "value" | "onChange" | "aria-relevant"
+ >;
+ helperText?: string;
+ validator?: OperationInputValidator<string>;
+}
+
+export interface OperationBoolInputInfo {
+ type: "bool";
+ label: string;
+ initValue?: boolean;
+}
+
+export interface OperationSelectInputInfoOption {
+ value: string;
+ label: string;
+ icon?: React.ReactElement;
+}
+
+export interface OperationSelectInputInfo {
+ type: "select";
+ label: string;
+ options: OperationSelectInputInfoOption[];
+ initValue?: string;
+}
+
+export type OperationInputInfo =
+ | OperationTextInputInfo
+ | OperationBoolInputInfo
+ | OperationSelectInputInfo;
+
+interface OperationResult {
+ type: "success" | "failure";
+ data: unknown;
+}
+
+interface OperationDialogProps {
+ open: boolean;
+ close: () => void;
+ title: React.ReactNode;
+ titleColor?: "default" | "dangerous" | "create" | string;
+ onProcess: (inputs: (string | boolean)[]) => Promise<unknown>;
+ inputScheme?: OperationInputInfo[];
+ inputPrompt?: string | (() => React.ReactNode);
+ processPrompt?: () => React.ReactNode;
+ successPrompt?: (data: unknown) => React.ReactNode;
+ failurePrompt?: (error: unknown) => React.ReactNode;
+ onSuccessAndClose?: () => void;
+}
+
+const OperationDialog: React.FC<OperationDialogProps> = (props) => {
+ const inputScheme = props.inputScheme ?? [];
+
+ const { t } = useTranslation();
+
+ type Step = "input" | "process" | OperationResult;
+ const [step, setStep] = useState<Step>("input");
+ const [values, setValues] = useState<(boolean | string)[]>(
+ inputScheme.map((i) => {
+ if (i.type === "bool") {
+ return i.initValue ?? false;
+ } else if (i.type === "text" || i.type === "select") {
+ return i.initValue ?? "";
+ } else {
+ throw new UiLogicError("Unknown input scheme.");
+ }
+ })
+ );
+ const [inputError, setInputError] = useState<OperationInputErrorInfo>({});
+
+ const close = (): void => {
+ if (step !== "process") {
+ props.close();
+ if (
+ typeof step === "object" &&
+ step.type === "success" &&
+ props.onSuccessAndClose
+ ) {
+ props.onSuccessAndClose();
+ }
+ } else {
+ console.log("Attempt to close modal when processing.");
+ }
+ };
+
+ const onConfirm = (): void => {
+ setStep("process");
+ props.onProcess(values).then(
+ (d: unknown) => {
+ setStep({
+ type: "success",
+ data: d,
+ });
+ },
+ (e: unknown) => {
+ setStep({
+ type: "failure",
+ data: e,
+ });
+ }
+ );
+ };
+
+ let body: React.ReactNode;
+ if (step === "input" || step === "process") {
+ const process = step === "process";
+
+ let inputPrompt =
+ typeof props.inputPrompt === "function"
+ ? props.inputPrompt()
+ : props.inputPrompt;
+ inputPrompt = <h6>{inputPrompt}</h6>;
+
+ const updateValue = (
+ index: number,
+ newValue: string | boolean
+ ): (string | boolean)[] => {
+ const oldValues = values;
+ const newValues = oldValues.slice();
+ newValues[index] = newValue;
+ setValues(newValues);
+ return newValues;
+ };
+
+ const testErrorInfo = (errorInfo: OperationInputErrorInfo): boolean => {
+ for (let i = 0; i < inputScheme.length; i++) {
+ if (inputScheme[i].type === "text" && errorInfo[i] != null) {
+ return true;
+ }
+ }
+ return false;
+ };
+
+ const calculateError = (
+ oldError: OperationInputErrorInfo,
+ index: number,
+ newError: OperationInputOptionalError | OperationInputErrorInfo
+ ): OperationInputErrorInfo => {
+ if (newError === undefined) {
+ return oldError;
+ } else if (newError === null || typeof newError === "string") {
+ return { ...oldError, [index]: newError };
+ } else {
+ const newInputError: OperationInputErrorInfo = { ...oldError };
+ for (const [index, error] of Object.entries(newError)) {
+ if (error !== undefined) {
+ newInputError[+index] = error as OperationInputOptionalError;
+ }
+ }
+ return newInputError;
+ }
+ };
+
+ const validateAll = (): boolean => {
+ let newInputError = inputError;
+ for (let i = 0; i < inputScheme.length; i++) {
+ const item = inputScheme[i];
+ if (item.type === "text") {
+ newInputError = calculateError(
+ newInputError,
+ i,
+ item.validator?.(values[i] as string, values)
+ );
+ }
+ }
+ const result = !testErrorInfo(newInputError);
+ setInputError(newInputError);
+ return result;
+ };
+
+ body = (
+ <>
+ <Modal.Body>
+ {inputPrompt}
+ {inputScheme.map((item, index) => {
+ const value = values[index];
+ const error: string | undefined = ((e) =>
+ typeof e === "string" ? t(e) : undefined)(inputError?.[index]);
+
+ if (item.type === "text") {
+ return (
+ <Form.Group key={index}>
+ {item.label && <Form.Label>{t(item.label)}</Form.Label>}
+ <Form.Control
+ type={item.password === true ? "password" : "text"}
+ value={value as string}
+ onChange={(e) => {
+ const v = e.target.value;
+ const newValues = updateValue(index, v);
+ setInputError(
+ calculateError(
+ inputError,
+ index,
+ item.validator?.(v, newValues)
+ )
+ );
+ }}
+ isInvalid={error != null}
+ disabled={process}
+ />
+ {error != null && (
+ <Form.Control.Feedback type="invalid">
+ {error}
+ </Form.Control.Feedback>
+ )}
+ {item.helperText && (
+ <Form.Text>{t(item.helperText)}</Form.Text>
+ )}
+ </Form.Group>
+ );
+ } else if (item.type === "bool") {
+ return (
+ <Form.Group key={index}>
+ <Form.Check<"input">
+ type="checkbox"
+ checked={value as boolean}
+ onChange={(event) => {
+ updateValue(index, event.currentTarget.checked);
+ }}
+ label={t(item.label)}
+ disabled={process}
+ />
+ </Form.Group>
+ );
+ } else if (item.type === "select") {
+ return (
+ <Form.Group key={index}>
+ <Form.Label>{t(item.label)}</Form.Label>
+ <Form.Control
+ as="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}
+ {t(option.label)}
+ </option>
+ );
+ })}
+ </Form.Control>
+ </Form.Group>
+ );
+ }
+ })}
+ </Modal.Body>
+ <Modal.Footer>
+ <Button variant="outline-secondary" onClick={close}>
+ {t("operationDialog.cancel")}
+ </Button>
+ <LoadingButton
+ variant="primary"
+ loading={process}
+ disabled={testErrorInfo(inputError)}
+ onClick={() => {
+ if (validateAll()) {
+ onConfirm();
+ }
+ }}
+ >
+ {t("operationDialog.confirm")}
+ </LoadingButton>
+ </Modal.Footer>
+ </>
+ );
+ } 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="text-success">{content}</p>;
+ } else {
+ content = props.failurePrompt?.(result.data) ?? <DefaultErrorPrompt />;
+ if (typeof content === "string")
+ content = <DefaultErrorPrompt error={content} />;
+ }
+ body = (
+ <>
+ <Modal.Body>{content}</Modal.Body>
+ <Modal.Footer>
+ <Button variant="primary" onClick={close}>
+ {t("operationDialog.ok")}
+ </Button>
+ </Modal.Footer>
+ </>
+ );
+ }
+
+ const title = typeof props.title === "string" ? t(props.title) : props.title;
+
+ return (
+ <Modal show={props.open} onHide={close}>
+ <Modal.Header
+ className={
+ props.titleColor != null
+ ? "text-" +
+ (props.titleColor === "create"
+ ? "success"
+ : props.titleColor === "dangerous"
+ ? "danger"
+ : props.titleColor)
+ : undefined
+ }
+ >
+ {title}
+ </Modal.Header>
+ {body}
+ </Modal>
+ );
+};
+
+export default OperationDialog;
diff --git a/FrontEnd/src/app/views/common/SearchInput.tsx b/FrontEnd/src/app/views/common/SearchInput.tsx
new file mode 100644
index 00000000..9833d515
--- /dev/null
+++ b/FrontEnd/src/app/views/common/SearchInput.tsx
@@ -0,0 +1,63 @@
+import React, { useCallback } from "react";
+import clsx from "clsx";
+import { useTranslation } from "react-i18next";
+import { Spinner, Form, Button } from "react-bootstrap";
+
+export interface SearchInputProps {
+ value: string;
+ onChange: (value: string) => void;
+ onButtonClick: () => void;
+ className?: string;
+ loading?: boolean;
+ buttonText?: string;
+ placeholder?: string;
+ additionalButton?: React.ReactNode;
+}
+
+const SearchInput: React.FC<SearchInputProps> = (props) => {
+ const { onChange, onButtonClick } = 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();
+ }
+ },
+ [onButtonClick]
+ );
+
+ return (
+ <Form inline className={clsx("my-2", props.className)}>
+ <Form.Control
+ className="mr-sm-2 flex-grow-1"
+ value={props.value}
+ onChange={onInputChange}
+ onKeyPress={onInputKeyPress}
+ placeholder={props.placeholder}
+ />
+ <div className="mt-2 mt-sm-0 order-sm-last ml-sm-3">
+ {props.additionalButton}
+ </div>
+ <div className="mt-2 mt-sm-0 ml-auto ml-sm-0">
+ {props.loading ? (
+ <Spinner variant="primary" animation="border" />
+ ) : (
+ <Button variant="outline-primary" onClick={props.onButtonClick}>
+ {props.buttonText ?? t("search")}
+ </Button>
+ )}
+ </div>
+ </Form>
+ );
+};
+
+export default SearchInput;
diff --git a/FrontEnd/src/app/views/common/TimelineLogo.tsx b/FrontEnd/src/app/views/common/TimelineLogo.tsx
new file mode 100644
index 00000000..27d188fc
--- /dev/null
+++ b/FrontEnd/src/app/views/common/TimelineLogo.tsx
@@ -0,0 +1,26 @@
+import React, { SVGAttributes } from "react";
+
+export interface TimelineLogoProps extends SVGAttributes<SVGElement> {
+ color?: string;
+}
+
+const TimelineLogo: React.FC<TimelineLogoProps> = (props) => {
+ const { color, ...forwardProps } = props;
+ const coercedColor = color ?? "currentcolor";
+ return (
+ <svg
+ className={props.className}
+ viewBox="0 0 100 100"
+ fill="none"
+ strokeWidth="12"
+ stroke={coercedColor}
+ {...forwardProps}
+ >
+ <line x1="50" y1="0" x2="50" y2="25" />
+ <circle cx="50" cy="50" r="22" />
+ <line x1="50" y1="75" x2="50" y2="100" />
+ </svg>
+ );
+};
+
+export default TimelineLogo;
diff --git a/FrontEnd/src/app/views/common/UserTimelineLogo.tsx b/FrontEnd/src/app/views/common/UserTimelineLogo.tsx
new file mode 100644
index 00000000..29f6a69f
--- /dev/null
+++ b/FrontEnd/src/app/views/common/UserTimelineLogo.tsx
@@ -0,0 +1,26 @@
+import React, { SVGAttributes } from "react";
+
+export interface UserTimelineLogoProps extends SVGAttributes<SVGElement> {
+ color?: string;
+}
+
+const UserTimelineLogo: React.FC<UserTimelineLogoProps> = (props) => {
+ const { color, ...forwardProps } = props;
+ const coercedColor = color ?? "currentcolor";
+
+ return (
+ <svg viewBox="0 0 100 100" {...forwardProps}>
+ <g fill="none" stroke={coercedColor} strokeWidth="12">
+ <line x1="50" x2="50" y1="0" y2="25" />
+ <circle cx="50" cy="50" r="22" />
+ <line x1="50" x2="50" y1="75" y2="100" />
+ </g>
+ <g fill={color}>
+ <circle cx="85" cy="75" r="10" />
+ <path d="m70,100c0,0 15,-30 30,0.25" />
+ </g>
+ </svg>
+ );
+};
+
+export default UserTimelineLogo;
diff --git a/FrontEnd/src/app/views/common/alert/AlertHost.tsx b/FrontEnd/src/app/views/common/alert/AlertHost.tsx
new file mode 100644
index 00000000..c74f18e2
--- /dev/null
+++ b/FrontEnd/src/app/views/common/alert/AlertHost.tsx
@@ -0,0 +1,101 @@
+import React, { useCallback } from "react";
+import without from "lodash/without";
+import concat from "lodash/concat";
+import { useTranslation } from "react-i18next";
+import { Alert } from "react-bootstrap";
+
+import {
+ alertService,
+ AlertInfoEx,
+ kAlertHostId,
+ AlertInfo,
+} from "@/services/alert";
+
+interface AutoCloseAlertProps {
+ alert: AlertInfo;
+ close: () => void;
+}
+
+export const AutoCloseAlert: React.FC<AutoCloseAlertProps> = (props) => {
+ const { alert } = props;
+ const { dismissTime } = alert;
+
+ const { t } = useTranslation();
+
+ React.useEffect(() => {
+ const tag =
+ dismissTime === "never"
+ ? null
+ : typeof dismissTime === "number"
+ ? window.setTimeout(props.close, dismissTime)
+ : window.setTimeout(props.close, 5000);
+ return () => {
+ if (tag != null) {
+ window.clearTimeout(tag);
+ }
+ };
+ }, [dismissTime, props.close]);
+
+ return (
+ <Alert
+ className="m-3"
+ variant={alert.type ?? "primary"}
+ onClose={props.close}
+ dismissible
+ >
+ {(() => {
+ const { message } = alert;
+ if (typeof message === "function") {
+ const Message = message;
+ return <Message />;
+ } else if (typeof message === "object" && message.type === "i18n") {
+ return t(message.key);
+ } else return alert.message;
+ })()}
+ </Alert>
+ );
+};
+
+// oh what a bad name!
+interface AlertInfoExEx extends AlertInfoEx {
+ close: () => void;
+}
+
+const AlertHost: React.FC = () => {
+ const [alerts, setAlerts] = React.useState<AlertInfoExEx[]>([]);
+
+ // react guarantee that state setters are stable, so we don't need to add it to dependency list
+
+ const consume = useCallback((alert: AlertInfoEx): void => {
+ const alertEx: AlertInfoExEx = {
+ ...alert,
+ close: () => {
+ setAlerts((oldAlerts) => {
+ return without(oldAlerts, alertEx);
+ });
+ },
+ };
+ setAlerts((oldAlerts) => {
+ return concat(oldAlerts, alertEx);
+ });
+ }, []);
+
+ React.useEffect(() => {
+ alertService.registerConsumer(consume);
+ return () => {
+ alertService.unregisterConsumer(consume);
+ };
+ }, [consume]);
+
+ return (
+ <div id={kAlertHostId} className="alert-container">
+ {alerts.map((alert) => {
+ return (
+ <AutoCloseAlert key={alert.id} alert={alert} close={alert.close} />
+ );
+ })}
+ </div>
+ );
+};
+
+export default AlertHost;
diff --git a/FrontEnd/src/app/views/common/alert/alert.sass b/FrontEnd/src/app/views/common/alert/alert.sass
new file mode 100644
index 00000000..c3560b87
--- /dev/null
+++ b/FrontEnd/src/app/views/common/alert/alert.sass
@@ -0,0 +1,15 @@
+.alert-container
+ position: fixed
+ z-index: $zindex-popover
+
+@include media-breakpoint-up(sm)
+ .alert-container
+ bottom: 0
+ right: 0
+
+@include media-breakpoint-down(sm)
+ .alert-container
+ bottom: 0
+ right: 0
+ left: 0
+ text-align: center
diff --git a/FrontEnd/src/app/views/common/common.sass b/FrontEnd/src/app/views/common/common.sass
new file mode 100644
index 00000000..78e6fd14
--- /dev/null
+++ b/FrontEnd/src/app/views/common/common.sass
@@ -0,0 +1,33 @@
+.image-cropper-container
+ position: relative
+ box-sizing: border-box
+ user-select: none
+
+.image-cropper-container img
+ position: absolute
+ left: 0
+ top: 0
+ width: 100%
+ height: 100%
+
+.image-cropper-mask-container
+ position: absolute
+ left: 0
+ top: 0
+ right: 0
+ bottom: 0
+ overflow: hidden
+
+.image-cropper-mask
+ position: absolute
+ box-shadow: 0 0 0 10000px rgba(255, 255, 255, 80%)
+ touch-action: none
+
+.image-cropper-handler
+ position: absolute
+ width: 26px
+ height: 26px
+ border: black solid 2px
+ border-radius: 50%
+ background: white
+ touch-action: none
diff --git a/FrontEnd/src/app/views/home/BoardWithUser.tsx b/FrontEnd/src/app/views/home/BoardWithUser.tsx
new file mode 100644
index 00000000..dcd39cbe
--- /dev/null
+++ b/FrontEnd/src/app/views/home/BoardWithUser.tsx
@@ -0,0 +1,101 @@
+import React from "react";
+import { Row, Col } from "react-bootstrap";
+import { useTranslation } from "react-i18next";
+
+import { UserWithToken } from "@/services/user";
+import { TimelineInfo } from "@/services/timeline";
+import { getHttpTimelineClient } from "@/http/timeline";
+
+import TimelineBoard from "./TimelineBoard";
+import OfflineBoard from "./OfflineBoard";
+
+const BoardWithUser: React.FC<{ user: UserWithToken }> = ({ user }) => {
+ const { t } = useTranslation();
+
+ const [ownTimelines, setOwnTimelines] = React.useState<
+ TimelineInfo[] | "offline" | "loading"
+ >("loading");
+ const [joinTimelines, setJoinTimelines] = React.useState<
+ TimelineInfo[] | "offline" | "loading"
+ >("loading");
+
+ React.useEffect(() => {
+ let subscribe = true;
+ if (ownTimelines === "loading") {
+ void getHttpTimelineClient()
+ .listTimeline({ relate: user.username, relateType: "own" })
+ .then(
+ (timelines) => {
+ if (subscribe) {
+ setOwnTimelines(timelines);
+ }
+ },
+ () => {
+ setOwnTimelines("offline");
+ }
+ );
+ }
+ return () => {
+ subscribe = false;
+ };
+ }, [user, ownTimelines]);
+
+ React.useEffect(() => {
+ let subscribe = true;
+ if (joinTimelines === "loading") {
+ void getHttpTimelineClient()
+ .listTimeline({ relate: user.username, relateType: "join" })
+ .then(
+ (timelines) => {
+ if (subscribe) {
+ setJoinTimelines(timelines);
+ }
+ },
+ () => {
+ setJoinTimelines("offline");
+ }
+ );
+ }
+ return () => {
+ subscribe = false;
+ };
+ }, [user, joinTimelines]);
+
+ return (
+ <Row className="my-2 justify-content-center">
+ {ownTimelines === "offline" && joinTimelines === "offline" ? (
+ <Col className="py-2" sm="8" lg="6">
+ <OfflineBoard
+ onReload={() => {
+ setOwnTimelines("loading");
+ setJoinTimelines("loading");
+ }}
+ />
+ </Col>
+ ) : (
+ <>
+ <Col sm="6" lg="5" className="py-2">
+ <TimelineBoard
+ title={t("home.ownTimeline")}
+ timelines={ownTimelines}
+ onReload={() => {
+ setOwnTimelines("loading");
+ }}
+ />
+ </Col>
+ <Col sm="6" lg="5" className="py-2">
+ <TimelineBoard
+ title={t("home.joinTimeline")}
+ timelines={joinTimelines}
+ onReload={() => {
+ setJoinTimelines("loading");
+ }}
+ />
+ </Col>
+ </>
+ )}
+ </Row>
+ );
+};
+
+export default BoardWithUser;
diff --git a/FrontEnd/src/app/views/home/BoardWithoutUser.tsx b/FrontEnd/src/app/views/home/BoardWithoutUser.tsx
new file mode 100644
index 00000000..ebfddb50
--- /dev/null
+++ b/FrontEnd/src/app/views/home/BoardWithoutUser.tsx
@@ -0,0 +1,60 @@
+import React from "react";
+import { Row, Col } from "react-bootstrap";
+
+import { TimelineInfo } from "@/services/timeline";
+import { getHttpTimelineClient } from "@/http/timeline";
+
+import TimelineBoard from "./TimelineBoard";
+import OfflineBoard from "./OfflineBoard";
+
+const BoardWithoutUser: React.FC = () => {
+ const [publicTimelines, setPublicTimelines] = React.useState<
+ TimelineInfo[] | "offline" | "loading"
+ >("loading");
+
+ React.useEffect(() => {
+ let subscribe = true;
+ if (publicTimelines === "loading") {
+ void getHttpTimelineClient()
+ .listTimeline({ visibility: "Public" })
+ .then(
+ (timelines) => {
+ if (subscribe) {
+ setPublicTimelines(timelines);
+ }
+ },
+ () => {
+ setPublicTimelines("offline");
+ }
+ );
+ }
+ return () => {
+ subscribe = false;
+ };
+ }, [publicTimelines]);
+
+ return (
+ <Row className="my-2 justify-content-center">
+ {publicTimelines === "offline" ? (
+ <Col sm="8" lg="6">
+ <OfflineBoard
+ onReload={() => {
+ setPublicTimelines("loading");
+ }}
+ />
+ </Col>
+ ) : (
+ <Col sm="8" lg="6">
+ <TimelineBoard
+ timelines={publicTimelines}
+ onReload={() => {
+ setPublicTimelines("loading");
+ }}
+ />
+ </Col>
+ )}
+ </Row>
+ );
+};
+
+export default BoardWithoutUser;
diff --git a/FrontEnd/src/app/views/home/OfflineBoard.tsx b/FrontEnd/src/app/views/home/OfflineBoard.tsx
new file mode 100644
index 00000000..fc05bd74
--- /dev/null
+++ b/FrontEnd/src/app/views/home/OfflineBoard.tsx
@@ -0,0 +1,61 @@
+import React from "react";
+import { Link } from "react-router-dom";
+import { Trans } from "react-i18next";
+
+import { getAllCachedTimelineNames } from "@/services/timeline";
+import UserTimelineLogo from "../common/UserTimelineLogo";
+import TimelineLogo from "../common/TimelineLogo";
+
+export interface OfflineBoardProps {
+ onReload: () => void;
+}
+
+const OfflineBoard: React.FC<OfflineBoardProps> = ({ onReload }) => {
+ const [timelines, setTimelines] = React.useState<string[]>([]);
+
+ React.useEffect(() => {
+ let subscribe = true;
+ void getAllCachedTimelineNames().then((t) => {
+ if (subscribe) setTimelines(t);
+ });
+ return () => {
+ subscribe = false;
+ };
+ });
+
+ return (
+ <>
+ <Trans i18nKey="home.offlinePrompt">
+ 0
+ <a
+ href="#"
+ onClick={(e) => {
+ onReload();
+ e.preventDefault();
+ }}
+ >
+ 1
+ </a>
+ 2
+ </Trans>
+ {timelines.map((timeline) => {
+ const isPersonal = timeline.startsWith("@");
+ const url = isPersonal
+ ? `/users/${timeline.slice(1)}`
+ : `/timelines/${timeline}`;
+ return (
+ <div key={timeline} className="timeline-board-item">
+ {isPersonal ? (
+ <UserTimelineLogo className="icon" />
+ ) : (
+ <TimelineLogo className="icon" />
+ )}
+ <Link to={url}>{timeline}</Link>
+ </div>
+ );
+ })}
+ </>
+ );
+};
+
+export default OfflineBoard;
diff --git a/FrontEnd/src/app/views/home/TimelineBoard.tsx b/FrontEnd/src/app/views/home/TimelineBoard.tsx
new file mode 100644
index 00000000..a3d176e1
--- /dev/null
+++ b/FrontEnd/src/app/views/home/TimelineBoard.tsx
@@ -0,0 +1,73 @@
+import React from "react";
+import clsx from "clsx";
+import { Link } from "react-router-dom";
+import { Trans } from "react-i18next";
+import { Spinner } from "react-bootstrap";
+
+import { TimelineInfo } from "@/services/timeline";
+import TimelineLogo from "../common/TimelineLogo";
+import UserTimelineLogo from "../common/UserTimelineLogo";
+
+export interface TimelineBoardProps {
+ title?: string;
+ timelines: TimelineInfo[] | "offline" | "loading";
+ onReload: () => void;
+ className?: string;
+}
+
+const TimelineBoard: React.FC<TimelineBoardProps> = (props) => {
+ const { title, timelines, className } = props;
+
+ return (
+ <div className={clsx("timeline-board", className)}>
+ {title != null && <h3 className="text-center">{title}</h3>}
+ {(() => {
+ if (timelines === "loading") {
+ return (
+ <div className="d-flex flex-grow-1 justify-content-center align-items-center">
+ <Spinner variant="primary" animation="border" />
+ </div>
+ );
+ } else if (timelines === "offline") {
+ return (
+ <div className="d-flex flex-grow-1 justify-content-center align-items-center">
+ <Trans i18nKey="loadFailReload" parent="div">
+ 0
+ <a
+ href="#"
+ onClick={(e) => {
+ props.onReload();
+ e.preventDefault();
+ }}
+ >
+ 1
+ </a>
+ 2
+ </Trans>
+ </div>
+ );
+ } else {
+ return timelines.map((timeline) => {
+ const { name } = timeline;
+ const isPersonal = name.startsWith("@");
+ const url = isPersonal
+ ? `/users/${timeline.owner.username}`
+ : `/timelines/${name}`;
+ return (
+ <div key={name} className="timeline-board-item">
+ {isPersonal ? (
+ <UserTimelineLogo className="icon" />
+ ) : (
+ <TimelineLogo className="icon" />
+ )}
+ <Link to={url}>{name}</Link>
+ </div>
+ );
+ });
+ }
+ })()}
+ </div>
+ );
+};
+
+export default TimelineBoard;
diff --git a/FrontEnd/src/app/views/home/TimelineCreateDialog.tsx b/FrontEnd/src/app/views/home/TimelineCreateDialog.tsx
new file mode 100644
index 00000000..d9467719
--- /dev/null
+++ b/FrontEnd/src/app/views/home/TimelineCreateDialog.tsx
@@ -0,0 +1,53 @@
+import React from "react";
+import { useHistory } from "react-router";
+
+import { validateTimelineName, timelineService } from "@/services/timeline";
+import OperationDialog from "../common/OperationDialog";
+
+interface TimelineCreateDialogProps {
+ open: boolean;
+ close: () => void;
+}
+
+const TimelineCreateDialog: React.FC<TimelineCreateDialogProps> = (props) => {
+ const history = useHistory();
+
+ let nameSaved: string;
+
+ return (
+ <OperationDialog
+ open={props.open}
+ close={props.close}
+ titleColor="success"
+ title="home.createDialog.title"
+ inputScheme={[
+ {
+ type: "text",
+ label: "home.createDialog.name",
+ helperText: "home.createDialog.nameFormat",
+ validator: (name) => {
+ if (name.length === 0) {
+ return "home.createDialog.noEmpty";
+ } else if (name.length > 26) {
+ return "home.createDialog.tooLong";
+ } else if (!validateTimelineName(name)) {
+ return "home.createDialog.badFormat";
+ } else {
+ return null;
+ }
+ },
+ },
+ ]}
+ onProcess={([name]) => {
+ nameSaved = name as string;
+ return timelineService.createTimeline(nameSaved).toPromise();
+ }}
+ onSuccessAndClose={() => {
+ history.push(`timelines/${nameSaved}`);
+ }}
+ failurePrompt={(e) => `${e as string}`}
+ />
+ );
+};
+
+export default TimelineCreateDialog;
diff --git a/FrontEnd/src/app/views/home/home.sass b/FrontEnd/src/app/views/home/home.sass
new file mode 100644
index 00000000..28a2e5f3
--- /dev/null
+++ b/FrontEnd/src/app/views/home/home.sass
@@ -0,0 +1,13 @@
+.timeline-board-item
+ font-size: 1.1em
+ @extend .my-2
+ .icon
+ height: 1.3em
+ @extend .mr-2
+
+.timeline-board
+ @extend .cru-card
+ @extend .d-flex
+ @extend .flex-column
+ @extend .p-3
+ min-height: 200px
diff --git a/FrontEnd/src/app/views/home/index.tsx b/FrontEnd/src/app/views/home/index.tsx
new file mode 100644
index 00000000..760adcea
--- /dev/null
+++ b/FrontEnd/src/app/views/home/index.tsx
@@ -0,0 +1,99 @@
+import React from "react";
+import { useHistory } from "react-router";
+import { useTranslation } from "react-i18next";
+import { Row, Container, Button, Col } from "react-bootstrap";
+
+import { useUser } from "@/services/user";
+import SearchInput from "../common/SearchInput";
+
+import BoardWithoutUser from "./BoardWithoutUser";
+import BoardWithUser from "./BoardWithUser";
+import TimelineCreateDialog from "./TimelineCreateDialog";
+
+const HomePage: React.FC = () => {
+ const history = useHistory();
+
+ const { t } = useTranslation();
+
+ const user = useUser();
+
+ const [navText, setNavText] = React.useState<string>("");
+
+ const [dialog, setDialog] = React.useState<"create" | null>(null);
+
+ const goto = React.useCallback((): void => {
+ if (navText === "") {
+ history.push("users/crupest");
+ } else if (navText.startsWith("@")) {
+ history.push(`users/${navText.slice(1)}`);
+ } else {
+ history.push(`timelines/${navText}`);
+ }
+ }, [navText, history]);
+
+ return (
+ <>
+ <Container fluid>
+ <Row className="justify-content-center">
+ <Col xs={12} sm={10} md={8} lg={6}>
+ <SearchInput
+ className="justify-content-center"
+ value={navText}
+ onChange={setNavText}
+ onButtonClick={goto}
+ buttonText={t("home.go")}
+ placeholder="@crupest"
+ additionalButton={
+ user != null && (
+ <Button
+ variant="outline-success"
+ onClick={() => {
+ setDialog("create");
+ }}
+ >
+ {t("home.createButton")}
+ </Button>
+ )
+ }
+ />
+ </Col>
+ </Row>
+ {(() => {
+ if (user == null) {
+ return <BoardWithoutUser />;
+ } else {
+ return <BoardWithUser user={user} />;
+ }
+ })()}
+ </Container>
+ <footer className="text-right">
+ <a
+ className="mx-3 text-muted"
+ href="http://beian.miit.gov.cn/"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ <small>鄂ICP备18030913号-1</small>
+ </a>
+ <a
+ className="mx-3 text-muted"
+ href="http://www.beian.gov.cn/"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ <small className="white-space-no-wrap">公安备案 42112102000124</small>
+ </a>
+ </footer>
+ {dialog === "create" && (
+ <TimelineCreateDialog
+ open
+ close={() => {
+ setDialog(null);
+ }}
+ />
+ )}
+ </>
+ );
+};
+
+export default HomePage;
diff --git a/FrontEnd/src/app/views/login/index.tsx b/FrontEnd/src/app/views/login/index.tsx
new file mode 100644
index 00000000..61b9a525
--- /dev/null
+++ b/FrontEnd/src/app/views/login/index.tsx
@@ -0,0 +1,151 @@
+import React from "react";
+import { useHistory } from "react-router";
+import { useTranslation } from "react-i18next";
+import { Container, Form } from "react-bootstrap";
+
+import { useUser, userService } from "@/services/user";
+
+import AppBar from "../common/AppBar";
+import LoadingButton from "../common/LoadingButton";
+
+const LoginPage: React.FC = (_) => {
+ const { t } = useTranslation();
+ const history = useHistory();
+ 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(() => history.push("/"), 3000);
+ return () => {
+ clearTimeout(id);
+ };
+ }
+ }, [history, user]);
+
+ if (user != null) {
+ return (
+ <>
+ <AppBar />
+ <p className="mt-appbar">{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) {
+ history.push("/");
+ } else {
+ history.goBack();
+ }
+ },
+ (e: Error) => {
+ setProcess(false);
+ setError(e.message);
+ }
+ );
+ };
+
+ const onEnterPressInPassword: React.KeyboardEventHandler = (e) => {
+ if (e.key === "Enter") {
+ submit();
+ }
+ };
+
+ return (
+ <Container fluid className="login-container mt-2">
+ <h1 className="text-center">{t("welcome")}</h1>
+ <Form>
+ <Form.Group>
+ <Form.Label htmlFor="username">{t("user.username")}</Form.Label>
+ <Form.Control
+ id="username"
+ disabled={process}
+ onChange={(e) => {
+ setUsername(e.target.value);
+ setUsernameDirty(true);
+ }}
+ value={username}
+ isInvalid={usernameDirty && username === ""}
+ />
+ {usernameDirty && username === "" && (
+ <Form.Control.Feedback type="invalid">
+ {t("login.emptyUsername")}
+ </Form.Control.Feedback>
+ )}
+ </Form.Group>
+ <Form.Group>
+ <Form.Label htmlFor="password">{t("user.password")}</Form.Label>
+ <Form.Control
+ id="password"
+ type="password"
+ disabled={process}
+ onChange={(e) => {
+ setPassword(e.target.value);
+ setPasswordDirty(true);
+ }}
+ value={password}
+ onKeyDown={onEnterPressInPassword}
+ isInvalid={passwordDirty && password === ""}
+ />
+ {passwordDirty && password === "" && (
+ <Form.Control.Feedback type="invalid">
+ {t("login.emptyPassword")}
+ </Form.Control.Feedback>
+ )}
+ </Form.Group>
+ <Form.Group>
+ <Form.Check<"input">
+ id="remember-me"
+ type="checkbox"
+ checked={rememberMe}
+ onChange={(e) => {
+ setRememberMe(e.currentTarget.checked);
+ }}
+ label={t("user.rememberMe")}
+ />
+ </Form.Group>
+ {error ? <p className="text-danger">{t(error)}</p> : null}
+ <div className="text-right">
+ <LoadingButton
+ loading={process}
+ variant="primary"
+ onClick={(e) => {
+ submit();
+ e.preventDefault();
+ }}
+ disabled={username === "" || password === "" ? true : undefined}
+ >
+ {t("user.login")}
+ </LoadingButton>
+ </div>
+ </Form>
+ </Container>
+ );
+};
+
+export default LoginPage;
diff --git a/FrontEnd/src/app/views/login/login.sass b/FrontEnd/src/app/views/login/login.sass
new file mode 100644
index 00000000..0bf385f5
--- /dev/null
+++ b/FrontEnd/src/app/views/login/login.sass
@@ -0,0 +1,2 @@
+.login-container
+ max-width: 600px
diff --git a/FrontEnd/src/app/views/settings/index.tsx b/FrontEnd/src/app/views/settings/index.tsx
new file mode 100644
index 00000000..964e7442
--- /dev/null
+++ b/FrontEnd/src/app/views/settings/index.tsx
@@ -0,0 +1,209 @@
+import React, { useState } from "react";
+import { useHistory } from "react-router";
+import { useTranslation } from "react-i18next";
+import { Form, Container, Row, Col, Button, Modal } from "react-bootstrap";
+
+import { useUser, userService } from "@/services/user";
+import OperationDialog, {
+ OperationInputErrorInfo,
+} from "../common/OperationDialog";
+
+interface ChangePasswordDialogProps {
+ open: boolean;
+ close: () => void;
+}
+
+const ChangePasswordDialog: React.FC<ChangePasswordDialogProps> = (props) => {
+ const history = useHistory();
+ const { t } = useTranslation();
+
+ const [redirect, setRedirect] = useState<boolean>(false);
+
+ return (
+ <OperationDialog
+ open={props.open}
+ title={t("settings.dialogChangePassword.title")}
+ titleColor="dangerous"
+ inputPrompt={t("settings.dialogChangePassword.prompt")}
+ inputScheme={[
+ {
+ type: "text",
+ label: t("settings.dialogChangePassword.inputOldPassword"),
+ password: true,
+ validator: (v) =>
+ v === ""
+ ? "settings.dialogChangePassword.errorEmptyOldPassword"
+ : null,
+ },
+ {
+ type: "text",
+ label: t("settings.dialogChangePassword.inputNewPassword"),
+ password: true,
+ validator: (v, values) => {
+ const error: OperationInputErrorInfo = {};
+ error[1] =
+ v === ""
+ ? "settings.dialogChangePassword.errorEmptyNewPassword"
+ : null;
+ if (v === values[2]) {
+ error[2] = null;
+ } else {
+ if (values[2] !== "") {
+ error[2] = "settings.dialogChangePassword.errorRetypeNotMatch";
+ }
+ }
+ return error;
+ },
+ },
+ {
+ type: "text",
+ label: t("settings.dialogChangePassword.inputRetypeNewPassword"),
+ password: true,
+ validator: (v, values) =>
+ v !== values[1]
+ ? "settings.dialogChangePassword.errorRetypeNotMatch"
+ : null,
+ },
+ ]}
+ onProcess={async ([oldPassword, newPassword]) => {
+ await userService
+ .changePassword(oldPassword as string, newPassword as string)
+ .toPromise();
+ await userService.logout();
+ setRedirect(true);
+ }}
+ close={() => {
+ props.close();
+ if (redirect) {
+ history.push("/login");
+ }
+ }}
+ />
+ );
+};
+
+const ConfirmLogoutDialog: React.FC<{
+ toggle: () => void;
+ onConfirm: () => void;
+}> = ({ toggle, onConfirm }) => {
+ const { t } = useTranslation();
+
+ return (
+ <Modal show centered onHide={toggle}>
+ <Modal.Header>
+ <Modal.Title className="text-danger">
+ {t("settings.dialogConfirmLogout.title")}
+ </Modal.Title>
+ </Modal.Header>
+ <Modal.Body>{t("settings.dialogConfirmLogout.prompt")}</Modal.Body>
+ <Modal.Footer>
+ <Button variant="secondary" onClick={toggle}>
+ {t("operationDialog.cancel")}
+ </Button>
+ <Button variant="danger" onClick={onConfirm}>
+ {t("operationDialog.confirm")}
+ </Button>
+ </Modal.Footer>
+ </Modal>
+ );
+};
+
+const SettingsPage: React.FC = (_) => {
+ const { i18n, t } = useTranslation();
+ const user = useUser();
+ const history = useHistory();
+
+ const [dialog, setDialog] = useState<null | "changepassword" | "logout">(
+ null
+ );
+
+ const language = i18n.language.slice(0, 2);
+
+ return (
+ <Container fluid>
+ {user ? (
+ <>
+ <Row className="border-bottom p-3 cursor-pointer">
+ <Col xs="12">
+ <h5
+ onClick={() => {
+ history.push(`/users/${user.username}`);
+ }}
+ >
+ {t("settings.gotoSelf")}
+ </h5>
+ </Col>
+ </Row>
+ <Row className="border-bottom p-3 cursor-pointer">
+ <Col xs="12">
+ <h5
+ className="text-danger"
+ onClick={() => setDialog("changepassword")}
+ >
+ {t("settings.changePassword")}
+ </h5>
+ </Col>
+ </Row>
+ <Row className="border-bottom p-3 cursor-pointer">
+ <Col xs="12">
+ <h5
+ className="text-danger"
+ onClick={() => {
+ setDialog("logout");
+ }}
+ >
+ {t("settings.logout")}
+ </h5>
+ </Col>
+ </Row>
+ </>
+ ) : null}
+ <Row className="align-items-center border-bottom p-3">
+ <Col xs="12" sm="auto">
+ <h5>{t("settings.languagePrimary")}</h5>
+ <p>{t("settings.languageSecondary")}</p>
+ </Col>
+ <Col xs="auto" className="ml-auto">
+ <Form.Control
+ as="select"
+ value={language}
+ onChange={(e) => {
+ void i18n.changeLanguage(e.target.value);
+ }}
+ >
+ <option value="zh">中文</option>
+ <option value="en">English</option>
+ </Form.Control>
+ </Col>
+ </Row>
+ {(() => {
+ switch (dialog) {
+ case "changepassword":
+ return (
+ <ChangePasswordDialog
+ open
+ close={() => {
+ setDialog(null);
+ }}
+ />
+ );
+ case "logout":
+ return (
+ <ConfirmLogoutDialog
+ toggle={() => setDialog(null)}
+ onConfirm={() => {
+ void userService.logout().then(() => {
+ history.push("/");
+ });
+ }}
+ />
+ );
+ default:
+ return null;
+ }
+ })()}
+ </Container>
+ );
+};
+
+export default SettingsPage;
diff --git a/FrontEnd/src/app/views/timeline-common/CollapseButton.tsx b/FrontEnd/src/app/views/timeline-common/CollapseButton.tsx
new file mode 100644
index 00000000..3c52150f
--- /dev/null
+++ b/FrontEnd/src/app/views/timeline-common/CollapseButton.tsx
@@ -0,0 +1,23 @@
+import React from "react";
+import clsx from "clsx";
+import Svg from "react-inlinesvg";
+import arrowsAngleContractIcon from "bootstrap-icons/icons/arrows-angle-contract.svg";
+import arrowsAngleExpandIcon from "bootstrap-icons/icons/arrows-angle-expand.svg";
+
+const CollapseButton: React.FC<{
+ collapse: boolean;
+ onClick: () => void;
+ className?: string;
+ style?: React.CSSProperties;
+}> = ({ collapse, onClick, className, style }) => {
+ return (
+ <Svg
+ src={collapse ? arrowsAngleExpandIcon : arrowsAngleContractIcon}
+ onClick={onClick}
+ className={clsx("text-primary icon-button", className)}
+ style={style}
+ />
+ );
+};
+
+export default CollapseButton;
diff --git a/FrontEnd/src/app/views/timeline-common/InfoCardTemplate.tsx b/FrontEnd/src/app/views/timeline-common/InfoCardTemplate.tsx
new file mode 100644
index 00000000..a8de20aa
--- /dev/null
+++ b/FrontEnd/src/app/views/timeline-common/InfoCardTemplate.tsx
@@ -0,0 +1,26 @@
+import React from "react";
+import clsx from "clsx";
+
+import { TimelineCardComponentProps } from "../timeline-common/TimelinePageTemplateUI";
+import SyncStatusBadge from "../timeline-common/SyncStatusBadge";
+import CollapseButton from "../timeline-common/CollapseButton";
+
+const InfoCardTemplate: React.FC<
+ Pick<
+ TimelineCardComponentProps<"">,
+ "collapse" | "toggleCollapse" | "syncStatus" | "className"
+ > & { children: React.ReactElement[] }
+> = ({ collapse, toggleCollapse, syncStatus, className, children }) => {
+ return (
+ <div className={clsx("cru-card p-2 clearfix", className)}>
+ <div className="float-right d-flex align-items-center">
+ <SyncStatusBadge status={syncStatus} className="mr-2" />
+ <CollapseButton collapse={collapse} onClick={toggleCollapse} />
+ </div>
+
+ <div style={{ display: collapse ? "none" : "block" }}>{children}</div>
+ </div>
+ );
+};
+
+export default InfoCardTemplate;
diff --git a/FrontEnd/src/app/views/timeline-common/SyncStatusBadge.tsx b/FrontEnd/src/app/views/timeline-common/SyncStatusBadge.tsx
new file mode 100644
index 00000000..e67cfb43
--- /dev/null
+++ b/FrontEnd/src/app/views/timeline-common/SyncStatusBadge.tsx
@@ -0,0 +1,58 @@
+import React from "react";
+import clsx from "clsx";
+import { useTranslation } from "react-i18next";
+
+import { UiLogicError } from "@/common";
+
+export type TimelineSyncStatus = "syncing" | "synced" | "offline";
+
+const SyncStatusBadge: React.FC<{
+ status: TimelineSyncStatus;
+ style?: React.CSSProperties;
+ className?: string;
+}> = ({ status, style, className }) => {
+ const { t } = useTranslation();
+
+ return (
+ <div style={style} className={clsx("timeline-sync-state-badge", className)}>
+ {(() => {
+ switch (status) {
+ case "syncing": {
+ return (
+ <>
+ <span className="timeline-sync-state-badge-pin bg-warning" />
+ <span className="text-warning">
+ {t("timeline.postSyncState.syncing")}
+ </span>
+ </>
+ );
+ }
+ case "synced": {
+ return (
+ <>
+ <span className="timeline-sync-state-badge-pin bg-success" />
+ <span className="text-success">
+ {t("timeline.postSyncState.synced")}
+ </span>
+ </>
+ );
+ }
+ case "offline": {
+ return (
+ <>
+ <span className="timeline-sync-state-badge-pin bg-danger" />
+ <span className="text-danger">
+ {t("timeline.postSyncState.offline")}
+ </span>
+ </>
+ );
+ }
+ default:
+ throw new UiLogicError("Unknown sync state.");
+ }
+ })()}
+ </div>
+ );
+};
+
+export default SyncStatusBadge;
diff --git a/FrontEnd/src/app/views/timeline-common/Timeline.tsx b/FrontEnd/src/app/views/timeline-common/Timeline.tsx
new file mode 100644
index 00000000..fd051d45
--- /dev/null
+++ b/FrontEnd/src/app/views/timeline-common/Timeline.tsx
@@ -0,0 +1,84 @@
+import React from "react";
+import clsx from "clsx";
+
+import { TimelinePostInfo } from "@/services/timeline";
+
+import TimelineItem from "./TimelineItem";
+
+export interface TimelinePostInfoEx extends TimelinePostInfo {
+ deletable: boolean;
+}
+
+export type TimelineDeleteCallback = (index: number, id: number) => void;
+
+export interface TimelineProps {
+ className?: string;
+ posts: TimelinePostInfoEx[];
+ onDelete: TimelineDeleteCallback;
+ onResize?: () => void;
+ containerRef?: React.Ref<HTMLDivElement>;
+}
+
+const Timeline: React.FC<TimelineProps> = (props) => {
+ const { posts, onDelete, onResize } = props;
+
+ const [indexShowDeleteButton, setIndexShowDeleteButton] = React.useState<
+ number
+ >(-1);
+
+ const onItemClick = React.useCallback(() => {
+ setIndexShowDeleteButton(-1);
+ }, []);
+
+ const onToggleDelete = React.useMemo(() => {
+ return posts.map((post, i) => {
+ return post.deletable
+ ? () => {
+ setIndexShowDeleteButton((oldIndexShowDeleteButton) => {
+ return oldIndexShowDeleteButton !== i ? i : -1;
+ });
+ }
+ : undefined;
+ });
+ }, [posts]);
+
+ const onItemDelete = React.useMemo(() => {
+ return posts.map((post, i) => {
+ return () => {
+ onDelete(i, post.id);
+ };
+ });
+ }, [posts, onDelete]);
+
+ return (
+ <div ref={props.containerRef} className={clsx("timeline", props.className)}>
+ {(() => {
+ const length = posts.length;
+ return posts.map((post, i) => {
+ const toggleMore = onToggleDelete[i];
+
+ return (
+ <TimelineItem
+ post={post}
+ key={post.id}
+ current={length - 1 === i}
+ more={
+ toggleMore
+ ? {
+ isOpen: indexShowDeleteButton === i,
+ toggle: toggleMore,
+ onDelete: onItemDelete[i],
+ }
+ : undefined
+ }
+ onClick={onItemClick}
+ onResize={onResize}
+ />
+ );
+ });
+ })()}
+ </div>
+ );
+};
+
+export default Timeline;
diff --git a/FrontEnd/src/app/views/timeline-common/TimelineItem.tsx b/FrontEnd/src/app/views/timeline-common/TimelineItem.tsx
new file mode 100644
index 00000000..4db23371
--- /dev/null
+++ b/FrontEnd/src/app/views/timeline-common/TimelineItem.tsx
@@ -0,0 +1,172 @@
+import React from "react";
+import clsx from "clsx";
+import { Link } from "react-router-dom";
+import { useTranslation } from "react-i18next";
+import Svg from "react-inlinesvg";
+import chevronDownIcon from "bootstrap-icons/icons/chevron-down.svg";
+import trashIcon from "bootstrap-icons/icons/trash.svg";
+import { Modal, Button } from "react-bootstrap";
+
+import { useAvatar } from "@/services/user";
+import { TimelinePostInfo } from "@/services/timeline";
+
+import BlobImage from "../common/BlobImage";
+
+const TimelinePostDeleteConfirmDialog: React.FC<{
+ toggle: () => void;
+ onConfirm: () => void;
+}> = ({ toggle, onConfirm }) => {
+ const { t } = useTranslation();
+
+ return (
+ <Modal toggle={toggle} isOpen centered>
+ <Modal.Header>
+ <Modal.Title className="text-danger">
+ {t("timeline.post.deleteDialog.title")}
+ </Modal.Title>
+ </Modal.Header>
+ <Modal.Body>{t("timeline.post.deleteDialog.prompt")}</Modal.Body>
+ <Modal.Footer>
+ <Button variant="secondary" onClick={toggle}>
+ {t("operationDialog.cancel")}
+ </Button>
+ <Button
+ variant="danger"
+ onClick={() => {
+ onConfirm();
+ toggle();
+ }}
+ >
+ {t("operationDialog.confirm")}
+ </Button>
+ </Modal.Footer>
+ </Modal>
+ );
+};
+
+export interface TimelineItemProps {
+ post: TimelinePostInfo;
+ current?: boolean;
+ more?: {
+ isOpen: boolean;
+ toggle: () => void;
+ onDelete: () => void;
+ };
+ onClick?: () => void;
+ onResize?: () => void;
+ className?: string;
+ style?: React.CSSProperties;
+}
+
+const TimelineItem: React.FC<TimelineItemProps> = (props) => {
+ const { i18n } = useTranslation();
+
+ const current = props.current === true;
+
+ const { more, onResize } = props;
+
+ const avatar = useAvatar(props.post.author.username);
+
+ const [deleteDialog, setDeleteDialog] = React.useState<boolean>(false);
+ const toggleDeleteDialog = React.useCallback(
+ () => setDeleteDialog((old) => !old),
+ []
+ );
+
+ return (
+ <div
+ className={clsx(
+ "timeline-item position-relative",
+ current && "current",
+ props.className
+ )}
+ onClick={props.onClick}
+ style={props.style}
+ >
+ <div className="timeline-line-area-container">
+ <div className="timeline-line-area">
+ <div className="timeline-line-segment start"></div>
+ <div className="timeline-line-node-container">
+ <div className="timeline-line-node"></div>
+ </div>
+ <div className="timeline-line-segment end"></div>
+ {current && <div className="timeline-line-segment current-end" />}
+ </div>
+ </div>
+ <div className="timeline-content-area">
+ <div>
+ <span className="mr-2">
+ <span className="text-primary white-space-no-wrap mr-2">
+ {props.post.time.toLocaleString(i18n.languages)}
+ </span>
+ <small className="text-dark">{props.post.author.nickname}</small>
+ </span>
+ {more != null ? (
+ <Svg
+ src={chevronDownIcon}
+ className="text-info icon-button"
+ onClick={(e) => {
+ more.toggle();
+ e.stopPropagation();
+ }}
+ />
+ ) : null}
+ </div>
+ <div className="timeline-content">
+ <Link
+ className="float-left m-2"
+ to={"/users/" + props.post.author.username}
+ >
+ <BlobImage
+ onLoad={onResize}
+ blob={avatar}
+ className="avatar rounded"
+ />
+ </Link>
+ {(() => {
+ const { content } = props.post;
+ if (content.type === "text") {
+ return content.text;
+ } else {
+ return (
+ <BlobImage
+ onLoad={onResize}
+ blob={content.data}
+ className="timeline-content-image"
+ />
+ );
+ }
+ })()}
+ </div>
+ </div>
+ {more != null && more.isOpen ? (
+ <>
+ <div
+ className="position-absolute position-lt w-100 h-100 mask d-flex justify-content-center align-items-center"
+ onClick={more.toggle}
+ >
+ <Svg
+ src={trashIcon}
+ className="text-danger icon-button large"
+ onClick={(e) => {
+ toggleDeleteDialog();
+ e.stopPropagation();
+ }}
+ />
+ </div>
+ {deleteDialog ? (
+ <TimelinePostDeleteConfirmDialog
+ toggle={() => {
+ toggleDeleteDialog();
+ more.toggle();
+ }}
+ onConfirm={more.onDelete}
+ />
+ ) : null}
+ </>
+ ) : null}
+ </div>
+ );
+};
+
+export default TimelineItem;
diff --git a/FrontEnd/src/app/views/timeline-common/TimelineMember.tsx b/FrontEnd/src/app/views/timeline-common/TimelineMember.tsx
new file mode 100644
index 00000000..67a8543a
--- /dev/null
+++ b/FrontEnd/src/app/views/timeline-common/TimelineMember.tsx
@@ -0,0 +1,211 @@
+import React, { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Container, ListGroup, Modal, Row, Col, Button } from "react-bootstrap";
+
+import { User, useAvatar } from "@/services/user";
+
+import SearchInput from "../common/SearchInput";
+import BlobImage from "../common/BlobImage";
+
+const TimelineMemberItem: React.FC<{
+ user: User;
+ owner: boolean;
+ onRemove?: (username: string) => void;
+}> = ({ user, owner, onRemove }) => {
+ const { t } = useTranslation();
+
+ const avatar = useAvatar(user.username);
+
+ return (
+ <ListGroup.Item className="container">
+ <Row>
+ <Col xs="auto">
+ <BlobImage blob={avatar} className="avatar small" />
+ </Col>
+ <Col>
+ <Row>{user.nickname}</Row>
+ <Row>
+ <small>{"@" + user.username}</small>
+ </Row>
+ </Col>
+ {(() => {
+ if (owner) {
+ return null;
+ }
+ if (onRemove == null) {
+ return null;
+ }
+ return (
+ <Button
+ className="align-self-center"
+ variant="danger"
+ onClick={() => {
+ onRemove(user.username);
+ }}
+ >
+ {t("timeline.member.remove")}
+ </Button>
+ );
+ })()}
+ </Row>
+ </ListGroup.Item>
+ );
+};
+
+export interface TimelineMemberCallbacks {
+ onCheckUser: (username: string) => Promise<User | null>;
+ onAddUser: (user: User) => Promise<void>;
+ onRemoveUser: (username: string) => void;
+}
+
+export interface TimelineMemberProps {
+ members: User[];
+ edit: TimelineMemberCallbacks | null | undefined;
+}
+
+const TimelineMember: React.FC<TimelineMemberProps> = (props) => {
+ const { t } = useTranslation();
+
+ const [userSearchText, setUserSearchText] = useState<string>("");
+ const [userSearchState, setUserSearchState] = useState<
+ | {
+ type: "user";
+ data: User;
+ }
+ | { type: "error"; data: string }
+ | { type: "loading" }
+ | { type: "init" }
+ >({ type: "init" });
+
+ const userSearchAvatar = useAvatar(
+ userSearchState.type === "user" ? userSearchState.data.username : undefined
+ );
+
+ const members = props.members;
+
+ return (
+ <Container className="px-4">
+ <ListGroup className="my-3">
+ {members.map((member, index) => (
+ <TimelineMemberItem
+ key={member.username}
+ user={member}
+ owner={index === 0}
+ onRemove={props.edit?.onRemoveUser}
+ />
+ ))}
+ </ListGroup>
+ {(() => {
+ const edit = props.edit;
+ if (edit != null) {
+ return (
+ <>
+ <SearchInput
+ value={userSearchText}
+ onChange={(v) => {
+ setUserSearchText(v);
+ }}
+ loading={userSearchState.type === "loading"}
+ onButtonClick={() => {
+ if (userSearchText === "") {
+ setUserSearchState({
+ type: "error",
+ data: "login.emptyUsername",
+ });
+ return;
+ }
+
+ setUserSearchState({ type: "loading" });
+ edit.onCheckUser(userSearchText).then(
+ (u) => {
+ if (u == null) {
+ setUserSearchState({
+ type: "error",
+ data: "timeline.userNotExist",
+ });
+ } else {
+ setUserSearchState({ type: "user", data: u });
+ }
+ },
+ (e) => {
+ setUserSearchState({
+ type: "error",
+ data: `${e as string}`,
+ });
+ }
+ );
+ }}
+ />
+ {(() => {
+ if (userSearchState.type === "user") {
+ const u = userSearchState.data;
+ const addable =
+ members.findIndex((m) => m.username === u.username) === -1;
+ return (
+ <>
+ {!addable ? (
+ <p>{t("timeline.member.alreadyMember")}</p>
+ ) : null}
+ <Container className="mb-3">
+ <Row>
+ <Col className="col-auto">
+ <BlobImage
+ blob={userSearchAvatar}
+ className="avatar small"
+ />
+ </Col>
+ <Col>
+ <Row>{u.nickname}</Row>
+ <Row>
+ <small>{"@" + u.username}</small>
+ </Row>
+ </Col>
+ <Button
+ variant="primary"
+ className="align-self-center"
+ disabled={!addable}
+ onClick={() => {
+ void edit.onAddUser(u).then((_) => {
+ setUserSearchText("");
+ setUserSearchState({ type: "init" });
+ });
+ }}
+ >
+ {t("timeline.member.add")}
+ </Button>
+ </Row>
+ </Container>
+ </>
+ );
+ } else if (userSearchState.type === "error") {
+ return (
+ <p className="text-danger">{t(userSearchState.data)}</p>
+ );
+ }
+ })()}
+ </>
+ );
+ } else {
+ return null;
+ }
+ })()}
+ </Container>
+ );
+};
+
+export default TimelineMember;
+
+export interface TimelineMemberDialogProps extends TimelineMemberProps {
+ open: boolean;
+ onClose: () => void;
+}
+
+export const TimelineMemberDialog: React.FC<TimelineMemberDialogProps> = (
+ props
+) => {
+ return (
+ <Modal show centered onHide={props.onClose}>
+ <TimelineMember {...props} />
+ </Modal>
+ );
+};
diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx
new file mode 100644
index 00000000..d5c91622
--- /dev/null
+++ b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx
@@ -0,0 +1,185 @@
+import React from "react";
+import { useTranslation } from "react-i18next";
+import { of } from "rxjs";
+import { catchError } from "rxjs/operators";
+
+import { UiLogicError } from "@/common";
+import { pushAlert } from "@/services/alert";
+import { useUser, userInfoService, UserNotExistError } from "@/services/user";
+import {
+ timelineService,
+ usePostList,
+ useTimelineInfo,
+} from "@/services/timeline";
+
+import { TimelineDeleteCallback } from "./Timeline";
+import { TimelineMemberDialog } from "./TimelineMember";
+import TimelinePropertyChangeDialog from "./TimelinePropertyChangeDialog";
+import { TimelinePageTemplateUIProps } from "./TimelinePageTemplateUI";
+import { TimelinePostSendCallback } from "./TimelinePostEdit";
+
+export interface TimelinePageTemplateProps<TManageItem> {
+ name: string;
+ onManage: (item: TManageItem) => void;
+ UiComponent: React.ComponentType<
+ Omit<TimelinePageTemplateUIProps<TManageItem>, "CardComponent">
+ >;
+ notFoundI18nKey: string;
+}
+
+export default function TimelinePageTemplate<TManageItem>(
+ props: TimelinePageTemplateProps<TManageItem>
+): React.ReactElement | null {
+ const { t } = useTranslation();
+
+ const { name } = props;
+
+ const service = timelineService;
+
+ const user = useUser();
+
+ const [dialog, setDialog] = React.useState<null | "property" | "member">(
+ null
+ );
+
+ const timelineState = useTimelineInfo(name);
+
+ const timeline = timelineState?.timeline;
+
+ const postListState = usePostList(name);
+
+ const error: string | undefined = (() => {
+ if (timelineState != null) {
+ const { type, timeline } = timelineState;
+ if (type === "offline" && timeline == null) return "Network Error";
+ if (type === "synced" && timeline == null)
+ return t(props.notFoundI18nKey);
+ }
+ return undefined;
+ })();
+
+ const closeDialog = React.useCallback((): void => {
+ setDialog(null);
+ }, []);
+
+ let dialogElement: React.ReactElement | undefined;
+
+ if (dialog === "property") {
+ if (timeline == null) {
+ throw new UiLogicError(
+ "Timeline is null but attempt to open change property dialog."
+ );
+ }
+
+ dialogElement = (
+ <TimelinePropertyChangeDialog
+ open
+ close={closeDialog}
+ oldInfo={{
+ visibility: timeline.visibility,
+ description: timeline.description,
+ }}
+ onProcess={(req) => {
+ return service.changeTimelineProperty(name, req).toPromise().then();
+ }}
+ />
+ );
+ } else if (dialog === "member") {
+ if (timeline == null) {
+ throw new UiLogicError(
+ "Timeline is null but attempt to open change property dialog."
+ );
+ }
+
+ dialogElement = (
+ <TimelineMemberDialog
+ open
+ onClose={closeDialog}
+ members={[timeline.owner, ...timeline.members]}
+ edit={
+ service.hasManagePermission(user, timeline)
+ ? {
+ onCheckUser: (u) => {
+ return userInfoService
+ .getUserInfo(u)
+ .pipe(
+ catchError((e) => {
+ if (e instanceof UserNotExistError) {
+ return of(null);
+ } else {
+ throw e;
+ }
+ })
+ )
+ .toPromise();
+ },
+ onAddUser: (u) => {
+ return service.addMember(name, u.username).toPromise().then();
+ },
+ onRemoveUser: (u) => {
+ service.removeMember(name, u);
+ },
+ }
+ : null
+ }
+ />
+ );
+ }
+
+ const { UiComponent } = props;
+
+ const onDelete: TimelineDeleteCallback = React.useCallback(
+ (index, id) => {
+ service.deletePost(name, id).subscribe(null, () => {
+ pushAlert({
+ type: "danger",
+ message: t("timeline.deletePostFailed"),
+ });
+ });
+ },
+ [service, name, t]
+ );
+
+ const onPost: TimelinePostSendCallback = React.useCallback(
+ (req) => {
+ return service.createPost(name, req).toPromise().then();
+ },
+ [service, name]
+ );
+
+ const onManageProp = props.onManage;
+
+ const onManage = React.useCallback(
+ (item: "property" | TManageItem) => {
+ if (item === "property") {
+ setDialog(item);
+ } else {
+ onManageProp(item);
+ }
+ },
+ [onManageProp]
+ );
+
+ return (
+ <>
+ <UiComponent
+ error={error}
+ timeline={timeline ?? undefined}
+ postListState={postListState}
+ onDelete={onDelete}
+ onPost={
+ timeline != null && service.hasPostPermission(user, timeline)
+ ? onPost
+ : undefined
+ }
+ onManage={
+ timeline != null && service.hasManagePermission(user, timeline)
+ ? onManage
+ : undefined
+ }
+ onMember={() => setDialog("member")}
+ />
+ {dialogElement}
+ </>
+ );
+}
diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx
new file mode 100644
index 00000000..6c2c43c1
--- /dev/null
+++ b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx
@@ -0,0 +1,243 @@
+import React from "react";
+import { useTranslation } from "react-i18next";
+import { fromEvent } from "rxjs";
+import { Spinner } from "react-bootstrap";
+
+import { getAlertHost } from "@/services/alert";
+import { useEventEmiiter, UiLogicError } from "@/common";
+import {
+ TimelineInfo,
+ TimelinePostsWithSyncState,
+ timelineService,
+} from "@/services/timeline";
+import { userService } from "@/services/user";
+
+import Timeline, {
+ TimelinePostInfoEx,
+ TimelineDeleteCallback,
+} from "./Timeline";
+import TimelineTop from "./TimelineTop";
+import TimelinePostEdit, { TimelinePostSendCallback } from "./TimelinePostEdit";
+import { TimelineSyncStatus } from "./SyncStatusBadge";
+
+export interface TimelineCardComponentProps<TManageItems> {
+ timeline: TimelineInfo;
+ onManage?: (item: TManageItems | "property") => void;
+ onMember: () => void;
+ className?: string;
+ collapse: boolean;
+ syncStatus: TimelineSyncStatus;
+ toggleCollapse: () => void;
+}
+
+export interface TimelinePageTemplateUIProps<TManageItems> {
+ timeline?: TimelineInfo;
+ postListState?: TimelinePostsWithSyncState;
+ CardComponent: React.ComponentType<TimelineCardComponentProps<TManageItems>>;
+ onMember: () => void;
+ onManage?: (item: TManageItems | "property") => void;
+ onPost?: TimelinePostSendCallback;
+ onDelete: TimelineDeleteCallback;
+ error?: string;
+}
+
+export default function TimelinePageTemplateUI<TManageItems>(
+ props: TimelinePageTemplateUIProps<TManageItems>
+): React.ReactElement | null {
+ const { timeline, postListState } = props;
+
+ const { t } = useTranslation();
+
+ const bottomSpaceRef = React.useRef<HTMLDivElement | null>(null);
+
+ const onPostEditHeightChange = React.useCallback((height: number): void => {
+ const { current: bottomSpaceDiv } = bottomSpaceRef;
+ if (bottomSpaceDiv != null) {
+ bottomSpaceDiv.style.height = `${height}px`;
+ }
+ if (height === 0) {
+ const alertHost = getAlertHost();
+ if (alertHost != null) {
+ alertHost.style.removeProperty("margin-bottom");
+ }
+ } else {
+ const alertHost = getAlertHost();
+ if (alertHost != null) {
+ alertHost.style.marginBottom = `${height}px`;
+ }
+ }
+ }, []);
+
+ const timelineRef = React.useRef<HTMLDivElement | null>(null);
+
+ const [getResizeEvent, triggerResizeEvent] = useEventEmiiter();
+
+ React.useEffect(() => {
+ const { current: timelineElement } = timelineRef;
+ if (timelineElement != null) {
+ let loadingScrollToBottom = true;
+ let pinBottom = false;
+
+ const isAtBottom = (): boolean =>
+ window.innerHeight + window.scrollY + 10 >= document.body.scrollHeight;
+
+ const disableLoadingScrollToBottom = (): void => {
+ loadingScrollToBottom = false;
+ if (isAtBottom()) pinBottom = true;
+ };
+
+ const checkAndScrollToBottom = (): void => {
+ if (loadingScrollToBottom || pinBottom) {
+ window.scrollTo(0, document.body.scrollHeight);
+ }
+ };
+
+ const subscriptions = [
+ fromEvent(timelineElement, "wheel").subscribe(
+ disableLoadingScrollToBottom
+ ),
+ fromEvent(timelineElement, "pointerdown").subscribe(
+ disableLoadingScrollToBottom
+ ),
+ fromEvent(timelineElement, "keydown").subscribe(
+ disableLoadingScrollToBottom
+ ),
+ fromEvent(window, "scroll").subscribe(() => {
+ if (loadingScrollToBottom) return;
+
+ if (isAtBottom()) {
+ pinBottom = true;
+ } else {
+ pinBottom = false;
+ }
+ }),
+ fromEvent(window, "resize").subscribe(checkAndScrollToBottom),
+ getResizeEvent().subscribe(checkAndScrollToBottom),
+ ];
+
+ return () => {
+ subscriptions.forEach((s) => s.unsubscribe());
+ };
+ }
+ }, [getResizeEvent, triggerResizeEvent, timeline, postListState]);
+
+ const genCardCollapseLocalStorageKey = (uniqueId: string): string =>
+ `timeline.${uniqueId}.cardCollapse`;
+
+ const cardCollapseLocalStorageKey =
+ timeline != null ? genCardCollapseLocalStorageKey(timeline.uniqueId) : null;
+
+ const [cardCollapse, setCardCollapse] = React.useState<boolean>(true);
+ React.useEffect(() => {
+ if (cardCollapseLocalStorageKey != null) {
+ const savedCollapse =
+ window.localStorage.getItem(cardCollapseLocalStorageKey) === "true";
+ setCardCollapse(savedCollapse);
+ }
+ }, [cardCollapseLocalStorageKey]);
+
+ const toggleCardCollapse = (): void => {
+ const newState = !cardCollapse;
+ setCardCollapse(newState);
+ if (timeline != null) {
+ window.localStorage.setItem(
+ genCardCollapseLocalStorageKey(timeline.uniqueId),
+ newState.toString()
+ );
+ }
+ };
+
+ let body: React.ReactElement;
+
+ if (props.error != null) {
+ body = <p className="text-danger">{t(props.error)}</p>;
+ } else {
+ if (timeline != null) {
+ let timelineBody: React.ReactElement;
+ if (postListState != null) {
+ if (postListState.type === "notexist") {
+ throw new UiLogicError(
+ "Timeline is not null but post list state is notexist."
+ );
+ }
+ if (postListState.type === "forbid") {
+ timelineBody = (
+ <p className="text-danger">{t("timeline.messageCantSee")}</p>
+ );
+ } else {
+ const posts: TimelinePostInfoEx[] = postListState.posts.map(
+ (post) => ({
+ ...post,
+ deletable: timelineService.hasModifyPostPermission(
+ userService.currentUser,
+ timeline,
+ post
+ ),
+ })
+ );
+
+ timelineBody = (
+ <Timeline
+ containerRef={timelineRef}
+ posts={posts}
+ onDelete={props.onDelete}
+ onResize={triggerResizeEvent}
+ />
+ );
+ if (props.onPost != null) {
+ timelineBody = (
+ <>
+ {timelineBody}
+ <div ref={bottomSpaceRef} className="flex-fix-length" />
+ <TimelinePostEdit
+ className="fixed-bottom"
+ onPost={props.onPost}
+ onHeightChange={onPostEditHeightChange}
+ timelineUniqueId={timeline.uniqueId}
+ />
+ </>
+ );
+ }
+ }
+ } else {
+ timelineBody = (
+ <div className="full-viewport-center-child">
+ <Spinner variant="primary" animation="grow" />
+ </div>
+ );
+ }
+
+ const { CardComponent } = props;
+ const syncStatus: TimelineSyncStatus =
+ postListState == null || postListState.syncing
+ ? "syncing"
+ : postListState.type === "synced"
+ ? "synced"
+ : "offline";
+
+ body = (
+ <>
+ <CardComponent
+ className="timeline-template-card"
+ timeline={timeline}
+ onManage={props.onManage}
+ onMember={props.onMember}
+ syncStatus={syncStatus}
+ collapse={cardCollapse}
+ toggleCollapse={toggleCardCollapse}
+ />
+ <TimelineTop height="56px" />
+ {timelineBody}
+ </>
+ );
+ } else {
+ body = (
+ <div className="full-viewport-center-child">
+ <Spinner variant="primary" animation="grow" />
+ </div>
+ );
+ }
+ }
+
+ return body;
+}
diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePostEdit.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePostEdit.tsx
new file mode 100644
index 00000000..dfa2f879
--- /dev/null
+++ b/FrontEnd/src/app/views/timeline-common/TimelinePostEdit.tsx
@@ -0,0 +1,241 @@
+import React from "react";
+import clsx from "clsx";
+import { useTranslation } from "react-i18next";
+import Svg from "react-inlinesvg";
+import { Button, Spinner, Row, Col, Form } from "react-bootstrap";
+import textIcon from "bootstrap-icons/icons/card-text.svg";
+import imageIcon from "bootstrap-icons/icons/image.svg";
+
+import { UiLogicError } from "@/common";
+
+import { pushAlert } from "@/services/alert";
+import { TimelineCreatePostRequest } from "@/services/timeline";
+
+interface TimelinePostEditImageProps {
+ onSelect: (blob: Blob | null) => void;
+}
+
+const TimelinePostEditImage: React.FC<TimelinePostEditImageProps> = (props) => {
+ const { onSelect } = props;
+ const { t } = useTranslation();
+
+ const [file, setFile] = React.useState<File | null>(null);
+ const [fileUrl, setFileUrl] = React.useState<string | null>(null);
+ const [error, setError] = React.useState<string | null>(null);
+
+ React.useEffect(() => {
+ if (file != null) {
+ const url = URL.createObjectURL(file);
+ setFileUrl(url);
+ return () => {
+ URL.revokeObjectURL(url);
+ };
+ }
+ }, [file]);
+
+ const onInputChange: React.ChangeEventHandler<HTMLInputElement> = React.useCallback(
+ (e) => {
+ const files = e.target.files;
+ if (files == null || files.length === 0) {
+ setFile(null);
+ setFileUrl(null);
+ } else {
+ setFile(files[0]);
+ }
+ onSelect(null);
+ setError(null);
+ },
+ [onSelect]
+ );
+
+ const onImgLoad = React.useCallback(() => {
+ onSelect(file);
+ }, [onSelect, file]);
+
+ const onImgError = React.useCallback(() => {
+ setError("loadImageError");
+ }, []);
+
+ return (
+ <>
+ <Form.File
+ label={t("chooseImage")}
+ onChange={onInputChange}
+ accept="image/*"
+ className="mx-3 my-1 d-inline-block"
+ />
+ {fileUrl && error == null && (
+ <img
+ src={fileUrl}
+ className="timeline-post-edit-image"
+ onLoad={onImgLoad}
+ onError={onImgError}
+ />
+ )}
+ {error != null && <div className="text-danger">{t(error)}</div>}
+ </>
+ );
+};
+
+export type TimelinePostSendCallback = (
+ content: TimelineCreatePostRequest
+) => Promise<void>;
+
+export interface TimelinePostEditProps {
+ className?: string;
+ onPost: TimelinePostSendCallback;
+ onHeightChange?: (height: number) => void;
+ timelineUniqueId: string;
+}
+
+const TimelinePostEdit: React.FC<TimelinePostEditProps> = (props) => {
+ const { onPost } = props;
+
+ const { t } = useTranslation();
+
+ const [state, setState] = React.useState<"input" | "process">("input");
+ const [kind, setKind] = React.useState<"text" | "image">("text");
+ const [text, setText] = React.useState<string>("");
+ const [imageBlob, setImageBlob] = React.useState<Blob | null>(null);
+
+ const draftLocalStorageKey = `timeline.${props.timelineUniqueId}.postDraft`;
+
+ React.useEffect(() => {
+ setText(window.localStorage.getItem(draftLocalStorageKey) ?? "");
+ }, [draftLocalStorageKey]);
+
+ const canSend = kind === "text" || (kind === "image" && imageBlob != null);
+
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ const containerRef = React.useRef<HTMLDivElement>(null!);
+
+ const notifyHeightChange = (): void => {
+ if (props.onHeightChange) {
+ props.onHeightChange(containerRef.current.clientHeight);
+ }
+ };
+
+ React.useEffect(() => {
+ if (props.onHeightChange) {
+ props.onHeightChange(containerRef.current.clientHeight);
+ }
+ return () => {
+ if (props.onHeightChange) {
+ props.onHeightChange(0);
+ }
+ };
+ });
+
+ const toggleKind = React.useCallback(() => {
+ setKind((oldKind) => (oldKind === "text" ? "image" : "text"));
+ setImageBlob(null);
+ }, []);
+
+ const onSend = React.useCallback(() => {
+ setState("process");
+
+ const req: TimelineCreatePostRequest = (() => {
+ switch (kind) {
+ case "text":
+ return {
+ content: {
+ type: "text",
+ text: text,
+ },
+ } as TimelineCreatePostRequest;
+ case "image":
+ if (imageBlob == null) {
+ throw new UiLogicError(
+ "Content type is image but image blob is null."
+ );
+ }
+ return {
+ content: {
+ type: "image",
+ data: imageBlob,
+ },
+ } as TimelineCreatePostRequest;
+ default:
+ throw new UiLogicError("Unknown content type.");
+ }
+ })();
+
+ onPost(req).then(
+ (_) => {
+ if (kind === "text") {
+ setText("");
+ window.localStorage.removeItem(draftLocalStorageKey);
+ }
+ setState("input");
+ setKind("text");
+ },
+ (_) => {
+ pushAlert({
+ type: "danger",
+ message: t("timeline.sendPostFailed"),
+ });
+ setState("input");
+ }
+ );
+ }, [onPost, kind, text, imageBlob, t, draftLocalStorageKey]);
+
+ const onImageSelect = React.useCallback((blob: Blob | null) => {
+ setImageBlob(blob);
+ }, []);
+
+ return (
+ <div
+ ref={containerRef}
+ className={clsx("container-fluid bg-light", props.className)}
+ >
+ <Row>
+ <Col className="px-1 py-1">
+ {kind === "text" ? (
+ <Form.Control
+ as="textarea"
+ className="w-100 h-100 timeline-post-edit"
+ value={text}
+ disabled={state === "process"}
+ onChange={(event: React.ChangeEvent<HTMLTextAreaElement>) => {
+ const value = event.currentTarget.value;
+ setText(value);
+ window.localStorage.setItem(draftLocalStorageKey, value);
+ }}
+ />
+ ) : (
+ <TimelinePostEditImage onSelect={onImageSelect} />
+ )}
+ </Col>
+ <Col xs="auto" className="align-self-end m-1">
+ {(() => {
+ if (state === "input") {
+ return (
+ <>
+ <div className="d-block text-center mt-1 mb-2">
+ <Svg
+ onLoad={notifyHeightChange}
+ src={kind === "text" ? imageIcon : textIcon}
+ className="icon-button"
+ onClick={toggleKind}
+ />
+ </div>
+ <Button
+ variant="primary"
+ onClick={onSend}
+ disabled={!canSend}
+ >
+ {t("timeline.send")}
+ </Button>
+ </>
+ );
+ } else {
+ return <Spinner variant="primary" animation="border" />;
+ }
+ })()}
+ </Col>
+ </Row>
+ </div>
+ );
+};
+
+export default TimelinePostEdit;
diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx
new file mode 100644
index 00000000..87638f31
--- /dev/null
+++ b/FrontEnd/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx
@@ -0,0 +1,72 @@
+import React from "react";
+
+import {
+ TimelineVisibility,
+ kTimelineVisibilities,
+ TimelineChangePropertyRequest,
+} from "@/services/timeline";
+
+import OperationDialog, {
+ OperationSelectInputInfoOption,
+} from "../common/OperationDialog";
+
+export interface TimelinePropertyInfo {
+ visibility: TimelineVisibility;
+ description: string;
+}
+
+export interface TimelinePropertyChangeDialogProps {
+ open: boolean;
+ close: () => void;
+ oldInfo: TimelinePropertyInfo;
+ onProcess: (request: TimelineChangePropertyRequest) => Promise<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
+) => {
+ return (
+ <OperationDialog
+ title={"timeline.dialogChangeProperty.title"}
+ titleColor="default"
+ inputScheme={[
+ {
+ type: "select",
+ label: "timeline.dialogChangeProperty.visibility",
+ options: kTimelineVisibilities.map<OperationSelectInputInfoOption>(
+ (v) => ({
+ label: labelMap[v],
+ value: v,
+ })
+ ),
+ initValue: props.oldInfo.visibility,
+ },
+ {
+ type: "text",
+ label: "timeline.dialogChangeProperty.description",
+ initValue: props.oldInfo.description,
+ },
+ ]}
+ open={props.open}
+ close={props.close}
+ onProcess={([newVisibility, newDescription]) => {
+ const req: TimelineChangePropertyRequest = {};
+ if (newVisibility !== props.oldInfo.visibility) {
+ req.visibility = newVisibility as TimelineVisibility;
+ }
+ if (newDescription !== props.oldInfo.description) {
+ req.description = newDescription as string;
+ }
+ return props.onProcess(req);
+ }}
+ />
+ );
+};
+
+export default TimelinePropertyChangeDialog;
diff --git a/FrontEnd/src/app/views/timeline-common/TimelineTop.tsx b/FrontEnd/src/app/views/timeline-common/TimelineTop.tsx
new file mode 100644
index 00000000..93a2a32c
--- /dev/null
+++ b/FrontEnd/src/app/views/timeline-common/TimelineTop.tsx
@@ -0,0 +1,21 @@
+import React from "react";
+
+export interface TimelineTopProps {
+ height?: number | string;
+ children?: React.ReactElement;
+}
+
+const TimelineTop: React.FC<TimelineTopProps> = ({ height, children }) => {
+ return (
+ <div style={{ height: height }} className="timeline-top">
+ <div className="timeline-line-area-container">
+ <div className="timeline-line-area">
+ <div className="timeline-line-segment"></div>
+ </div>
+ </div>
+ {children}
+ </div>
+ );
+};
+
+export default TimelineTop;
diff --git a/FrontEnd/src/app/views/timeline-common/timeline-common.sass b/FrontEnd/src/app/views/timeline-common/timeline-common.sass
new file mode 100644
index 00000000..4151bfcc
--- /dev/null
+++ b/FrontEnd/src/app/views/timeline-common/timeline-common.sass
@@ -0,0 +1,146 @@
+@use 'sass:color'
+
+.timeline
+ z-index: 0
+ position: relative
+
+ &-item
+ display: flex
+
+$timeline-line-width: 7px
+$timeline-line-node-radius: 18px
+$timeline-line-color: $primary
+$timeline-line-color-current: #36c2e6
+
+@keyframes timeline-line-node-noncurrent
+ from
+ background: $timeline-line-color
+
+ to
+ background: color.adjust($timeline-line-color, $lightness: +10%)
+ box-shadow: 0 0 20px 3px color.adjust($timeline-line-color, $lightness: +10%, $alpha: -0.1)
+
+@keyframes timeline-line-node-current
+ from
+ background: $timeline-line-color-current
+
+ to
+ background: color.adjust($timeline-line-color-current, $lightness: +10%)
+ box-shadow: 0 0 20px 3px color.adjust($timeline-line-color-current, $lightness: +10%, $alpha: -0.1)
+
+.timeline-line
+ &-area-container
+ display: flex
+ justify-content: flex-end
+ padding-right: 5px
+
+ flex: 0 0 auto
+ width: 60px
+
+ &-area
+ display: flex
+ flex-direction: column
+ align-items: center
+ width: 30px
+
+ &-segment
+ width: $timeline-line-width
+ background: $timeline-line-color
+
+ &.start
+ height: 14px
+ flex: 0 0 auto
+
+ &.end
+ flex: 1 1 auto
+
+ &.current-end
+ height: 20px
+ flex: 0 0 auto
+ background: linear-gradient($timeline-line-color-current, transparent)
+
+ &-node-container
+ flex: 0 0 auto
+ position: relative
+ width: $timeline-line-node-radius
+ height: $timeline-line-node-radius
+
+ &-node
+ width: $timeline-line-node-radius + 2
+ height: $timeline-line-node-radius + 2
+ position: absolute
+ left: -1px
+ top: -1px
+ border-radius: 50%
+ box-sizing: border-box
+ z-index: 1
+ animation: 1s infinite alternate
+ animation-name: timeline-line-node-noncurrent
+
+.timeline-top
+ display: flex
+ justify-content: space-between
+
+ .timeline-line-segment
+ flex: 1 1 auto
+
+.current
+ .timeline-line
+ &-segment
+
+ &.start
+ background: linear-gradient($timeline-line-color, $timeline-line-color-current)
+
+ &.end
+ background: $timeline-line-color-current
+
+ &-node
+ animation-name: timeline-line-node-current
+
+.timeline-content-area
+ padding: 10px 0
+ flex-grow: 1
+
+.timeline-item-delete-button
+ position: absolute
+ right: 0
+ bottom: 0
+
+.timeline-content
+ white-space: pre-line
+
+.timeline-content-image
+ max-width: 60%
+ max-height: 200px
+
+.timeline-post-edit-image
+ max-width: 100px
+ max-height: 100px
+
+.mask
+ background: change-color($color: white, $alpha: 0.8)
+ z-index: 100
+
+.timeline-page-top-space
+ transition: height 0.5s
+
+.timeline-sync-state-badge
+ font-size: 0.8em
+ padding: 3px 8px
+ 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-template-card
+ position: fixed
+ z-index: 1
+ top: 56px
+ right: 0
+ margin: 0.5em
diff --git a/FrontEnd/src/app/views/timeline/TimelineDeleteDialog.tsx b/FrontEnd/src/app/views/timeline/TimelineDeleteDialog.tsx
new file mode 100644
index 00000000..894b8195
--- /dev/null
+++ b/FrontEnd/src/app/views/timeline/TimelineDeleteDialog.tsx
@@ -0,0 +1,55 @@
+import React from "react";
+import { useHistory } from "react-router";
+import { Trans } from "react-i18next";
+
+import { timelineService } from "@/services/timeline";
+
+import OperationDialog from "../common/OperationDialog";
+
+interface TimelineDeleteDialog {
+ open: boolean;
+ name: string;
+ close: () => void;
+}
+
+const TimelineDeleteDialog: React.FC<TimelineDeleteDialog> = (props) => {
+ const history = useHistory();
+
+ const { name } = props;
+
+ return (
+ <OperationDialog
+ open={props.open}
+ close={props.close}
+ title="timeline.deleteDialog.title"
+ titleColor="danger"
+ inputPrompt={() => {
+ return (
+ <Trans i18nKey="timeline.deleteDialog.inputPrompt">
+ 0<code className="mx-2">{{ name }}</code>2
+ </Trans>
+ );
+ }}
+ inputScheme={[
+ {
+ type: "text",
+ validator: (value) => {
+ if (value !== name) {
+ return "timeline.deleteDialog.notMatch";
+ } else {
+ return null;
+ }
+ },
+ },
+ ]}
+ onProcess={() => {
+ return timelineService.deleteTimeline(name).toPromise();
+ }}
+ onSuccessAndClose={() => {
+ history.replace("/");
+ }}
+ />
+ );
+};
+
+export default TimelineDeleteDialog;
diff --git a/FrontEnd/src/app/views/timeline/TimelineInfoCard.tsx b/FrontEnd/src/app/views/timeline/TimelineInfoCard.tsx
new file mode 100644
index 00000000..2d787709
--- /dev/null
+++ b/FrontEnd/src/app/views/timeline/TimelineInfoCard.tsx
@@ -0,0 +1,85 @@
+import React from "react";
+import { useTranslation } from "react-i18next";
+import { Dropdown, Button } from "react-bootstrap";
+
+import { useAvatar } from "@/services/user";
+import { timelineVisibilityTooltipTranslationMap } from "@/services/timeline";
+
+import BlobImage from "../common/BlobImage";
+import { TimelineCardComponentProps } from "../timeline-common/TimelinePageTemplateUI";
+import InfoCardTemplate from "../timeline-common/InfoCardTemplate";
+
+export type OrdinaryTimelineManageItem = "delete";
+
+export type TimelineInfoCardProps = TimelineCardComponentProps<
+ OrdinaryTimelineManageItem
+>;
+
+const TimelineInfoCard: React.FC<TimelineInfoCardProps> = (props) => {
+ const {
+ timeline,
+ collapse,
+ onMember,
+ onManage,
+ syncStatus,
+ toggleCollapse,
+ } = props;
+
+ const { t } = useTranslation();
+
+ const avatar = useAvatar(timeline?.owner?.username);
+
+ return (
+ <InfoCardTemplate
+ className={props.className}
+ syncStatus={syncStatus}
+ collapse={collapse}
+ toggleCollapse={toggleCollapse}
+ >
+ <h3 className="text-primary mx-3 d-inline-block align-middle">
+ {timeline.name}
+ </h3>
+ <div className="d-inline-block align-middle">
+ <BlobImage blob={avatar} className="avatar small rounded-circle" />
+ {timeline.owner.nickname}
+ <small className="ml-3 text-secondary">
+ @{timeline.owner.username}
+ </small>
+ </div>
+ <p className="mb-0">{timeline.description}</p>
+ <small className="mt-1 d-block">
+ {t(timelineVisibilityTooltipTranslationMap[timeline.visibility])}
+ </small>
+ <div className="text-right mt-2">
+ {onManage != null ? (
+ <Dropdown>
+ <Dropdown.Toggle variant="outline-primary">
+ {t("timeline.manage")}
+ </Dropdown.Toggle>
+ <Dropdown.Menu>
+ <Dropdown.Item onClick={() => onManage("property")}>
+ {t("timeline.manageItem.property")}
+ </Dropdown.Item>
+ <Dropdown.Item onClick={onMember}>
+ {t("timeline.manageItem.member")}
+ </Dropdown.Item>
+ <Dropdown.Divider />
+ <Dropdown.Item
+ className="text-danger"
+ onClick={() => onManage("delete")}
+ >
+ {t("timeline.manageItem.delete")}
+ </Dropdown.Item>
+ </Dropdown.Menu>
+ </Dropdown>
+ ) : (
+ <Button variant="outline-primary" onClick={onMember}>
+ {t("timeline.memberButton")}
+ </Button>
+ )}
+ </div>
+ </InfoCardTemplate>
+ );
+};
+
+export default TimelineInfoCard;
diff --git a/FrontEnd/src/app/views/timeline/TimelinePageUI.tsx b/FrontEnd/src/app/views/timeline/TimelinePageUI.tsx
new file mode 100644
index 00000000..67ea699e
--- /dev/null
+++ b/FrontEnd/src/app/views/timeline/TimelinePageUI.tsx
@@ -0,0 +1,20 @@
+import React from "react";
+
+import TimelinePageTemplateUI, {
+ TimelinePageTemplateUIProps,
+} from "../timeline-common/TimelinePageTemplateUI";
+
+import TimelineInfoCard, {
+ OrdinaryTimelineManageItem,
+} from "./TimelineInfoCard";
+
+export type TimelinePageUIProps = Omit<
+ TimelinePageTemplateUIProps<OrdinaryTimelineManageItem>,
+ "CardComponent"
+>;
+
+const TimelinePageUI: React.FC<TimelinePageUIProps> = (props) => {
+ return <TimelinePageTemplateUI {...props} CardComponent={TimelineInfoCard} />;
+};
+
+export default TimelinePageUI;
diff --git a/FrontEnd/src/app/views/timeline/index.tsx b/FrontEnd/src/app/views/timeline/index.tsx
new file mode 100644
index 00000000..225a1a59
--- /dev/null
+++ b/FrontEnd/src/app/views/timeline/index.tsx
@@ -0,0 +1,37 @@
+import React from "react";
+import { useParams } from "react-router";
+
+import TimelinePageTemplate from "../timeline-common/TimelinePageTemplate";
+
+import TimelinePageUI from "./TimelinePageUI";
+import { OrdinaryTimelineManageItem } from "./TimelineInfoCard";
+import TimelineDeleteDialog from "./TimelineDeleteDialog";
+
+const TimelinePage: React.FC = (_) => {
+ const { name } = useParams<{ name: string }>();
+
+ const [dialog, setDialog] = React.useState<OrdinaryTimelineManageItem | null>(
+ null
+ );
+
+ let dialogElement: React.ReactElement | undefined;
+ if (dialog === "delete") {
+ dialogElement = (
+ <TimelineDeleteDialog open close={() => setDialog(null)} name={name} />
+ );
+ }
+
+ return (
+ <>
+ <TimelinePageTemplate
+ name={name}
+ UiComponent={TimelinePageUI}
+ onManage={(item) => setDialog(item)}
+ notFoundI18nKey="timeline.timelineNotExist"
+ />
+ {dialogElement}
+ </>
+ );
+};
+
+export default TimelinePage;
diff --git a/FrontEnd/src/app/views/timeline/timeline.sass b/FrontEnd/src/app/views/timeline/timeline.sass
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/FrontEnd/src/app/views/timeline/timeline.sass
diff --git a/FrontEnd/src/app/views/user/ChangeAvatarDialog.tsx b/FrontEnd/src/app/views/user/ChangeAvatarDialog.tsx
new file mode 100644
index 00000000..ffa2218b
--- /dev/null
+++ b/FrontEnd/src/app/views/user/ChangeAvatarDialog.tsx
@@ -0,0 +1,302 @@
+import React, { useState, useEffect } from "react";
+import { useTranslation } from "react-i18next";
+import { AxiosError } from "axios";
+import { Modal, Row, Button } from "react-bootstrap";
+
+import { UiLogicError } from "@/common";
+
+import ImageCropper, { Clip, applyClipToImage } from "../common/ImageCropper";
+
+export interface ChangeAvatarDialogProps {
+ open: boolean;
+ close: () => void;
+ process: (blob: Blob) => Promise<void>;
+}
+
+const ChangeAvatarDialog: React.FC<ChangeAvatarDialogProps> = (props) => {
+ const { t } = useTranslation();
+
+ 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<
+ string | { type: "custom"; text: string } | null
+ >("userPage.dialogChangeAvatar.prompt.select");
+
+ const trueMessage =
+ message == null
+ ? null
+ : typeof message === "string"
+ ? t(message)
+ : message.text;
+
+ 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 process = props.process;
+
+ const upload = React.useCallback(() => {
+ if (resultBlob == null) {
+ throw new UiLogicError();
+ }
+
+ setState("uploading");
+ process(resultBlob).then(
+ () => {
+ setState("success");
+ },
+ (e: unknown) => {
+ setState("error");
+ setMessage({ type: "custom", text: (e as AxiosError).message });
+ }
+ );
+ }, [resultBlob, process]);
+
+ const createPreviewRow = (): React.ReactElement => {
+ if (resultUrl == null) {
+ throw new UiLogicError();
+ }
+ return (
+ <Row className="justify-content-center">
+ <img
+ className="change-avatar-img"
+ src={resultUrl}
+ alt={t("userPage.dialogChangeAvatar.previewImgAlt")}
+ />
+ </Row>
+ );
+ };
+
+ return (
+ <Modal show={props.open} onHide={close}>
+ <Modal.Header>
+ <Modal.Title> {t("userPage.dialogChangeAvatar.title")}</Modal.Title>
+ </Modal.Header>
+ {(() => {
+ if (state === "select") {
+ return (
+ <>
+ <Modal.Body className="container">
+ <Row>{t("userPage.dialogChangeAvatar.prompt.select")}</Row>
+ <Row>
+ <input type="file" accept="image/*" onChange={onSelectFile} />
+ </Row>
+ </Modal.Body>
+ <Modal.Footer>
+ <Button variant="secondary" onClick={close}>
+ {t("operationDialog.cancel")}
+ </Button>
+ </Modal.Footer>
+ </>
+ );
+ } else if (state === "crop") {
+ if (fileUrl == null) {
+ throw new UiLogicError();
+ }
+ return (
+ <>
+ <Modal.Body className="container">
+ <Row className="justify-content-center">
+ <ImageCropper
+ clip={clip}
+ onChange={setClip}
+ imageUrl={fileUrl}
+ imageElementCallback={setCropImgElement}
+ />
+ </Row>
+ <Row>{t("userPage.dialogChangeAvatar.prompt.crop")}</Row>
+ </Modal.Body>
+ <Modal.Footer>
+ <Button variant="secondary" onClick={close}>
+ {t("operationDialog.cancel")}
+ </Button>
+ <Button variant="secondary" onClick={onCropPrevious}>
+ {t("operationDialog.previousStep")}
+ </Button>
+ <Button
+ color="primary"
+ onClick={onCropNext}
+ disabled={
+ cropImgElement == null || clip == null || clip.width === 0
+ }
+ >
+ {t("operationDialog.nextStep")}
+ </Button>
+ </Modal.Footer>
+ </>
+ );
+ } else if (state === "processcrop") {
+ return (
+ <>
+ <Modal.Body className="container">
+ <Row>
+ {t("userPage.dialogChangeAvatar.prompt.processingCrop")}
+ </Row>
+ </Modal.Body>
+ <Modal.Footer>
+ <Button variant="secondary" onClick={close}>
+ {t("operationDialog.cancel")}
+ </Button>
+ <Button variant="secondary" onClick={onPreviewPrevious}>
+ {t("operationDialog.previousStep")}
+ </Button>
+ </Modal.Footer>
+ </>
+ );
+ } else if (state === "preview") {
+ return (
+ <>
+ <Modal.Body className="container">
+ {createPreviewRow()}
+ <Row>{t("userPage.dialogChangeAvatar.prompt.preview")}</Row>
+ </Modal.Body>
+ <Modal.Footer>
+ <Button variant="secondary" onClick={close}>
+ {t("operationDialog.cancel")}
+ </Button>
+ <Button variant="secondary" onClick={onPreviewPrevious}>
+ {t("operationDialog.previousStep")}
+ </Button>
+ <Button variant="primary" onClick={upload}>
+ {t("userPage.dialogChangeAvatar.upload")}
+ </Button>
+ </Modal.Footer>
+ </>
+ );
+ } else if (state === "uploading") {
+ return (
+ <>
+ <Modal.Body className="container">
+ {createPreviewRow()}
+ <Row>{t("userPage.dialogChangeAvatar.prompt.uploading")}</Row>
+ </Modal.Body>
+ <Modal.Footer></Modal.Footer>
+ </>
+ );
+ } else if (state === "success") {
+ return (
+ <>
+ <Modal.Body className="container">
+ <Row className="p-4 text-success">
+ {t("operationDialog.success")}
+ </Row>
+ </Modal.Body>
+ <Modal.Footer>
+ <Button variant="success" onClick={close}>
+ {t("operationDialog.ok")}
+ </Button>
+ </Modal.Footer>
+ </>
+ );
+ } else {
+ return (
+ <>
+ <Modal.Body className="container">
+ {createPreviewRow()}
+ <Row className="text-danger">{trueMessage}</Row>
+ </Modal.Body>
+ <Modal.Footer>
+ <Button variant="secondary" onClick={close}>
+ {t("operationDialog.cancel")}
+ </Button>
+ <Button variant="primary" onClick={upload}>
+ {t("operationDialog.retry")}
+ </Button>
+ </Modal.Footer>
+ </>
+ );
+ }
+ })()}
+ </Modal>
+ );
+};
+
+export default ChangeAvatarDialog;
diff --git a/FrontEnd/src/app/views/user/ChangeNicknameDialog.tsx b/FrontEnd/src/app/views/user/ChangeNicknameDialog.tsx
new file mode 100644
index 00000000..251b18c5
--- /dev/null
+++ b/FrontEnd/src/app/views/user/ChangeNicknameDialog.tsx
@@ -0,0 +1,28 @@
+import React from "react";
+
+import OperationDialog from "../common/OperationDialog";
+
+export interface ChangeNicknameDialogProps {
+ open: boolean;
+ close: () => void;
+ onProcess: (newNickname: string) => Promise<void>;
+}
+
+const ChangeNicknameDialog: React.FC<ChangeNicknameDialogProps> = (props) => {
+ return (
+ <OperationDialog
+ open={props.open}
+ title="userPage.dialogChangeNickname.title"
+ titleColor="default"
+ inputScheme={[
+ { type: "text", label: "userPage.dialogChangeNickname.inputLabel" },
+ ]}
+ onProcess={([newNickname]) => {
+ return props.onProcess(newNickname as string);
+ }}
+ close={props.close}
+ />
+ );
+};
+
+export default ChangeNicknameDialog;
diff --git a/FrontEnd/src/app/views/user/UserInfoCard.tsx b/FrontEnd/src/app/views/user/UserInfoCard.tsx
new file mode 100644
index 00000000..888fb18a
--- /dev/null
+++ b/FrontEnd/src/app/views/user/UserInfoCard.tsx
@@ -0,0 +1,80 @@
+import React from "react";
+import { useTranslation } from "react-i18next";
+import { Dropdown, Button } from "react-bootstrap";
+
+import { timelineVisibilityTooltipTranslationMap } from "@/services/timeline";
+import { useAvatar } from "@/services/user";
+
+import BlobImage from "../common/BlobImage";
+import { TimelineCardComponentProps } from "../timeline-common/TimelinePageTemplateUI";
+import InfoCardTemplate from "../timeline-common/InfoCardTemplate";
+
+export type PersonalTimelineManageItem = "avatar" | "nickname";
+
+export type UserInfoCardProps = TimelineCardComponentProps<
+ PersonalTimelineManageItem
+>;
+
+const UserInfoCard: React.FC<UserInfoCardProps> = (props) => {
+ const {
+ timeline,
+ collapse,
+ onMember,
+ onManage,
+ syncStatus,
+ toggleCollapse,
+ } = props;
+ const { t } = useTranslation();
+
+ const avatar = useAvatar(timeline?.owner?.username);
+
+ return (
+ <InfoCardTemplate
+ className={props.className}
+ syncStatus={syncStatus}
+ collapse={collapse}
+ toggleCollapse={toggleCollapse}
+ >
+ <div>
+ <BlobImage blob={avatar} className="avatar" />
+ {timeline.owner.nickname}
+ <small className="ml-3 text-secondary">
+ @{timeline.owner.username}
+ </small>
+ </div>
+ <p className="mb-0">{timeline.description}</p>
+ <small className="mt-1 d-block">
+ {t(timelineVisibilityTooltipTranslationMap[timeline.visibility])}
+ </small>
+ <div className="text-right mt-2">
+ {onManage != null ? (
+ <Dropdown>
+ <Dropdown.Toggle variant="outline-primary">
+ {t("timeline.manage")}
+ </Dropdown.Toggle>
+ <Dropdown.Menu>
+ <Dropdown.Item onClick={() => onManage("nickname")}>
+ {t("timeline.manageItem.nickname")}
+ </Dropdown.Item>
+ <Dropdown.Item onClick={() => onManage("avatar")}>
+ {t("timeline.manageItem.avatar")}
+ </Dropdown.Item>
+ <Dropdown.Item onClick={() => onManage("property")}>
+ {t("timeline.manageItem.property")}
+ </Dropdown.Item>
+ <Dropdown.Item onClick={onMember}>
+ {t("timeline.manageItem.member")}
+ </Dropdown.Item>
+ </Dropdown.Menu>
+ </Dropdown>
+ ) : (
+ <Button variant="outline-primary" onClick={onMember}>
+ {t("timeline.memberButton")}
+ </Button>
+ )}
+ </div>
+ </InfoCardTemplate>
+ );
+};
+
+export default UserInfoCard;
diff --git a/FrontEnd/src/app/views/user/UserPageUI.tsx b/FrontEnd/src/app/views/user/UserPageUI.tsx
new file mode 100644
index 00000000..d405399c
--- /dev/null
+++ b/FrontEnd/src/app/views/user/UserPageUI.tsx
@@ -0,0 +1,18 @@
+import React from "react";
+
+import TimelinePageTemplateUI, {
+ TimelinePageTemplateUIProps,
+} from "../timeline-common/TimelinePageTemplateUI";
+
+import UserInfoCard, { PersonalTimelineManageItem } from "./UserInfoCard";
+
+export type UserPageUIProps = Omit<
+ TimelinePageTemplateUIProps<PersonalTimelineManageItem>,
+ "CardComponent"
+>;
+
+const UserPageUI: React.FC<UserPageUIProps> = (props) => {
+ return <TimelinePageTemplateUI {...props} CardComponent={UserInfoCard} />;
+};
+
+export default UserPageUI;
diff --git a/FrontEnd/src/app/views/user/index.tsx b/FrontEnd/src/app/views/user/index.tsx
new file mode 100644
index 00000000..7c0b1563
--- /dev/null
+++ b/FrontEnd/src/app/views/user/index.tsx
@@ -0,0 +1,72 @@
+import React, { useState } from "react";
+import { useParams } from "react-router";
+
+import { UiLogicError } from "@/common";
+import { useUser, userInfoService } from "@/services/user";
+
+import TimelinePageTemplate from "../timeline-common/TimelinePageTemplate";
+
+import UserPageUI from "./UserPageUI";
+import { PersonalTimelineManageItem } from "./UserInfoCard";
+import ChangeNicknameDialog from "./ChangeNicknameDialog";
+import ChangeAvatarDialog from "./ChangeAvatarDialog";
+
+const UserPage: React.FC = (_) => {
+ const { username } = useParams<{ username: string }>();
+
+ const user = useUser();
+
+ const [dialog, setDialog] = useState<null | PersonalTimelineManageItem>(null);
+
+ let dialogElement: React.ReactElement | undefined;
+
+ const closeDialogHandler = (): void => {
+ setDialog(null);
+ };
+
+ if (dialog === "nickname") {
+ if (user == null) {
+ throw new UiLogicError("Change nickname without login.");
+ }
+
+ dialogElement = (
+ <ChangeNicknameDialog
+ open
+ close={closeDialogHandler}
+ onProcess={(newNickname) =>
+ userInfoService.setNickname(username, newNickname)
+ }
+ />
+ );
+ } else if (dialog === "avatar") {
+ if (user == null) {
+ throw new UiLogicError("Change avatar without login.");
+ }
+
+ dialogElement = (
+ <ChangeAvatarDialog
+ open
+ close={closeDialogHandler}
+ process={(file) => userInfoService.setAvatar(username, file)}
+ />
+ );
+ }
+
+ const onManage = React.useCallback((item: PersonalTimelineManageItem) => {
+ setDialog(item);
+ }, []);
+
+ return (
+ <>
+ <TimelinePageTemplate
+ name={`@${username}`}
+ UiComponent={UserPageUI}
+ onManage={onManage}
+ notFoundI18nKey="timeline.userNotExist"
+ />
+ {dialogElement}
+ </>
+ );
+};
+
+export default UserPage;
diff --git a/FrontEnd/src/app/views/user/user.sass b/FrontEnd/src/app/views/user/user.sass
new file mode 100644
index 00000000..63a28e05
--- /dev/null
+++ b/FrontEnd/src/app/views/user/user.sass
@@ -0,0 +1,7 @@
+.change-avatar-cropper-row
+ max-height: 400px
+
+.change-avatar-img
+ min-width: 50%
+ max-width: 100%
+ max-height: 400px