diff options
author | crupest <crupest@outlook.com> | 2020-06-11 17:27:15 +0800 |
---|---|---|
committer | crupest <crupest@outlook.com> | 2020-06-11 17:27:15 +0800 |
commit | 93ce8560fa19c3a91de99643fdbbe4f895a47b84 (patch) | |
tree | 66a9e6f1bbbbd5a0a25c13a0e51e7a7c1225871c /Timeline/ClientApp/src/app | |
parent | 6893a1c1e43b8fc17eaaba72db90494d946b5091 (diff) | |
download | timeline-93ce8560fa19c3a91de99643fdbbe4f895a47b84.tar.gz timeline-93ce8560fa19c3a91de99643fdbbe4f895a47b84.tar.bz2 timeline-93ce8560fa19c3a91de99643fdbbe4f895a47b84.zip |
feat(front): Service worker is coming!
Diffstat (limited to 'Timeline/ClientApp/src/app')
63 files changed, 6455 insertions, 0 deletions
diff --git a/Timeline/ClientApp/src/app/App.tsx b/Timeline/ClientApp/src/app/App.tsx new file mode 100644 index 00000000..befa2a9c --- /dev/null +++ b/Timeline/ClientApp/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 './common/AppBar'; +import LoadingPage from './common/LoadingPage'; +import Home from './home/Home'; +import Login from './user/Login'; +import Settings from './settings/Settings'; +import About from './about/About'; +import User from './user/User'; +import TimelinePage from './timeline/TimelinePage'; +import AlertHost from './common/AlertHost'; + +import { checkUserLoginState, useOptionalUser } from './data/user'; + +const NoMatch: React.FC = () => { + return ( + <> + <AppBar /> + <div style={{ height: 56 }} /> + <div>Ah-oh, 404!</div> + </> + ); +}; + +const LazyAdmin = React.lazy(() => + import(/* webpackChunkName: "admin" */ './admin/Admin') +); + +const App: React.FC = () => { + const user = useOptionalUser(); + + React.useEffect(() => { + void checkUserLoginState(); + }, []); + + let body; + if (user === undefined) { + body = <LoadingPage />; + } else { + body = ( + <React.Suspense fallback={<LoadingPage />}> + <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> + </React.Suspense> + ); + } + + return ( + <> + <Router>{body}</Router> + <AlertHost /> + </> + ); +}; + +export default hot(App); diff --git a/Timeline/ClientApp/src/app/about/About.tsx b/Timeline/ClientApp/src/app/about/About.tsx new file mode 100644 index 00000000..bce4c06e --- /dev/null +++ b/Timeline/ClientApp/src/app/about/About.tsx @@ -0,0 +1,180 @@ +import React from 'react'; +import { useTranslation, Trans } from 'react-i18next'; + +import authorAvatarUrl from './author-avatar.png'; + +import AppBar from '../common/AppBar'; + +const frontendCredits: { + name: string; + url: string; + icon?: string; +}[] = [ + { + name: 'reactjs', + url: 'https://reactjs.org', + icon: 'react', + }, + { + name: 'typescript', + url: 'https://www.typescriptlang.org', + }, + { + name: 'bootstrap', + url: 'https://getbootstrap.com', + icon: 'bootstrap', + }, + { + name: 'reactstrap', + url: 'https://reactstrap.github.io', + }, + { + name: 'babeljs', + url: 'https://babeljs.io', + }, + { + name: 'webpack', + url: 'https://webpack.js.org', + }, + { + name: 'sass', + url: 'https://sass-lang.com', + icon: 'sass', + }, + { + name: 'fontawesome', + url: 'https://fontawesome.com', + icon: 'font-awesome-flag', + }, + { + name: 'eslint', + url: 'https://eslint.org', + }, + { + name: 'prettier', + url: 'https://prettier.io', + }, + { + name: 'pepjs', + url: 'https://github.com/jquery/PEP', + }, +]; + +const backendCredits: { + name: string; + url: string; + icon?: 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 About: React.FC = () => { + const { t } = useTranslation(); + + return ( + <> + <AppBar /> + <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" + > + <i className="fab fa-github 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.icon != null && ( + <i className={'fab fa-' + item.icon + ' mx-2'} /> + )} + {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.icon != null && ( + <i className={'fab fa-' + item.icon + ' mx-2'} /> + )} + {item.name} + </a> + </li> + ); + })} + <li>...</li> + </ul> + </div> + </div> + </> + ); +}; + +export default About; diff --git a/Timeline/ClientApp/src/app/about/about.sass b/Timeline/ClientApp/src/app/about/about.sass new file mode 100644 index 00000000..0b0f89f5 --- /dev/null +++ b/Timeline/ClientApp/src/app/about/about.sass @@ -0,0 +1,3 @@ +.about-link-icon + @extend .mx-2 + font-size: 1.2em diff --git a/Timeline/ClientApp/src/app/about/author-avatar.png b/Timeline/ClientApp/src/app/about/author-avatar.png Binary files differnew file mode 100644 index 00000000..d890d8d0 --- /dev/null +++ b/Timeline/ClientApp/src/app/about/author-avatar.png diff --git a/Timeline/ClientApp/src/app/admin/Admin.tsx b/Timeline/ClientApp/src/app/admin/Admin.tsx new file mode 100644 index 00000000..9e7d592f --- /dev/null +++ b/Timeline/ClientApp/src/app/admin/Admin.tsx @@ -0,0 +1,78 @@ +import React, { Fragment } from 'react'; +import { Nav, NavItem, NavLink } from 'reactstrap'; +import { + Redirect, + Route, + Switch, + useRouteMatch, + useHistory +} from 'react-router'; +import classnames from 'classnames'; + +import AppBar from '../common/AppBar'; +import UserAdmin from './UserAdmin'; + +import { UserWithToken } from '../data/user'; + +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}`}> + <AppBar /> + <div style={{ height: 56 }} className="flex-fix-length" /> + <Nav tabs> + <NavItem> + <NavLink + className={classnames({ active: tabName === 'users' })} + onClick={() => { + toggle('users'); + }} + > + Users + </NavLink> + </NavItem> + <NavItem> + <NavLink + className={classnames({ active: tabName === 'more' })} + onClick={() => { + toggle('more'); + }} + > + More + </NavLink> + </NavItem> + </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/Timeline/ClientApp/src/app/admin/UserAdmin.tsx b/Timeline/ClientApp/src/app/admin/UserAdmin.tsx new file mode 100644 index 00000000..ba14fc84 --- /dev/null +++ b/Timeline/ClientApp/src/app/admin/UserAdmin.tsx @@ -0,0 +1,463 @@ +import React, { useState, useEffect } from 'react'; +import { + ListGroupItem, + Row, + Col, + UncontrolledDropdown, + DropdownToggle, + DropdownMenu, + DropdownItem, + Spinner, + Button, +} from 'reactstrap'; +import axios from 'axios'; + +import OperationDialog from '../common/OperationDialog'; + +import { User, UserWithToken } from '../data/user'; +import { apiBaseUrl } from '../config'; + +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 ( + <ListGroupItem 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"> + <UncontrolledDropdown> + <DropdownToggle color="warning" className="text-light" caret> + Manage + </DropdownToggle> + <DropdownMenu> + <DropdownItem onClick={createClickCallback(kChangeUsername)}> + Change Username + </DropdownItem> + <DropdownItem onClick={createClickCallback(kChangePassword)}> + Change Password + </DropdownItem> + <DropdownItem onClick={createClickCallback(kChangePermission)}> + Change Permission + </DropdownItem> + <DropdownItem + className="text-danger" + onClick={createClickCallback(kDelete)} + > + Delete + </DropdownItem> + </DropdownMenu> + </UncontrolledDropdown> + </Col> + </Row> + </ListGroupItem> + ); +}; + +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 + color="success" + onClick={() => + setDialog({ + type: 'create', + }) + } + className="align-self-end" + > + Create User + </Button> + {userComponents} + {dialogNode} + </> + ); + } else { + return <Spinner />; + } +}; + +export default UserAdmin; diff --git a/Timeline/ClientApp/src/app/common.ts b/Timeline/ClientApp/src/app/common.ts new file mode 100644 index 00000000..0b11791a --- /dev/null +++ b/Timeline/ClientApp/src/app/common.ts @@ -0,0 +1,4 @@ +// This error is thrown when ui goes wrong with bad logic. +// Such as am 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 {} diff --git a/Timeline/ClientApp/src/app/common/AlertHost.tsx b/Timeline/ClientApp/src/app/common/AlertHost.tsx new file mode 100644 index 00000000..c815db2b --- /dev/null +++ b/Timeline/ClientApp/src/app/common/AlertHost.tsx @@ -0,0 +1,75 @@ +import React, { useCallback } from 'react'; +import { Alert } from 'reactstrap'; +import without from 'lodash/without'; +import concat from 'lodash/concat'; + +import { + alertService, + AlertInfoEx, + kAlertHostId, + AlertInfo, +} from './alert-service'; + +interface AutoCloseAlertProps { + alert: AlertInfo; + close: () => void; +} + +export const AutoCloseAlert: React.FC<AutoCloseAlertProps> = (props) => { + const { alert } = props; + + React.useEffect(() => { + const tag = window.setTimeout(props.close, 5000); + return () => window.clearTimeout(tag); + }, [props.close]); + + return ( + <Alert className="m-3" color={alert.type ?? 'primary'} toggle={props.close}> + {alert.message} + </Alert> + ); +}; + +// oh what a bad name! +interface AlertInfoExEx extends AlertInfoEx { + close: () => void; +} + +export 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/Timeline/ClientApp/src/app/common/AppBar.tsx b/Timeline/ClientApp/src/app/common/AppBar.tsx new file mode 100644 index 00000000..f75fe08f --- /dev/null +++ b/Timeline/ClientApp/src/app/common/AppBar.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import { useHistory, matchPath } from 'react-router'; +import { Link, NavLink } from 'react-router-dom'; +import { Navbar, NavbarToggler, Collapse, Nav, NavItem } from 'reactstrap'; +import { useMediaQuery } from 'react-responsive'; +import { useTranslation } from 'react-i18next'; + +import { useUser } from '../data/user'; +import { useOptionalVersionedAvatarUrl } from '../user/api'; + +import TimelineLogo from './TimelineLogo'; + +const AppBar: React.FC = (_) => { + const history = useHistory(); + const user = useUser(); + const avatarUrl = useOptionalVersionedAvatarUrl(user?._links?.avatar); + + const { t } = useTranslation(); + + const isUpMd = useMediaQuery({ + minWidth: getComputedStyle(document.documentElement).getPropertyValue( + '--breakpoint-md' + ), + }); + + const [isMenuOpen, setIsMenuOpen] = React.useState(false); + + const toggleMenu = React.useCallback((): void => { + setIsMenuOpen((oldIsMenuOpen) => !oldIsMenuOpen); + }, []); + + const isAdministrator = user && user.administrator; + + const rightArea = ( + <div className="ml-auto mr-2"> + {user != null ? ( + <NavLink to={`/users/${user.username}`}> + <img + className="avatar small rounded-circle bg-white" + src={avatarUrl} + /> + </NavLink> + ) : ( + <NavLink className="text-light" to="/login"> + {t('nav.login')} + </NavLink> + )} + </div> + ); + + return ( + <Navbar dark className="fixed-top w-100 bg-primary app-bar" expand="md"> + <Link to="/" className="navbar-brand d-flex align-items-center"> + <TimelineLogo style={{ height: '1em' }} /> + Timeline + </Link> + + {isUpMd ? null : rightArea} + + <NavbarToggler onClick={toggleMenu} /> + <Collapse isOpen={isMenuOpen} navbar> + <Nav className="mr-auto" navbar> + <NavItem + className={ + matchPath(history.location.pathname, '/settings') + ? 'active' + : undefined + } + > + <NavLink className="nav-link" to="/settings"> + {t('nav.settings')} + </NavLink> + </NavItem> + + <NavItem + className={ + matchPath(history.location.pathname, '/about') + ? 'active' + : undefined + } + > + <NavLink className="nav-link" to="/about"> + {t('nav.about')} + </NavLink> + </NavItem> + + {isAdministrator && ( + <NavItem + className={ + matchPath(history.location.pathname, '/admin') + ? 'active' + : undefined + } + > + <NavLink className="nav-link" to="/admin"> + Administration + </NavLink> + </NavItem> + )} + </Nav> + {isUpMd ? rightArea : null} + </Collapse> + </Navbar> + ); +}; + +export default AppBar; diff --git a/Timeline/ClientApp/src/app/common/CollapseButton.tsx b/Timeline/ClientApp/src/app/common/CollapseButton.tsx new file mode 100644 index 00000000..5307c4ac --- /dev/null +++ b/Timeline/ClientApp/src/app/common/CollapseButton.tsx @@ -0,0 +1,101 @@ +import React from 'react'; + +export interface CollapseButtonProps { + collapse: boolean; + onClick: () => void; + className?: string; +} + +const CollapseButton: React.FC<CollapseButtonProps> = (props) => { + const { onClick, collapse, className } = props; + + return ( + <svg + width="25" + height="25" + viewBox="0 0 100 100" + className={className} + onClick={onClick} + > + {(() => { + if (collapse) { + return ( + <> + <line + stroke="currentcolor" + strokeWidth="14" + x1="50" + x2="90" + y1="17" + y2="17" + /> + <line + stroke="currentcolor" + strokeWidth="14" + x1="10" + x2="50" + y1="83" + y2="83" + /> + <line + stroke="currentcolor" + strokeWidth="14" + x1="17" + x2="17" + y1="50" + y2="90" + /> + <line + stroke="currentcolor" + strokeWidth="14" + x1="83" + x2="83" + y1="10" + y2="50" + /> + </> + ); + } else { + return ( + <> + <line + stroke="currentcolor" + strokeWidth="14" + x1="55" + x2="95" + y1="38" + y2="38" + /> + <line + stroke="currentcolor" + strokeWidth="14" + x1="5" + x2="45" + y1="62" + y2="62" + /> + <line + stroke="currentcolor" + strokeWidth="14" + x1="38" + x2="38" + y1="55" + y2="95" + /> + <line + stroke="currentcolor" + strokeWidth="14" + x1="62" + x2="62" + y1="5" + y2="45" + /> + </> + ); + } + })()} + </svg> + ); +}; + +export default CollapseButton; diff --git a/Timeline/ClientApp/src/app/common/FileInput.tsx b/Timeline/ClientApp/src/app/common/FileInput.tsx new file mode 100644 index 00000000..20da7b71 --- /dev/null +++ b/Timeline/ClientApp/src/app/common/FileInput.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import clsx from 'clsx'; + +import { ExcludeKey } from '../type-utilities'; + +export interface FileInputProps + extends ExcludeKey< + React.InputHTMLAttributes<HTMLInputElement>, + 'type' | 'id' + > { + inputId?: string; + labelText: string; + color?: string; + className?: string; +} + +const FileInput: React.FC<FileInputProps> = props => { + const { inputId, labelText, color, className, ...otherProps } = props; + + const realInputId = React.useMemo<string>(() => { + if (inputId != null) return inputId; + return ( + 'file-input-' + + (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1) + ); + }, [inputId]); + + return ( + <> + <input className="d-none" type="file" id={realInputId} {...otherProps} /> + <label + htmlFor={realInputId} + className={clsx('btn', 'btn-' + (color ?? 'primary'), className)} + > + {labelText} + </label> + </> + ); +}; + +export default FileInput; diff --git a/Timeline/ClientApp/src/app/common/ImageCropper.tsx b/Timeline/ClientApp/src/app/common/ImageCropper.tsx new file mode 100644 index 00000000..7cb8d3cf --- /dev/null +++ b/Timeline/ClientApp/src/app/common/ImageCropper.tsx @@ -0,0 +1,306 @@ +import * as 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/Timeline/ClientApp/src/app/common/LoadingPage.tsx b/Timeline/ClientApp/src/app/common/LoadingPage.tsx new file mode 100644 index 00000000..81bc74cf --- /dev/null +++ b/Timeline/ClientApp/src/app/common/LoadingPage.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { Spinner } from 'reactstrap'; + +const LoadingPage: React.FC = () => { + return ( + <div className="position-fixed w-100 h-100 d-flex justify-content-center align-items-center"> + <Spinner style={{ height: '2.5rem', width: '2.5rem' }} color="primary" /> + </div> + ); +}; + +export default LoadingPage; diff --git a/Timeline/ClientApp/src/app/common/OperationDialog.tsx b/Timeline/ClientApp/src/app/common/OperationDialog.tsx new file mode 100644 index 00000000..30db4053 --- /dev/null +++ b/Timeline/ClientApp/src/app/common/OperationDialog.tsx @@ -0,0 +1,381 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Spinner, + Container, + ModalBody, + Label, + Input, + FormGroup, + FormFeedback, + ModalFooter, + Button, + Modal, + ModalHeader, + FormText, +} from 'reactstrap'; + +import { UiLogicError } from '../common'; + +const DefaultProcessPrompt: React.FC = (_) => { + return ( + <Container className="justify-content-center align-items-center"> + <Spinner /> + </Container> + ); +}; + +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' + >; + 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') { + 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 = ( + <> + <ModalBody> + {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 ( + <FormGroup key={index}> + {item.label && <Label>{t(item.label)}</Label>} + <Input + 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) + ) + ); + }} + invalid={error != null} + {...item.textFieldProps} + /> + {error != null && <FormFeedback>{error}</FormFeedback>} + {item.helperText && <FormText>{t(item.helperText)}</FormText>} + </FormGroup> + ); + } else if (item.type === 'bool') { + return ( + <FormGroup check key={index}> + <Input + type="checkbox" + value={value as string} + onChange={(e) => { + updateValue( + index, + (e.target as HTMLInputElement).checked + ); + }} + /> + <Label check>{t(item.label)}</Label> + </FormGroup> + ); + } else if (item.type === 'select') { + return ( + <FormGroup key={index}> + <Label>{t(item.label)}</Label> + <Input + type="select" + value={value as string} + onChange={(event) => { + updateValue(index, event.target.value); + }} + > + {item.options.map((option, i) => { + return ( + <option value={option.value} key={i}> + {option.icon} + {t(option.label)} + </option> + ); + })} + </Input> + </FormGroup> + ); + } + })} + </ModalBody> + <ModalFooter> + <Button color="secondary" onClick={close}> + {t('operationDialog.cancel')} + </Button> + <Button + color="primary" + disabled={testErrorInfo(inputError)} + onClick={() => { + if (validateAll()) { + onConfirm(); + } + }} + > + {t('operationDialog.confirm')} + </Button> + </ModalFooter> + </> + ); + } else if (step === 'process') { + body = ( + <ModalBody> + {props.processPrompt?.() ?? <DefaultProcessPrompt />} + </ModalBody> + ); + } 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 = ( + <> + <ModalBody>{content}</ModalBody> + <ModalFooter> + <Button color="primary" onClick={close}> + {t('operationDialog.ok')} + </Button> + </ModalFooter> + </> + ); + } + + const title = typeof props.title === 'string' ? t(props.title) : props.title; + + return ( + <Modal isOpen={props.open} toggle={close}> + <ModalHeader + className={ + props.titleColor != null + ? 'text-' + + (props.titleColor === 'create' + ? 'success' + : props.titleColor === 'dangerous' + ? 'danger' + : props.titleColor) + : undefined + } + > + {title} + </ModalHeader> + {body} + </Modal> + ); +}; + +export default OperationDialog; diff --git a/Timeline/ClientApp/src/app/common/SearchInput.tsx b/Timeline/ClientApp/src/app/common/SearchInput.tsx new file mode 100644 index 00000000..50c252fa --- /dev/null +++ b/Timeline/ClientApp/src/app/common/SearchInput.tsx @@ -0,0 +1,63 @@ +import React, { useCallback } from 'react'; +import clsx from 'clsx'; +import { Spinner, Input, Button } from 'reactstrap'; +import { useTranslation } from 'react-i18next'; + +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 ( + <div className={clsx('form-inline my-2', props.className)}> + <Input + className="mr-sm-2" + 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 /> + ) : ( + <Button outline color="primary" onClick={props.onButtonClick}> + {props.buttonText ?? t('search')} + </Button> + )} + </div> + </div> + ); +}; + +export default SearchInput; diff --git a/Timeline/ClientApp/src/app/common/TimelineLogo.tsx b/Timeline/ClientApp/src/app/common/TimelineLogo.tsx new file mode 100644 index 00000000..8dd9e97b --- /dev/null +++ b/Timeline/ClientApp/src/app/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/Timeline/ClientApp/src/app/common/UserTimelineLogo.tsx b/Timeline/ClientApp/src/app/common/UserTimelineLogo.tsx new file mode 100644 index 00000000..58a429d8 --- /dev/null +++ b/Timeline/ClientApp/src/app/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/Timeline/ClientApp/src/app/common/alert-service.ts b/Timeline/ClientApp/src/app/common/alert-service.ts new file mode 100644 index 00000000..6d3f8af8 --- /dev/null +++ b/Timeline/ClientApp/src/app/common/alert-service.ts @@ -0,0 +1,59 @@ +import pull from 'lodash/pull'; + +export interface AlertInfo { + type?: 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info'; + message: string; +} + +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/Timeline/ClientApp/src/app/common/alert.sass b/Timeline/ClientApp/src/app/common/alert.sass new file mode 100644 index 00000000..5b6e65c2 --- /dev/null +++ b/Timeline/ClientApp/src/app/common/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/Timeline/ClientApp/src/app/common/common.sass b/Timeline/ClientApp/src/app/common/common.sass new file mode 100644 index 00000000..15d34d7c --- /dev/null +++ b/Timeline/ClientApp/src/app/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/Timeline/ClientApp/src/app/config.ts b/Timeline/ClientApp/src/app/config.ts new file mode 100644 index 00000000..081a2cf6 --- /dev/null +++ b/Timeline/ClientApp/src/app/config.ts @@ -0,0 +1 @@ +export const apiBaseUrl = '/api'; diff --git a/Timeline/ClientApp/src/app/data/base64.ts b/Timeline/ClientApp/src/app/data/base64.ts new file mode 100644 index 00000000..7f7c6fcc --- /dev/null +++ b/Timeline/ClientApp/src/app/data/base64.ts @@ -0,0 +1,9 @@ +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); + }); +} diff --git a/Timeline/ClientApp/src/app/data/common.ts b/Timeline/ClientApp/src/app/data/common.ts new file mode 100644 index 00000000..1cdf93f1 --- /dev/null +++ b/Timeline/ClientApp/src/app/data/common.ts @@ -0,0 +1,25 @@ +import { AxiosError } from 'axios'; + +export function extractStatusCode(error: AxiosError): number | null { + const code = error.response && error.response.status; + if (typeof code === 'number') { + return code; + } else { + return null; + } +} + +export interface CommonErrorResponse { + code: number; + message: string; +} + +export function extractErrorCode(error: AxiosError): number | null { + const { response } = error as AxiosError<CommonErrorResponse>; + const code = response && response.data && response.data.code; + if (typeof code === 'number') { + return code; + } else { + return null; + } +} diff --git a/Timeline/ClientApp/src/app/data/timeline.ts b/Timeline/ClientApp/src/app/data/timeline.ts new file mode 100644 index 00000000..19bb3d45 --- /dev/null +++ b/Timeline/ClientApp/src/app/data/timeline.ts @@ -0,0 +1,345 @@ +import axios from 'axios'; +import XRegExp from 'xregexp'; + +import { base64 } from './base64'; +import { apiBaseUrl } from '../config'; +import { User, UserAuthInfo, getCurrentUser, UserWithToken } from './user'; +import { UiLogicError } from '../common'; + +export const kTimelineVisibilities = ['Public', 'Register', 'Private'] as const; + +export type TimelineVisibility = typeof kTimelineVisibilities[number]; + +export const timelineVisibilityTooltipTranslationMap: Record< + TimelineVisibility, + string +> = { + Public: 'timeline.visibilityTooltip.public', + Register: 'timeline.visibilityTooltip.register', + Private: 'timeline.visibilityTooltip.private', +}; + +export interface TimelineInfo { + name: string; + description: string; + owner: User; + visibility: TimelineVisibility; + members: User[]; + _links: { + posts: string; + }; +} + +export interface TimelinePostTextContent { + type: 'text'; + text: string; +} + +export interface TimelinePostImageContent { + type: 'image'; + url: string; +} + +export type TimelinePostContent = + | TimelinePostTextContent + | TimelinePostImageContent; + +export interface TimelinePostInfo { + id: number; + content: TimelinePostContent; + time: Date; + author: User; +} + +export interface CreatePostRequestTextContent { + type: 'text'; + text: string; +} + +export interface CreatePostRequestImageContent { + type: 'image'; + data: Blob; +} + +export type CreatePostRequestContent = + | CreatePostRequestTextContent + | CreatePostRequestImageContent; + +export interface CreatePostRequest { + content: CreatePostRequestContent; + time?: Date; +} + +// TODO: Remove in the future +export interface TimelineChangePropertyRequest { + visibility?: TimelineVisibility; + description?: string; +} + +export interface PersonalTimelineChangePropertyRequest { + visibility?: TimelineVisibility; + description?: string; +} + +export interface OrdinaryTimelineChangePropertyRequest { + // not supported by server now + // name?: string; + visibility?: TimelineVisibility; + description?: string; +} + +//-------------------- begin: internal model -------------------- + +interface RawTimelinePostTextContent { + type: 'text'; + text: string; +} + +interface RawTimelinePostImageContent { + type: 'image'; + url: string; +} + +type RawTimelinePostContent = + | RawTimelinePostTextContent + | RawTimelinePostImageContent; + +interface RawTimelinePostInfo { + id: number; + content: RawTimelinePostContent; + time: string; + author: User; +} + +interface RawCreatePostRequestTextContent { + type: 'text'; + text: string; +} + +interface RawCreatePostRequestImageContent { + type: 'text'; + data: string; +} + +type RawCreatePostRequestContent = + | RawCreatePostRequestTextContent + | RawCreatePostRequestImageContent; + +interface RawCreatePostRequest { + content: RawCreatePostRequestContent; + time?: string; +} + +//-------------------- end: internal model -------------------- + +function processRawTimelinePostInfo( + raw: RawTimelinePostInfo, + token?: string +): TimelinePostInfo { + return { + ...raw, + content: (() => { + if (raw.content.type === 'image' && token != null) { + return { + ...raw.content, + url: raw.content.url + '?token=' + token, + }; + } + return raw.content; + })(), + time: new Date(raw.time), + }; +} + +type TimelineUrlResolver = (name: string) => string; + +export class TimelineServiceTemplate< + TTimeline extends TimelineInfo, + TChangePropertyRequest +> { + private checkUser(): UserWithToken { + const user = getCurrentUser(); + if (user == null) { + throw new UiLogicError('You must login to perform the operation.'); + } + return user; + } + + constructor(private urlResolver: TimelineUrlResolver) {} + + changeProperty( + name: string, + req: TChangePropertyRequest + ): Promise<TTimeline> { + const user = this.checkUser(); + + return axios + .patch<TTimeline>(`${this.urlResolver(name)}?token=${user.token}`, req) + .then((res) => res.data); + } + + fetch(name: string): Promise<TTimeline> { + return axios + .get<TTimeline>(`${this.urlResolver(name)}`) + .then((res) => res.data); + } + + fetchPosts(name: string): Promise<TimelinePostInfo[]> { + const token = getCurrentUser()?.token; + return axios + .get<RawTimelinePostInfo[]>( + token == null + ? `${this.urlResolver(name)}/posts` + : `${this.urlResolver(name)}/posts?token=${token}` + ) + .then((res) => res.data.map((p) => processRawTimelinePostInfo(p, token))); + } + + createPost( + name: string, + request: CreatePostRequest + ): Promise<TimelinePostInfo> { + const user = this.checkUser(); + + const rawReq: Promise<RawCreatePostRequest> = new Promise< + RawCreatePostRequestContent + >((resolve) => { + if (request.content.type === 'image') { + void base64(request.content.data).then((d) => + resolve({ + ...request.content, + data: d, + } as RawCreatePostRequestImageContent) + ); + } else { + resolve(request.content); + } + }).then((content) => { + const rawReq: RawCreatePostRequest = { + content, + }; + if (request.time != null) { + rawReq.time = request.time.toISOString(); + } + return rawReq; + }); + + return rawReq + .then((req) => + axios.post<RawTimelinePostInfo>( + `${this.urlResolver(name)}/posts?token=${user.token}`, + req + ) + ) + .then((res) => processRawTimelinePostInfo(res.data, user.token)); + } + + deletePost(name: string, id: number): Promise<void> { + const user = this.checkUser(); + + return axios.delete( + `${this.urlResolver(name)}/posts/${id}?token=${user.token}` + ); + } + + addMember(name: string, username: string): Promise<void> { + const user = this.checkUser(); + + return axios.put( + `${this.urlResolver(name)}/members/${username}?token=${user.token}` + ); + } + + removeMember(name: string, username: string): Promise<void> { + const user = this.checkUser(); + + return axios.delete( + `${this.urlResolver(name)}/members/${username}?token=${user.token}` + ); + } + + isMemberOf(username: string, timeline: TTimeline): boolean { + return timeline.members.findIndex((m) => m.username == username) >= 0; + } + + hasReadPermission( + user: UserAuthInfo | null | undefined, + timeline: TTimeline + ): 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 && this.isMemberOf(user.username, timeline)) { + return true; + } + } + return false; + } + + hasPostPermission( + user: UserAuthInfo | null | undefined, + timeline: TTimeline + ): 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: TTimeline + ): boolean { + if (user != null && user.administrator) return true; + + return user != null && user.username == timeline.owner.username; + } + + hasModifyPostPermission( + user: UserAuthInfo | null | undefined, + timeline: TTimeline, + post: TimelinePostInfo + ): boolean { + if (user != null && user.administrator) return true; + + return ( + user != null && + (user.username === timeline.owner.username || + user.username === post.author.username) + ); + } +} + +export type PersonalTimelineService = TimelineServiceTemplate< + TimelineInfo, + PersonalTimelineChangePropertyRequest +>; + +export const personalTimelineService: PersonalTimelineService = new TimelineServiceTemplate< + TimelineInfo, + PersonalTimelineChangePropertyRequest +>((name) => `${apiBaseUrl}/timelines/@${name}`); + +export type OrdinaryTimelineService = TimelineServiceTemplate< + TimelineInfo, + OrdinaryTimelineChangePropertyRequest +>; + +export const ordinaryTimelineService: OrdinaryTimelineService = new TimelineServiceTemplate< + TimelineInfo, + TimelineChangePropertyRequest +>((name) => `${apiBaseUrl}/timelines/${name}`); + +const timelineNameReg = XRegExp('^[-_\\p{L}]*$', 'u'); + +export function validateTimelineName(name: string): boolean { + return timelineNameReg.test(name); +} diff --git a/Timeline/ClientApp/src/app/data/user.ts b/Timeline/ClientApp/src/app/data/user.ts new file mode 100644 index 00000000..8f787478 --- /dev/null +++ b/Timeline/ClientApp/src/app/data/user.ts @@ -0,0 +1,224 @@ +import axios, { AxiosError } from 'axios'; +import { useState, useEffect } from 'react'; +import { BehaviorSubject, Observable } from 'rxjs'; + +import { apiBaseUrl } from '../config'; +import { extractErrorCode } from './common'; +import { pushAlert } from '../common/alert-service'; +import { i18nPromise } from '../i18n'; +import { UiLogicError } from '../common'; + +export interface UserAuthInfo { + username: string; + administrator: boolean; +} + +export interface User { + username: string; + administrator: boolean; + nickname: string; + _links: { + avatar: string; + timeline: string; + }; +} + +export interface UserWithToken extends User { + token: string; +} + +interface CreateTokenRequest { + username: string; + password: string; +} + +interface CreateTokenResponse { + token: string; + user: User; +} + +interface VerifyTokenRequest { + token: string; +} + +interface VerifyTokenResponse { + user: User; +} + +export type LoginCredentials = CreateTokenRequest; + +const userSubject = new BehaviorSubject<UserWithToken | null | undefined>( + undefined +); + +export const user$: Observable<UserWithToken | null | undefined> = userSubject; + +export function getCurrentUser(): UserWithToken | null | undefined { + return userSubject.value; +} + +const kCreateTokenUrl = '/token/create'; +const kVerifyTokenUrl = '/token/verify'; +const createTokenUrl = apiBaseUrl + kCreateTokenUrl; +const verifyTokenUrl = apiBaseUrl + kVerifyTokenUrl; + +function verifyToken(token: string): Promise<User> { + return axios + .post<VerifyTokenResponse>(verifyTokenUrl, { + token: token, + } as VerifyTokenRequest) + .then((res) => res.data.user); +} + +const TOKEN_STORAGE_KEY = 'token'; + +export function checkUserLoginState(): Promise<UserWithToken | null> { + if (getCurrentUser() !== undefined) + throw new UiLogicError("Already checked user. Can't check twice."); + + const savedToken = window.localStorage.getItem(TOKEN_STORAGE_KEY); + if (savedToken) { + return verifyToken(savedToken) + .then( + (u) => { + const user: UserWithToken = { + ...u, + token: savedToken, + }; + void i18nPromise.then((t) => { + pushAlert({ + type: 'success', + message: t('user.welcomeBack'), + }); + }); + return user; + }, + (e: AxiosError) => { + if (e.response != null) { + window.localStorage.removeItem(TOKEN_STORAGE_KEY); + void i18nPromise.then((t) => { + pushAlert({ + type: 'danger', + message: t('user.verifyTokenFailed'), + }); + }); + } else { + void i18nPromise.then((t) => { + pushAlert({ + type: 'danger', + message: t('user.verifyTokenFailedNetwork'), + }); + }); + } + + return null; + } + ) + .then((u) => { + userSubject.next(u); + return u; + }); + } + userSubject.next(null); + return Promise.resolve(null); +} + +export class BadCredentialError { + constructor(public innerError: Error) {} + + message = 'login.badCredential'; +} + +export function userLogin( + credentials: LoginCredentials, + rememberMe: boolean +): Promise<UserWithToken> { + if (getCurrentUser()) { + throw new UiLogicError('Already login.'); + } + return axios + .post<CreateTokenResponse>(createTokenUrl, { ...credentials, expire: 30 }) + .catch((e: AxiosError) => { + if (extractErrorCode(e) === 11010101) { + throw new BadCredentialError(e); + } + throw e; + }) + .then((res) => { + const body = res.data; + const token = body.token; + if (rememberMe) { + window.localStorage.setItem(TOKEN_STORAGE_KEY, token); + } + const user = { + ...body.user, + token, + }; + userSubject.next(user); + return user; + }); +} + +export function userLogout(): void { + if (getCurrentUser() === undefined) { + throw new UiLogicError('Please check user first.'); + } + if (getCurrentUser() === null) { + throw new UiLogicError('No login.'); + } + window.localStorage.removeItem(TOKEN_STORAGE_KEY); + userSubject.next(null); +} + +export function useOptionalUser(): UserWithToken | null | undefined { + const [user, setUser] = useState<UserWithToken | null | undefined>( + userSubject.value + ); + useEffect(() => { + const sub = user$.subscribe((u) => setUser(u)); + return () => { + sub.unsubscribe(); + }; + }); + return user; +} + +export function useUser(): UserWithToken | null { + const [user, setUser] = useState<UserWithToken | null>(() => { + const initUser = userSubject.value; + 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 = 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 fetchUser(username: string): Promise<User> { + return axios + .get<User>(`${apiBaseUrl}/users/${username}`) + .then((res) => res.data); +} diff --git a/Timeline/ClientApp/src/app/helper.ts b/Timeline/ClientApp/src/app/helper.ts new file mode 100644 index 00000000..b48829b5 --- /dev/null +++ b/Timeline/ClientApp/src/app/helper.ts @@ -0,0 +1,35 @@ +//copied from https://stackoverflow.com/questions/5999118/how-can-i-add-or-update-a-query-string-parameter +export function updateQueryString( + key: string, + value?: string | null, + url?: string +): string { + if (!url) url = window.location.href; + 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; + } + } +} diff --git a/Timeline/ClientApp/src/app/home/Home.tsx b/Timeline/ClientApp/src/app/home/Home.tsx new file mode 100644 index 00000000..775a1a87 --- /dev/null +++ b/Timeline/ClientApp/src/app/home/Home.tsx @@ -0,0 +1,155 @@ +import React from 'react'; +import { useHistory } from 'react-router'; +import { Row, Container, Button, Col } from 'reactstrap'; +import { useTranslation } from 'react-i18next'; +import axios from 'axios'; + +import { apiBaseUrl } from '../config'; +import { useUser } from '../data/user'; +import { TimelineInfo } from '../data/timeline'; + +import AppBar from '../common/AppBar'; +import SearchInput from '../common/SearchInput'; +import TimelineBoardAreaWithoutUser from './TimelineBoardAreaWithoutUser'; +import TimelineBoardAreaWithUser from './TimelineBoardAreaWithUser'; +import TimelineCreateDialog from './TimelineCreateDialog'; + +const Home: React.FC = (_) => { + const history = useHistory(); + + const { t } = useTranslation(); + + const user = useUser(); + + const [navText, setNavText] = React.useState<string>(''); + + const [publicTimelines, setPublicTimelines] = React.useState< + TimelineInfo[] | undefined + >(undefined); + const [ownTimelines, setOwnTimelines] = React.useState< + TimelineInfo[] | undefined + >(undefined); + const [joinTimelines, setJoinTimelines] = React.useState< + TimelineInfo[] | undefined + >(undefined); + + React.useEffect(() => { + let subscribe = true; + if (user == null) { + setOwnTimelines(undefined); + setJoinTimelines(undefined); + void axios + .get<TimelineInfo[]>(`${apiBaseUrl}/timelines?visibility=public`) + .then((res) => { + if (subscribe) { + setPublicTimelines(res.data); + } + }); + } else { + setPublicTimelines(undefined); + void axios + .get<TimelineInfo[]>( + `${apiBaseUrl}/timelines?relate=${user.username}&relateType=own` + ) + .then((res) => { + if (subscribe) { + setOwnTimelines(res.data); + } + }); + void axios + .get<TimelineInfo[]>( + `${apiBaseUrl}/timelines?relate=${user.username}&relateType=join` + ) + .then((res) => { + if (subscribe) { + setJoinTimelines(res.data); + } + }); + } + return () => { + subscribe = false; + }; + }, [user]); + + 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]); + + const openCreateDialog = React.useCallback(() => { + setDialog('create'); + }, []); + + const closeDialog = React.useCallback(() => { + setDialog(null); + }, []); + + return ( + <> + <AppBar /> + <Container fluid style={{ marginTop: '56px' }}> + <Row> + <Col> + <SearchInput + className="justify-content-center" + value={navText} + onChange={setNavText} + onButtonClick={goto} + buttonText={t('home.go')} + placeholder="@crupest" + additionalButton={ + user != null && ( + <Button color="success" outline onClick={openCreateDialog}> + {t('home.createButton')} + </Button> + ) + } + /> + </Col> + </Row> + {(() => { + if (user == null) { + return ( + <TimelineBoardAreaWithoutUser publicTimelines={publicTimelines} /> + ); + } else { + return ( + <TimelineBoardAreaWithUser + ownTimelines={ownTimelines} + joinTimelines={joinTimelines} + /> + ); + } + })()} + </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={closeDialog} />} + </> + ); +}; + +export default Home; diff --git a/Timeline/ClientApp/src/app/home/TimelineBoard.tsx b/Timeline/ClientApp/src/app/home/TimelineBoard.tsx new file mode 100644 index 00000000..ca77ab43 --- /dev/null +++ b/Timeline/ClientApp/src/app/home/TimelineBoard.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import clsx from 'clsx'; +import { Link } from 'react-router-dom'; +import { Spinner } from 'reactstrap'; + +import { TimelineInfo } from '../data/timeline'; + +import TimelineLogo from '../common/TimelineLogo'; +import UserTimelineLogo from '../common/UserTimelineLogo'; + +export interface TimelineBoardProps { + title?: string; + timelines?: TimelineInfo[]; + 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 == null) { + return ( + <div className="d-flex flex-grow-1 justify-content-center align-items-center"> + <Spinner color="primary" /> + </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/Timeline/ClientApp/src/app/home/TimelineBoardAreaWithUser.tsx b/Timeline/ClientApp/src/app/home/TimelineBoardAreaWithUser.tsx new file mode 100644 index 00000000..ea1c6beb --- /dev/null +++ b/Timeline/ClientApp/src/app/home/TimelineBoardAreaWithUser.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { Row, Col } from 'reactstrap'; +import { useTranslation } from 'react-i18next'; + +import TimelineBoard from './TimelineBoard'; +import { TimelineInfo } from '../data/timeline'; + +interface TimelineBoardAreaWithUserProps { + ownTimelines?: TimelineInfo[]; + joinTimelines?: TimelineInfo[]; +} + +const TimelineBoardAreaWithUser: React.FC<TimelineBoardAreaWithUserProps> = ( + props +) => { + const { t } = useTranslation(); + + return ( + <Row className="my-2 justify-content-center"> + <Col sm="6" lg="5" className="py-2"> + <TimelineBoard + title={t('home.ownTimeline')} + timelines={props.ownTimelines} + /> + </Col> + <Col sm="6" lg="5" className="py-2"> + <TimelineBoard + title={t('home.joinTimeline')} + timelines={props.joinTimelines} + /> + </Col> + </Row> + ); +}; + +export default TimelineBoardAreaWithUser; diff --git a/Timeline/ClientApp/src/app/home/TimelineBoardAreaWithoutUser.tsx b/Timeline/ClientApp/src/app/home/TimelineBoardAreaWithoutUser.tsx new file mode 100644 index 00000000..c79558b6 --- /dev/null +++ b/Timeline/ClientApp/src/app/home/TimelineBoardAreaWithoutUser.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { Row, Col } from 'reactstrap'; + +import { TimelineInfo } from '../data/timeline'; + +import TimelineBoard from './TimelineBoard'; + +interface TimelineBoardAreaWithoutUserProps { + publicTimelines?: TimelineInfo[]; +} + +const TimelineBoardAreaWithoutUser: React.FC<TimelineBoardAreaWithoutUserProps> = ( + props +) => { + const { publicTimelines } = props; + + return ( + <Row className="my-2 justify-content-center"> + <Col sm="8" lg="6"> + <TimelineBoard timelines={publicTimelines} /> + </Col> + </Row> + ); +}; + +export default TimelineBoardAreaWithoutUser; diff --git a/Timeline/ClientApp/src/app/home/TimelineCreateDialog.tsx b/Timeline/ClientApp/src/app/home/TimelineCreateDialog.tsx new file mode 100644 index 00000000..925c6c76 --- /dev/null +++ b/Timeline/ClientApp/src/app/home/TimelineCreateDialog.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { useHistory } from 'react-router'; +import axios from 'axios'; + +import { apiBaseUrl } from '../config'; +import { useUserLoggedIn } from '../data/user'; +import { validateTimelineName } from '../data/timeline'; + +import OperationDialog from '../common/OperationDialog'; + +interface TimelineCreateDialogProps { + open: boolean; + close: () => void; +} + +const TimelineCreateDialog: React.FC<TimelineCreateDialogProps> = (props) => { + const history = useHistory(); + const user = useUserLoggedIn(); + + 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 axios.post(`${apiBaseUrl}/timelines?token=${user.token}`, { + name, + }); + }} + onSuccessAndClose={() => { + history.push(`timelines/${nameSaved}`); + }} + failurePrompt={(e) => `${e as string}`} + /> + ); +}; + +export default TimelineCreateDialog; diff --git a/Timeline/ClientApp/src/app/home/home.sass b/Timeline/ClientApp/src/app/home/home.sass new file mode 100644 index 00000000..f5d6ffc3 --- /dev/null +++ b/Timeline/ClientApp/src/app/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/Timeline/ClientApp/src/app/i18n.ts b/Timeline/ClientApp/src/app/i18n.ts new file mode 100644 index 00000000..bda33fe8 --- /dev/null +++ b/Timeline/ClientApp/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/Timeline/ClientApp/src/app/index.sass b/Timeline/ClientApp/src/app/index.sass new file mode 100644 index 00000000..86276701 --- /dev/null +++ b/Timeline/ClientApp/src/app/index.sass @@ -0,0 +1,86 @@ +@import '~bootstrap/scss/bootstrap' +$fa-font-path: '~@fortawesome/fontawesome-free/webfonts' +@import '~@fortawesome/fontawesome-free/scss/fontawesome' +@import '~@fortawesome/fontawesome-free/scss/solid' +@import '~@fortawesome/fontawesome-free/scss/brands' + +@import './common/common' +@import './common/alert' +@import './home/home' +@import './about/about' +@import './timeline/timeline' +@import './timeline/timeline-ui' +@import './user/user-page' + +body + margin: 0 + +#app + display: flex + flex-direction: column + +small + line-height: 1.2 + +.width-1px + width: 1px + +.flex-fix-length + flex-grow: 0 + flex-shrink: 0 + +.position-lt + left: 0 + top: 0 + +.position-rb + right: 0 + bottom: 0 + +.app-bar + z-index: 1035 + +.avatar + width: 60px + +.avatar.large + width: 100px + +.avatar.small + width: 40px + +.mt-appbar + margin-top: 56px + +.icon-button + font-size: 20px + +.large-icon + font-size: 26px + +.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/Timeline/ClientApp/src/app/index.tsx b/Timeline/ClientApp/src/app/index.tsx new file mode 100644 index 00000000..73788c3a --- /dev/null +++ b/Timeline/ClientApp/src/app/index.tsx @@ -0,0 +1,22 @@ +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')); + +if ('serviceWorker' in navigator) { + // Use the window load event to keep the page load performant + window.addEventListener('load', () => { + void navigator.serviceWorker.register('/sw.js'); + }); +} diff --git a/Timeline/ClientApp/src/app/locales/en/translation.ts b/Timeline/ClientApp/src/app/locales/en/translation.ts new file mode 100644 index 00000000..00672f67 --- /dev/null +++ b/Timeline/ClientApp/src/app/locales/en/translation.ts @@ -0,0 +1,183 @@ +import TranslationResource from '../scheme'; + +const translation: TranslationResource = { + welcome: 'Welcome!', + search: 'Search', + 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', + 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.', + }, + 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/Timeline/ClientApp/src/app/locales/scheme.ts b/Timeline/ClientApp/src/app/locales/scheme.ts new file mode 100644 index 00000000..bb3f14df --- /dev/null +++ b/Timeline/ClientApp/src/app/locales/scheme.ts @@ -0,0 +1,167 @@ +export default interface TranslationResource { + welcome: string; + search: string; + chooseImage: string; + loadImageError: string; + nav: { + settings: string; + login: string; + about: string; + }; + home: { + go: string; + allTimeline: string; + joinTimeline: string; + ownTimeline: 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; + }; + 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/Timeline/ClientApp/src/app/locales/zh/translation.ts b/Timeline/ClientApp/src/app/locales/zh/translation.ts new file mode 100644 index 00000000..66421375 --- /dev/null +++ b/Timeline/ClientApp/src/app/locales/zh/translation.ts @@ -0,0 +1,178 @@ +import TranslationResource from '../scheme'; + +const translation: TranslationResource = { + welcome: '欢迎!', + search: '搜索', + nav: { + settings: '设置', + login: '登陆', + about: '关于', + }, + chooseImage: '选择一个图片', + loadImageError: '加载图片失败', + home: { + go: '冲!', + allTimeline: '所有的时间线', + joinTimeline: '加入的时间线', + ownTimeline: '拥有的时间线', + 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: '名字不匹配', + }, + 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/Timeline/ClientApp/src/app/settings/Settings.tsx b/Timeline/ClientApp/src/app/settings/Settings.tsx new file mode 100644 index 00000000..96a3fab4 --- /dev/null +++ b/Timeline/ClientApp/src/app/settings/Settings.tsx @@ -0,0 +1,253 @@ +import React, { useState } from 'react'; +import { useHistory } from 'react-router'; +import { useTranslation } from 'react-i18next'; +import axios, { AxiosError } from 'axios'; +import { + Container, + Row, + Col, + Input, + Modal, + ModalHeader, + ModalBody, + ModalFooter, + Button, +} from 'reactstrap'; + +import { apiBaseUrl } from '../config'; + +import { useUser, userLogout, useUserLoggedIn } from '../data/user'; + +import AppBar from '../common/AppBar'; +import OperationDialog, { + OperationInputErrorInfo, +} from '../common/OperationDialog'; +import { CommonErrorResponse } from '../data/common'; + +interface ChangePasswordDialogProps { + open: boolean; + close: () => void; +} + +async function changePassword( + oldPassword: string, + newPassword: string, + token: string +): Promise<void> { + const url = `${apiBaseUrl}/userop/changepassword?token=${token}`; + try { + await axios.post(url, { + oldPassword, + newPassword, + }); + } catch (e) { + const error = e as AxiosError<CommonErrorResponse>; + if ( + error.response && + error.response.status === 400 && + error.response.data && + error.response.data.message + ) { + throw error.response.data.message; + } + throw e; + } +} + +const ChangePasswordDialog: React.FC<ChangePasswordDialogProps> = (props) => { + const user = useUserLoggedIn(); + 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 changePassword( + oldPassword as string, + newPassword as string, + user.token + ); + userLogout(); + 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 isOpen centered> + <ModalHeader className="text-danger"> + {t('settings.dialogConfirmLogout.title')} + </ModalHeader> + <ModalBody>{t('settings.dialogConfirmLogout.prompt')}</ModalBody> + <ModalFooter> + <Button color="secondary" onClick={toggle}> + {t('operationDialog.cancel')} + </Button> + <Button color="danger" onClick={onConfirm}> + {t('operationDialog.confirm')} + </Button> + </ModalFooter> + </Modal> + ); +}; + +const Settings: 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 ( + <> + <AppBar /> + <Container fluid className="mt-appbar"> + {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"> + <Input + type="select" + value={language} + onChange={(e) => { + void i18n.changeLanguage(e.target.value); + }} + > + <option value="zh">中文</option> + <option value="en">English</option> + </Input> + </Col> + </Row> + {(() => { + switch (dialog) { + case 'changepassword': + return ( + <ChangePasswordDialog + open + close={() => { + setDialog(null); + }} + /> + ); + case 'logout': + return ( + <ConfirmLogoutDialog + toggle={() => setDialog(null)} + onConfirm={() => { + userLogout(); + history.push('/'); + }} + /> + ); + default: + return null; + } + })()} + </Container> + </> + ); +}; + +export default Settings; diff --git a/Timeline/ClientApp/src/app/timeline/Timeline.tsx b/Timeline/ClientApp/src/app/timeline/Timeline.tsx new file mode 100644 index 00000000..35d6490b --- /dev/null +++ b/Timeline/ClientApp/src/app/timeline/Timeline.tsx @@ -0,0 +1,99 @@ +import React from 'react'; +import clsx from 'clsx'; +import { Container } from 'reactstrap'; + +import { TimelinePostInfo } from '../data/timeline'; +import { useUser } from '../data/user'; +import { useAvatarVersion } from '../user/api'; + +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; +} + +const Timeline: React.FC<TimelineProps> = (props) => { + const user = useUser(); + const avatarVersion = useAvatarVersion(); + + 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 ( + <Container + fluid + className={clsx('d-flex flex-column position-relative', props.className)} + > + <div className="timeline-enter-animation-mask" /> + {(() => { + const length = posts.length; + return posts.map((post, i) => { + const av: number | undefined = + user != null && user.username === post.author.username + ? avatarVersion + : undefined; + + 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} + avatarVersion={av} + onResize={onResize} + /> + ); + }); + })()} + </Container> + ); +}; + +export default Timeline; diff --git a/Timeline/ClientApp/src/app/timeline/TimelineDeleteDialog.tsx b/Timeline/ClientApp/src/app/timeline/TimelineDeleteDialog.tsx new file mode 100644 index 00000000..5e7c6dd4 --- /dev/null +++ b/Timeline/ClientApp/src/app/timeline/TimelineDeleteDialog.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import axios from 'axios'; +import { useHistory } from 'react-router'; +import { Trans } from 'react-i18next'; + +import { apiBaseUrl } from '../config'; +import { useUserLoggedIn } from '../data/user'; +import OperationDialog from '../common/OperationDialog'; + +interface TimelineDeleteDialog { + open: boolean; + name: string; + close: () => void; +} + +const TimelineDeleteDialog: React.FC<TimelineDeleteDialog> = (props) => { + const user = useUserLoggedIn(); + 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 axios.delete( + `${apiBaseUrl}/timelines/${name}?token=${user.token}` + ); + }} + onSuccessAndClose={() => { + history.replace('/'); + }} + /> + ); +}; + +export default TimelineDeleteDialog; diff --git a/Timeline/ClientApp/src/app/timeline/TimelineInfoCard.tsx b/Timeline/ClientApp/src/app/timeline/TimelineInfoCard.tsx new file mode 100644 index 00000000..e4bc07d1 --- /dev/null +++ b/Timeline/ClientApp/src/app/timeline/TimelineInfoCard.tsx @@ -0,0 +1,109 @@ +import React from 'react'; +import clsx from 'clsx'; +import { + Dropdown, + DropdownToggle, + DropdownMenu, + DropdownItem, + Button, +} from 'reactstrap'; +import { useTranslation } from 'react-i18next'; +import { fromEvent } from 'rxjs'; + +import { + timelineVisibilityTooltipTranslationMap, + TimelineInfo, +} from '../data/timeline'; +import { TimelineCardComponentProps } from './TimelinePageTemplateUI'; + +export type OrdinaryTimelineManageItem = 'delete'; + +export type TimelineInfoCardProps = TimelineCardComponentProps< + TimelineInfo, + OrdinaryTimelineManageItem +>; + +const TimelineInfoCard: React.FC<TimelineInfoCardProps> = (props) => { + const { onHeight, onManage } = props; + + const { t } = useTranslation(); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const containerRef = React.useRef<HTMLDivElement>(null!); + + const notifyHeight = React.useCallback((): void => { + if (onHeight) { + onHeight(containerRef.current.getBoundingClientRect().height); + } + }, [onHeight]); + + React.useEffect(() => { + const subscription = fromEvent(window, 'resize').subscribe(notifyHeight); + return () => subscription.unsubscribe(); + }); + + const [manageDropdownOpen, setManageDropdownOpen] = React.useState<boolean>( + false + ); + const toggleManageDropdown = React.useCallback( + (): void => setManageDropdownOpen((old) => !old), + [] + ); + + return ( + <div + ref={containerRef} + className={clsx('rounded border p-2 bg-light', props.className)} + onTransitionEnd={notifyHeight} + > + <h3 className="text-primary mx-3 d-inline-block align-middle"> + {props.timeline.name} + </h3> + <div className="d-inline-block align-middle"> + <img + src={props.timeline.owner._links.avatar} + onLoad={notifyHeight} + className="avatar small rounded-circle" + /> + {props.timeline.owner.nickname} + <small className="ml-3 text-secondary"> + @{props.timeline.owner.username} + </small> + </div> + <p className="mb-0">{props.timeline.description}</p> + <small className="mt-1 d-block"> + {t(timelineVisibilityTooltipTranslationMap[props.timeline.visibility])} + </small> + <div className="text-right mt-2"> + {onManage != null ? ( + <Dropdown isOpen={manageDropdownOpen} toggle={toggleManageDropdown}> + <DropdownToggle outline color="primary"> + {t('timeline.manage')} + </DropdownToggle> + <DropdownMenu> + <DropdownItem onClick={() => onManage('property')}> + {t('timeline.manageItem.property')} + </DropdownItem> + <DropdownItem onClick={props.onMember}> + {t('timeline.manageItem.member')} + </DropdownItem> + <DropdownItem divider /> + <DropdownItem + className="text-danger" + onClick={() => onManage('delete')} + > + {t('timeline.manageItem.delete')} + </DropdownItem> + </DropdownMenu> + </Dropdown> + ) : ( + <Button color="primary" outline onClick={props.onMember}> + {t('timeline.memberButton')} + </Button> + )} + </div> + </div> + ); +}; + +export default TimelineInfoCard; diff --git a/Timeline/ClientApp/src/app/timeline/TimelineItem.tsx b/Timeline/ClientApp/src/app/timeline/TimelineItem.tsx new file mode 100644 index 00000000..4737fd7d --- /dev/null +++ b/Timeline/ClientApp/src/app/timeline/TimelineItem.tsx @@ -0,0 +1,175 @@ +import React from 'react'; +import clsx from 'clsx'; +import { + Row, + Col, + Modal, + ModalHeader, + ModalBody, + ModalFooter, + Button, +} from 'reactstrap'; +import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; + +import { TimelinePostInfo } from '../data/timeline'; +import { useAvatarUrlWithGivenVersion } from '../user/api'; + +const TimelinePostDeleteConfirmDialog: React.FC<{ + toggle: () => void; + onConfirm: () => void; +}> = ({ toggle, onConfirm }) => { + const { t } = useTranslation(); + + return ( + <Modal toggle={toggle} isOpen centered> + <ModalHeader className="text-danger"> + {t('timeline.post.deleteDialog.title')} + </ModalHeader> + <ModalBody>{t('timeline.post.deleteDialog.prompt')}</ModalBody> + <ModalFooter> + <Button color="secondary" onClick={toggle}> + {t('operationDialog.cancel')} + </Button> + <Button + color="danger" + onClick={() => { + onConfirm(); + toggle(); + }} + > + {t('operationDialog.confirm')} + </Button> + </ModalFooter> + </Modal> + ); +}; + +export interface TimelineItemProps { + post: TimelinePostInfo; + current?: boolean; + more?: { + isOpen: boolean; + toggle: () => void; + onDelete: () => void; + }; + onClick?: () => void; + avatarVersion?: number; + 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 avatarUrl = useAvatarUrlWithGivenVersion( + props.avatarVersion, + props.post.author._links.avatar + ); + + const [deleteDialog, setDeleteDialog] = React.useState<boolean>(false); + const toggleDeleteDialog = React.useCallback( + () => setDeleteDialog((old) => !old), + [] + ); + + return ( + <Row + className={clsx( + 'position-relative flex-nowrap', + current && 'current', + props.className + )} + onClick={props.onClick} + style={props.style} + > + <Col 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" />} + </Col> + <Col className="timeline-pt-start"> + <Row className="flex-nowrap"> + <div className="col-auto flex-shrink-1 px-0"> + <Row className="ml-n3 mr-0 align-items-center"> + <span className="ml-3 text-primary white-space-no-wrap"> + {props.post.time.toLocaleString(i18n.languages)} + </span> + <small className="text-dark ml-3"> + {props.post.author.nickname} + </small> + </Row> + </div> + {more != null ? ( + <div className="col-auto px-2 d-flex justify-content-center align-items-center"> + <i + className="fas fa-chevron-circle-down text-info icon-button" + onClick={(e) => { + more.toggle(); + e.stopPropagation(); + }} + /> + </div> + ) : null} + </Row> + <p className="row d-block timeline-content"> + <Link + className="float-right float-sm-left mx-2" + to={'/users/' + props.post.author.username} + > + <img onLoad={onResize} src={avatarUrl} className="avatar rounded" /> + </Link> + {(() => { + const { content } = props.post; + if (content.type === 'text') { + return content.text; + } else { + return ( + <img + onLoad={onResize} + src={content.url} + className="timeline-content-image" + /> + ); + } + })()} + </p> + </Col> + {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} + > + <i + className="fas fa-trash text-danger large-icon" + onClick={(e) => { + toggleDeleteDialog(); + e.stopPropagation(); + }} + /> + </div> + {deleteDialog ? ( + <TimelinePostDeleteConfirmDialog + toggle={() => { + toggleDeleteDialog(); + more.toggle(); + }} + onConfirm={more.onDelete} + /> + ) : null} + </> + ) : null} + </Row> + ); +}; + +export default TimelineItem; diff --git a/Timeline/ClientApp/src/app/timeline/TimelineMember.tsx b/Timeline/ClientApp/src/app/timeline/TimelineMember.tsx new file mode 100644 index 00000000..f9747b4d --- /dev/null +++ b/Timeline/ClientApp/src/app/timeline/TimelineMember.tsx @@ -0,0 +1,196 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { User } from '../data/user'; + +import SearchInput from '../common/SearchInput'; +import { + Container, + ListGroup, + ListGroupItem, + Modal, + Row, + Col, + Button, +} from 'reactstrap'; + +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 members = props.members; + + return ( + <Container className="px-4"> + <ListGroup className="my-3"> + {members.map((member, index) => ( + <ListGroupItem key={member.username} className="container"> + <Row> + <Col className="col-auto"> + <img src={member._links.avatar} className="avatar small" /> + </Col> + <Col> + <Row>{member.nickname}</Row> + <Row> + <small>{'@' + member.username}</small> + </Row> + </Col> + {(() => { + if (index === 0) { + return null; + } + const onRemove = props.edit?.onRemoveUser; + if (onRemove == null) { + return null; + } + return ( + <Button + className="align-self-center" + color="danger" + onClick={() => { + onRemove(member.username); + }} + > + {t('timeline.member.remove')} + </Button> + ); + })()} + </Row> + </ListGroupItem> + ))} + </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"> + <img + src={u._links.avatar} + className="avatar small" + /> + </Col> + <Col> + <Row>{u.nickname}</Row> + <Row> + <small>{'@' + u.username}</small> + </Row> + </Col> + <Button + color="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 isOpen={props.open} toggle={props.onClose}> + <TimelineMember {...props} /> + </Modal> + ); +}; diff --git a/Timeline/ClientApp/src/app/timeline/TimelinePage.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePage.tsx new file mode 100644 index 00000000..900d6e6a --- /dev/null +++ b/Timeline/ClientApp/src/app/timeline/TimelinePage.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { useParams } from 'react-router'; + +import { ordinaryTimelineService } from '../data/timeline'; + +import TimelinePageUI from './TimelinePageUI'; +import TimelinePageTemplate from '../timeline/TimelinePageTemplate'; +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)} + service={ordinaryTimelineService} + notFoundI18nKey="timeline.timelineNotExist" + /> + {dialogElement} + </> + ); +}; + +export default TimelinePage; diff --git a/Timeline/ClientApp/src/app/timeline/TimelinePageTemplate.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePageTemplate.tsx new file mode 100644 index 00000000..38ecd8f9 --- /dev/null +++ b/Timeline/ClientApp/src/app/timeline/TimelinePageTemplate.tsx @@ -0,0 +1,275 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { AxiosError } from 'axios'; +import concat from 'lodash/concat'; +import without from 'lodash/without'; + +import { ExcludeKey } from '../type-utilities'; +import { useUser, fetchUser } from '../data/user'; +import { pushAlert } from '../common/alert-service'; +import { extractStatusCode, extractErrorCode } from '../data/common'; +import { + TimelineServiceTemplate, + TimelineInfo, + TimelineChangePropertyRequest, +} from '../data/timeline'; + +import { TimelinePostInfoEx, TimelineDeleteCallback } from './Timeline'; +import { TimelineMemberDialog } from './TimelineMember'; +import TimelinePropertyChangeDialog from './TimelinePropertyChangeDialog'; +import { TimelinePageTemplateUIProps } from './TimelinePageTemplateUI'; +import { TimelinePostSendCallback } from './TimelinePostEdit'; +import { UiLogicError } from '../common'; + +export interface TimelinePageTemplateProps< + TManageItem, + TTimeline extends TimelineInfo +> { + name: string; + onManage: (item: TManageItem) => void; + service: TimelineServiceTemplate<TTimeline, TimelineChangePropertyRequest>; + UiComponent: React.ComponentType< + ExcludeKey< + TimelinePageTemplateUIProps<TTimeline, TManageItem>, + 'CardComponent' + > + >; + dataVersion?: number; + notFoundI18nKey: string; +} + +export default function TimelinePageTemplate< + TManageItem, + TTimeline extends TimelineInfo +>( + props: TimelinePageTemplateProps<TManageItem, TTimeline> +): React.ReactElement | null { + const { t } = useTranslation(); + + const { name } = props; + + const user = useUser(); + + const [dialog, setDialog] = React.useState<null | 'property' | 'member'>( + null + ); + const [timeline, setTimeline] = React.useState<TTimeline | undefined>( + undefined + ); + const [posts, setPosts] = React.useState< + TimelinePostInfoEx[] | 'forbid' | undefined + >(undefined); + const [error, setError] = React.useState<string | undefined>(undefined); + + const service = props.service; + + React.useEffect(() => { + let subscribe = true; + service.fetch(name).then( + (ti) => { + if (subscribe) { + setTimeline(ti); + if (!service.hasReadPermission(user, ti)) { + setPosts('forbid'); + } else { + service.fetchPosts(name).then( + (data) => { + if (subscribe) { + setPosts( + data.map((post) => ({ + ...post, + deletable: service.hasModifyPostPermission( + user, + ti, + post + ), + })) + ); + } + }, + (error) => { + if (subscribe) { + setError(`${error as string}`); + } + } + ); + } + } + }, + (error: AxiosError) => { + if (subscribe) { + if ( + extractStatusCode(error) === 404 || + extractErrorCode(error) === 11020101 + ) { + setError(t(props.notFoundI18nKey)); + } else { + setError(error.toString()); + } + } + } + ); + return () => { + subscribe = false; + }; + }, [name, service, user, t, props.dataVersion, props.notFoundI18nKey]); + + 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.changeProperty(name, req).then((newTimeline) => { + setTimeline(newTimeline); + }); + }} + /> + ); + } 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 fetchUser(u).catch((e) => { + if ( + extractStatusCode(e) === 404 || + extractErrorCode(e) === 11020101 + ) { + return Promise.resolve(null); + } else { + return Promise.reject(e); + } + }); + }, + onAddUser: (u) => { + return service.addMember(name, u.username).then((_) => { + setTimeline({ + ...timeline, + members: concat(timeline.members, u), + }); + }); + }, + onRemoveUser: (u) => { + void service.removeMember(name, u).then((_) => { + setTimeline({ + ...timeline, + members: without( + timeline.members, + timeline.members.find((m) => m.username === u) + ), + }); + }); + }, + } + : null + } + /> + ); + } + + const { UiComponent } = props; + + const onDelete: TimelineDeleteCallback = React.useCallback( + (index, id) => { + service.deletePost(name, id).then( + (_) => { + setPosts((oldPosts) => + without( + oldPosts as TimelinePostInfoEx[], + (oldPosts as TimelinePostInfoEx[])[index] + ) + ); + }, + () => { + pushAlert({ + type: 'danger', + message: t('timeline.deletePostFailed'), + }); + } + ); + }, + [service, name, t] + ); + + const onPost: TimelinePostSendCallback = React.useCallback( + (req) => { + return service.createPost(name, req).then((newPost) => { + setPosts((oldPosts) => + concat(oldPosts as TimelinePostInfoEx[], { + ...newPost, + deletable: true, + }) + ); + }); + }, + [service, name] + ); + + const onManageProp = props.onManage; + + const onManage = React.useCallback( + (item: 'property' | TManageItem) => { + if (item === 'property') { + setDialog(item); + } else { + onManageProp(item); + } + }, + [onManageProp] + ); + + const onMember = React.useCallback(() => { + setDialog('member'); + }, []); + + return ( + <> + <UiComponent + error={error} + timeline={timeline} + posts={posts} + onDelete={onDelete} + onPost={ + timeline != null && service.hasPostPermission(user, timeline) + ? onPost + : undefined + } + onManage={ + timeline != null && service.hasManagePermission(user, timeline) + ? onManage + : undefined + } + onMember={onMember} + /> + {dialogElement} + </> + ); +} diff --git a/Timeline/ClientApp/src/app/timeline/TimelinePageTemplateUI.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePageTemplateUI.tsx new file mode 100644 index 00000000..d74fffc4 --- /dev/null +++ b/Timeline/ClientApp/src/app/timeline/TimelinePageTemplateUI.tsx @@ -0,0 +1,212 @@ +import React from 'react'; +import { Spinner } from 'reactstrap'; +import { useTranslation } from 'react-i18next'; +import { Subject, fromEvent } from 'rxjs'; + +import { getAlertHost } from '../common/alert-service'; + +import Timeline, { + TimelinePostInfoEx, + TimelineDeleteCallback, +} from './Timeline'; +import AppBar from '../common/AppBar'; +import TimelinePostEdit, { TimelinePostSendCallback } from './TimelinePostEdit'; +import CollapseButton from '../common/CollapseButton'; + +export interface TimelineCardComponentProps<TTimeline, TManageItems> { + timeline: TTimeline; + onManage?: (item: TManageItems | 'property') => void; + onMember: () => void; + className?: string; + onHeight?: (height: number) => void; +} + +export interface TimelinePageTemplateUIProps< + TTimeline extends { name: string }, + TManageItems +> { + avatarKey?: string | number; + timeline?: TTimeline; + posts?: TimelinePostInfoEx[] | 'forbid'; + CardComponent: React.ComponentType< + TimelineCardComponentProps<TTimeline, TManageItems> + >; + onMember: () => void; + onManage?: (item: TManageItems | 'property') => void; + onPost?: TimelinePostSendCallback; + onDelete: TimelineDeleteCallback; + error?: string; +} + +export default function TimelinePageTemplateUI< + TTimeline extends { name: string }, + TEditItems +>( + props: TimelinePageTemplateUIProps<TTimeline, TEditItems> +): React.ReactElement | null { + const { timeline } = 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 resizeSubject = React.useMemo(() => new Subject(), []); + const triggerResizeEvent = React.useCallback(() => { + resizeSubject.next(null); + }, [resizeSubject]); + + React.useEffect(() => { + let scrollToBottom = true; + const disableScrollToBottom = (): void => { + scrollToBottom = false; + }; + + const subscriptions = [ + fromEvent(window, 'wheel').subscribe(disableScrollToBottom), + fromEvent(window, 'pointerdown').subscribe(disableScrollToBottom), + fromEvent(window, 'keydown').subscribe(disableScrollToBottom), + resizeSubject.subscribe(() => { + if (scrollToBottom) { + window.scrollTo(0, document.body.scrollHeight); + } + }), + ]; + + return () => { + subscriptions.forEach((s) => s.unsubscribe()); + }; + }, [resizeSubject, timeline, props.posts]); + + const [cardHeight, setCardHeight] = React.useState<number>(0); + + const onCardHeightChange = React.useCallback((height: number) => { + setCardHeight(height); + }, []); + + const genCardCollapseLocalStorageKey = (timelineName: string): string => + `timeline.${timelineName}.cardCollapse`; + + const cardCollapseLocalStorageKey = + timeline != null ? genCardCollapseLocalStorageKey(timeline.name) : null; + + const [infoCardCollapse, setInfoCardCollapse] = React.useState<boolean>(true); + React.useEffect(() => { + if (cardCollapseLocalStorageKey != null) { + const savedCollapse = + window.localStorage.getItem(cardCollapseLocalStorageKey) === 'true'; + setInfoCardCollapse(savedCollapse); + } + }, [cardCollapseLocalStorageKey]); + + 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 (props.posts != null) { + if (props.posts === 'forbid') { + timelineBody = ( + <p className="text-danger">{t('timeline.messageCantSee')}</p> + ); + } else { + timelineBody = ( + <Timeline + posts={props.posts} + onDelete={props.onDelete} + onResize={triggerResizeEvent} + /> + ); + if (props.onPost != null) { + timelineBody = ( + <> + {timelineBody} + <div ref={bottomSpaceRef} className="flex-fix-length" /> + <TimelinePostEdit + onPost={props.onPost} + onHeightChange={onPostEditHeightChange} + timelineName={timeline.name} + /> + </> + ); + } + } + } else { + timelineBody = ( + <div className="full-viewport-center-child"> + <Spinner color="primary" type="grow" /> + </div> + ); + } + const { CardComponent } = props; + + body = ( + <> + <div + className="fixed-top mt-appbar info-card-container" + data-collapse={infoCardCollapse ? 'true' : 'false'} + > + <CollapseButton + collapse={infoCardCollapse} + onClick={() => { + const newState = !infoCardCollapse; + setInfoCardCollapse(newState); + window.localStorage.setItem( + genCardCollapseLocalStorageKey(timeline.name), + newState.toString() + ); + }} + className="float-right m-1 info-card-collapse-button text-orange" + /> + <CardComponent + timeline={timeline} + onManage={props.onManage} + onMember={props.onMember} + onHeight={onCardHeightChange} + className="info-card-content" + /> + </div> + {timelineBody} + </> + ); + } else { + body = ( + <div className="full-viewport-center-child"> + <Spinner color="primary" type="grow" /> + </div> + ); + } + } + + return ( + <> + <AppBar /> + <div> + <div + style={{ height: 56 + cardHeight }} + className="timeline-page-top-space flex-fix-length" + /> + {body} + </div> + </> + ); +} diff --git a/Timeline/ClientApp/src/app/timeline/TimelinePageUI.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePageUI.tsx new file mode 100644 index 00000000..d7e9d81b --- /dev/null +++ b/Timeline/ClientApp/src/app/timeline/TimelinePageUI.tsx @@ -0,0 +1,22 @@ +import React from 'react'; + +import { ExcludeKey } from '../type-utilities'; +import { TimelineInfo } from '../data/timeline'; + +import TimelinePageTemplateUI, { + TimelinePageTemplateUIProps +} from './TimelinePageTemplateUI'; +import TimelineInfoCard, { + OrdinaryTimelineManageItem +} from './TimelineInfoCard'; + +export type TimelinePageUIProps = ExcludeKey< + TimelinePageTemplateUIProps<TimelineInfo, OrdinaryTimelineManageItem>, + 'CardComponent' +>; + +const TimelinePageUI: React.FC<TimelinePageUIProps> = props => { + return <TimelinePageTemplateUI {...props} CardComponent={TimelineInfoCard} />; +}; + +export default TimelinePageUI; diff --git a/Timeline/ClientApp/src/app/timeline/TimelinePostEdit.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePostEdit.tsx new file mode 100644 index 00000000..f12b6892 --- /dev/null +++ b/Timeline/ClientApp/src/app/timeline/TimelinePostEdit.tsx @@ -0,0 +1,224 @@ +import React from 'react'; +import clsx from 'clsx'; +import { Button, Spinner, Row, Col } from 'reactstrap'; +import { useTranslation } from 'react-i18next'; + +import { pushAlert } from '../common/alert-service'; +import { CreatePostRequest } from '../data/timeline'; + +import FileInput from '../common/FileInput'; +import { UiLogicError } from '../common'; + +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 ( + <> + <FileInput + labelText={t('chooseImage')} + onChange={onInputChange} + accept="image/*" + className="mx-3 my-1" + /> + {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: CreatePostRequest +) => Promise<void>; + +export interface TimelinePostEditProps { + className?: string; + onPost: TimelinePostSendCallback; + onHeightChange?: (height: number) => void; + timelineName: 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.timelineName}.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!); + + 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: CreatePostRequest = (() => { + switch (kind) { + case 'text': + return { + content: { + type: 'text', + text: text, + }, + } as CreatePostRequest; + case 'image': + if (imageBlob == null) { + throw new UiLogicError( + 'Content type is image but image blob is null.' + ); + } + return { + content: { + type: 'image', + data: imageBlob, + }, + } as CreatePostRequest; + 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="container-fluid fixed-bottom bg-light"> + <Row> + <Col className="px-1 py-1"> + {kind === 'text' ? ( + <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 sm="col-auto align-self-end m-1"> + {(() => { + if (state === 'input') { + return ( + <> + <i + className={clsx( + 'fas d-block text-center large-icon mt-1 mb-2', + kind === 'text' ? 'fa-image' : 'fa-font' + )} + onClick={toggleKind} + /> + <Button color="primary" onClick={onSend} disabled={!canSend}> + {t('timeline.send')} + </Button> + </> + ); + } else { + return <Spinner />; + } + })()} + </Col> + </Row> + </div> + ); +}; + +export default TimelinePostEdit; diff --git a/Timeline/ClientApp/src/app/timeline/TimelinePropertyChangeDialog.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePropertyChangeDialog.tsx new file mode 100644 index 00000000..ca1be31c --- /dev/null +++ b/Timeline/ClientApp/src/app/timeline/TimelinePropertyChangeDialog.tsx @@ -0,0 +1,70 @@ +import React from 'react'; + +import { + TimelineVisibility, + kTimelineVisibilities, + PersonalTimelineChangePropertyRequest +} from '../data/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: PersonalTimelineChangePropertyRequest) => 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: PersonalTimelineChangePropertyRequest = {}; + 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/Timeline/ClientApp/src/app/timeline/timeline-ui.sass b/Timeline/ClientApp/src/app/timeline/timeline-ui.sass new file mode 100644 index 00000000..c3616caf --- /dev/null +++ b/Timeline/ClientApp/src/app/timeline/timeline-ui.sass @@ -0,0 +1,18 @@ +.info-card-container + .info-card-collapse-button + z-index: 1 + position: relative + + .info-card-content + width: 100% + position: absolute + transform-origin: right top + transition: transform 0.5s + + &[data-collapse='true'] + .info-card-content + transform: scale(0) + +.timeline-page-top-space + transition: height 0.5s + diff --git a/Timeline/ClientApp/src/app/timeline/timeline.sass b/Timeline/ClientApp/src/app/timeline/timeline.sass new file mode 100644 index 00000000..0b0e73b5 --- /dev/null +++ b/Timeline/ClientApp/src/app/timeline/timeline.sass @@ -0,0 +1,125 @@ +@use 'sass:color' + +@keyframes timeline-enter-animation-mask-animation + to + height: 0 + +.timeline-enter-animation-mask + position: absolute + left: 0 + top: 0 + height: calc(100% + 300px) + width: 100% + background: linear-gradient(to top, #ffffff00 0, 200px, white 300px, white) + z-index: 100 + animation: timeline-enter-animation-mask-animation 5s 0.3s forwards // Give it 0.3s to load, which I think is reasonable + +$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 + display: flex + flex-direction: column + align-items: center + flex: 0 0 auto + width: 60px + + &-segment + width: $timeline-line-width + background: $timeline-line-color + + &.start + height: 20px + 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 + + +.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-pt-start + padding-top: 18px + +.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 + +textarea.timeline-post-edit + @extend .border-primary + @extend .rounded + + &:focus + outline: none + box-shadow: 0 0 5px 0 $primary diff --git a/Timeline/ClientApp/src/app/tsconfig.json b/Timeline/ClientApp/src/app/tsconfig.json new file mode 100644 index 00000000..14e6327f --- /dev/null +++ b/Timeline/ClientApp/src/app/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "lib": [ + "dom", + "dom.iterable", + "esnext" + ] + }, + "include": [ + "." + ] +} diff --git a/Timeline/ClientApp/src/app/type-utilities.ts b/Timeline/ClientApp/src/app/type-utilities.ts new file mode 100644 index 00000000..8df9bf0f --- /dev/null +++ b/Timeline/ClientApp/src/app/type-utilities.ts @@ -0,0 +1 @@ +export type ExcludeKey<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>; diff --git a/Timeline/ClientApp/src/app/typings.d.ts b/Timeline/ClientApp/src/app/typings.d.ts new file mode 100644 index 00000000..426fb214 --- /dev/null +++ b/Timeline/ClientApp/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/Timeline/ClientApp/src/app/user/ChangeAvatarDialog.tsx b/Timeline/ClientApp/src/app/user/ChangeAvatarDialog.tsx new file mode 100644 index 00000000..a0e56621 --- /dev/null +++ b/Timeline/ClientApp/src/app/user/ChangeAvatarDialog.tsx @@ -0,0 +1,306 @@ +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Modal, + ModalHeader, + Row, + Button, + ModalBody, + ModalFooter, +} from 'reactstrap'; +import { AxiosError } from 'axios'; + +import ImageCropper, { Clip, applyClipToImage } from '../common/ImageCropper'; +import { UiLogicError } from '../common'; + +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 toggle = 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 isOpen={props.open} toggle={toggle}> + <ModalHeader> {t('userPage.dialogChangeAvatar.title')}</ModalHeader> + {(() => { + if (state === 'select') { + return ( + <> + <ModalBody className="container"> + <Row>{t('userPage.dialogChangeAvatar.prompt.select')}</Row> + <Row> + <input type="file" accept="image/*" onChange={onSelectFile} /> + </Row> + </ModalBody> + <ModalFooter> + <Button color="secondary" onClick={toggle}> + {t('operationDialog.cancel')} + </Button> + </ModalFooter> + </> + ); + } else if (state === 'crop') { + if (fileUrl == null) { + throw new UiLogicError(); + } + return ( + <> + <ModalBody className="container"> + <Row className="justify-content-center"> + <ImageCropper + clip={clip} + onChange={setClip} + imageUrl={fileUrl} + imageElementCallback={setCropImgElement} + /> + </Row> + <Row>{t('userPage.dialogChangeAvatar.prompt.crop')}</Row> + </ModalBody> + <ModalFooter> + <Button color="secondary" onClick={toggle}> + {t('operationDialog.cancel')} + </Button> + <Button color="secondary" onClick={onCropPrevious}> + {t('operationDialog.previousStep')} + </Button> + <Button + color="primary" + onClick={onCropNext} + disabled={ + cropImgElement == null || clip == null || clip.width === 0 + } + > + {t('operationDialog.nextStep')} + </Button> + </ModalFooter> + </> + ); + } else if (state === 'processcrop') { + return ( + <> + <ModalBody className="container"> + <Row> + {t('userPage.dialogChangeAvatar.prompt.processingCrop')} + </Row> + </ModalBody> + <ModalFooter> + <Button color="secondary" onClick={toggle}> + {t('operationDialog.cancel')} + </Button> + <Button color="secondary" onClick={onPreviewPrevious}> + {t('operationDialog.previousStep')} + </Button> + </ModalFooter> + </> + ); + } else if (state === 'preview') { + return ( + <> + <ModalBody className="container"> + {createPreviewRow()} + <Row>{t('userPage.dialogChangeAvatar.prompt.preview')}</Row> + </ModalBody> + <ModalFooter> + <Button color="secondary" onClick={toggle}> + {t('operationDialog.cancel')} + </Button> + <Button color="secondary" onClick={onPreviewPrevious}> + {t('operationDialog.previousStep')} + </Button> + <Button color="primary" onClick={upload}> + {t('userPage.dialogChangeAvatar.upload')} + </Button> + </ModalFooter> + </> + ); + } else if (state === 'uploading') { + return ( + <> + <ModalBody className="container"> + {createPreviewRow()} + <Row>{t('userPage.dialogChangeAvatar.prompt.uploading')}</Row> + </ModalBody> + <ModalFooter></ModalFooter> + </> + ); + } else if (state === 'success') { + return ( + <> + <ModalBody className="container"> + <Row className="p-4 text-success"> + {t('operationDialog.success')} + </Row> + </ModalBody> + <ModalFooter> + <Button color="success" onClick={toggle}> + {t('operationDialog.ok')} + </Button> + </ModalFooter> + </> + ); + } else { + return ( + <> + <ModalBody className="container"> + {createPreviewRow()} + <Row className="text-danger">{trueMessage}</Row> + </ModalBody> + <ModalFooter> + <Button color="secondary" onClick={toggle}> + {t('operationDialog.cancel')} + </Button> + <Button color="primary" onClick={upload}> + {t('operationDialog.retry')} + </Button> + </ModalFooter> + </> + ); + } + })()} + </Modal> + ); +}; + +export default ChangeAvatarDialog; diff --git a/Timeline/ClientApp/src/app/user/ChangeNicknameDialog.tsx b/Timeline/ClientApp/src/app/user/ChangeNicknameDialog.tsx new file mode 100644 index 00000000..dbc216e1 --- /dev/null +++ b/Timeline/ClientApp/src/app/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/Timeline/ClientApp/src/app/user/Login.tsx b/Timeline/ClientApp/src/app/user/Login.tsx new file mode 100644 index 00000000..f8b3f0e7 --- /dev/null +++ b/Timeline/ClientApp/src/app/user/Login.tsx @@ -0,0 +1,147 @@ +import React, { Fragment, useState, useEffect } from 'react'; +import { useHistory } from 'react-router'; +import { useTranslation } from 'react-i18next'; +import { AxiosError } from 'axios'; + +import AppBar from '../common/AppBar'; + +import { userLogin, useUser } from '../data/user'; +import { + Label, + FormGroup, + Input, + Form, + FormFeedback, + Spinner, + Button, +} from 'reactstrap'; + +const Login: React.FC = (_) => { + const { t } = useTranslation(); + const history = useHistory(); + const [username, setUsername] = useState<string>(''); + const [usernameDirty, setUsernameDirty] = useState<boolean>(false); + const [password, setPassword] = useState<string>(''); + const [passwordDirty, setPasswordDirty] = useState<boolean>(false); + const [rememberMe, setRememberMe] = useState<boolean>(true); + const [process, setProcess] = useState<boolean>(false); + const [error, setError] = useState<string | null>(null); + + const user = useUser(); + + 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> + </> + ); + } + + function onSubmit(event: React.SyntheticEvent): void { + if (username === '' || password === '') { + setUsernameDirty(true); + setPasswordDirty(true); + return; + } + + setProcess(true); + userLogin( + { + username: username, + password: password, + }, + rememberMe + ).then( + (_) => { + if (history.length === 0) { + history.push('/'); + } else { + history.goBack(); + } + }, + (e: AxiosError | Error) => { + setProcess(false); + setError(e.message); + } + ); + event.preventDefault(); + } + + return ( + <Fragment> + <AppBar /> + <div className="container login-container mt-appbar"> + <h1>{t('welcome')}</h1> + <Form> + <FormGroup> + <Label for="username">{t('user.username')}</Label> + <Input + id="username" + disabled={process} + onChange={(e) => { + setUsername(e.target.value); + setUsernameDirty(true); + }} + value={username} + invalid={usernameDirty && username === ''} + /> + {usernameDirty && username === '' && ( + <FormFeedback>{t('login.emptyUsername')}</FormFeedback> + )} + </FormGroup> + <FormGroup> + <Label for="password">{t('user.password')}</Label> + <Input + id="password" + type="password" + disabled={process} + onChange={(e) => { + setPassword(e.target.value); + setPasswordDirty(true); + }} + value={password} + invalid={passwordDirty && password === ''} + /> + {passwordDirty && password === '' && ( + <FormFeedback>{t('login.emptyPassword')}</FormFeedback> + )} + </FormGroup> + <FormGroup check> + <Input + id="remember-me" + type="checkbox" + checked={rememberMe} + onChange={(e) => { + const v = (e.target as HTMLInputElement).checked; + setRememberMe(v); + }} + /> + <Label for="remember-me">{t('user.rememberMe')}</Label> + </FormGroup> + {error ? <p className="text-error">{t(error)}</p> : null} + <div> + {process ? ( + <Spinner /> + ) : ( + <Button color="primary" onClick={onSubmit}> + {t('user.login')} + </Button> + )} + </div> + </Form> + </div> + </Fragment> + ); +}; + +export default Login; diff --git a/Timeline/ClientApp/src/app/user/User.tsx b/Timeline/ClientApp/src/app/user/User.tsx new file mode 100644 index 00000000..0e1977b1 --- /dev/null +++ b/Timeline/ClientApp/src/app/user/User.tsx @@ -0,0 +1,79 @@ +import React, { useState } from 'react'; +import { useParams } from 'react-router'; + +import { useUser } from '../data/user'; +import { changeNickname, changeAvatar } from './api'; +import { personalTimelineService } from '../data/timeline'; + +import UserPage from './UserPage'; +import ChangeNicknameDialog from './ChangeNicknameDialog'; +import ChangeAvatarDialog from './ChangeAvatarDialog'; +import TimelinePageTemplate from '../timeline/TimelinePageTemplate'; +import { PersonalTimelineManageItem } from './UserInfoCard'; +import { UiLogicError } from '../common'; + +const User: React.FC = (_) => { + const { username } = useParams<{ username: string }>(); + + const user = useUser(); + + const [dialog, setDialog] = useState<null | PersonalTimelineManageItem>(null); + const [dataKey, setDataKey] = useState<number>(0); + + 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) => { + const p = changeNickname(user.token, username, newNickname); + return p.then((_) => { + setDataKey(dataKey + 1); + }); + }} + /> + ); + } else if (dialog === 'avatar') { + if (user == null) { + throw new UiLogicError('Change avatar without login.'); + } + + dialogElement = ( + <ChangeAvatarDialog + open + close={closeDialogHandler} + process={(file) => changeAvatar(user.token, username, file, file.type)} + /> + ); + } + + const onManage = React.useCallback((item: PersonalTimelineManageItem) => { + setDialog(item); + }, []); + + return ( + <> + <TimelinePageTemplate + dataVersion={dataKey} + name={username} + UiComponent={UserPage} + onManage={onManage} + service={personalTimelineService} + notFoundI18nKey="timeline.userNotExist" + /> + {dialogElement} + </> + ); +}; + +export default User; diff --git a/Timeline/ClientApp/src/app/user/UserInfoCard.tsx b/Timeline/ClientApp/src/app/user/UserInfoCard.tsx new file mode 100644 index 00000000..b4924a5d --- /dev/null +++ b/Timeline/ClientApp/src/app/user/UserInfoCard.tsx @@ -0,0 +1,116 @@ +import React from 'react'; +import clsx from 'clsx'; +import { + Dropdown, + DropdownToggle, + DropdownMenu, + DropdownItem, + Button, +} from 'reactstrap'; +import { useTranslation } from 'react-i18next'; +import { fromEvent } from 'rxjs'; + +import { + TimelineInfo, + timelineVisibilityTooltipTranslationMap, +} from '../data/timeline'; +import { useAvatarVersion, useAvatarUrlWithGivenVersion } from './api'; +import { useUser } from '../data/user'; + +import { TimelineCardComponentProps } from '../timeline/TimelinePageTemplateUI'; + +export type PersonalTimelineManageItem = 'avatar' | 'nickname'; + +export type UserInfoCardProps = TimelineCardComponentProps< + TimelineInfo, + PersonalTimelineManageItem +>; + +const UserInfoCard: React.FC<UserInfoCardProps> = (props) => { + const { onHeight, onManage } = props; + const { t } = useTranslation(); + const user = useUser(); + + const avatarVersion = useAvatarVersion(); + const avatarUrl = useAvatarUrlWithGivenVersion( + user != null && user.username === props.timeline.owner.username + ? avatarVersion + : undefined, + props.timeline.owner._links.avatar + ); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const containerRef = React.useRef<HTMLDivElement>(null!); + + const notifyHeight = React.useCallback((): void => { + if (onHeight) { + onHeight(containerRef.current.getBoundingClientRect().height); + } + }, [onHeight]); + + React.useEffect(() => { + const subscription = fromEvent(window, 'resize').subscribe(notifyHeight); + return () => subscription.unsubscribe(); + }); + + const [manageDropdownOpen, setManageDropdownOpen] = React.useState<boolean>( + false + ); + const toggleManageDropdown = React.useCallback( + (): void => setManageDropdownOpen((old) => !old), + [] + ); + + return ( + <div + ref={containerRef} + className={clsx('rounded border bg-light p-2', props.className)} + onTransitionEnd={notifyHeight} + > + <img + src={avatarUrl} + onLoad={notifyHeight} + className="avatar large mr-2 mb-2 rounded-circle float-left" + /> + <div> + {props.timeline.owner.nickname} + <small className="ml-3 text-secondary"> + @{props.timeline.owner.username} + </small> + </div> + <p className="mb-0">{props.timeline.description}</p> + <small className="mt-1 d-block"> + {t(timelineVisibilityTooltipTranslationMap[props.timeline.visibility])} + </small> + <div className="text-right mt-2"> + {onManage != null ? ( + <Dropdown isOpen={manageDropdownOpen} toggle={toggleManageDropdown}> + <DropdownToggle outline color="primary"> + {t('timeline.manage')} + </DropdownToggle> + <DropdownMenu> + <DropdownItem onClick={() => onManage('nickname')}> + {t('timeline.manageItem.nickname')} + </DropdownItem> + <DropdownItem onClick={() => onManage('avatar')}> + {t('timeline.manageItem.avatar')} + </DropdownItem> + <DropdownItem onClick={() => onManage('property')}> + {t('timeline.manageItem.property')} + </DropdownItem> + <DropdownItem onClick={props.onMember}> + {t('timeline.manageItem.member')} + </DropdownItem> + </DropdownMenu> + </Dropdown> + ) : ( + <Button color="primary" outline onClick={props.onMember}> + {t('timeline.memberButton')} + </Button> + )} + </div> + </div> + ); +}; + +export default UserInfoCard; diff --git a/Timeline/ClientApp/src/app/user/UserPage.tsx b/Timeline/ClientApp/src/app/user/UserPage.tsx new file mode 100644 index 00000000..98fd06f8 --- /dev/null +++ b/Timeline/ClientApp/src/app/user/UserPage.tsx @@ -0,0 +1,20 @@ +import React from 'react'; + +import { ExcludeKey } from '../type-utilities'; +import { TimelineInfo } from '../data/timeline'; + +import TimelinePageTemplateUI, { + TimelinePageTemplateUIProps +} from '../timeline/TimelinePageTemplateUI'; +import UserInfoCard, { PersonalTimelineManageItem } from './UserInfoCard'; + +export type UserPageProps = ExcludeKey< + TimelinePageTemplateUIProps<TimelineInfo, PersonalTimelineManageItem>, + 'CardComponent' +>; + +const UserPage: React.FC<UserPageProps> = props => { + return <TimelinePageTemplateUI {...props} CardComponent={UserInfoCard} />; +}; + +export default UserPage; diff --git a/Timeline/ClientApp/src/app/user/api.ts b/Timeline/ClientApp/src/app/user/api.ts new file mode 100644 index 00000000..a9855eb0 --- /dev/null +++ b/Timeline/ClientApp/src/app/user/api.ts @@ -0,0 +1,79 @@ +import React from 'react'; +import axios from 'axios'; +import { BehaviorSubject, Observable } from 'rxjs'; + +import { apiBaseUrl } from '../config'; +import { User } from '../data/user'; +import { updateQueryString } from '../helper'; + +export function changeNickname( + token: string, + username: string, + newNickname: string +): Promise<User> { + return axios + .patch<User>(`${apiBaseUrl}/users/${username}?token=${token}`, { + nickname: newNickname, + }) + .then((res) => res.data); +} + +const avatarVersionSubject = new BehaviorSubject<number | undefined>(undefined); + +export function changeAvatar( + token: string, + username: string, + data: Blob, + type: string +): Promise<void> { + return axios + .put(`${apiBaseUrl}/users/${username}/avatar?token=${token}`, data, { + headers: { + 'Content-Type': type, + }, + }) + .then(() => { + avatarVersionSubject.next((avatarVersionSubject.value ?? 0) + 1); + }); +} + +export const avatarVersion$: Observable< + number | undefined +> = avatarVersionSubject; + +export function useAvatarVersion(): number | undefined { + const [version, setVersion] = React.useState<number | undefined>(); + React.useEffect(() => { + const subscription = avatarVersion$.subscribe((v) => setVersion(v)); + return () => subscription.unsubscribe(); + }, []); + return version; +} + +export function useOptionalVersionedAvatarUrl( + url: string | undefined +): string | undefined { + const avatarVersion = useAvatarVersion(); + return React.useMemo( + () => + url == null + ? undefined + : updateQueryString( + 'v', + avatarVersion == null ? null : avatarVersion.toString(), + url + ), + [avatarVersion, url] + ); +} + +export function useAvatarUrlWithGivenVersion( + version: number | null | undefined, + url: string +): string { + return React.useMemo( + () => + updateQueryString('v', version == null ? null : version.toString(), url), + [version, url] + ); +} diff --git a/Timeline/ClientApp/src/app/user/user-page.sass b/Timeline/ClientApp/src/app/user/user-page.sass new file mode 100644 index 00000000..fe3ab70b --- /dev/null +++ b/Timeline/ClientApp/src/app/user/user-page.sass @@ -0,0 +1,11 @@ +.login-container + max-width: 600px + +.change-avatar-cropper-row + max-height: 400px + +.change-avatar-img + min-width: 50% + max-width: 100% + max-height: 400px + |