Commit 06bffd0b authored by LeeShuang's avatar LeeShuang

migrate frontend

parent 2f72bfa9
{
"extends": ["prettier"]
}
node_modules
.DS_Store
dist
dist-ssr
*.local
.yarn/*
{
"printWidth": 140,
"useTabs": false,
"semi": true,
"singleQuote": false
}
This diff is collapsed.
# Memos web
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/logo.svg" sizes="64x64" type="image/*" />
<meta name="theme-color" content="#f6f5f4" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no" />
<title>Memos</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<!-- Global site tag (gtag.js) - Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-QMWPX445H6"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag() {
dataLayer.push(arguments);
}
gtag("js", new Date());
gtag("config", "G-QMWPX445H6");
</script>
</body>
</html>
{
"name": "memos",
"version": "2.0.6",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"serve": "vite preview"
},
"dependencies": {
"prismjs": "^1.25.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"tiny-undo": "^0.0.8"
},
"devDependencies": {
"@types/prismjs": "^1.16.6",
"@types/react": "^17.0.2",
"@types/react-dom": "^17.0.2",
"@vitejs/plugin-react": "^1.0.0",
"less": "^4.1.1",
"typescript": "^4.3.2",
"vite": "^2.6.14"
},
"license": "MIT"
}
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#37352f"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12l4.58-4.59z"/></svg>
\ No newline at end of file
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#FFFFFF"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6-6-6z"/></svg>
\ No newline at end of file
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#37352f"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6-6-6z"/></svg>
\ No newline at end of file
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#37352f"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14zM17.99 9l-1.41-1.42-6.59 6.59-2.58-2.57-1.42 1.41 4 3.99z"/></svg>
\ No newline at end of file
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#37352f"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M19 5v14H5V5h14m0-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z"/></svg>
\ No newline at end of file
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#37352f"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/></svg>
\ No newline at end of file
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#FFFFFF"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M14.06 9.02l.92.92L5.92 19H5v-.92l9.06-9.06M17.66 3c-.25 0-.51.1-.7.29l-1.83 1.83 3.75 3.75 1.83-1.83c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.2-.2-.45-.29-.71-.29zm-3.6 3.19L3 17.25V21h3.75L17.81 9.94l-3.75-3.75z"/></svg>
\ No newline at end of file
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#37352f"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M14.06 9.02l.92.92L5.92 19H5v-.92l9.06-9.06M17.66 3c-.25 0-.51.1-.7.29l-1.83 1.83 3.75 3.75 1.83-1.83c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.2-.2-.45-.29-.71-.29zm-3.6 3.19L3 17.25V21h3.75L17.81 9.94l-3.75-3.75z"/></svg>
\ No newline at end of file
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#37352f"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z"/></svg>
\ No newline at end of file
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#FFFFFF"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M6 10c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm12 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm-6 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/></svg>
\ No newline at end of file
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#37352f"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M6 10c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm12 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm-6 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/></svg>
\ No newline at end of file
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#37352f"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
\ No newline at end of file
<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="24px" viewBox="0 0 24 24" width="24px" fill="#37352f"><g><rect fill="none" height="24" width="24"/></g><g><path d="M16,5l-1.42,1.42l-1.59-1.59V16h-1.98V4.83L9.42,6.42L8,5l4-4L16,5z M20,10v11c0,1.1-0.9,2-2,2H6c-1.11,0-2-0.9-2-2V10 c0-1.11,0.89-2,2-2h3v2H6v11h12V10h-3V8h3C19.1,8,20,8.89,20,10z"/></g></svg>
\ No newline at end of file
<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><g><rect fill="none" height="24" width="24"/></g><g><path d="M20,10V8h-4V4h-2v4h-4V4H8v4H4v2h4v4H4v2h4v4h2v-4h4v4h2v-4h4v-2h-4v-4H20z M14,14h-4v-4h4V14z"/></g></svg>
\ No newline at end of file
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text x="0.1em" y=".9em" font-size="90">✍️</text></svg>
\ No newline at end of file
import { useContext, useEffect } from "react";
import appContext from "./stores/appContext";
import { appRouterSwitch } from "./routers";
import { globalStateService } from "./services";
import "./less/app.less";
import 'prismjs/themes/prism.css';
function App() {
const {
locationState: { pathname },
} = useContext(appContext);
useEffect(() => {
const handleWindowResize = () => {
globalStateService.setIsMobileView(document.body.clientWidth <= 875);
};
handleWindowResize();
window.addEventListener("resize", handleWindowResize);
return () => {
window.removeEventListener("resize", handleWindowResize);
};
}, []);
return <>{appRouterSwitch(pathname)}</>;
}
export default App;
import { showDialog } from "./Dialog";
import "../less/about-site-dialog.less";
interface Props extends DialogProps {}
const AboutSiteDialog: React.FC<Props> = ({ destroy }: Props) => {
const handleCloseBtnClick = () => {
destroy();
};
return (
<>
<div className="dialog-header-container">
<p className="title-text">
<span className="icon-text">🤠</span>关于 <b>Memos</b>
</p>
<button className="btn close-btn" onClick={handleCloseBtnClick}>
<img className="icon-img" src="/icons/close.svg" />
</button>
</div>
<div className="dialog-content-container">
<p>一个碎片化知识记录工具。</p>
<br />
<i>为何做这个?</i>
<ul>
<li>
实践 <strong>卢曼卡片盒笔记法</strong>
</li>
<li>用于记录:📅 每日/周计划、💡 突发奇想、📕 读后感...</li>
<li>代替了我在微信上经常使用的“文件传输助手”;</li>
<li>打造一个属于自己的轻量化“卡片”笔记簿;</li>
</ul>
<br />
<i>有何特点呢?</i>
<ul>
<li>
{" "}
<a target="_blank" href="https://github.com/boojack/insmemo-web" rel="noreferrer">
开源项目
</a>
</li>
<li>😋 精美且细节的视觉样式;</li>
<li>📑 体验优良的交互逻辑;</li>
</ul>
<br />
<a target="_blank" href="https://github.com/boojack/insmemo-web/discussions" rel="noreferrer">
🤔 问题反馈
</a>
<br />
<p>Enjoy it and have fun~ </p>
<hr />
<p className="normal-text">
Last updated on <span className="pre-text">2021/11/26 16:17:44</span> 🎉
</p>
</div>
</>
);
};
export default function showAboutSiteDialog(): void {
showDialog(
{
className: "about-site-dialog",
},
AboutSiteDialog
);
}
import { useEffect, useState } from "react";
import { userService } from "../services";
import { showDialog } from "./Dialog";
import toastHelper from "./Toast";
import "../less/change-password-dialog.less";
interface Props extends DialogProps {}
const BindWxUserIdDialog: React.FC<Props> = ({ destroy }: Props) => {
const [wxUserId, setWxUserId] = useState("");
useEffect(() => {
// do nth
}, []);
const handleCloseBtnClick = () => {
destroy();
};
const handleWxUserIdChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
const text = e.target.value as string;
setWxUserId(text);
};
const handleSaveBtnClick = async () => {
if (wxUserId === "") {
toastHelper.error("微信 id 不能为空");
return;
}
try {
await userService.updateWxUserId(wxUserId);
userService.doSignIn();
toastHelper.info("绑定成功!");
handleCloseBtnClick();
} catch (error: any) {
toastHelper.error(error);
}
};
return (
<>
<div className="dialog-header-container">
<p className="title-text">绑定微信 OpenID</p>
<button className="btn close-btn" onClick={handleCloseBtnClick}>
<img className="icon-img" src="/icons/close.svg" />
</button>
</div>
<div className="dialog-content-container">
<p className="tip-text">
关注微信公众号“小谈闲事”,主动发送任意消息,即可获取 <strong>OpenID</strong>
</p>
<label className="form-label input-form-label">
<span className={"normal-text " + (wxUserId === "" ? "" : "not-null")}>微信 OpenID</span>
<input type="text" value={wxUserId} onChange={handleWxUserIdChanged} />
</label>
<div className="btns-container">
<span className="btn cancel-btn" onClick={handleCloseBtnClick}>
取消
</span>
<span className="btn confirm-btn" onClick={handleSaveBtnClick}>
保存
</span>
</div>
</div>
</>
);
};
function showBindWxUserIdDialog() {
showDialog(
{
className: "bind-wxid-dialog",
},
BindWxUserIdDialog
);
}
export default showBindWxUserIdDialog;
import { useEffect, useState } from "react";
import { validate, ValidatorConfig } from "../helpers/validator";
import { userService } from "../services";
import { showDialog } from "./Dialog";
import toastHelper from "./Toast";
import "../less/change-password-dialog.less";
const validateConfig: ValidatorConfig = {
minLength: 4,
maxLength: 24,
noSpace: true,
noChinese: true,
};
interface Props extends DialogProps {}
const ChangePasswordDialog: React.FC<Props> = ({ destroy }: Props) => {
const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [newPasswordAgain, setNewPasswordAgain] = useState("");
useEffect(() => {
// do nth
}, []);
const handleCloseBtnClick = () => {
destroy();
};
const handleOldPasswordChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
const text = e.target.value as string;
setOldPassword(text);
};
const handleNewPasswordChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
const text = e.target.value as string;
setNewPassword(text);
};
const handleNewPasswordAgainChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
const text = e.target.value as string;
setNewPasswordAgain(text);
};
const handleSaveBtnClick = async () => {
if (oldPassword === "" || newPassword === "" || newPasswordAgain === "") {
toastHelper.error("密码不能为空");
return;
}
if (newPassword !== newPasswordAgain) {
toastHelper.error("新密码两次输入不一致");
setNewPasswordAgain("");
return;
}
const passwordValidResult = validate(newPassword, validateConfig);
if (!passwordValidResult.result) {
toastHelper.error("密码 " + passwordValidResult.reason);
return;
}
try {
const isValid = await userService.checkPasswordValid(oldPassword);
if (!isValid) {
toastHelper.error("旧密码不匹配");
setOldPassword("");
return;
}
await userService.updatePassword(newPassword);
toastHelper.info("密码修改成功!");
handleCloseBtnClick();
} catch (error: any) {
toastHelper.error(error);
}
};
return (
<>
<div className="dialog-header-container">
<p className="title-text">修改密码</p>
<button className="btn close-btn" onClick={handleCloseBtnClick}>
<img className="icon-img" src="/icons/close.svg" />
</button>
</div>
<div className="dialog-content-container">
<label className="form-label input-form-label">
<span className={"normal-text " + (oldPassword === "" ? "" : "not-null")}>旧密码</span>
<input type="password" value={oldPassword} onChange={handleOldPasswordChanged} />
</label>
<label className="form-label input-form-label">
<span className={"normal-text " + (newPassword === "" ? "" : "not-null")}>新密码</span>
<input type="password" value={newPassword} onChange={handleNewPasswordChanged} />
</label>
<label className="form-label input-form-label">
<span className={"normal-text " + (newPasswordAgain === "" ? "" : "not-null")}>再次输入新密码</span>
<input type="password" value={newPasswordAgain} onChange={handleNewPasswordAgainChanged} />
</label>
<div className="btns-container">
<span className="btn cancel-btn" onClick={handleCloseBtnClick}>
取消
</span>
<span className="btn confirm-btn" onClick={handleSaveBtnClick}>
保存
</span>
</div>
</div>
</>
);
};
function showChangePasswordDialog() {
showDialog(
{
className: "change-password-dialog",
},
ChangePasswordDialog
);
}
export default showChangePasswordDialog;
import React, { memo, useCallback, useEffect, useState } from "react";
import { memoService, queryService } from "../services";
import { checkShouldShowMemoWithFilters, filterConsts, getDefaultFilter, relationConsts } from "../helpers/filter";
import useLoading from "../hooks/useLoading";
import { showDialog } from "./Dialog";
import toastHelper from "./Toast";
import Selector from "./common/Selector";
import "../less/create-query-dialog.less";
interface Props extends DialogProps {
queryId?: string;
}
const CreateQueryDialog: React.FC<Props> = (props: Props) => {
const { destroy, queryId } = props;
const [title, setTitle] = useState<string>("");
const [filters, setFilters] = useState<Filter[]>([]);
const requestState = useLoading(false);
const shownMemoLength = memoService.getState().memos.filter((memo) => {
return checkShouldShowMemoWithFilters(memo, filters);
}).length;
useEffect(() => {
const queryTemp = queryService.getQueryById(queryId ?? "");
if (queryTemp) {
setTitle(queryTemp.title);
const temp = JSON.parse(queryTemp.querystring);
if (Array.isArray(temp)) {
setFilters(temp);
}
}
}, [queryId]);
const handleTitleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const text = e.target.value as string;
setTitle(text);
};
const handleSaveBtnClick = async () => {
if (!title) {
toastHelper.error("标题不能为空!");
return;
}
try {
if (queryId) {
const editedQuery = await queryService.updateQuery(queryId, title, JSON.stringify(filters));
queryService.editQuery(editedQuery);
} else {
const query = await queryService.createQuery(title, JSON.stringify(filters));
queryService.pushQuery(query);
}
} catch (error: any) {
toastHelper.error(error.message);
}
destroy();
};
const handleAddFilterBenClick = () => {
if (filters.length > 0) {
const lastFilter = filters[filters.length - 1];
if (lastFilter.value.value === "") {
toastHelper.info("先完善上一个过滤器吧");
return;
}
}
setFilters([...filters, getDefaultFilter()]);
};
const handleFilterChange = useCallback((index: number, filter: Filter) => {
setFilters((filters) => {
const temp = [...filters];
temp[index] = filter;
return temp;
});
}, []);
const handleFilterRemove = useCallback((index: number) => {
setFilters((filters) => {
const temp = filters.filter((_, i) => i !== index);
return temp;
});
}, []);
return (
<>
<div className="dialog-header-container">
<p className="title-text">
<span className="icon-text">🔖</span>
{queryId ? "编辑检索" : "创建检索"}
</p>
<button className="btn close-btn" onClick={destroy}>
<img className="icon-img" src="/icons/close.svg" />
</button>
</div>
<div className="dialog-content-container">
<div className="form-item-container input-form-container">
<span className="normal-text">标题</span>
<input className="title-input" type="text" value={title} onChange={handleTitleInputChange} />
</div>
<div className="form-item-container filter-form-container">
<span className="normal-text">过滤器</span>
<div className="filters-wrapper">
{filters.map((f, index) => {
return (
<MemoFilterInputer
key={index}
index={index}
filter={f}
handleFilterChange={handleFilterChange}
handleFilterRemove={handleFilterRemove}
/>
);
})}
<div className="create-filter-btn" onClick={handleAddFilterBenClick}>
添加筛选条件
</div>
</div>
</div>
</div>
<div className="dialog-footer-container">
<div></div>
<div className="btns-container">
<span className={`tip-text ${filters.length === 0 && "hidden"}`}>
符合条件的 Memo 有 <strong>{shownMemoLength}</strong>
</span>
<button className={`btn save-btn ${requestState.isLoading ? "requesting" : ""}`} onClick={handleSaveBtnClick}>
保存
</button>
</div>
</div>
</>
);
};
interface MemoFilterInputerProps {
index: number;
filter: Filter;
handleFilterChange: (index: number, filter: Filter) => void;
handleFilterRemove: (index: number) => void;
}
const MemoFilterInputer: React.FC<MemoFilterInputerProps> = memo((props: MemoFilterInputerProps) => {
const { index, filter, handleFilterChange, handleFilterRemove } = props;
const { type } = filter;
const [inputElements, setInputElements] = useState<JSX.Element>(<></>);
useEffect(() => {
let operatorElement = <></>;
if (Object.keys(filterConsts).includes(type)) {
operatorElement = (
<Selector
className="operator-selector"
dataSource={Object.values(filterConsts[type as FilterType].operators)}
value={filter.value.operator}
handleValueChanged={handleOperatorChange}
/>
);
}
let valueElement = <></>;
switch (type) {
case "TYPE": {
valueElement = (
<Selector
className="value-selector"
dataSource={filterConsts["TYPE"].values}
value={filter.value.value}
handleValueChanged={handleValueChange}
/>
);
break;
}
case "TAG": {
valueElement = (
<Selector
className="value-selector"
dataSource={memoService
.getState()
.tags.sort()
.map((t) => {
return { text: t, value: t };
})}
value={filter.value.value}
handleValueChanged={handleValueChange}
/>
);
break;
}
case "TEXT": {
valueElement = (
<input
type="text"
className="value-inputer"
value={filter.value.value}
onChange={(event) => {
handleValueChange(event.target.value);
event.target.focus();
}}
/>
);
break;
}
}
setInputElements(
<>
{operatorElement}
{valueElement}
</>
);
}, [type, filter]);
const handleRelationChange = useCallback(
(value: string) => {
if (["AND", "OR"].includes(value)) {
handleFilterChange(index, {
...filter,
relation: value as MemoFilterRalation,
});
}
},
[filter]
);
const handleTypeChange = useCallback(
(value: string) => {
if (filter.type !== value) {
const ops = Object.values(filterConsts[value as FilterType].operators);
handleFilterChange(index, {
...filter,
type: value as FilterType,
value: {
operator: ops[0].value,
value: "",
},
});
}
},
[filter]
);
const handleOperatorChange = useCallback(
(value: string) => {
handleFilterChange(index, {
...filter,
value: {
...filter.value,
operator: value,
},
});
},
[filter]
);
const handleValueChange = useCallback(
(value: string) => {
handleFilterChange(index, {
...filter,
value: {
...filter.value,
value,
},
});
},
[filter]
);
const handleRemoveBtnClick = () => {
handleFilterRemove(index);
};
return (
<div className="memo-filter-input-wrapper">
{index > 0 ? (
<Selector
className="relation-selector"
dataSource={relationConsts}
value={filter.relation}
handleValueChanged={handleRelationChange}
/>
) : null}
<Selector
className="type-selector"
dataSource={Object.values(filterConsts)}
value={filter.type}
handleValueChanged={handleTypeChange}
/>
{inputElements}
<img className="remove-btn" src="/icons/close.svg" onClick={handleRemoveBtnClick} />
</div>
);
});
export default function showCreateQueryDialog(queryId?: string): void {
showDialog(
{
className: "create-query-dialog",
},
CreateQueryDialog,
{ queryId }
);
}
import { IMAGE_URL_REG } from "../helpers/consts";
import utils from "../helpers/utils";
import { formatMemoContent } from "./Memo";
import Only from "./common/OnlyWhen";
import "../less/daily-memo.less";
interface DailyMemo extends FormattedMemo {
timeStr: string;
}
interface Props {
memo: Model.Memo;
}
const DailyMemo: React.FC<Props> = (props: Props) => {
const { memo: propsMemo } = props;
const memo: DailyMemo = {
...propsMemo,
createdAtStr: utils.getDateTimeString(propsMemo.createdAt),
timeStr: utils.getTimeString(propsMemo.createdAt),
};
const imageUrls = Array.from(memo.content.match(IMAGE_URL_REG) ?? []);
return (
<div className="daily-memo-wrapper">
<div className="time-wrapper">
<span className="normal-text">{memo.timeStr}</span>
</div>
<div className="memo-content-container">
<div className="memo-content-text" dangerouslySetInnerHTML={{ __html: formatMemoContent(memo.content) }}></div>
<Only when={imageUrls.length > 0}>
<div className="images-container">
{imageUrls.map((imgUrl, idx) => (
<img key={idx} crossOrigin="anonymous" src={imgUrl} decoding="async" />
))}
</div>
</Only>
</div>
</div>
);
};
export default DailyMemo;
import { useEffect, useRef, useState } from "react";
import { memoService } from "../services";
import toImage from "../labs/html2image";
import useToggle from "../hooks/useToggle";
import useLoading from "../hooks/useLoading";
import { DAILY_TIMESTAMP } from "../helpers/consts";
import utils from "../helpers/utils";
import { showDialog } from "./Dialog";
import showPreviewImageDialog from "./PreviewImageDialog";
import DailyMemo from "./DailyMemo";
import DatePicker from "./common/DatePicker";
import "../less/daily-memo-diary-dialog.less";
interface Props extends DialogProps {
currentDateStamp: DateStamp;
}
const monthChineseStrArray = ["一月", "二月", "三月", "四月", "五月", "六月", "七月", "八月", "九月", "十月", "十一月", "十二月"];
const weekdayChineseStrArray = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
const DailyMemoDiaryDialog: React.FC<Props> = (props: Props) => {
const loadingState = useLoading();
const [memos, setMemos] = useState<Model.Memo[]>([]);
const [currentDateStamp, setCurrentDateStamp] = useState(utils.getDateStampByDate(utils.getDateString(props.currentDateStamp)));
const [showDatePicker, toggleShowDatePicker] = useToggle(false);
const memosElRef = useRef<HTMLDivElement>(null);
const currentDate = new Date(currentDateStamp);
useEffect(() => {
const setDailyMemos = () => {
const dailyMemos = memoService
.getState()
.memos.filter(
(a) =>
utils.getTimeStampByDate(a.createdAt) >= currentDateStamp &&
utils.getTimeStampByDate(a.createdAt) < currentDateStamp + DAILY_TIMESTAMP
)
.sort((a, b) => utils.getTimeStampByDate(a.createdAt) - utils.getTimeStampByDate(b.createdAt));
setMemos(dailyMemos);
loadingState.setFinish();
};
setDailyMemos();
}, [currentDateStamp]);
const handleShareBtnClick = () => {
toggleShowDatePicker(false);
setTimeout(() => {
if (!memosElRef.current) {
return;
}
toImage(memosElRef.current, {
backgroundColor: "#ffffff",
pixelRatio: window.devicePixelRatio * 2,
})
.then((url) => {
showPreviewImageDialog(url);
})
.catch(() => {
// do nth
});
}, 0);
};
const handleDataPickerChange = (datestamp: DateStamp): void => {
setCurrentDateStamp(datestamp);
toggleShowDatePicker(false);
};
return (
<>
<div className="dialog-header-container">
<div className="header-wrapper">
<p className="title-text">Daily Memos</p>
<div className="btns-container">
<span className="btn-text" onClick={() => setCurrentDateStamp(currentDateStamp - DAILY_TIMESTAMP)}>
<img className="icon-img" src="/icons/arrow-left.svg" />
</span>
<span className="btn-text" onClick={() => setCurrentDateStamp(currentDateStamp + DAILY_TIMESTAMP)}>
<img className="icon-img" src="/icons/arrow-right.svg" />
</span>
<span className="btn-text share-btn" onClick={handleShareBtnClick}>
<img className="icon-img" src="/icons/share.svg" />
</span>
<span className="btn-text" onClick={() => props.destroy()}>
<img className="icon-img" src="/icons/close.svg" />
</span>
</div>
</div>
</div>
<div className="dialog-content-container" ref={memosElRef}>
<div className="date-card-container" onClick={() => toggleShowDatePicker()}>
<div className="year-text">{currentDate.getFullYear()}</div>
<div className="date-container">
<div className="month-text">{monthChineseStrArray[currentDate.getMonth()]}</div>
<div className="date-text">{currentDate.getDate()}</div>
<div className="day-text">{weekdayChineseStrArray[currentDate.getDay()]}</div>
</div>
</div>
<DatePicker
className={`date-picker ${showDatePicker ? "" : "hidden"}`}
datestamp={currentDateStamp}
handleDateStampChange={handleDataPickerChange}
/>
{loadingState.isLoading ? (
<div className="tip-container">
<p className="tip-text">努力加载中...</p>
</div>
) : memos.length === 0 ? (
<div className="tip-container">
<p className="tip-text">空空如也</p>
</div>
) : (
<div className="dailymemos-wrapper">
{memos.map((memo) => (
<DailyMemo key={`${memo.id}-${memo.updatedAt}`} memo={memo} />
))}
</div>
)}
</div>
</>
);
};
export default function showDailyMemoDiaryDialog(datestamp: DateStamp = Date.now()): void {
showDialog(
{
className: "daily-memo-diary-dialog",
},
DailyMemoDiaryDialog,
{ currentDateStamp: datestamp }
);
}
import { IMAGE_URL_REG } from "../helpers/consts";
import utils from "../helpers/utils";
import useToggle from "../hooks/useToggle";
import { memoService } from "../services";
import Only from "./common/OnlyWhen";
import Image from "./Image";
import toastHelper from "./Toast";
import { formatMemoContent } from "./Memo";
import "../less/memo.less";
interface Props {
memo: Model.Memo;
handleDeletedMemoAction: (memoId: string) => void;
}
const DeletedMemo: React.FC<Props> = (props: Props) => {
const { memo: propsMemo, handleDeletedMemoAction } = props;
const memo: FormattedMemo = {
...propsMemo,
createdAtStr: utils.getDateTimeString(propsMemo.createdAt),
deletedAtStr: utils.getDateTimeString(propsMemo.deletedAt ?? Date.now()),
};
const [showConfirmDeleteBtn, toggleConfirmDeleteBtn] = useToggle(false);
const imageUrls = Array.from(memo.content.match(IMAGE_URL_REG) ?? []);
const handleDeleteMemoClick = async () => {
if (showConfirmDeleteBtn) {
try {
await memoService.deleteMemoById(memo.id);
handleDeletedMemoAction(memo.id);
} catch (error: any) {
toastHelper.error(error.message);
}
} else {
toggleConfirmDeleteBtn();
}
};
const handleRestoreMemoClick = async () => {
try {
await memoService.restoreMemoById(memo.id);
handleDeletedMemoAction(memo.id);
toastHelper.info("恢复成功");
} catch (error: any) {
toastHelper.error(error.message);
}
};
const handleMouseLeaveMemoWrapper = () => {
if (showConfirmDeleteBtn) {
toggleConfirmDeleteBtn(false);
}
};
return (
<div className={`memo-wrapper ${"memos-" + memo.id}`} onMouseLeave={handleMouseLeaveMemoWrapper}>
<div className="memo-top-wrapper">
<span className="time-text">删除于 {memo.deletedAtStr}</span>
<div className="btns-container">
<span className="btn more-action-btn">
<img className="icon-img" src="/icons/more.svg" />
</span>
<div className="more-action-btns-wrapper">
<div className="more-action-btns-container">
<span className="btn restore-btn" onClick={handleRestoreMemoClick}>
恢复
</span>
<span className={`btn delete-btn ${showConfirmDeleteBtn ? "final-confirm" : ""}`} onClick={handleDeleteMemoClick}>
{showConfirmDeleteBtn ? "确定删除!" : "完全删除"}
</span>
</div>
</div>
</div>
</div>
<div className="memo-content-text" dangerouslySetInnerHTML={{ __html: formatMemoContent(memo.content) }}></div>
<Only when={imageUrls.length > 0}>
<div className="images-wrapper">
{imageUrls.map((imgUrl, idx) => (
<Image className="memo-img" key={idx} imgUrl={imgUrl} />
))}
</div>
</Only>
</div>
);
};
export default DeletedMemo;
import ReactDOM from "react-dom";
import appContext from "../stores/appContext";
import Provider from "../labs/Provider";
import appStore from "../stores/appStore";
import { ANIMATION_DURATION } from "../helpers/consts";
import "../less/dialog.less";
interface DialogConfig {
className: string;
useAppContext?: boolean;
clickSpaceDestroy?: boolean;
}
interface Props extends DialogConfig, DialogProps {
children: React.ReactNode;
}
const BaseDialog: React.FC<Props> = (props: Props) => {
const { children, className, clickSpaceDestroy, destroy } = props;
const handleSpaceClicked = () => {
if (clickSpaceDestroy) {
destroy();
}
};
return (
<div className={`dialog-wrapper ${className}`} onClick={handleSpaceClicked}>
<div className="dialog-container" onClick={(e) => e.stopPropagation()}>
{children}
</div>
</div>
);
};
export function showDialog<T extends DialogProps>(
config: DialogConfig,
DialogComponent: React.FC<T>,
props?: Omit<T, "destroy">
): DialogCallback {
const tempDiv = document.createElement("div");
document.body.append(tempDiv);
setTimeout(() => {
tempDiv.firstElementChild?.classList.add("showup");
}, 0);
const cbs: DialogCallback = {
destroy: () => {
tempDiv.firstElementChild?.classList.remove("showup");
tempDiv.firstElementChild?.classList.add("showoff");
setTimeout(() => {
tempDiv.remove();
ReactDOM.unmountComponentAtNode(tempDiv);
}, ANIMATION_DURATION);
},
};
const dialogProps = {
...props,
destroy: cbs.destroy,
} as T;
let Fragment = (
<BaseDialog destroy={cbs.destroy} clickSpaceDestroy={true} {...config}>
<DialogComponent {...dialogProps} />
</BaseDialog>
);
if (config.useAppContext) {
Fragment = (
<Provider store={appStore} context={appContext}>
{Fragment}
</Provider>
);
}
ReactDOM.render(Fragment, tempDiv);
return cbs;
}
import { forwardRef, useCallback, useContext, useEffect, useImperativeHandle, useRef } from "react";
import TinyUndo from "tiny-undo";
import appContext from "../../stores/appContext";
import { storage } from "../../helpers/storage";
import useRefresh from "../../hooks/useRefresh";
import Only from "../common/OnlyWhen";
import "../../less/editor.less";
export interface EditorRefActions {
focus: FunctionType;
insertText: (text: string) => void;
setContent: (text: string) => void;
getContent: () => string;
}
interface Props {
className: string;
initialContent: string;
placeholder: string;
showConfirmBtn: boolean;
showCancelBtn: boolean;
showTools: boolean;
onConfirmBtnClick: (content: string) => void;
onCancelBtnClick: () => void;
onContentChange: (content: string) => void;
}
const Editor = forwardRef((props: Props, ref: React.ForwardedRef<EditorRefActions>) => {
const {
globalState: { useTinyUndoHistoryCache },
} = useContext(appContext);
const {
className,
initialContent,
placeholder,
showConfirmBtn,
showCancelBtn,
showTools,
onConfirmBtnClick: handleConfirmBtnClickCallback,
onCancelBtnClick: handleCancelBtnClickCallback,
onContentChange: handleContentChangeCallback,
} = props;
const editorRef = useRef<HTMLTextAreaElement>(null);
const tinyUndoRef = useRef<TinyUndo | null>(null);
const refresh = useRefresh();
useEffect(() => {
if (initialContent) {
editorRef.current!.value = initialContent;
refresh();
}
}, []);
useEffect(() => {
if (useTinyUndoHistoryCache) {
const { tinyUndoActionsCache, tinyUndoIndexCache } = storage.get(["tinyUndoActionsCache", "tinyUndoIndexCache"]);
tinyUndoRef.current = new TinyUndo(editorRef.current!, {
interval: 5000,
initialActions: tinyUndoActionsCache,
initialIndex: tinyUndoIndexCache,
});
tinyUndoRef.current.subscribe((actions, index) => {
storage.set({
tinyUndoActionsCache: actions,
tinyUndoIndexCache: index,
});
});
return () => {
tinyUndoRef.current?.destroy();
};
} else {
tinyUndoRef.current?.destroy();
tinyUndoRef.current = null;
storage.remove(["tinyUndoActionsCache", "tinyUndoIndexCache"]);
}
}, [useTinyUndoHistoryCache]);
useEffect(() => {
if (editorRef.current) {
editorRef.current.style.height = "auto";
editorRef.current.style.height = (editorRef.current.scrollHeight ?? 0) + "px";
}
}, [editorRef.current?.value]);
useImperativeHandle(
ref,
() => ({
focus: () => {
editorRef.current!.focus();
},
insertText: (rawText: string) => {
const prevValue = editorRef.current!.value;
editorRef.current!.value = prevValue + rawText;
handleContentChangeCallback(editorRef.current!.value);
refresh();
},
setContent: (text: string) => {
editorRef.current!.value = text;
refresh();
},
getContent: (): string => {
return editorRef.current?.value ?? "";
},
}),
[]
);
const handleEditorInput = useCallback(() => {
handleContentChangeCallback(editorRef.current!.value);
refresh();
}, []);
const handleEditorKeyDown = useCallback((event: React.KeyboardEvent<HTMLTextAreaElement>) => {
event.stopPropagation();
if (event.code === "Enter") {
if (event.metaKey || event.ctrlKey) {
handleCommonConfirmBtnClick();
}
}
refresh();
}, []);
const handleCommonConfirmBtnClick = useCallback(() => {
handleConfirmBtnClickCallback(editorRef.current!.value);
editorRef.current!.value = "";
refresh();
// After confirm btn clicked, tiny-undo should reset state(clear actions and index)
tinyUndoRef.current?.resetState();
}, []);
const handleCommonCancelBtnClick = useCallback(() => {
handleCancelBtnClickCallback();
}, []);
return (
<div className={"common-editor-wrapper " + className}>
<textarea
className="common-editor-inputer"
rows={1}
placeholder={placeholder}
ref={editorRef}
onInput={handleEditorInput}
onKeyDown={handleEditorKeyDown}
></textarea>
<div className="common-tools-wrapper">
<Only when={showTools}>
<div className={"common-tools-container"}>{/* nth */}</div>
</Only>
<div className="btns-container">
<Only when={showCancelBtn}>
<button className="action-btn cancel-btn" onClick={handleCommonCancelBtnClick}>
撤销修改
</button>
</Only>
<Only when={showConfirmBtn}>
<button className="action-btn confirm-btn" disabled={!editorRef.current?.value} onClick={handleCommonConfirmBtnClick}>
记下<span className="icon-text">✍️</span>
</button>
</Only>
</div>
</div>
</div>
);
});
export default Editor;
import showPreviewImageDialog from "./PreviewImageDialog";
import "../less/image.less";
interface Props {
imgUrl: string;
className?: string;
}
const Image: React.FC<Props> = (props: Props) => {
const { className, imgUrl } = props;
const handleImageClick = () => {
showPreviewImageDialog(imgUrl);
};
return (
<div className={"image-container " + className} onClick={handleImageClick}>
<img src={imgUrl} decoding="async" loading="lazy" />
</div>
);
};
export default Image;
import { memo } from "react";
import { IMAGE_URL_REG, LINK_REG, MEMO_LINK_REG, TAG_REG } from "../helpers/consts";
import { encodeHtml, parseMarkedToHtml, parseRawTextToHtml } from "../helpers/marked";
import utils from "../helpers/utils";
import useToggle from "../hooks/useToggle";
import { globalStateService, memoService } from "../services";
import Only from "./common/OnlyWhen";
import Image from "./Image";
import showMemoCardDialog from "./MemoCardDialog";
import showShareMemoImageDialog from "./ShareMemoImageDialog";
import toastHelper from "./Toast";
import "../less/memo.less";
interface Props {
memo: Model.Memo;
}
const Memo: React.FC<Props> = (props: Props) => {
const { memo: propsMemo } = props;
const memo: FormattedMemo = {
...propsMemo,
createdAtStr: utils.getDateTimeString(propsMemo.createdAt),
};
const [showConfirmDeleteBtn, toggleConfirmDeleteBtn] = useToggle(false);
const imageUrls = Array.from(memo.content.match(IMAGE_URL_REG) ?? []);
const handleShowMemoStoryDialog = () => {
showMemoCardDialog(memo);
};
const handleMarkMemoClick = () => {
globalStateService.setMarkMemoId(memo.id);
};
const handleEditMemoClick = () => {
globalStateService.setEditMemoId(memo.id);
};
const handleDeleteMemoClick = async () => {
if (showConfirmDeleteBtn) {
try {
await memoService.hideMemoById(memo.id);
} catch (error: any) {
toastHelper.error(error.message);
}
if (globalStateService.getState().editMemoId === memo.id) {
globalStateService.setEditMemoId("");
}
} else {
toggleConfirmDeleteBtn();
}
};
const handleMouseLeaveMemoWrapper = () => {
if (showConfirmDeleteBtn) {
toggleConfirmDeleteBtn(false);
}
};
const handleGenMemoImageBtnClick = () => {
showShareMemoImageDialog(memo);
};
const handleMemoContentClick = async (e: React.MouseEvent) => {
const targetEl = e.target as HTMLElement;
if (targetEl.className === "memo-link-text") {
const memoId = targetEl.dataset?.value;
const memoTemp = memoService.getMemoById(memoId ?? "");
if (memoTemp) {
showMemoCardDialog(memoTemp);
} else {
toastHelper.error("MEMO Not Found");
targetEl.classList.remove("memo-link-text");
}
} else if (targetEl.className === "todo-block") {
// do nth
}
};
return (
<div className={`memo-wrapper ${"memos-" + memo.id}`} onMouseLeave={handleMouseLeaveMemoWrapper}>
<div className="memo-top-wrapper">
<span className="time-text" onClick={handleShowMemoStoryDialog}>
{memo.createdAtStr}
</span>
<div className="btns-container">
<span className="btn more-action-btn">
<img className="icon-img" src="/icons/more.svg" />
</span>
<div className="more-action-btns-wrapper">
<div className="more-action-btns-container">
<span className="btn" onClick={handleShowMemoStoryDialog}>
查看详情
</span>
<span className="btn" onClick={handleMarkMemoClick}>
Mark
</span>
<span className="btn" onClick={handleGenMemoImageBtnClick}>
分享
</span>
<span className="btn" onClick={handleEditMemoClick}>
编辑
</span>
<span className={`btn delete-btn ${showConfirmDeleteBtn ? "final-confirm" : ""}`} onClick={handleDeleteMemoClick}>
{showConfirmDeleteBtn ? "确定删除!" : "删除"}
</span>
</div>
</div>
</div>
</div>
<div
className="memo-content-text"
onClick={handleMemoContentClick}
dangerouslySetInnerHTML={{ __html: formatMemoContent(memo.content) }}
></div>
<Only when={imageUrls.length > 0}>
<div className="images-wrapper">
{imageUrls.map((imgUrl, idx) => (
<Image className="memo-img" key={idx} imgUrl={imgUrl} />
))}
</div>
</Only>
</div>
);
};
export function formatMemoContent(content: string) {
content = encodeHtml(content);
content = parseRawTextToHtml(content)
.split("<br>")
.map((t) => {
return `<p>${t !== "" ? t : "<br>"}</p>`;
})
.join("");
const { shouldUseMarkdownParser, shouldSplitMemoWord, shouldHideImageUrl } = globalStateService.getState();
if (shouldUseMarkdownParser) {
content = parseMarkedToHtml(content);
}
if (shouldHideImageUrl) {
content = content.replace(IMAGE_URL_REG, "");
}
// 中英文之间加空格
if (shouldSplitMemoWord) {
content = content
.replace(/([\u4e00-\u9fa5])([A-Za-z0-9?.,;[\]]+)/g, "$1 $2")
.replace(/([A-Za-z0-9?.,;[\]]+)([\u4e00-\u9fa5])/g, "$1 $2");
}
content = content
.replace(TAG_REG, "<span class='tag-span'>#$1</span>")
.replace(LINK_REG, "<a class='link' target='_blank' rel='noreferrer' href='$1'>$1</a>")
.replace(MEMO_LINK_REG, "<span class='memo-link-text' data-value='$2'>$1</span>");
const tempDivContainer = document.createElement("div");
tempDivContainer.innerHTML = content;
for (let i = 0; i < tempDivContainer.children.length; i++) {
const c = tempDivContainer.children[i];
if (c.tagName === "P" && c.textContent === "" && c.firstElementChild?.tagName !== "BR") {
c.remove();
i--;
continue;
}
}
return tempDivContainer.innerHTML;
}
export default memo(Memo);
import { useState, useEffect, useCallback } from "react";
import { IMAGE_URL_REG, MEMO_LINK_REG } from "../helpers/consts";
import utils from "../helpers/utils";
import { globalStateService, memoService } from "../services";
import { parseHtmlToRawText } from "../helpers/marked";
import { formatMemoContent } from "./Memo";
import toastHelper from "./Toast";
import { showDialog } from "./Dialog";
import Only from "./common/OnlyWhen";
import Image from "./Image";
import "../less/memo-card-dialog.less";
interface LinkedMemo extends FormattedMemo {
dateStr: string;
}
interface Props extends DialogProps {
memo: Model.Memo;
}
const MemoCardDialog: React.FC<Props> = (props: Props) => {
const [memo, setMemo] = useState<FormattedMemo>({
...props.memo,
createdAtStr: utils.getDateTimeString(props.memo.createdAt),
});
const [linkMemos, setLinkMemos] = useState<LinkedMemo[]>([]);
const [linkedMemos, setLinkedMemos] = useState<LinkedMemo[]>([]);
const imageUrls = Array.from(memo.content.match(IMAGE_URL_REG) ?? []);
useEffect(() => {
const fetchLinkedMemos = async () => {
try {
const linkMemos: LinkedMemo[] = [];
const matchedArr = [...memo.content.matchAll(MEMO_LINK_REG)];
for (const matchRes of matchedArr) {
if (matchRes && matchRes.length === 3) {
const id = matchRes[2];
const memoTemp = memoService.getMemoById(id);
if (memoTemp) {
linkMemos.push({
...memoTemp,
createdAtStr: utils.getDateTimeString(memoTemp.createdAt),
dateStr: utils.getDateString(memoTemp.createdAt),
});
}
}
}
setLinkMemos([...linkMemos]);
const linkedMemos = await memoService.getLinkedMemos(memo.id);
setLinkedMemos(
linkedMemos
.sort((a, b) => utils.getTimeStampByDate(b.createdAt) - utils.getTimeStampByDate(a.createdAt))
.map((m) => ({
...m,
createdAtStr: utils.getDateTimeString(m.createdAt),
dateStr: utils.getDateString(m.createdAt),
}))
);
} catch (error) {
// do nth
}
};
fetchLinkedMemos();
}, [memo.id]);
const handleMemoContentClick = useCallback(async (e: React.MouseEvent) => {
const targetEl = e.target as HTMLElement;
if (targetEl.className === "memo-link-text") {
const nextMemoId = targetEl.dataset?.value;
const memoTemp = memoService.getMemoById(nextMemoId ?? "");
if (memoTemp) {
const nextMemo = {
...memoTemp,
createdAtStr: utils.getDateTimeString(memoTemp.createdAt),
};
setLinkMemos([]);
setLinkedMemos([]);
setMemo(nextMemo);
} else {
toastHelper.error("MEMO Not Found");
targetEl.classList.remove("memo-link-text");
}
}
}, []);
const handleLinkedMemoClick = useCallback((memo: FormattedMemo) => {
setLinkMemos([]);
setLinkedMemos([]);
setMemo(memo);
}, []);
const handleEditMemoBtnClick = useCallback(() => {
props.destroy();
globalStateService.setEditMemoId(memo.id);
}, [memo.id]);
return (
<>
<div className="memo-card-container">
<div className="header-container">
<p className="time-text">{memo.createdAtStr}</p>
<div className="btns-container">
<button className="btn edit-btn" onClick={handleEditMemoBtnClick}>
<img className="icon-img" src="/icons/edit.svg" />
</button>
<button className="btn close-btn" onClick={props.destroy}>
<img className="icon-img" src="/icons/close.svg" />
</button>
</div>
</div>
<div className="memo-container">
<div
className="memo-content-text"
onClick={handleMemoContentClick}
dangerouslySetInnerHTML={{ __html: formatMemoContent(memo.content) }}
></div>
<Only when={imageUrls.length > 0}>
<div className="images-wrapper">
{imageUrls.map((imgUrl, idx) => (
<Image className="memo-img" key={idx} imgUrl={imgUrl} />
))}
</div>
</Only>
</div>
<div className="layer-container"></div>
{linkMemos.map((_, idx) => {
if (idx < 4) {
return (
<div
className="background-layer-container"
key={idx}
style={{
bottom: (idx + 1) * -3 + "px",
left: (idx + 1) * 5 + "px",
width: `calc(100% - ${(idx + 1) * 10}px)`,
zIndex: -idx - 1,
}}
></div>
);
} else {
return null;
}
})}
</div>
{linkMemos.length > 0 ? (
<div className="linked-memos-wrapper">
<p className="normal-text">关联了 {linkMemos.length} 个 MEMO</p>
{linkMemos.map((m) => {
const rawtext = parseHtmlToRawText(formatMemoContent(m.content)).replaceAll("\n", " ");
return (
<div className="linked-memo-container" key={m.id} onClick={() => handleLinkedMemoClick(m)}>
<span className="time-text">{m.dateStr} </span>
{rawtext}
</div>
);
})}
</div>
) : null}
{linkedMemos.length > 0 ? (
<div className="linked-memos-wrapper">
<p className="normal-text">{linkedMemos.length} 个链接至此的 MEMO</p>
{linkedMemos.map((m) => {
const rawtext = parseHtmlToRawText(formatMemoContent(m.content)).replaceAll("\n", " ");
return (
<div className="linked-memo-container" key={m.id} onClick={() => handleLinkedMemoClick(m)}>
<span className="time-text">{m.dateStr} </span>
{rawtext}
</div>
);
})}
</div>
) : null}
</>
);
};
export default function showMemoCardDialog(memo: Model.Memo): void {
showDialog(
{
className: "memo-card-dialog",
},
MemoCardDialog,
{ memo }
);
}
import { useCallback, useContext, useEffect, useMemo, useRef } from "react";
import appContext from "../stores/appContext";
import { globalStateService, locationService, memoService } from "../services";
import utils from "../helpers/utils";
import { storage } from "../helpers/storage";
import toastHelper from "./Toast";
import Editor, { EditorRefActions } from "./Editor/Editor";
import "../less/memo-editor.less";
interface Props {
className?: string;
editMemoId?: string;
}
const MemoEditor: React.FC<Props> = (props: Props) => {
const { className, editMemoId } = props;
const { globalState } = useContext(appContext);
const editorRef = useRef<EditorRefActions>(null);
const prevGlobalStateRef = useRef(globalState);
useEffect(() => {
if (globalState.markMemoId) {
const editorCurrentValue = editorRef.current?.getContent();
const memoLinkText = `${editorCurrentValue ? "\n" : ""}Mark: [@MEMO](${globalState.markMemoId})`;
editorRef.current?.insertText(memoLinkText);
globalStateService.setMarkMemoId("");
}
if (editMemoId && globalState.editMemoId) {
const editMemo = memoService.getMemoById(globalState.editMemoId);
if (editMemo) {
editorRef.current?.setContent(editMemo.content ?? "");
editorRef.current?.focus();
}
}
prevGlobalStateRef.current = globalState;
}, [globalState.markMemoId, globalState.editMemoId]);
const handleSaveBtnClick = useCallback(async (content: string) => {
if (content === "") {
toastHelper.error("内容不能为空呀");
return;
}
content = content.replaceAll("&nbsp;", " ");
try {
if (editMemoId) {
const prevMemo = memoService.getMemoById(editMemoId);
if (prevMemo && prevMemo.content !== content) {
const editedMemo = await memoService.updateMemo(prevMemo.id, content);
editedMemo.updatedAt = utils.getDateTimeString(Date.now());
memoService.editMemo(editedMemo);
}
globalStateService.setEditMemoId("");
} else {
const newMemo = await memoService.createMemo(content);
memoService.pushMemo(newMemo);
locationService.clearQuery();
}
} catch (error: any) {
toastHelper.error(error.message);
}
setEditorContentCache("");
}, []);
const handleCancelBtnClick = useCallback(() => {
globalStateService.setEditMemoId("");
editorRef.current?.setContent("");
setEditorContentCache("");
}, []);
const handleContentChange = useCallback((content: string) => {
const tempDiv = document.createElement("div");
tempDiv.innerHTML = content;
if (tempDiv.innerText.trim() === "") {
content = "";
}
setEditorContentCache(content);
}, []);
const showEditStatus = Boolean(editMemoId);
const editorConfig = useMemo(
() => ({
className: "memo-editor",
initialContent: getEditorContentCache(),
placeholder: "现在的想法是...",
showConfirmBtn: true,
showCancelBtn: showEditStatus,
showTools: true,
onConfirmBtnClick: handleSaveBtnClick,
onCancelBtnClick: handleCancelBtnClick,
onContentChange: handleContentChange,
}),
[editMemoId]
);
return (
<div className={`memo-editor-wrapper ${className} ${editMemoId ? "edit-ing" : ""}`}>
<p className={"tip-text " + (editMemoId ? "" : "hidden")}>正在修改中...</p>
<Editor ref={editorRef} {...editorConfig} />
</div>
);
};
function getEditorContentCache(): string {
return storage.get(["editorContentCache"]).editorContentCache ?? "";
}
function setEditorContentCache(content: string) {
storage.set({
editorContentCache: content,
});
}
export default MemoEditor;
import { useContext } from "react";
import appContext from "../stores/appContext";
import { locationService, queryService } from "../services";
import utils from "../helpers/utils";
import { getTextWithMemoType } from "../helpers/filter";
import "../less/memo-filter.less";
interface FilterProps {}
const MemoFilter: React.FC<FilterProps> = () => {
const {
locationState: { query },
} = useContext(appContext);
const { tag: tagQuery, duration, type: memoType, text: textQuery, filter } = query;
const queryFilter = queryService.getQueryById(filter);
const showFilter = Boolean(tagQuery || (duration && duration.from < duration.to) || memoType || textQuery || queryFilter);
return (
<div className={`filter-query-container ${showFilter ? "" : "hidden"}`}>
<span className="tip-text">筛选:</span>
<div
className={"filter-item-container " + (queryFilter ? "" : "hidden")}
onClick={() => {
locationService.setMemoFilter("");
}}
>
<span className="icon-text">🔖</span> {queryFilter?.title}
</div>
<div
className={"filter-item-container " + (tagQuery ? "" : "hidden")}
onClick={() => {
locationService.setTagQuery("");
}}
>
<span className="icon-text">🏷️</span> {tagQuery}
</div>
<div
className={"filter-item-container " + (memoType ? "" : "hidden")}
onClick={() => {
locationService.setMemoTypeQuery("");
}}
>
<span className="icon-text">📦</span> {getTextWithMemoType(memoType as MemoSpecType)}
</div>
{duration && duration.from < duration.to ? (
<div
className="filter-item-container"
onClick={() => {
locationService.setFromAndToQuery(0, 0);
}}
>
<span className="icon-text">🗓️</span> {utils.getDateString(duration.from)}{utils.getDateString(duration.to)}
</div>
) : null}
<div
className={"filter-item-container " + (textQuery ? "" : "hidden")}
onClick={() => {
locationService.setTextQuery("");
}}
>
<span className="icon-text">🔍</span> {textQuery}
</div>
</div>
);
};
export default MemoFilter;
import { useCallback, useContext, useEffect, useRef, useState } from "react";
import appContext from "../stores/appContext";
import { locationService, memoService, queryService } from "../services";
import { IMAGE_URL_REG, LINK_REG, MEMO_LINK_REG, TAG_REG } from "../helpers/consts";
import utils from "../helpers/utils";
import { checkShouldShowMemoWithFilters } from "../helpers/filter";
import Memo from "./Memo";
import toastHelper from "./Toast";
import MemoEditor from "./MemoEditor";
import "../less/memolist.less";
interface Props {}
const MemoList: React.FC<Props> = () => {
const {
locationState: { query },
memoState: { memos },
globalState,
} = useContext(appContext);
const [isFetching, setFetchStatus] = useState(true);
const wrapperElement = useRef<HTMLDivElement>(null);
const { tag: tagQuery, duration, type: memoType, text: textQuery, filter: queryId } = query;
const showMemoFilter = Boolean(tagQuery || (duration && duration.from < duration.to) || memoType || textQuery);
const shownMemos =
showMemoFilter || queryId
? memos.filter((memo) => {
let shouldShow = true;
const query = queryService.getQueryById(queryId);
if (query) {
const filters = JSON.parse(query.querystring) as Filter[];
if (Array.isArray(filters)) {
shouldShow = checkShouldShowMemoWithFilters(memo, filters);
}
}
if (tagQuery && !memo.content.includes(`# ${tagQuery}`)) {
shouldShow = false;
}
if (
duration &&
duration.from < duration.to &&
(utils.getTimeStampByDate(memo.createdAt) < duration.from || utils.getTimeStampByDate(memo.createdAt) > duration.to)
) {
shouldShow = false;
}
if (memoType) {
if (memoType === "NOT_TAGGED" && memo.content.match(TAG_REG) !== null) {
shouldShow = false;
} else if (memoType === "LINKED" && memo.content.match(LINK_REG) === null) {
shouldShow = false;
} else if (memoType === "IMAGED" && memo.content.match(IMAGE_URL_REG) === null) {
shouldShow = false;
} else if (memoType === "CONNECTED" && memo.content.match(MEMO_LINK_REG) === null) {
shouldShow = false;
}
}
if (textQuery && !memo.content.includes(textQuery)) {
shouldShow = false;
}
return shouldShow;
})
: memos;
useEffect(() => {
memoService
.fetchAllMemos()
.then(() => {
setFetchStatus(false);
})
.catch(() => {
toastHelper.error("😭 请求数据失败了");
});
}, []);
useEffect(() => {
wrapperElement.current?.scrollTo({ top: 0 });
}, [query]);
const handleMemoListClick = useCallback((event: React.MouseEvent) => {
const targetEl = event.target as HTMLElement;
if (targetEl.tagName === "SPAN" && targetEl.className === "tag-span") {
const tagName = targetEl.innerText.slice(1);
const currTagQuery = locationService.getState().query.tag;
if (currTagQuery === tagName) {
locationService.setTagQuery("");
} else {
locationService.setTagQuery(tagName);
}
}
}, []);
return (
<div className={`memolist-wrapper ${isFetching ? "" : "completed"}`} onClick={handleMemoListClick} ref={wrapperElement}>
{shownMemos.map((memo) =>
globalState.editMemoId === memo.id ? (
<MemoEditor key={memo.id} className="memo-edit" editMemoId={memo.id} />
) : (
<Memo key={`${memo.id}-${memo.updatedAt}`} memo={memo} />
)
)}
<div className="status-text-container">
<p className="status-text">
{isFetching ? "努力请求数据中..." : shownMemos.length === 0 ? "空空如也" : showMemoFilter ? "" : "所有数据加载完啦 🎉"}
</p>
</div>
</div>
);
};
export default MemoList;
import { useCallback, useContext, useEffect, useState } from "react";
import appContext from "../stores/appContext";
import SearchBar from "./SearchBar";
import { globalStateService, memoService, queryService } from "../services";
import Only from "./common/OnlyWhen";
import "../less/memos-header.less";
let prevRequestTimestamp = Date.now();
interface Props {}
const MemosHeader: React.FC<Props> = () => {
const {
locationState: {
query: { filter },
},
globalState: { isMobileView },
queryState: { queries },
} = useContext(appContext);
const [titleText, setTitleText] = useState("MEMOS");
useEffect(() => {
const query = queryService.getQueryById(filter);
if (query) {
setTitleText(query.title);
} else {
setTitleText("MEMOS");
}
}, [filter, queries]);
const handleMemoTextClick = useCallback(() => {
const now = Date.now();
if (now - prevRequestTimestamp > 10 * 1000) {
prevRequestTimestamp = now;
memoService.fetchAllMemos().catch(() => {
// do nth
});
}
}, []);
const handleShowSidebarBtnClick = useCallback(() => {
globalStateService.setShowSiderbarInMobileView(true);
}, []);
return (
<div className="section-header-container memos-header-container">
<div className="title-text" onClick={handleMemoTextClick}>
<Only when={isMobileView}>
<button className="action-btn" onClick={handleShowSidebarBtnClick}>
<img className="icon-img" src="/icons/menu.svg" alt="menu" />
</button>
</Only>
<span className="normal-text">{titleText}</span>
</div>
<SearchBar />
</div>
);
};
export default MemosHeader;
import { useEffect, useRef } from "react";
import { locationService, userService } from "../services";
import showAboutSiteDialog from "./AboutSiteDialog";
import "../less/menu-btns-popup.less";
interface Props {
shownStatus: boolean;
setShownStatus: (status: boolean) => void;
}
const MenuBtnsPopup: React.FC<Props> = (props: Props) => {
const { shownStatus, setShownStatus } = props;
const popupElRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (shownStatus) {
const handleClickOutside = (event: MouseEvent) => {
if (!popupElRef.current?.contains(event.target as Node)) {
event.stopPropagation();
}
setShownStatus(false);
};
window.addEventListener("click", handleClickOutside, {
capture: true,
once: true,
});
}
}, [shownStatus]);
const handleMyAccountBtnClick = () => {
locationService.pushHistory("/setting");
};
const handleMemosTrashBtnClick = () => {
locationService.pushHistory("/recycle");
};
const handleAboutBtnClick = () => {
showAboutSiteDialog();
};
const handleSignOutBtnClick = async () => {
await userService.doSignOut();
locationService.replaceHistory("/signin");
};
return (
<div className={`menu-btns-popup ${shownStatus ? "" : "hidden"}`} ref={popupElRef}>
<button className="btn action-btn" onClick={handleMyAccountBtnClick}>
<span className="icon">👤</span> 账号与设置
</button>
<button className="btn action-btn" onClick={handleMemosTrashBtnClick}>
<span className="icon">🗑️</span> 回收站
</button>
<button className="btn action-btn" onClick={handleAboutBtnClick}>
<span className="icon">🤠</span> 关于
</button>
<button className="btn action-btn" onClick={handleSignOutBtnClick}>
<span className="icon">👋</span> 退出
</button>
</div>
);
};
export default MenuBtnsPopup;
import { useContext, useState } from "react";
import appContext from "../stores/appContext";
import { userService } from "../services";
import utils from "../helpers/utils";
import { validate, ValidatorConfig } from "../helpers/validator";
import Only from "./common/OnlyWhen";
import toastHelper from "./Toast";
import showChangePasswordDialog from "./ChangePasswordDialog";
import showBindWxUserIdDialog from "./BindWxUserIdDialog";
import "../less/my-account-section.less";
const validateConfig: ValidatorConfig = {
minLength: 4,
maxLength: 24,
noSpace: true,
noChinese: true,
};
interface Props {}
const MyAccountSection: React.FC<Props> = () => {
const { userState } = useContext(appContext);
const user = userState.user as Model.User;
const [username, setUsername] = useState<string>(user.username);
const [showEditUsernameInputs, setShowEditUsernameInputs] = useState(false);
const [showConfirmUnbindGithubBtn, setShowConfirmUnbindGithubBtn] = useState(false);
const [showConfirmUnbindWxBtn, setShowConfirmUnbindWxBtn] = useState(false);
const handleUsernameChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
const nextUsername = e.target.value as string;
setUsername(nextUsername);
};
const handleConfirmEditUsernameBtnClick = async () => {
if (user.username === "guest") {
toastHelper.info("🈲 不要修改我的用户名");
return;
}
if (username === user.username) {
setShowEditUsernameInputs(false);
return;
}
const usernameValidResult = validate(username, validateConfig);
if (!usernameValidResult.result) {
toastHelper.error("用户名 " + usernameValidResult.reason);
return;
}
try {
const isUsable = await userService.checkUsernameUsable(username);
if (!isUsable) {
toastHelper.error("用户名无法使用");
return;
}
await userService.updateUsername(username);
await userService.doSignIn();
setShowEditUsernameInputs(false);
toastHelper.info("修改成功~");
} catch (error: any) {
toastHelper.error(error.message);
}
};
const handleChangePasswordBtnClick = () => {
if (user.username === "guest") {
toastHelper.info("🈲 不要修改我的密码");
return;
}
showChangePasswordDialog();
};
const handleUnbindGithubBtnClick = async () => {
if (showConfirmUnbindGithubBtn) {
try {
await userService.removeGithubName();
await userService.doSignIn();
} catch (error: any) {
toastHelper.error(error.message);
}
setShowConfirmUnbindGithubBtn(false);
} else {
setShowConfirmUnbindGithubBtn(true);
}
};
const handleUnbindWxBtnClick = async () => {
if (showConfirmUnbindWxBtn) {
try {
await userService.updateWxUserId("");
await userService.doSignIn();
} catch (error: any) {
toastHelper.error(error.message);
}
setShowConfirmUnbindWxBtn(false);
} else {
setShowConfirmUnbindWxBtn(true);
}
};
const handlePreventDefault = (e: React.MouseEvent) => {
e.preventDefault();
};
return (
<>
<div className="section-container account-section-container">
<p className="title-text">基本信息</p>
<label className="form-label input-form-label">
<span className="normal-text">ID:</span>
<span className="normal-text">{user.id}</span>
</label>
<label className="form-label input-form-label">
<span className="normal-text">创建时间:</span>
<span className="normal-text">{utils.getDateString(user.createdAt)}</span>
</label>
<label className="form-label input-form-label username-label">
<span className="normal-text">账号:</span>
<input
type="text"
readOnly={!showEditUsernameInputs}
value={username}
onClick={() => {
setShowEditUsernameInputs(true);
}}
onChange={handleUsernameChanged}
/>
<div className="btns-container" onClick={handlePreventDefault}>
<span className={"btn confirm-btn " + (showEditUsernameInputs ? "" : "hidden")} onClick={handleConfirmEditUsernameBtnClick}>
保存
</span>
<span
className={"btn cancel-btn " + (showEditUsernameInputs ? "" : "hidden")}
onClick={() => {
setUsername(user.username);
setShowEditUsernameInputs(false);
}}
>
撤销
</span>
</div>
</label>
<label className="form-label password-label">
<span className="normal-text">密码:</span>
<span className="btn" onClick={handleChangePasswordBtnClick}>
修改密码
</span>
</label>
</div>
{/* Account Binding Settings: only can use for domain: memos.justsven.top */}
<Only when={window.location.origin.includes("justsven.top")}>
<div className="section-container connect-section-container">
<p className="title-text">关联账号</p>
<label className="form-label input-form-label">
<span className="normal-text">微信 OpenID:</span>
{user.wxUserId ? (
<>
<span className="value-text">************</span>
<span
className={`btn-text unbind-btn ${showConfirmUnbindWxBtn ? "final-confirm" : ""}`}
onMouseLeave={() => setShowConfirmUnbindWxBtn(false)}
onClick={handleUnbindWxBtnClick}
>
{showConfirmUnbindWxBtn ? "确定取消绑定!" : "取消绑定"}
</span>
</>
) : (
<>
<span className="value-text"></span>
<span
className="btn-text bind-btn"
onClick={() => {
showBindWxUserIdDialog();
}}
>
绑定 ID
</span>
</>
)}
</label>
<label className="form-label input-form-label">
<span className="normal-text">GitHub:</span>
{user.githubName ? (
<>
<a className="value-text" href={"https://github.com/" + user.githubName}>
{user.githubName}
</a>
<span
className={`btn-text unbind-btn ${showConfirmUnbindGithubBtn ? "final-confirm" : ""}`}
onMouseLeave={() => setShowConfirmUnbindGithubBtn(false)}
onClick={handleUnbindGithubBtnClick}
>
{showConfirmUnbindGithubBtn ? "确定取消绑定!" : "取消绑定"}
</span>
</>
) : (
<>
<span className="value-text"></span>
<a
className="btn-text link-btn"
href="https://github.com/login/oauth/authorize?client_id=187ba36888f152b06612&scope=read:user,gist"
>
前往绑定
</a>
</>
)}
</label>
</div>
</Only>
</>
);
};
export default MyAccountSection;
import { useContext } from "react";
import appContext from "../stores/appContext";
import { globalStateService, memoService } from "../services";
import { parseHtmlToRawText } from "../helpers/marked";
import { formatMemoContent } from "./Memo";
import "../less/preferences-section.less";
interface Props {}
const PreferencesSection: React.FC<Props> = () => {
const { globalState } = useContext(appContext);
const { useTinyUndoHistoryCache, shouldHideImageUrl, shouldSplitMemoWord, shouldUseMarkdownParser } = globalState;
const demoMemoContent = `👋 你好呀~\n我是一个demo:\n* 👏 欢迎使用memos;`;
const handleOpenTinyUndoChanged = () => {
globalStateService.setAppSetting({
useTinyUndoHistoryCache: !useTinyUndoHistoryCache,
});
};
const handleSplitWordsValueChanged = () => {
globalStateService.setAppSetting({
shouldSplitMemoWord: !shouldSplitMemoWord,
});
};
const handleHideImageUrlValueChanged = () => {
globalStateService.setAppSetting({
shouldHideImageUrl: !shouldHideImageUrl,
});
};
const handleUseMarkdownParserChanged = () => {
globalStateService.setAppSetting({
shouldUseMarkdownParser: !shouldUseMarkdownParser,
});
};
const handleExportBtnClick = async () => {
const formatedMemos = memoService.getState().memos.map((m) => {
return {
...m,
};
});
const jsonStr = JSON.stringify(formatedMemos);
const element = document.createElement("a");
element.setAttribute("href", "data:text/plain;charset=utf-8," + encodeURIComponent(jsonStr));
element.setAttribute("download", "data.json");
element.style.display = "none";
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
};
const handleFormatMemosBtnClick = async () => {
const memos = memoService.getState().memos;
for (const m of memos) {
memoService.updateMemo(m.id, parseHtmlToRawText(m.content));
}
};
return (
<>
<div className="section-container preferences-section-container">
<p className="title-text">Memo 显示相关</p>
<div
className="demo-content-container memo-content-text"
dangerouslySetInnerHTML={{ __html: formatMemoContent(demoMemoContent) }}
></div>
<label className="form-label checkbox-form-label" onClick={handleSplitWordsValueChanged}>
<span className="normal-text">中英文内容自动间隔</span>
<img className="icon-img" src={shouldSplitMemoWord ? "/icons/checkbox-active.svg" : "/icons/checkbox.svg"} />
</label>
<label className="form-label checkbox-form-label" onClick={handleUseMarkdownParserChanged}>
<span className="normal-text">部分 markdown 格式解析</span>
<img className="icon-img" src={shouldUseMarkdownParser ? "/icons/checkbox-active.svg" : "/icons/checkbox.svg"} />
</label>
<label className="form-label checkbox-form-label" onClick={handleHideImageUrlValueChanged}>
<span className="normal-text">隐藏图片链接地址</span>
<img className="icon-img" src={shouldHideImageUrl ? "/icons/checkbox-active.svg" : "/icons/checkbox.svg"} />
</label>
</div>
<div className="section-container preferences-section-container">
<p className="title-text">编辑器</p>
<label className="form-label checkbox-form-label" onClick={handleOpenTinyUndoChanged}>
<span className="normal-text">
启用{" "}
<a target="_blank" href="https://github.com/boojack/tiny-undo" onClick={(e) => e.stopPropagation()}>
tiny-undo
</a>
</span>
<img className="icon-img" src={useTinyUndoHistoryCache ? "/icons/checkbox-active.svg" : "/icons/checkbox.svg"} />
</label>
</div>
<div className="section-container hidden">
<p className="title-text">其他</p>
<div className="btn-container">
<button className="btn export-btn" onClick={handleExportBtnClick}>
导出数据(JSON)
</button>
<button className="btn format-btn" onClick={handleFormatMemosBtnClick}>
格式化数据
</button>
</div>
</div>
</>
);
};
export default PreferencesSection;
import { useEffect, useRef, useState } from "react";
import utils from "../helpers/utils";
import { showDialog } from "./Dialog";
import "../less/preview-image-dialog.less";
interface Props extends DialogProps {
imgUrl: string;
}
const PreviewImageDialog: React.FC<Props> = ({ destroy, imgUrl }: Props) => {
const imgRef = useRef<HTMLImageElement>(null);
const [imgWidth, setImgWidth] = useState<number>(-1);
useEffect(() => {
utils.getImageSize(imgUrl).then(({ width }) => {
if (width !== 0) {
setImgWidth(80);
} else {
setImgWidth(0);
}
});
}, []);
const handleCloseBtnClick = () => {
destroy();
};
const handleDecreaseImageSize = () => {
if (imgWidth > 30) {
setImgWidth(imgWidth - 10);
}
};
const handleIncreaseImageSize = () => {
setImgWidth(imgWidth + 10);
};
return (
<>
<button className="btn close-btn" onClick={handleCloseBtnClick}>
<img className="icon-img" src="/icons/close.svg" />
</button>
<div className="img-container">
<img className={imgWidth <= 0 ? "hidden" : ""} ref={imgRef} width={imgWidth + "%"} src={imgUrl} />
<span className={"loading-text " + (imgWidth === -1 ? "" : "hidden")}>图片加载中...</span>
<span className={"loading-text " + (imgWidth === 0 ? "" : "hidden")}>😟 图片加载失败,可能是无效的链接</span>
</div>
<div className="action-btns-container">
<button className="btn" onClick={handleDecreaseImageSize}>
</button>
<button className="btn" onClick={handleIncreaseImageSize}>
</button>
<button className="btn" onClick={() => setImgWidth(80)}>
</button>
</div>
</>
);
};
export default function showPreviewImageDialog(imgUrl: string): void {
showDialog(
{
className: "preview-image-dialog",
},
PreviewImageDialog,
{ imgUrl }
);
}
import React, { useContext, useEffect } from "react";
import appContext from "../stores/appContext";
import useToggle from "../hooks/useToggle";
import useLoading from "../hooks/useLoading";
import Only from "./common/OnlyWhen";
import utils from "../helpers/utils";
import toastHelper from "./Toast";
import { locationService, queryService } from "../services";
import showCreateQueryDialog from "./CreateQueryDialog";
import "../less/query-list.less";
interface Props {}
const QueryList: React.FC<Props> = () => {
const {
queryState: { queries },
locationState: {
query: { filter },
},
} = useContext(appContext);
const loadingState = useLoading();
const sortedQueries = queries
.sort((a, b) => utils.getTimeStampByDate(b.createdAt) - utils.getTimeStampByDate(a.createdAt))
.sort((a, b) => utils.getTimeStampByDate(b.pinnedAt ?? 0) - utils.getTimeStampByDate(a.pinnedAt ?? 0));
useEffect(() => {
queryService
.getMyAllQueries()
.catch(() => {
// do nth
})
.finally(() => {
loadingState.setFinish();
});
}, []);
return (
<div className="queries-wrapper">
<p className="title-text">
<span className="normal-text">快速检索</span>
<span className="btn" onClick={() => showCreateQueryDialog()}>
+
</span>
</p>
<Only when={loadingState.isSucceed && sortedQueries.length === 0}>
<div className="create-query-btn-container">
<span className="btn" onClick={() => showCreateQueryDialog()}>
创建检索
</span>
</div>
</Only>
<div className="queries-container">
{sortedQueries.map((q) => {
return <QueryItemContainer key={q.id} query={q} isActive={q.id === filter} />;
})}
</div>
</div>
);
};
interface QueryItemContainerProps {
query: Model.Query;
isActive: boolean;
}
const QueryItemContainer: React.FC<QueryItemContainerProps> = (props: QueryItemContainerProps) => {
const { query, isActive } = props;
const [showActionBtns, toggleShowActionBtns] = useToggle(false);
const [showConfirmDeleteBtn, toggleConfirmDeleteBtn] = useToggle(false);
const handleQueryClick = () => {
if (isActive) {
locationService.setMemoFilter("");
} else {
if (!["/", "/recycle"].includes(locationService.getState().pathname)) {
locationService.setPathname("/");
}
locationService.setMemoFilter(query.id);
}
};
const handleShowActionBtnClick = (event: React.MouseEvent) => {
event.stopPropagation();
toggleShowActionBtns();
};
const handleActionBtnContainerMouseLeave = () => {
toggleShowActionBtns(false);
};
const handleDeleteMemoClick = async (event: React.MouseEvent) => {
event.stopPropagation();
if (showConfirmDeleteBtn) {
try {
await queryService.deleteQuery(query.id);
} catch (error: any) {
toastHelper.error(error.message);
}
} else {
toggleConfirmDeleteBtn();
}
};
const handleEditQueryBtnClick = (event: React.MouseEvent) => {
event.stopPropagation();
showCreateQueryDialog(query.id);
};
const handlePinQueryBtnClick = async (event: React.MouseEvent) => {
event.stopPropagation();
try {
if (query.pinnedAt) {
await queryService.unpinQuery(query.id);
queryService.editQuery({
...query,
pinnedAt: undefined,
});
} else {
await queryService.pinQuery(query.id);
queryService.editQuery({
...query,
pinnedAt: utils.getDateTimeString(Date.now()),
});
}
} catch (error) {
// do nth
}
};
const handleDeleteBtnMouseLeave = () => {
toggleConfirmDeleteBtn(false);
};
return (
<>
<div className={`query-item-container ${isActive ? "active" : ""}`} onClick={handleQueryClick}>
<div className="query-text-container">
<span className="icon-text">#</span>
<span className="query-text">{query.title}</span>
</div>
<div className="btns-container">
<span className="action-btn toggle-btn" onClick={handleShowActionBtnClick}>
<img className="icon-img" src={`/icons/more${isActive ? "-white" : ""}.svg`} />
</span>
<div className={`action-btns-wrapper ${showActionBtns ? "" : "hidden"}`} onMouseLeave={handleActionBtnContainerMouseLeave}>
<div className="action-btns-container">
<span className="btn" onClick={handlePinQueryBtnClick}>
{query.pinnedAt ? "取消置顶" : "置顶"}
</span>
<span className="btn" onClick={handleEditQueryBtnClick}>
编辑
</span>
<span
className={`btn delete-btn ${showConfirmDeleteBtn ? "final-confirm" : ""}`}
onClick={handleDeleteMemoClick}
onMouseLeave={handleDeleteBtnMouseLeave}
>
{showConfirmDeleteBtn ? "确定删除!" : "删除"}
</span>
</div>
</div>
</div>
</div>
</>
);
};
export default QueryList;
import { useContext } from "react";
import appContext from "../stores/appContext";
import { locationService } from "../services";
import { memoSpecialTypes } from "../helpers/filter";
import "../less/search-bar.less";
interface Props {}
const SearchBar: React.FC<Props> = () => {
const {
locationState: {
query: { type: memoType },
},
} = useContext(appContext);
const handleMemoTypeItemClick = (type: MemoSpecType | "") => {
const { type: prevType } = locationService.getState().query;
if (type === prevType) {
type = "";
}
locationService.setMemoTypeQuery(type);
};
const handleTextQueryInput = (event: React.FormEvent<HTMLInputElement>) => {
const text = event.currentTarget.value;
locationService.setTextQuery(text);
};
return (
<div className="search-bar-container">
<div className="search-bar-inputer">
<img className="icon-img" src="/icons/search.svg" />
<input className="text-input" type="text" placeholder="" onChange={handleTextQueryInput} />
</div>
<div className="quickly-action-wrapper">
<div className="quickly-action-container">
<p className="title-text">QUICKLY FILTER</p>
<div className="section-container types-container">
<span className="section-text">类型:</span>
<div className="values-container">
{memoSpecialTypes.map((t, idx) => {
return (
<div key={t.value}>
<span
className={`type-item ${memoType === t.value ? "selected" : ""}`}
onClick={() => {
handleMemoTypeItemClick(t.value as MemoSpecType);
}}
>
{t.text}
</span>
{idx + 1 < memoSpecialTypes.length ? <span className="split-text">/</span> : null}
</div>
);
})}
</div>
</div>
</div>
</div>
</div>
);
};
export default SearchBar;
import { useEffect, useRef, useState } from "react";
import { userService } from "../services";
import toImage from "../labs/html2image";
import { ANIMATION_DURATION, IMAGE_URL_REG } from "../helpers/consts";
import utils from "../helpers/utils";
import { showDialog } from "./Dialog";
import { formatMemoContent } from "./Memo";
import Only from "./common/OnlyWhen";
import toastHelper from "./Toast";
import "../less/share-memo-image-dialog.less";
interface Props extends DialogProps {
memo: Model.Memo;
}
const ShareMemoImageDialog: React.FC<Props> = (props: Props) => {
const { memo: propsMemo, destroy } = props;
const { user: userinfo } = userService.getState();
const memo: FormattedMemo = {
...propsMemo,
createdAtStr: utils.getDateTimeString(propsMemo.createdAt),
};
const memoImgUrls = Array.from(memo.content.match(IMAGE_URL_REG) ?? []);
const [shortcutImgUrl, setShortcutImgUrl] = useState("");
const [imgAmount, setImgAmount] = useState(memoImgUrls.length);
const memoElRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (imgAmount > 0) {
return;
}
setTimeout(() => {
if (!memoElRef.current) {
return;
}
toImage(memoElRef.current, {
backgroundColor: "#eaeaea",
pixelRatio: window.devicePixelRatio * 2,
})
.then((url) => {
setShortcutImgUrl(url);
})
.catch(() => {
// do nth
});
}, ANIMATION_DURATION);
}, [imgAmount]);
const handleCloseBtnClick = () => {
destroy();
};
const handleImageOnLoad = (ev: React.SyntheticEvent<HTMLImageElement>) => {
if (ev.type === "error") {
toastHelper.error("有个图片加载失败了😟");
(ev.target as HTMLImageElement).remove();
}
setImgAmount(imgAmount - 1);
};
return (
<>
<div className="dialog-header-container">
<p className="title-text">
<span className="icon-text">🥰</span>分享 Memo 图片
</p>
<button className="btn close-btn" onClick={handleCloseBtnClick}>
<img className="icon-img" src="/icons/close.svg" />
</button>
</div>
<div className="dialog-content-container">
<div className={`tip-words-container ${shortcutImgUrl ? "finish" : "loading"}`}>
<p className="tip-text">{shortcutImgUrl ? "右键或长按即可保存图片 👇" : "图片生成中..."}</p>
</div>
<div className="memo-container" ref={memoElRef}>
<Only when={shortcutImgUrl !== ""}>
<img className="memo-shortcut-img" src={shortcutImgUrl} />
</Only>
<span className="time-text">{memo.createdAtStr}</span>
<div className="memo-content-text" dangerouslySetInnerHTML={{ __html: formatMemoContent(memo.content) }}></div>
<Only when={memoImgUrls.length > 0}>
<div className="images-container">
{memoImgUrls.map((imgUrl, idx) => (
<img
crossOrigin="anonymous"
decoding="async"
key={idx}
src={imgUrl}
onLoad={handleImageOnLoad}
onError={handleImageOnLoad}
/>
))}
</div>
</Only>
<div className="watermark-container">
<span className="normal-text">
<span className="icon-text">✍️</span> by <span className="name-text">{userinfo?.username}</span>
</span>
</div>
</div>
</div>
</>
);
};
export default function showShareMemoImageDialog(memo: Model.Memo): void {
showDialog(
{
className: "share-memo-image-dialog",
},
ShareMemoImageDialog,
{ memo }
);
}
import { useContext, useEffect, useMemo, useRef } from "react";
import appContext from "../stores/appContext";
import { SHOW_SIDERBAR_MOBILE_CLASSNAME } from "../helpers/consts";
import { globalStateService } from "../services";
import UserBanner from "./UserBanner";
import QueryList from "./QueryList";
import TagList from "./TagList";
import UsageHeatMap from "./UsageHeatMap";
import "../less/siderbar.less";
interface Props {}
const Sidebar: React.FC<Props> = () => {
const {
locationState,
globalState: { isMobileView, showSiderbarInMobileView },
} = useContext(appContext);
const wrapperElRef = useRef<HTMLElement>(null);
const handleClickOutsideOfWrapper = useMemo(() => {
return (event: MouseEvent) => {
const siderbarShown = globalStateService.getState().showSiderbarInMobileView;
if (!siderbarShown) {
window.removeEventListener("click", handleClickOutsideOfWrapper, {
capture: true,
});
return;
}
if (!wrapperElRef.current?.contains(event.target as Node)) {
if (wrapperElRef.current?.parentNode?.contains(event.target as Node)) {
if (siderbarShown) {
event.stopPropagation();
}
globalStateService.setShowSiderbarInMobileView(false);
window.removeEventListener("click", handleClickOutsideOfWrapper, {
capture: true,
});
}
}
};
}, []);
useEffect(() => {
globalStateService.setShowSiderbarInMobileView(false);
}, [locationState]);
useEffect(() => {
if (showSiderbarInMobileView) {
document.body.classList.add(SHOW_SIDERBAR_MOBILE_CLASSNAME);
} else {
document.body.classList.remove(SHOW_SIDERBAR_MOBILE_CLASSNAME);
}
}, [showSiderbarInMobileView]);
useEffect(() => {
if (isMobileView && showSiderbarInMobileView) {
window.addEventListener("click", handleClickOutsideOfWrapper, {
capture: true,
});
}
}, [isMobileView, showSiderbarInMobileView]);
return (
<aside className="sidebar-wrapper" ref={wrapperElRef}>
<UserBanner />
<UsageHeatMap />
<QueryList />
<TagList />
</aside>
);
};
export default Sidebar;
import { useContext, useEffect, useState } from "react";
import appContext from "../stores/appContext";
import { locationService, memoService } from "../services";
import useToggle from "../hooks/useToggle";
import Only from "./common/OnlyWhen";
import utils from "../helpers/utils";
import "../less/tag-list.less";
interface Tag {
key: string;
text: string;
subTags: Tag[];
}
interface Props {}
const TagList: React.FC<Props> = () => {
const {
locationState: {
query: { tag: tagQuery },
},
memoState: { tags: tagsText, memos },
} = useContext(appContext);
const [tags, setTags] = useState<Tag[]>([]);
useEffect(() => {
memoService.updateTagsState();
}, [memos]);
useEffect(() => {
const sortedTags = Array.from(tagsText).sort();
const root: KVObject<any> = {
subTags: [],
};
for (const tag of sortedTags) {
const subtags = tag.split("/");
let tempObj = root;
let tagText = "";
for (let i = 0; i < subtags.length; i++) {
const key = subtags[i];
if (i === 0) {
tagText += key;
} else {
tagText += "/" + key;
}
let obj = null;
for (const t of tempObj.subTags) {
if (t.text === tagText) {
obj = t;
break;
}
}
if (!obj) {
obj = {
key,
text: tagText,
subTags: [],
};
tempObj.subTags.push(obj);
}
tempObj = obj;
}
}
setTags(root.subTags as Tag[]);
}, [tagsText]);
return (
<div className="tags-wrapper">
<p className="title-text">常用标签</p>
<div className="tags-container">
{tags.map((t, idx) => (
<TagItemContainer key={t.text + "-" + idx} tag={t} tagQuery={tagQuery} />
))}
<Only when={tags.length < 5 && memoService.initialized}>
<p className="tag-tip-container">
输入<span className="code-text"># Tag </span>来创建标签吧~
</p>
</Only>
</div>
</div>
);
};
interface TagItemContainerProps {
tag: Tag;
tagQuery: string;
}
const TagItemContainer: React.FC<TagItemContainerProps> = (props: TagItemContainerProps) => {
const { tag, tagQuery } = props;
const isActive = tagQuery === tag.text;
const hasSubTags = tag.subTags.length > 0;
const [showSubTags, toggleSubTags] = useToggle(false);
const handleTagClick = () => {
if (isActive) {
locationService.setTagQuery("");
} else {
utils.copyTextToClipboard(`# ${tag.text} `);
if (!["/", "/recycle"].includes(locationService.getState().pathname)) {
locationService.setPathname("/");
}
locationService.setTagQuery(tag.text);
}
};
const handleToggleBtnClick = (event: React.MouseEvent) => {
event.stopPropagation();
toggleSubTags();
};
return (
<>
<div className={`tag-item-container ${isActive ? "active" : ""}`} onClick={handleTagClick}>
<div className="tag-text-container">
<span className="icon-text">#</span>
<span className="tag-text">{tag.key}</span>
</div>
<div className="btns-container">
{hasSubTags ? (
<span className={`action-btn toggle-btn ${showSubTags ? "shown" : ""}`} onClick={handleToggleBtnClick}>
<img className="icon-img" src="/icons/arrow-right.svg" />
</span>
) : null}
</div>
</div>
{hasSubTags ? (
<div className={`subtags-container ${showSubTags ? "" : "hidden"}`}>
{tag.subTags.map((st, idx) => (
<TagItemContainer key={st.text + "-" + idx} tag={st} tagQuery={tagQuery} />
))}
</div>
) : null}
</>
);
};
export default TagList;
import { useEffect } from "react";
import ReactDOM from "react-dom";
import { TOAST_ANIMATION_DURATION } from "../helpers/consts";
import "../less/toast.less";
type ToastType = "normal" | "success" | "info" | "error";
type ToastConfig = {
type: ToastType;
content: string;
duration: number;
};
type ToastItemProps = {
type: ToastType;
content: string;
duration: number;
destory: FunctionType;
};
const Toast: React.FC<ToastItemProps> = (props: ToastItemProps) => {
const { destory, duration } = props;
useEffect(() => {
if (duration > 0) {
setTimeout(() => {
destory();
}, duration);
}
}, []);
return (
<div className="toast-container" onClick={destory}>
<p className="content-text">{props.content}</p>
</div>
);
};
class ToastHelper {
private shownToastAmount = 0;
private toastWrapper: HTMLDivElement;
private shownToastContainers: HTMLDivElement[] = [];
constructor() {
const wrapperClassName = "toast-list-container";
const tempDiv = document.createElement("div");
tempDiv.className = wrapperClassName;
document.body.appendChild(tempDiv);
this.toastWrapper = tempDiv;
}
public info = (content: string, duration = 3000) => {
return this.showToast({ type: "normal", content, duration });
};
public success = (content: string, duration = 3000) => {
return this.showToast({ type: "success", content, duration });
};
public error = (content: string, duration = 3000) => {
return this.showToast({ type: "error", content, duration });
};
private showToast = (config: ToastConfig) => {
const tempDiv = document.createElement("div");
tempDiv.className = `toast-wrapper ${config.type}`;
this.toastWrapper.appendChild(tempDiv);
this.shownToastAmount++;
this.shownToastContainers.push(tempDiv);
setTimeout(() => {
tempDiv.classList.add("showup");
}, 0);
const cbs = {
destory: () => {
tempDiv.classList.add("destory");
setTimeout(() => {
if (!tempDiv.parentElement) {
return;
}
this.shownToastAmount--;
if (this.shownToastAmount === 0) {
for (const d of this.shownToastContainers) {
ReactDOM.unmountComponentAtNode(d);
d.remove();
}
this.shownToastContainers.splice(0, this.shownToastContainers.length);
}
}, TOAST_ANIMATION_DURATION);
},
};
ReactDOM.render(<Toast {...config} destory={cbs.destory} />, tempDiv);
return cbs;
};
}
const toastHelper = new ToastHelper();
export default toastHelper;
import { useCallback, useContext, useEffect, useRef, useState } from "react";
import appContext from "../stores/appContext";
import { globalStateService, locationService } from "../services";
import { DAILY_TIMESTAMP } from "../helpers/consts";
import utils from "../helpers/utils";
import "../less/usage-heat-map.less";
const tableConfig = {
width: 12,
height: 7,
};
const getInitialUsageStat = (usedDaysAmount: number, beginDayTimestemp: number): DailyUsageStat[] => {
const initialUsageStat: DailyUsageStat[] = [];
for (let i = 1; i <= usedDaysAmount; i++) {
initialUsageStat.push({
timestamp: beginDayTimestemp + DAILY_TIMESTAMP * i,
count: 0,
});
}
return initialUsageStat;
};
interface DailyUsageStat {
timestamp: number;
count: number;
}
interface Props {}
const UsageHeatMap: React.FC<Props> = () => {
const todayTimeStamp = utils.getDateStampByDate(Date.now());
const todayDay = new Date(todayTimeStamp).getDay() || 7;
const nullCell = new Array(7 - todayDay).fill(0);
const usedDaysAmount = (tableConfig.width - 1) * tableConfig.height + todayDay;
const beginDayTimestemp = todayTimeStamp - usedDaysAmount * DAILY_TIMESTAMP;
const {
memoState: { memos },
} = useContext(appContext);
const [allStat, setAllStat] = useState<DailyUsageStat[]>(getInitialUsageStat(usedDaysAmount, beginDayTimestemp));
const [popupStat, setPopupStat] = useState<DailyUsageStat | null>(null);
const [currentStat, setCurrentStat] = useState<DailyUsageStat | null>(null);
const containerElRef = useRef<HTMLDivElement>(null);
const popupRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const newStat: DailyUsageStat[] = getInitialUsageStat(usedDaysAmount, beginDayTimestemp);
for (const m of memos) {
const index = (utils.getDateStampByDate(m.createdAt) - beginDayTimestemp) / (1000 * 3600 * 24) - 1;
if (index >= 0) {
newStat[index].count += 1;
}
}
setAllStat([...newStat]);
}, [memos]);
const handleUsageStatItemMouseEnter = useCallback((event: React.MouseEvent, item: DailyUsageStat) => {
setPopupStat(item);
if (!popupRef.current) {
return;
}
const { isMobileView } = globalStateService.getState();
const targetEl = event.target as HTMLElement;
const sidebarEl = document.querySelector(".sidebar-wrapper") as HTMLElement;
popupRef.current.style.left = targetEl.offsetLeft - (containerElRef.current?.offsetLeft ?? 0) + "px";
let topValue = targetEl.offsetTop;
if (!isMobileView) {
topValue -= sidebarEl.scrollTop;
}
popupRef.current.style.top = topValue + "px";
}, []);
const handleUsageStatItemMouseLeave = useCallback(() => {
setPopupStat(null);
}, []);
const handleUsageStatItemClick = useCallback((item: DailyUsageStat) => {
if (locationService.getState().query.duration?.from === item.timestamp) {
locationService.setFromAndToQuery(0, 0);
setCurrentStat(null);
} else if (item.count > 0) {
if (!["/", "/recycle"].includes(locationService.getState().pathname)) {
locationService.setPathname("/");
}
locationService.setFromAndToQuery(item.timestamp, item.timestamp + DAILY_TIMESTAMP);
setCurrentStat(item);
}
}, []);
return (
<div className="usage-heat-map-wrapper" ref={containerElRef}>
<div className="day-tip-text-container">
<span className="tip-text">Mon</span>
<span className="tip-text"></span>
<span className="tip-text">Wed</span>
<span className="tip-text"></span>
<span className="tip-text">Fri</span>
<span className="tip-text"></span>
<span className="tip-text">Sun</span>
</div>
{/* popup */}
<div ref={popupRef} className={"usage-detail-container pop-up " + (popupStat ? "" : "hidden")}>
{popupStat?.count} memos on <span className="date-text">{new Date(popupStat?.timestamp as number).toDateString()}</span>
</div>
<div className="usage-heat-map">
{allStat.map((v, i) => {
const count = v.count;
const colorLevel =
count <= 0
? ""
: count <= 1
? "stat-day-L1-bg"
: count <= 2
? "stat-day-L2-bg"
: count <= 4
? "stat-day-L3-bg"
: "stat-day-L4-bg";
return (
<span
className={`stat-container ${colorLevel} ${currentStat === v ? "current" : ""} ${
todayTimeStamp === v.timestamp ? "today" : ""
}`}
key={i}
onMouseEnter={(e) => handleUsageStatItemMouseEnter(e, v)}
onMouseLeave={handleUsageStatItemMouseLeave}
onClick={() => handleUsageStatItemClick(v)}
></span>
);
})}
{nullCell.map((v, i) => (
<span className="stat-container null" key={i}></span>
))}
</div>
</div>
);
};
export default UsageHeatMap;
import { useCallback, useContext, useState } from "react";
import appContext from "../stores/appContext";
import { locationService } from "../services";
import utils from "../helpers/utils";
import MenuBtnsPopup from "./MenuBtnsPopup";
import showDailyMemoDiaryDialog from "./DailyMemoDiaryDialog";
import "../less/user-banner.less";
interface Props {}
const UserBanner: React.FC<Props> = () => {
const {
memoState: { memos, tags },
userState: { user },
} = useContext(appContext);
const username = user ? user.username : "Memos";
const createdDays = user ? Math.ceil((Date.now() - utils.getTimeStampByDate(user.createdAt)) / 1000 / 3600 / 24) : 0;
const [shouldShowPopupBtns, setShouldShowPopupBtns] = useState(false);
const handleUsernameClick = useCallback(() => {
locationService.pushHistory("/");
locationService.clearQuery();
}, []);
const handlePopupBtnClick = () => {
const sidebarEl = document.querySelector(".sidebar-wrapper") as HTMLElement;
const popupEl = document.querySelector(".menu-btns-popup") as HTMLElement;
popupEl.style.top = 54 - sidebarEl.scrollTop + "px";
setShouldShowPopupBtns(true);
};
return (
<div className="user-banner-container">
<div className="userinfo-header-container">
<p className="username-text" onClick={handleUsernameClick}>
{username}
</p>
<span className="action-btn menu-popup-btn" onClick={handlePopupBtnClick}>
<img src="/icons/more.svg" className="icon-img" />
</span>
<MenuBtnsPopup shownStatus={shouldShowPopupBtns} setShownStatus={setShouldShowPopupBtns} />
</div>
<div className="status-text-container">
<div className="status-text memos-text">
<span className="amount-text">{memos.length}</span>
<span className="type-text">MEMO</span>
</div>
<div className="status-text tags-text">
<span className="amount-text">{tags.length}</span>
<span className="type-text">TAG</span>
</div>
<div className="status-text duration-text" onClick={() => showDailyMemoDiaryDialog()}>
<span className="amount-text">{createdDays}</span>
<span className="type-text">DAY</span>
</div>
</div>
</div>
);
};
export default UserBanner;
import { useEffect, useState } from "react";
import { DAILY_TIMESTAMP } from "../../helpers/consts";
import "../../less/common/date-picker.less";
interface DatePickerProps {
className?: string;
datestamp: DateStamp;
handleDateStampChange: (datastamp: DateStamp) => void;
}
const DatePicker: React.FC<DatePickerProps> = (props: DatePickerProps) => {
const { className, datestamp, handleDateStampChange } = props;
const [currentDateStamp, setCurrentDateStamp] = useState<DateStamp>(getMonthFirstDayDateStamp(datestamp));
useEffect(() => {
setCurrentDateStamp(getMonthFirstDayDateStamp(datestamp));
}, [datestamp]);
const firstDate = new Date(currentDateStamp);
const firstDateDay = firstDate.getDay() === 0 ? 7 : firstDate.getDay();
const dayList = [];
for (let i = 1; i < firstDateDay; i++) {
dayList.push({
date: 0,
datestamp: firstDate.getTime() - DAILY_TIMESTAMP * (7 - i),
});
}
const dayAmount = getMonthDayAmount(currentDateStamp);
for (let i = 1; i <= dayAmount; i++) {
dayList.push({
date: i,
datestamp: firstDate.getTime() + DAILY_TIMESTAMP * (i - 1),
});
}
const handleDateItemClick = (datestamp: DateStamp) => {
handleDateStampChange(datestamp);
};
const handleChangeMonthBtnClick = (i: -1 | 1) => {
const year = firstDate.getFullYear();
const month = firstDate.getMonth() + 1;
let nextDateStamp = 0;
if (month === 1 && i === -1) {
nextDateStamp = new Date(`${year - 1}/12/1`).getTime();
} else if (month === 12 && i === 1) {
nextDateStamp = new Date(`${year + 1}/1/1`).getTime();
} else {
nextDateStamp = new Date(`${year}/${month + i}/1`).getTime();
}
setCurrentDateStamp(getMonthFirstDayDateStamp(nextDateStamp));
};
return (
<div className={`date-picker-wrapper ${className}`}>
<div className="date-picker-header">
<span className="btn-text" onClick={() => handleChangeMonthBtnClick(-1)}>
<img className="icon-img" src="/icons/arrow-left.svg" />
</span>
<span className="normal-text">
{firstDate.getFullYear()}{firstDate.getMonth() + 1}
</span>
<span className="btn-text" onClick={() => handleChangeMonthBtnClick(1)}>
<img className="icon-img" src="/icons/arrow-right.svg" />
</span>
</div>
<div className="date-picker-day-container">
<div className="date-picker-day-header">
<span className="day-item">周一</span>
<span className="day-item">周二</span>
<span className="day-item">周三</span>
<span className="day-item">周四</span>
<span className="day-item">周五</span>
<span className="day-item">周六</span>
<span className="day-item">周日</span>
</div>
{dayList.map((d) => {
if (d.date === 0) {
return (
<span key={d.datestamp} className="day-item null">
{""}
</span>
);
} else {
return (
<span
key={d.datestamp}
className={`day-item ${d.datestamp === datestamp ? "current" : ""}`}
onClick={() => handleDateItemClick(d.datestamp)}
>
{d.date}
</span>
);
}
})}
</div>
</div>
);
};
function getMonthDayAmount(datestamp: DateStamp): number {
const dateTemp = new Date(datestamp);
const currentDate = new Date(`${dateTemp.getFullYear()}/${dateTemp.getMonth() + 1}/1`);
const nextMonthDate =
currentDate.getMonth() === 11
? new Date(`${currentDate.getFullYear() + 1}/1/1`)
: new Date(`${currentDate.getFullYear()}/${currentDate.getMonth() + 2}/1`);
return (nextMonthDate.getTime() - currentDate.getTime()) / DAILY_TIMESTAMP;
}
function getMonthFirstDayDateStamp(timestamp: TimeStamp): DateStamp {
const dateTemp = new Date(timestamp);
const currentDate = new Date(`${dateTemp.getFullYear()}/${dateTemp.getMonth() + 1}/1`);
return currentDate.getTime();
}
export default DatePicker;
interface OnlyWhenProps {
children: React.ReactElement;
when: boolean;
}
const OnlyWhen: React.FC<OnlyWhenProps> = (props: OnlyWhenProps) => {
const { children, when } = props;
return when ? <>{children}</> : null;
};
const Only = OnlyWhen;
export default Only;
import React, { memo, useEffect, useRef } from "react";
import useToggle from "../../hooks/useToggle";
import "../../less/common/selector.less";
interface TVObject {
text: string;
value: string;
}
interface Props {
className?: string;
value: string;
dataSource: TVObject[];
handleValueChanged?: (value: string) => void;
}
const nullItem = {
text: "请选择",
value: "",
};
const Selector: React.FC<Props> = (props: Props) => {
const { className, dataSource, handleValueChanged, value } = props;
const [showSelector, toggleSelectorStatus] = useToggle(false);
const seletorElRef = useRef<HTMLDivElement>(null);
let currentItem = nullItem;
for (const d of dataSource) {
if (d.value === value) {
currentItem = d;
break;
}
}
useEffect(() => {
if (showSelector) {
const handleClickOutside = (event: MouseEvent) => {
if (!seletorElRef.current?.contains(event.target as Node)) {
toggleSelectorStatus(false);
}
};
window.addEventListener("click", handleClickOutside, {
capture: true,
once: true,
});
}
}, [showSelector]);
const handleItemClick = (item: TVObject) => {
if (handleValueChanged) {
handleValueChanged(item.value);
}
toggleSelectorStatus(false);
};
const handleCurrentValueClick = (event: React.MouseEvent) => {
event.stopPropagation();
toggleSelectorStatus();
};
return (
<div className={`selector-wrapper ${className ?? ""}`} ref={seletorElRef}>
<div className={`current-value-container ${showSelector ? "active" : ""}`} onClick={handleCurrentValueClick}>
<span className="value-text">{currentItem.text}</span>
<span className="arrow-text">
<img className="icon-img" src="/icons/arrow-right.svg" />
</span>
</div>
<div className={`items-wrapper ${showSelector ? "" : "hidden"}`}>
{dataSource.map((d) => {
return (
<div
className={`item-container ${d.value === value ? "selected" : ""}`}
key={d.value}
onClick={() => {
handleItemClick(d);
}}
>
{d.text}
</div>
);
})}
</div>
</div>
);
};
export default memo(Selector);
type ResponseType<T = unknown> = {
succeed: boolean;
status: number;
message: string;
data: T;
};
async function get<T>(url: string): Promise<ResponseType<T>> {
const response = await fetch(url, {
method: "GET",
});
const resData = (await response.json()) as ResponseType<T>;
if (!resData.succeed) {
throw resData;
}
return resData;
}
async function post<T>(url: string, data?: BasicType): Promise<ResponseType<T>> {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
const resData = (await response.json()) as ResponseType<T>;
if (!resData.succeed) {
throw resData;
}
return resData;
}
namespace api {
export function getUserInfo() {
return get<Model.User>("/api/user/me");
}
export function signin(username: string, password: string) {
return post("/api/user/signin", { username, password });
}
export function signup(username: string, password: string) {
return post("/api/user/signup", { username, password });
}
export function signout() {
return post("/api/user/signout");
}
export function checkUsernameUsable(username: string) {
return get<boolean>("/api/user/checkusername?username=" + username);
}
export function checkPasswordValid(password: string) {
return post<boolean>("/api/user/checkpassword", { password });
}
export function updateUserinfo(username?: string, password?: string, githubName?: string, wxUserId?: string) {
return post("/api/user/update", {
username,
password,
githubName,
wxUserId,
});
}
export function getMyMemos() {
return get<Model.Memo[]>("/api/memo/all");
}
export function getMyDeletedMemos() {
return get<Model.Memo[]>("/api/memo/deleted");
}
export function createMemo(content: string) {
return post<Model.Memo>("/api/memo/new", { content });
}
export function getMemoById(id: string) {
return get<Model.Memo>("/api/memo/?id=" + id);
}
export function hideMemo(memoId: string) {
return post("/api/memo/hide", {
memoId,
});
}
export function restoreMemo(memoId: string) {
return post("/api/memo/restore", {
memoId,
});
}
export function deleteMemo(memoId: string) {
return post("/api/memo/delete", {
memoId,
});
}
export function updateMemo(memoId: string, content: string) {
return post<Model.Memo>("/api/memo/update", { memoId, content });
}
export function getLinkedMemos(memoId: string) {
return get<Model.Memo[]>("/api/memo/linked?memoId=" + memoId);
}
export function removeGithubName() {
return post("/api/user/updategh", { githubName: "" });
}
export function getMyQueries() {
return get<Model.Query[]>("/api/query/all");
}
export function createQuery(title: string, querystring: string) {
return post<Model.Query>("/api/query/new", { title, querystring });
}
export function updateQuery(queryId: string, title: string, querystring: string) {
return post<Model.Query>("/api/query/update", { queryId, title, querystring });
}
export function deleteQueryById(queryId: string) {
return post("/api/query/delete", { queryId });
}
export function pinQuery(queryId: string) {
return post("/api/query/pin", { queryId });
}
export function unpinQuery(queryId: string) {
return post("/api/query/unpin", { queryId });
}
}
export default api;
// 移动端样式适配额外类名
export const SHOW_SIDERBAR_MOBILE_CLASSNAME = "mobile-show-sidebar";
// 默认动画持续时长
export const ANIMATION_DURATION = 200;
// toast 动画持续时长
export const TOAST_ANIMATION_DURATION = 400;
// 一天的毫秒数
export const DAILY_TIMESTAMP = 3600 * 24 * 1000;
// 标签 正则
export const TAG_REG = /#\s(.+?)\s/g;
// URL 正则
export const LINK_REG = /(https?:\/\/[^\s<\\*>']+)/g;
// 图片 正则
export const IMAGE_URL_REG = /(https?:\/\/[^\s<\\*>']+\.(jpeg|jpg|gif|png|svg))/g;
// memo 关联正则
export const MEMO_LINK_REG = /\[@(.+?)\]\((.+?)\)/g;
import { IMAGE_URL_REG, LINK_REG, MEMO_LINK_REG, TAG_REG } from "./consts";
export const relationConsts = [
{ text: "且", value: "AND" },
{ text: "或", value: "OR" },
];
export const filterConsts = {
TAG: {
value: "TAG",
text: "标签",
operators: [
{
text: "包括",
value: "CONTAIN",
},
{
text: "排除",
value: "NOT_CONTAIN",
},
],
},
TYPE: {
value: "TYPE",
text: "类型",
operators: [
{
value: "IS",
text: "是",
},
{
value: "IS_NOT",
text: "不是",
},
],
values: [
{
value: "CONNECTED",
text: "有关联",
},
{
value: "NOT_TAGGED",
text: "无标签",
},
{
value: "LINKED",
text: "有超链接",
},
{
value: "IMAGED",
text: "有图片",
},
],
},
TEXT: {
value: "TEXT",
text: "文本",
operators: [
{
value: "CONTAIN",
text: "包括",
},
{
value: "NOT_CONTAIN",
text: "排除",
},
],
},
};
export const memoSpecialTypes = filterConsts["TYPE"].values;
export const getTextWithMemoType = (type: string): string => {
for (const t of memoSpecialTypes) {
if (t.value === type) {
return t.text;
}
}
return "";
};
export const getDefaultFilter = (): BaseFilter => {
return {
type: "TAG",
value: {
operator: "CONTAIN",
value: "",
},
relation: "AND",
};
};
export const checkShouldShowMemoWithFilters = (memo: Model.Memo, filters: Filter[]) => {
let shouldShow = true;
for (const f of filters) {
const { relation } = f;
const r = checkShouldShowMemo(memo, f);
if (relation === "OR") {
shouldShow = shouldShow || r;
} else {
shouldShow = shouldShow && r;
}
}
return shouldShow;
};
export const checkShouldShowMemo = (memo: Model.Memo, filter: Filter) => {
const {
type,
value: { operator, value },
} = filter;
if (value === "") {
return true;
}
let shouldShow = true;
if (type === "TAG") {
let contained = memo.content.includes(`# ${value}`);
if (operator === "NOT_CONTAIN") {
contained = !contained;
}
shouldShow = contained;
} else if (type === "TYPE") {
let matched = false;
if (value === "NOT_TAGGED" && memo.content.match(TAG_REG) === null) {
matched = true;
} else if (value === "LINKED" && memo.content.match(LINK_REG) !== null) {
matched = true;
} else if (value === "IMAGED" && memo.content.match(IMAGE_URL_REG) !== null) {
matched = true;
} else if (value === "CONNECTED" && memo.content.match(MEMO_LINK_REG) !== null) {
matched = true;
}
if (operator === "IS_NOT") {
matched = !matched;
}
shouldShow = matched;
} else if (type === "TEXT") {
let contained = memo.content.includes(value);
if (operator === "NOT_CONTAIN") {
contained = !contained;
}
shouldShow = contained;
}
return shouldShow;
};
/**
* 实现一个简易版的 markdown 解析
* - 列表解析;
* - 代码块;
* - 加粗/斜体;
* - TODO;
*/
import Prism from "prismjs";
const CODE_BLOCK_REG = /```([\s\S]*?)```/g;
const BOLD_TEXT_REG = /\*\*(.+?)\*\*/g;
const EM_TEXT_REG = /\*(.+?)\*/g;
const TODO_BLOCK_REG = /\[ \] /g;
const DONE_BLOCK_REG = /\[x\] /g;
const DOT_LI_REG = /[*] /g;
const NUM_LI_REG = /(\d+)\. /g;
const getCodeLanguage = (codeStr: string): string => {
const execRes = /^\w+/g.exec(codeStr);
if (execRes !== null) {
return execRes[0];
}
return "javascript";
};
const parseCodeToPrism = (codeStr: string): string => {
return codeStr.replace(CODE_BLOCK_REG, (_, matchedStr): string => {
const lang = getCodeLanguage(matchedStr);
let convertedStr = matchedStr
.replace(lang, "")
.replace(/<p>/g, "")
.replace(/<\/p>/g, "\r\n")
.replace(/<br>/g, "\r\n")
.replace(/&nbsp;/g, " ");
// 特定语言处理
switch (lang) {
case "html":
convertedStr = convertedStr.replace(/&lt;/g, "<").replace(/&gt;/g, ">");
}
try {
const resultStr = Prism.highlight(convertedStr, Prism.languages[lang], lang);
return `<pre>${resultStr}</pre>`;
} catch (error) {
// do nth
}
return `<pre>${codeStr}</pre>`;
});
};
const parseMarkedToHtml = (markedStr: string): string => {
const htmlText = parseCodeToPrism(markedStr)
.replace(DOT_LI_REG, "<span class='counter-block'>•</span>")
.replace(NUM_LI_REG, "<span class='counter-block'>$1.</span>")
.replace(TODO_BLOCK_REG, "<span class='todo-block' data-type='todo'>⬜</span>")
.replace(DONE_BLOCK_REG, "<span class='todo-block' data-type='done'>✅</span>")
.replace(BOLD_TEXT_REG, "<strong>$1</strong>")
.replace(EM_TEXT_REG, "<em>$1</em>");
return htmlText;
};
const parseHtmlToRawText = (htmlStr: string): string => {
const tempEl = document.createElement("div");
tempEl.className = "memo-content-text";
tempEl.innerHTML = htmlStr;
const text = tempEl.innerText;
return text;
};
const parseRawTextToHtml = (rawTextStr: string): string => {
const htmlText = rawTextStr.replace(/\n/g, "<br>");
return htmlText;
};
const encodeHtml = (htmlStr: string): string => {
const t = document.createElement("div");
t.textContent = htmlStr;
return t.innerHTML;
};
export { encodeHtml, parseMarkedToHtml, parseHtmlToRawText, parseRawTextToHtml };
(() => {
if (!String.prototype.replaceAll) {
String.prototype.replaceAll = function (str: any, newStr: any) {
// If a regex pattern
if (Object.prototype.toString.call(str).toLowerCase() === "[object regexp]") {
return this.replace(str, newStr);
}
// If a string
return this.replace(new RegExp(str, "g"), newStr);
};
}
})();
export default null;
import { InputAction } from "tiny-undo";
/**
* Define storage data type
*/
interface StorageData {
// 编辑器输入缓存内容
editorContentCache: string;
// 分词开关
shouldSplitMemoWord: boolean;
// 是否隐藏图片链接地址
shouldHideImageUrl: boolean;
// markdown 解析开关
shouldUseMarkdownParser: boolean;
// Editor setting
useTinyUndoHistoryCache: boolean;
// tiny undo actions cache
tinyUndoActionsCache: InputAction[];
// tiny undo index cache
tinyUndoIndexCache: number;
}
type StorageKey = keyof StorageData;
/**
* storage helper
*/
export namespace storage {
export function get(keys: StorageKey[]): Partial<StorageData> {
const data: Partial<StorageData> = {};
for (const key of keys) {
try {
const stringifyValue = localStorage.getItem(key);
if (stringifyValue !== null) {
const val = JSON.parse(stringifyValue);
data[key] = val;
}
} catch (error: any) {
console.error("Get storage failed in ", key, error);
}
}
return data;
}
export function set(data: Partial<StorageData>) {
for (const key in data) {
try {
const stringifyValue = JSON.stringify(data[key as StorageKey]);
localStorage.setItem(key, stringifyValue);
} catch (error: any) {
console.error("Save storage failed in ", key, error);
}
}
}
export function remove(keys: StorageKey[]) {
for (const key of keys) {
try {
localStorage.removeItem(key);
} catch (error: any) {
console.error("Remove storage failed in ", key, error);
}
}
}
export function emitStorageChangedEvent() {
const iframeEl = document.createElement("iframe");
iframeEl.style.display = "none";
document.body.appendChild(iframeEl);
iframeEl.contentWindow?.localStorage.setItem("t", Date.now().toString());
iframeEl.remove();
}
}
namespace utils {
export function getNowTimeStamp(): number {
return Date.now();
}
export function getOSVersion(): "Windows" | "MacOS" | "Linux" | "Unknown" {
const appVersion = navigator.userAgent;
let detectedOS: "Windows" | "MacOS" | "Linux" | "Unknown" = "Unknown";
if (appVersion.indexOf("Win") != -1) {
detectedOS = "Windows";
} else if (appVersion.indexOf("Mac") != -1) {
detectedOS = "MacOS";
} else if (appVersion.indexOf("Linux") != -1) {
detectedOS = "Linux";
}
return detectedOS;
}
export function getTimeStampByDate(t: Date | number | string): number {
if (typeof t === "string") {
t = t.replaceAll("-", "/");
}
const d = new Date(t);
return d.getTime();
}
export function getDateStampByDate(t: Date | number | string): number {
const d = new Date(getTimeStampByDate(t));
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
}
export function getDateString(t: Date | number | string): string {
const d = new Date(getTimeStampByDate(t));
const year = d.getFullYear();
const month = d.getMonth() + 1;
const date = d.getDate();
return `${year}/${month}/${date}`;
}
export function getTimeString(t: Date | number | string): string {
const d = new Date(getTimeStampByDate(t));
const hours = d.getHours();
const mins = d.getMinutes();
const hoursStr = hours < 10 ? "0" + hours : hours;
const minsStr = mins < 10 ? "0" + mins : mins;
return `${hoursStr}:${minsStr}`;
}
// For example: 2021-4-8 17:52:17
export function getDateTimeString(t: Date | number | string): string {
const d = new Date(getTimeStampByDate(t));
const year = d.getFullYear();
const month = d.getMonth() + 1;
const date = d.getDate();
const hours = d.getHours();
const mins = d.getMinutes();
const secs = d.getSeconds();
const monthStr = month < 10 ? "0" + month : month;
const dateStr = date < 10 ? "0" + date : date;
const hoursStr = hours < 10 ? "0" + hours : hours;
const minsStr = mins < 10 ? "0" + mins : mins;
const secsStr = secs < 10 ? "0" + secs : secs;
return `${year}/${monthStr}/${dateStr} ${hoursStr}:${minsStr}:${secsStr}`;
}
export function dedupe<T>(data: T[]): T[] {
return Array.from(new Set(data));
}
export function dedupeObjectWithId<T extends { id: string }>(data: T[]): T[] {
const idSet = new Set<string>();
const result = [];
for (const d of data) {
if (!idSet.has(d.id)) {
idSet.add(d.id);
result.push(d);
}
}
return result;
}
export function debounce(fn: FunctionType, delay: number) {
let timer: number | null = null;
return () => {
if (timer) {
clearTimeout(timer);
timer = setTimeout(fn, delay);
} else {
timer = setTimeout(fn, delay);
}
};
}
export function throttle(fn: FunctionType, delay: number) {
let valid = true;
return () => {
if (!valid) {
return false;
}
valid = false;
setTimeout(() => {
fn();
valid = true;
}, delay);
};
}
export function transformObjectToParamsString(object: KVObject): string {
const params = [];
const keys = Object.keys(object).sort();
for (const key of keys) {
const val = object[key];
if (val) {
if (typeof val === "object") {
params.push(...transformObjectToParamsString(val).split("&"));
} else {
params.push(`${key}=${val}`);
}
}
}
return params.join("&");
}
export function transformParamsStringToObject(paramsString: string): KVObject {
const object: KVObject = {};
const params = paramsString.split("&");
for (const p of params) {
const [key, val] = p.split("=");
if (key && val) {
object[key] = val;
}
}
return object;
}
export function filterObjectNullKeys(object: KVObject): KVObject {
if (!object) {
return {};
}
const finalObject: KVObject = {};
const keys = Object.keys(object).sort();
for (const key of keys) {
const val = object[key];
if (typeof val === "object") {
const temp = filterObjectNullKeys(JSON.parse(JSON.stringify(val)));
if (temp && Object.keys(temp).length > 0) {
finalObject[key] = temp;
}
} else {
if (Boolean(val)) {
finalObject[key] = val;
}
}
}
return finalObject;
}
export async function copyTextToClipboard(text: string) {
if (navigator.clipboard && navigator.clipboard.writeText) {
try {
await navigator.clipboard.writeText(text);
} catch (error: unknown) {
console.warn("Copy to clipboard failed.", error);
}
} else {
console.warn("Copy to clipboard failed, methods not supports.");
}
}
export function getImageSize(src: string): Promise<{ width: number; height: number }> {
return new Promise((resolve) => {
const imgEl = new Image();
imgEl.onload = () => {
const { width, height } = imgEl;
if (width > 0 && height > 0) {
resolve({ width, height });
} else {
resolve({ width: 0, height: 0 });
}
};
imgEl.onerror = () => {
resolve({ width: 0, height: 0 });
};
imgEl.className = "hidden";
imgEl.src = src;
document.body.appendChild(imgEl);
imgEl.remove();
});
}
}
export default utils;
// 验证器
// * 主要用于验证表单
const chineseReg = /[\u3000\u3400-\u4DBF\u4E00-\u9FFF]/;
export interface ValidatorConfig {
// 最小长度
minLength: number;
// 最大长度
maxLength: number;
// 无空格
noSpace: boolean;
// 无中文
noChinese: boolean;
}
export function validate(text: string, config: Partial<ValidatorConfig>): { result: boolean; reason?: string } {
if (config.minLength !== undefined) {
if (text.length < config.minLength) {
return {
result: false,
reason: "长度过短",
};
}
}
if (config.maxLength !== undefined) {
if (text.length > config.maxLength) {
return {
result: false,
reason: "长度超出",
};
}
}
if (config.noSpace && text.includes(" ")) {
return {
result: false,
reason: "不应含有空格",
};
}
if (config.noChinese && chineseReg.test(text)) {
return {
result: false,
reason: "不应含有中文字符",
};
}
return {
result: true,
};
}
This diff is collapsed.
This diff is collapsed.
import { useCallback, useState } from "react";
function useRefresh() {
const [_, setBoolean] = useState<Boolean>(false);
const refresh = useCallback(() => {
setBoolean((ps) => {
return !ps;
});
}, []);
return refresh;
}
export default useRefresh;
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment