Compare commits

...
Author SHA1 Message Date
Kilu d6e339158c fix: lazy load 2024-07-30 10:57:02 +08:00
Kilu 38784b9d73 fix: demond load outline 2024-07-29 22:44:05 +08:00
Kilu 49f8ae93c7 fix: replace appflowy icon 2024-07-29 22:32:13 +08:00
Kilu e323db2797 Merge branch 'fix/publish-view-bugs' into feat/support-global-comment-on-web 2024-07-29 22:29:30 +08:00
Kilu 7280d072f1 fix: global comments 2024-07-29 22:27:38 +08:00
Kilu 0ddabfa217 fix: text overflow 2024-07-25 14:41:02 +08:00
qinluhe 9d3a810847 fix: upgrade wasm package 2024-07-25 12:43:21 +08:00
qinluhe e0a70aa4d9 fix: adjust modal position 2024-07-24 14:38:00 +08:00
qinluhe 403fd2f5a0 fix: modified some copy 2024-07-24 12:30:00 +08:00
qinluhe 4976f870c8 fix: replace google svg 2024-07-24 10:31:54 +08:00
qinluhe 6fde3b9e3e feat: support duplicate UI on web 2024-07-24 10:22:35 +08:00
100 changed files with 3278 additions and 203 deletions

No files matched your search

+2 -4
View File
@@ -31,7 +31,6 @@
<body id="body">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<script src="https://ajax.googleapis.com/ajax/libs/webfont/1.6.26/webfont.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
@@ -64,8 +63,7 @@
}
});
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.26.0/prism.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.26.0/plugins/autoloader/prism-autoloader.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.5.1/highlight.min.js"></script>
</body>
</html>
+2 -1
View File
@@ -24,7 +24,7 @@
"coverage": "pnpm run test:unit && pnpm run test:components"
},
"dependencies": {
"@appflowyinc/client-api-wasm": "0.1.2",
"@appflowyinc/client-api-wasm": "0.1.4",
"@atlaskit/primitives": "^5.5.3",
"@emoji-mart/data": "^1.1.2",
"@emoji-mart/react": "^1.1.1",
@@ -49,6 +49,7 @@
"emoji-regex": "^10.2.1",
"events": "^3.3.0",
"google-protobuf": "^3.15.12",
"highlight.js": "^11.10.0",
"i18next": "^22.4.10",
"i18next-browser-languagedetector": "^7.0.1",
"i18next-resources-to-backend": "^1.1.4",
+12 -4
View File
@@ -6,8 +6,8 @@ settings:
dependencies:
'@appflowyinc/client-api-wasm':
specifier: 0.1.2
version: 0.1.2
specifier: 0.1.4
version: 0.1.4
'@atlaskit/primitives':
specifier: ^5.5.3
version: 5.7.0(@types/react@18.2.66)(react@18.2.0)
@@ -80,6 +80,9 @@ dependencies:
google-protobuf:
specifier: ^3.15.12
version: 3.21.2
highlight.js:
specifier: ^11.10.0
version: 11.10.0
i18next:
specifier: ^22.4.10
version: 22.5.1
@@ -451,8 +454,8 @@ packages:
'@jridgewell/gen-mapping': 0.3.5
'@jridgewell/trace-mapping': 0.3.25
/@appflowyinc/client-api-wasm@0.1.2:
resolution: {integrity: sha512-+v0hs7/7BVKtgev/Bcbr0u2HLDhUuw4ZvZTaMddI+06HK8vt5S52dMaZKUcMvh1eUjVX8hjC6Mfe0X/yHqvFgA==}
/@appflowyinc/client-api-wasm@0.1.4:
resolution: {integrity: sha512-3uBpy3n+aIG0fapPAroMfL8JLdAPtqPAkpV+LOxlRnMW4Au2JQcW8TW0P3K1YAe16tDZ62ZIZPoG6Bi40RDRoQ==}
dev: false
/@atlaskit/analytics-next-stable-react-context@1.0.1(react@18.2.0):
@@ -7068,6 +7071,11 @@ packages:
resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==}
dev: true
/highlight.js@11.10.0:
resolution: {integrity: sha512-SYVnVFswQER+zu1laSya563s+F8VDGt7o35d4utbamowvUNLLMovFqwCLSocpZTz3MgaSRA1IbqRWZv97dtErQ==}
engines: {node: '>=12.0.0'}
dev: false
/hoist-non-react-statics@3.3.2:
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
dependencies:
+8 -11
View File
@@ -1,12 +1,9 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M455.885 297.861C455.85 297.861 455.82 297.886 455.814 297.92C444.323 359.916 394.213 414.254 336.155 444.713C328.858 448.526 326.952 450.629 318.768 451.286H435.513C438.163 451.384 440.805 450.945 443.282 449.998C445.759 449.051 448.019 447.613 449.927 445.772C451.836 443.931 453.353 441.724 454.389 439.283C455.425 436.843 455.958 434.218 455.956 431.566V297.933C455.956 297.893 455.924 297.861 455.885 297.861V297.861Z" fill="#F7931E"/>
<path d="M327.511 447.668C325.125 447.832 322.731 447.832 320.345 447.668H327.511Z" fill="#FFCE00"/>
<path d="M210.239 178.025C208.465 179.504 206.69 180.852 204.882 182.134C175.071 203.103 84.0943 271.073 63.5522 241.821C43.3716 213.095 64.6368 131.617 117.027 92.3728C118.013 91.584 119 90.8609 120.018 90.1707C176.846 50.204 219.377 55.7257 240.05 85.0105C259.245 112.488 237.651 155.61 210.239 178.025Z" fill="#8427E0"/>
<path d="M432.62 241.066C404.486 260.787 360.082 237.78 338.061 209.481C337.173 208.33 336.319 207.18 335.497 205.997C314.528 176.088 246.624 85.1106 275.81 64.5356C304.996 43.9606 389.137 66.409 427.46 121.002C428.315 122.218 429.136 123.401 429.925 124.584C467.197 179.67 461.248 220.82 432.62 241.066Z" fill="#00B5FF"/>
<path d="M394.954 420.458C393.968 421.225 392.982 421.948 391.996 422.627C335.136 462.594 292.605 457.04 272.03 427.788C252.737 400.311 274.298 357.287 301.611 334.773C303.386 333.294 305.193 331.913 307.001 330.632C336.812 309.695 427.789 241.89 448.331 270.977C468.643 299.67 447.378 381.215 394.954 420.458Z" fill="#FFBD00"/>
<path d="M236.205 448.131C206.92 468.673 122.878 446.192 84.5216 391.632L82.2537 388.345C44.7849 333.095 50.701 291.978 79.3943 271.83C107.496 252.11 151.9 275.117 173.921 303.383C174.808 304.534 175.663 305.684 176.517 306.867C197.487 336.711 265.457 427.688 236.205 448.131Z" fill="#E3006D"/>
<path d="M240.905 137.634C230.873 159.981 212.651 177.627 189.993 186.935C146.049 205.439 81.5306 228.939 70.4214 203.369C59.5423 178.225 79.6572 127.018 116.995 92.3754L117.783 91.718C175.761 50.0421 219.114 55.3667 240.05 85.0131C250.403 99.902 248.859 119.458 240.905 137.634Z" fill="#9327FF"/>
<path d="M432.62 241.066C417.37 251.78 397.354 249.907 378.718 241.46C356.357 231.053 338.75 212.589 329.417 189.759C311.241 145.75 289.056 83.3019 314.101 72.4885C340.394 61.1164 395.382 83.7621 429.925 124.583C467.197 179.669 461.248 220.819 432.62 241.066Z" fill="#00C8FF"/>
<path d="M394.954 420.458L394.297 420.984C336.286 462.726 292.868 457.434 272.03 427.787C261.578 412.899 263.123 393.441 271.077 375.2C281.097 352.845 299.324 335.195 321.989 325.899C365.899 307.361 430.451 283.894 441.56 309.465C452.669 335.036 432.325 385.684 394.954 420.458Z" fill="#FFCE00"/>
<path d="M197.914 440.311C171.62 451.65 116.83 429.136 82.2537 388.446C44.7849 333.098 50.701 291.981 79.3943 271.833C94.6119 261.118 114.628 262.959 133.264 271.406C155.591 281.872 173.141 300.384 182.401 323.237C200.609 366.918 222.959 429.432 197.914 440.311Z" fill="#FB006D"/>
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<rect width="128" height="128" fill="url(#pattern0_114_7)"/>
<defs>
<pattern id="pattern0_114_7" patternContentUnits="objectBoundingBox" width="1" height="1">
<use xlink:href="#image0_114_7" transform="scale(0.0078125)"/>
</pattern>
<image id="image0_114_7" width="128" height="128" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAOLElEQVR4Ae1dT2skxxWvxGxMWOxVbGNINhZz0UjYYNYXB0wO8iWnkIiAd5dcPMeQaFb6BqtvMFrNGoMvEgYHHAizYdRSIIeB5BDYjdQfYT7CXOydv6sXv/4zU91TXV3VXV1d1TOCoXu6Wz099fu9937v1Z8hZP0n1wIdqBEH7pEu7C698Nz6r2It0IEN4sABuYDeD9sBcQAEXi65gA7pwpFHErzH+k+8BVo12GjtwG57Gw6e1uG0vQ2ddh3ck23o46u9DT3vtQXHT+vweWunAMu7hBo5hzMBsEUIAQGBDjzvId4Uq3Mlgo6AB+AO2tsAUq86uEgGJS2GLl7c2sUIEPUcfc+rrHrIiIEuB3gCQZ5uQz8XEdDyc4J/27mBd51XkRceY3oTDC2XioirhP0abuIBX4fHP1i8vKUnAL/kLergZgoNGd0+Av6pM4E/OGN44IyYLzyH13zkTOGuM4NbUVL0K08ELcBTBEFv8MUW3JPi9Dm4TEuNuvCINX/sTJmAJxGBPv5rZwI1Z0bfr3pE0A18zBsMpEjAAZpFDLRmGtCs+791xoBEokJFNYiAah4tMQaKkngvek/8fOFwIEEABCsr4Lz/ixAB00kbxWIg8DqiIBV+XR1coVAgIQDrzqwQAoTkiBDhEh4LPb8JF7XrsFeowKPivAxxntThKLV9JDTApxzBF4KoYvvBQiO4RnsDtPqTbWjJgKL52gE+I5cEElnAbzQRAEmEGiEQi33ShT3udyjjJMbYsmO9CJlOtuGQ2z4ONFhij3UMQVFh4TL3wLDgpY8mhQRTXX4CIXpcAvi1fzotS9wvgwChN/CyhQtocb+LjpNB+Varqk8AVvQZBqnt4nf8JAIfeoOyCBB6jEAboC7gh7XUL5zxgrZfzRNteGOua9VSOo/O4TAEmbctmwBIhI98gaifBLaC73mPHdjlct4PA6ldv1jFC62xzC2K0TedG30ksNDtR71PGgGQHV045lk/nlNVBVRBHvRGdy5u+PqGy3rBk6334V7OGBwFI2NOn+sZxAiwm0aAogtBssRAErxz8ao4YWhLqpdGjlQNEBpDihjEHkBZkIq+Hknw84sJP9UNv5/s1oY8Pw18PC/8vXHcH6dv4JYDxhEACYYk+MSZyPWCpjWK4RU+mbAiFydTvIDOaqCk9+g/7AzVDFI92YGGiGXZcM2TLThOI3vkfIoXMEkILhHkYiRH9sgXD95UJe7PySkiAOMNwfECdw3UAREiXIzy6YF2Hc7mjVeGYlf4mTiiOI6t0HuOFzBVB1AkGOxlrRRWIuWjCIRDzIUAZ13EqQvo6hamQJUUn+Ns37sqqj/0YMKjglgE4FQHP1A0LCw7wOxBqPT9HnaH/Opn/DtXSfghAXJZf9g4ONOHkRZumK4DcJSyrCBcW3+IemzrAE7qWOopxNybtjgT94W9wNr6Y6DTbxMEoQ1hQNgLVMn6Ufnniv00+OE+Y9iYiWVhlhdK9QIVtH418wVD8HGbIAhtCAOpXqBK1q9E+NHA0/uMQSNWhAFnBJ8l9RMEU7Jl6urGXluI66cJgPuxCmFRE0VYrjzPsfvdIXt4fFWqfifbMFAe9+Pg43vGTGLzi0JezYA9LtLEyRxhEUdm+8UONFh4FXIsFgpMGySS5CmWxGAwtNtYly5MgB2B2T+qmUCFAuwb4E0RTwJE9/HPuqNor2gl3H8Z4DNCwceGDBZNIVV0rqTt7l+r22d5ECoU2FITmPcS2qz+PbX/vuQiECwAVRyjQoHBI4XmJeu5DsBZs8IxlupeNeB/eqmTPVUAK3oPKiuwoiZwHgwWCVbmskYAYpr3NG2Spyhoqq/DmbsOgA1icC4ErYr/WRd/Ug00737B4JFfGS8Gx6ektWXVRI9o6sIDocxzfl9B33gxeD7qERvyfxR67SyDOcskQdBtbHRlEAmACyYYIOZ4+qNjlNCTIdU5HBpdGUQCPNmGYxMJgEKv9NxeBuyEa1+/gJ7BlcFnGALMWclrkWL2tHTmJICm9PAl1D50poOUqtw8N9d5nZcFmJQCGp3e5WDFG86soRNY8c8a/54YRIDqWD2DLA+6L4/FgUkf5q3iXt7cwbIJUFWrZ3CA4HAsFcApuoc/S6pkDdCpTKxnIR47ttcZbNx3Rq4iAHPphvmooCclzP2zMq+PgZn17cPLYe2BM+qXTYL51HHtHUE7cGRtXp8V9dj/7VwOa+V6AmquoMZh4JUWeTGMU99iOChJGEYXjsAYXGghqA6udWXcVPjUXfCwOzzSGQ7ud8fL6wwXkQl46l7Vjzipa28j74Tr+ejQBUg2ZgOo1AEIfHsd55ntzDuIIeF33VFhtYJE8PGhUJTlHRMQKPuVF3g8kEXOYZbw8Hx4pjQshCN/eA+QQwz21jGe17LZzikiQn8+7k/kMdB1CwpCBH1t7SKNmvMajwjOsPHgXLyKeP98jMWmg0wf7WUFW14XcS/8uVbvp1u34Bi9xJdF/ExrpiddvX/CYdz3u9/vYRXvgTM+9UjhE6PjvXdGB390vlO7QOTqNfP6G69bYN0C6xZYt8C6BdYtkNQCQA43gPyptngdlvObNUkPuOLHwSX3wCUN6rULLsmH0ZTs707Io+MJafanpAmM12BKmj28Bq9dcQy0fn0EF1xyANekB9dkANcEmC+XyP+YBIKJwDIAZ5FgfmxGmv0ZaZ6il9DaGivyYQHojQB0NuBsIoivID4lBy1Z4BOu703In9f5qQJyBsA/5lo6G/gFSa4IfwVxjPFZrD4BfNorrD1CRhIoAX5BjAHej/koCD4nzs/BTAObf/6RPb+EzWwlfQcVA5/uBYqw/AQy9Nb6IJlIhQG/8ALLWmBKHj1OAEuR5UezBxSKa22wTAJwyR5ckz5TyS8AXFhz1mN0GBiSw5pO8OnPGpEmX5Qst1Elj4BLahlUfXYivCCLdH1C9s9oUPTvr64uoNx9djCzeIEr4q+nWKb1R4m2eiQAl+xqcfcsglwRf4xg+da/0AarEg48q78incLjPAv48NgV8VdcKT7tWwActfik49UuJXs1el7JNgSo6K1LTgmqcDFQksAq5PigiimidpGXRiAkwIzs7xlIAJiSR9GlTC3X/eCSz3OVb9PAzHL+3z85JWOyf2QmAdCzHMj3XhlGFCNifRI5AgKUnP6lhRB79UCpCj8JdOr47NmbngcwmgBYLcT+CcMMm/s4QV7fKlXhU0AnPcfkb2+ZTwA/PNkTCky3epoM46/u2kKAJszII/W//sW1Y/mTwagcvdU8AUunQQ/3b57/GIZPN00XgRF9YGxqaFx6J0CKV73XfQJMSLNhbhYQIQD2SC53YcobqtL/CKw+eRyeABihVercTp/d8QkQjPkrpLu3CGKZUioOhN6pTtBUftb463d9AgTDv6whwJQ0Sw8Fgcsvvr++IO/hxf/2pk8A9IdTsu8WYa3F3bO8KiE8J/dK671TRIjZv34KQ5oAE7J/XBxYS3FcibfBCqbSYC5wswB86+J9PHRMvn0rSgDbdEBIVp3DyWx3+zQJRl/+MkoAPww0cWaPEuvUdx89oaBK4M/+edsHnw4BSAAbwwASTUcogGvi0hZk8/74m3fYBLA1DBSdFYBLcBaOFdW9tOe8+e9rC/DjHiAIA9JzAPW5e254KqRAFNT1KwE+kmMu/hB8FgHMHRzCBd/TLUUUiGxP9+IeYfTVL/gewNcCidO/TReIA5XdxlVy/UiEiPhL8gABAWzqG4iRUk23caD6K+P6kQBL1s8KAUgA/DNvlHB6CFhokfwjiOCanMXdp83vp/+4E3X9PA+ABLA4I0CPkEsQVs36UfkzrZ/nAXwSyK8KsrBCGYtVf22ewSNVs/4l5R9afxoBzJkulokgmQVhlZT/Ut5Pg59GAPQCZg8Z5xMjS4Wwanl/ousPiYBDwnh/elYL4QOZI6xIe4Equf9E4ReCL+IBfC3grRIWS7cKA03p58h6garU/FNdf0iCNA8Qeoep0bOHuGQU9gLeEK8K1Py5qj8EPtyKEsD3BHZmBaJeoCrxf/JXqrcvBDppK0MAi/WAkBeA/3nr8lhd/ROK+zQZZAiAXiBIDa0bODIif0ldgyiYs28tAWbOG+xqHw14fF+WAEiCYD0By0iQPnLIZgIwO3riYLPeZyGATwKrJpMEmQW/j8DWEJAZfCREVgIgCYKxA9Z4Ahzyhs+d9GdjH0Au8PMSABsSw4FFPYeDJPDD48at4MFJSzPF/HgYyOMBwkZDYWgPCVLCgL/evvFCcPL3n8kLvjj4KjwATYIpaXZylG2VVgCTniMtG4AX3pp9xhIAizzenD4WmFmOqfAAIQlwa3rFcEyaz+jnZe1rXaqV4+LjA1Aw3s8ndGQBm/U/qgmADWpySMDlZlig08dM8wI3z38EUtU9FtBJx4ogQNiYJnoDEQLg88MVOYpbYBnvUegpt3qaDEUSABvSrxzumzTXIDUTCAlcZtcwrt6hNNbToNP7RRMgbExchcSMTCG9Ihg+M251kwBFHncIFw2egv2XJ5upJXK6PXLvl00E/BUz2S+hIxzoBt6bFYQEatXK+UU3f10i/aEh6/rDgTBUviKINlfP8ha63D/P4vyMYf9MT3jIv6BE0GGUiwgIOnbbFiruWIBTx0btTRdaNbMW38S5CPi7BQWQYTAR6A7mETV+zvMIV+RYZAjZzX9uedOyJt++nTw+nwJn7p4LOPbyZHMwbG8eGQd+vIH9Pob9gynZx+pixg4nDDH7RyrnC8afM3zvdSS9ILuz3mt7s8vbjUlnozH55u3GdyfvGfP6vv3envHAhw0a3yKIgYc4wCFeKOZ8cnggI9AdPIbnUF/oAD3+jLa+/z8Xdl5dOM7c3QAAAABJRU5ErkJggg=Line truncated
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

@@ -0,0 +1,22 @@
export interface CommentUser {
uuid: string;
name: string;
avatarUrl: string | null;
}
export interface GlobalComment {
commentId: string;
user: CommentUser | null;
content: string;
createdAt: string;
lastUpdatedAt: string;
replyCommentId: string | null;
isDeleted: boolean;
canDeleted: boolean;
}
export interface Reaction {
reactionType: string;
reactUsers: CommentUser[];
commentId: string;
}
@@ -1,4 +1,5 @@
import { YDoc } from '@/application/collab.type';
import { GlobalComment, Reaction } from '@/application/comment.type';
import {
deleteView,
getPublishView,
@@ -14,12 +15,23 @@ import {
signInGithub,
signInDiscord,
signInWithUrl,
createGlobalCommentOnPublishView,
deleteGlobalCommentOnPublishView,
getPublishViewComments,
getWorkspaces,
getWorkspaceFolder,
getCurrentUser,
duplicatePublishView,
getReactions,
addReaction,
removeReaction,
} from '@/application/services/js-services/wasm/client_api';
import { AFService, AFServiceConfig } from '@/application/services/services.type';
import { emit, EventType } from '@/application/session';
import { afterAuth, AUTH_CALLBACK_URL, withSignIn } from '@/application/session/sign_in';
import { nanoid } from 'nanoid';
import * as Y from 'yjs';
import { DuplicatePublishView } from '@/application/types';
export class AFClientService implements AFService {
private deviceId: string = nanoid(8);
@@ -199,4 +211,61 @@ export class AFClientService implements AFService {
async signInDiscord(_: { redirectTo: string }) {
return await signInDiscord(AUTH_CALLBACK_URL);
}
async getWorkspaces() {
const data = getWorkspaces();
return data;
}
async getWorkspaceFolder(workspaceId: string) {
const data = await getWorkspaceFolder(workspaceId);
return data;
}
async getCurrentUser() {
const data = await getCurrentUser();
return {
uid: data.uid,
email: data.email,
name: data.name,
avatar: data.icon_url,
uuid: data.uuid,
};
}
async duplicatePublishView(params: DuplicatePublishView) {
return duplicatePublishView({
workspace_id: params.workspaceId,
dest_view_id: params.spaceViewId,
published_view_id: params.viewId,
published_collab_type: params.collabType,
});
}
createCommentOnPublishView(viewId: string, content: string, replyCommentId: string | undefined): Promise<void> {
return createGlobalCommentOnPublishView(viewId, content, replyCommentId);
}
deleteCommentOnPublishView(viewId: string, commentId: string): Promise<void> {
return deleteGlobalCommentOnPublishView(viewId, commentId);
}
getPublishViewGlobalComments(viewId: string): Promise<GlobalComment[]> {
return getPublishViewComments(viewId);
}
getPublishViewReactions(viewId: string, commentId?: string): Promise<Record<string, Reaction[]>> {
return getReactions(viewId, commentId);
}
addPublishViewReaction(viewId: string, commentId: string, reactionType: string): Promise<void> {
return addReaction(viewId, commentId, reactionType);
}
removePublishViewReaction(viewId: string, commentId: string, reactionType: string): Promise<void> {
return removeReaction(viewId, commentId, reactionType);
}
}
@@ -1,7 +1,9 @@
import { getToken, invalidToken, isTokenValid, refreshToken } from '@/application/session/token';
import { ClientAPI } from '@appflowyinc/client-api-wasm';
import { ClientAPI, WorkspaceFolder, DuplicatePublishViewPayload } from '@appflowyinc/client-api-wasm';
import { AFCloudConfig } from '@/application/services/services.type';
import { DatabaseId, PublishViewMetaData, RowId, ViewId, ViewLayout } from '@/application/collab.type';
import { FolderView } from '@/application/types';
import { GlobalComment, Reaction } from '@/application/comment.type';
let client: ClientAPI;
@@ -115,3 +117,120 @@ export async function signInGithub(redirectTo: string) {
export async function signInDiscord(redirectTo: string) {
return signInProvider('discord', redirectTo);
}
export async function getWorkspaces() {
try {
const { data } = await client.get_workspaces();
return data.map((workspace) => ({
id: workspace.workspace_id,
name: workspace.workspace_name,
icon: workspace.icon,
memberCount: workspace.member_count || 0,
}));
} catch (e) {
return Promise.reject(e);
}
}
export async function getWorkspaceFolder(workspaceId: string): Promise<FolderView> {
try {
const data = await client.get_folder(workspaceId);
// eslint-disable-next-line no-inner-declarations
function iterateFolder(folder: WorkspaceFolder): FolderView {
return {
id: folder.view_id,
name: folder.name,
icon: folder.icon,
isSpace: folder.is_space,
extra: folder.extra,
isPrivate: folder.is_private,
children: folder.children.map((child: WorkspaceFolder) => {
return iterateFolder(child);
}),
};
}
return iterateFolder(data);
} catch (e) {
return Promise.reject(e);
}
}
export function getCurrentUser() {
return client.get_user();
}
export function duplicatePublishView(payload: DuplicatePublishViewPayload) {
return client.duplicate_publish_view(payload);
}
export async function getPublishViewComments(viewId: string): Promise<GlobalComment[]> {
try {
const { comments } = await client.get_publish_view_comments(viewId);
return comments.map((comment) => {
return {
commentId: comment.comment_id,
user: {
uuid: comment.user?.uuid || '',
name: comment.user?.name || '',
avatarUrl: comment.user?.avatar_url || null,
},
content: comment.content,
createdAt: comment.created_at,
lastUpdatedAt: comment.last_updated_at,
replyCommentId: comment.reply_comment_id,
isDeleted: comment.is_deleted,
canDeleted: comment.can_be_deleted,
};
});
} catch (e) {
return Promise.reject(e);
}
}
export async function createGlobalCommentOnPublishView(viewId: string, content: string, replyCommentId?: string) {
return client.create_comment_on_publish_view(viewId, content, replyCommentId);
}
export async function deleteGlobalCommentOnPublishView(viewId: string, commentId: string) {
return client.delete_comment_on_publish_view(viewId, commentId);
}
export async function getReactions(viewId: string, commentId?: string): Promise<Record<string, Reaction[]>> {
try {
const { reactions } = await client.get_reactions(viewId, commentId);
const reactionsMap: Record<string, Reaction[]> = {};
for (const reaction of reactions) {
if (!reactionsMap[reaction.comment_id]) {
reactionsMap[reaction.comment_id] = [];
}
reactionsMap[reaction.comment_id].push({
reactionType: reaction.reaction_type,
commentId: reaction.comment_id,
reactUsers: reaction.react_users.map((user) => ({
uuid: user.uuid,
name: user.name,
avatarUrl: user.avatar_url,
})),
});
}
return reactionsMap;
} catch (e) {
return Promise.reject(e);
}
}
export async function addReaction(viewId: string, commentId: string, reactionType: string) {
return client.create_reaction(viewId, commentId, reactionType);
}
export async function removeReaction(viewId: string, commentId: string, reactionType: string) {
return client.delete_reaction(viewId, commentId, reactionType);
}
@@ -1,6 +1,8 @@
import { YDoc } from '@/application/collab.type';
import { GlobalComment, Reaction } from '@/application/comment.type';
import { ViewMeta } from '@/application/db/tables/view_metas';
import * as Y from 'yjs';
import { DuplicatePublishView, FolderView, User, Workspace } from '@/application/types';
export type AFService = PublishService;
@@ -27,10 +29,21 @@ export interface PublishService {
rows: Y.Map<YDoc>;
destroy: () => void;
}>;
getPublishViewGlobalComments: (viewId: string) => Promise<GlobalComment[]>;
createCommentOnPublishView: (viewId: string, content: string, replyCommentId?: string) => Promise<void>;
deleteCommentOnPublishView: (viewId: string, commentId: string) => Promise<void>;
getPublishViewReactions: (viewId: string, commentId?: string) => Promise<Record<string, Reaction[]>>;
addPublishViewReaction: (viewId: string, commentId: string, reactionType: string) => Promise<void>;
removePublishViewReaction: (viewId: string, commentId: string, reactionType: string) => Promise<void>;
loginAuth: (url: string) => Promise<void>;
signInMagicLink: (params: { email: string; redirectTo: string }) => Promise<void>;
signInGoogle: (params: { redirectTo: string }) => Promise<void>;
signInGithub: (params: { redirectTo: string }) => Promise<void>;
signInDiscord: (params: { redirectTo: string }) => Promise<void>;
getWorkspaces: () => Promise<Workspace[]>;
getWorkspaceFolder: (workspaceId: string) => Promise<FolderView>;
getCurrentUser: () => Promise<User>;
duplicatePublishView: (params: DuplicatePublishView) => Promise<void>;
}
@@ -2,6 +2,7 @@ import { YDoc } from '@/application/collab.type';
import { AFService } from '@/application/services/services.type';
import { nanoid } from 'nanoid';
import { YMap } from 'yjs/dist/src/types/YMap';
import { DuplicatePublishView, FolderView, User, Workspace } from '@/application/types';
export class AFClientService implements AFService {
private deviceId: string = nanoid(8);
@@ -53,4 +54,20 @@ export class AFClientService implements AFService {
}> {
return Promise.reject('Method not implemented');
}
duplicatePublishView(_params: DuplicatePublishView): Promise<void> {
return Promise.resolve(undefined);
}
getCurrentUser(): Promise<User> {
return Promise.reject('Method not implemented');
}
getWorkspaceFolder(_workspaceId: string): Promise<FolderView> {
return Promise.reject('Method not implemented');
}
getWorkspaces(): Promise<Workspace[]> {
return Promise.reject('Method not implemented');
}
}
@@ -28,7 +28,6 @@ export function withSignIn() {
saveRedirectTo(redirectTo);
console.log('=====saveRedirectTo', redirectTo);
try {
await originalMethod.apply(this, [args]);
} catch (e) {
@@ -0,0 +1,40 @@
import { CollabType } from '@/application/collab.type';
export interface Workspace {
icon: string;
id: string;
name: string;
memberCount: number;
}
export interface SpaceView {
id: string;
extra: string | null;
name: string;
isPrivate: boolean;
}
export interface FolderView {
id: string;
icon: string | null;
extra: string | null;
name: string;
isSpace: boolean;
isPrivate: boolean;
children: FolderView[];
}
export interface User {
email: string | null;
name: string | null;
uid: string;
avatar: string | null;
uuid: string;
}
export interface DuplicatePublishView {
workspaceId: string;
spaceViewId: string;
collabType: CollabType;
viewId: string;
}
@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 10C6 9.17 6.67 8.5 7.5 8.5C8.33 8.5 9 9.17 9 10C9 10.83 8.33 11.5 7.5 11.5C6.67 11.5 6 10.83 6 10ZM11 18C13.33 18 15.31 16.54 16.11 14.5H5.89C6.69 16.54 8.67 18 11 18ZM14.5 11.5C15.33 11.5 16 10.83 16 10C16 9.17 15.33 8.5 14.5 8.5C13.67 8.5 13 9.17 13 10C13 10.83 13.67 11.5 14.5 11.5ZM21 1.5H19V3.5H17V5.5H19V7.5H21V5.5H23V3.5H21V1.5ZM19 12.5C19 16.92 15.42 20.5 11 20.5C6.58 20.5 3 16.92 3 12.5C3 8.08 6.58 4.5 11 4.5C12.46 4.5 13.82 4.9 15 5.58V3.34C13.77 2.8 12.42 2.5 10.99 2.5C5.47 2.5 1 6.98 1 12.5C1 18.02 5.47 22.5 10.99 22.5C16.52 22.5 21 18.02 21 12.5C21 11.45 20.83 10.45 20.53 9.5H18.4C18.78 10.43 19 11.44 19 12.5Z"
fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 781 B

@@ -0,0 +1,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 13C10.7614 13 13 10.7614 13 8C13 5.23858 10.7614 3 8 3C5.23858 3 3 5.23858 3 8C3 10.7614 5.23858 13 8 13Z"
stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 5V8L10 9" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11.5 2.5L13.5 4.5" stroke="currentColor" stroke-linecap="round"/>
<path d="M4.5 2.5L2.5 4.5" stroke="currentColor" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 562 B

@@ -0,0 +1,6 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<g opacity="0.7">
<path d="M15.1924 15.1924C15.4853 14.8995 15.4875 14.4268 15.1946 14.1339L10.061 9.00027L15.1945 3.86672C15.4874 3.57382 15.4853 3.10107 15.1924 2.80818C14.8995 2.51529 14.4268 2.51316 14.1339 2.80606C13.841 3.09895 9.00031 7.93961 9.00031 7.93961L3.86671 2.80601C3.57382 2.51312 3.10107 2.51524 2.80817 2.80814C2.51528 3.10103 2.51316 3.57378 2.80605 3.86667L7.93965 9.00027L2.80601 14.1339C2.51312 14.4268 2.51524 14.8996 2.80814 15.1924C3.10103 15.4853 3.57378 15.4875 3.86667 15.1946L9.00031 10.0609L14.1339 15.1945C14.4268 15.4874 14.8995 15.4853 15.1924 15.1924Z"
fill="currentColor"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 747 B

@@ -0,0 +1,3 @@
<svg width="32" height="16" viewBox="0 0 32 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.8774 3.10363H15.9L28 3.0908C28.2761 3.0908 28.5 3.31796 28.5 3.5941C28.5 3.87024 28.2761 4.0908 28 4.0908L15.9 4.10363C14.7716 4.10363 13.9554 4.10402 13.3135 4.15647C12.6774 4.20844 12.2566 4.30902 11.911 4.48511C11.2525 4.82066 10.717 5.3561 10.3815 6.01466C10.2054 6.36025 10.1048 6.78107 10.0528 7.41715C10.0004 8.05908 10 8.87527 10 10.0036V11.6036C10 11.8797 9.77614 12.1036 9.5 12.1036C9.22386 12.1036 9 11.8797 9 11.6036V10.0036V9.981C9 8.88004 9 8.02311 9.05616 7.33572C9.11318 6.63779 9.23058 6.07073 9.49047 5.56067C9.9219 4.71394 10.6103 4.02553 11.457 3.5941C11.9671 3.33421 12.5342 3.21681 13.2321 3.15979C13.9195 3.10363 14.7764 3.10363 15.8774 3.10363Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 841 B

@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11 15H13V17H11V15ZM11 7H13V13H11V7ZM11.99 2C6.47 2 2 6.48 2 12C2 17.52 6.47 22 11.99 22C17.52 22 22 17.52 22 12C22 6.48 17.52 2 11.99 2ZM12 20C7.58 20 4 16.42 4 12C4 7.58 7.58 4 12 4C16.42 4 20 7.58 20 12C20 16.42 16.42 20 12 20Z"
fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 380 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

@@ -0,0 +1,5 @@
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path opacity="0.4" fill-rule="evenodd" clip-rule="evenodd"
d="M8.78714 4.75039V4.16515C8.78714 3.42984 8.6 1.25 5.99904 1.25C3.3 1.25 3.21094 3.42984 3.21094 4.16515V4.75039L3.38817 4.73529L3.2119 4.75123C2.30484 4.99873 2 5.7229 2 7.29041V8.20709C2 10.2238 2.50558 10.9996 4.14127 10.9996H7.85873C9.49442 10.9996 10 10.2238 10 8.20709V7.29041C10 5.7229 9.69516 4.99873 8.7881 4.75123L8.64331 4.73813L8.78714 4.75039ZM4.32618 4.16515V4.65048L7.6719 4.65039V4.16515C7.6719 3.0108 7.3 2.3002 5.99904 2.3002C4.65 2.3002 4.32618 3.0108 4.32618 4.16515ZM6.00078 8.95078C6.66352 8.95078 7.20078 8.41352 7.20078 7.75078C7.20078 7.08804 6.66352 6.55078 6.00078 6.55078C5.33804 6.55078 4.80078 7.08804 4.80078 7.75078C4.80078 8.41352 5.33804 8.95078 6.00078 8.95078Z"
fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 908 B

@@ -1,17 +1,2 @@
<svg xmlns="http://www.w3.org/2000/svg" width="21" height="20" viewBox="0 0 21 20" fill="none">
<g clip-path="url(#clip0_346_13747)">
<path d="M4.68181 9.99793C4.68181 9.36293 4.79014 8.75376 4.98181 8.18293L1.61681 5.66626C0.940723 7.00999 0.589632 8.4937 0.591807 9.99793C0.591807 11.5546 0.96014 13.0229 1.61514 14.3263L4.97847 11.8054C4.78236 11.223 4.68217 10.6125 4.68181 9.99793Z"
fill="#FBBC05"/>
<path d="M10.5917 4.22046C12 4.22046 13.2725 4.70879 14.2725 5.50879L17.1817 2.66463C15.4092 1.15379 13.1367 0.220459 10.5917 0.220459C6.64003 0.220459 3.24337 2.43296 1.6167 5.66629L4.98337 8.18296C5.75837 5.87796 7.96837 4.22046 10.5917 4.22046Z"
fill="#EA4335"/>
<path d="M10.5917 15.7755C7.96753 15.7755 5.75753 14.118 4.9817 11.813L1.6167 14.3297C3.24253 17.563 6.6392 19.7755 10.5917 19.7755C13.03 19.7755 15.3584 18.928 17.1067 17.3388L13.9117 14.9205C13.0109 15.4763 11.8759 15.7755 10.5909 15.7755"
fill="#34A853"/>
<path d="M20.1367 9.99796C20.1367 9.42046 20.045 8.79796 19.9092 8.22046H10.5908V11.998H15.9542C15.6867 13.2863 14.9567 14.2763 13.9125 14.9205L17.1067 17.3388C18.9425 15.6705 20.1367 13.1855 20.1367 9.99796Z"
fill="#4285F4"/>
</g>
<defs>
<clipPath id="clip0_346_13747">
<rect width="20" height="20" fill="white" transform="translate(0.5 -0.0020752)"/>
</clipPath>
</defs>
</svg>
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="-3 0 262 262" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid"><path d="M255.878 133.451c0-10.734-.871-18.567-2.756-26.69H130.55v48.448h71.947c-1.45 12.04-9.283 30.172-26.69 42.356l-.244 1.622 38.755 30.023 2.685.268c24.659-22.774 38.875-56.282 38.875-96.027" fill="#4285F4"/><path d="M130.55 261.1c35.248 0 64.839-11.605 86.453-31.622l-41.196-31.913c-11.024 7.688-25.82 13.055-45.257 13.055-34.523 0-63.824-22.773-74.269-54.25l-1.531.13-40.298 31.187-.527 1.465C35.393 231.798 79.49 261.1 130.55 261.1" fill="#34A853"/><path d="M56.281 156.37c-2.756-8.123-4.351-16.827-4.351-25.82 0-8.994 1.595-17.697 4.206-25.82l-.073-1.73L15.26 71.312l-1.335.635C5.077 89.644 0 109.517 0 130.55s5.077 40.905 13.925 58.602l42.356-32.782" fill="#FBBC05"/><path d="M130.55 50.479c24.514 0 41.05 10.589 50.479 19.438l36.844-35.974C195.245 12.91 165.798 0 130.55 0 79.49 0 35.393 29.301 13.925 71.947l42.211 32.783c10.59-31.477 39.891-54.251 74.414-54.251" fill="#EB4335"/></svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 8.5V4.5L3 11.5L10 18.5V14.4C15 14.4 18.5 16 21 19.5C20 14.5 17 9.5 10 8.5Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 218 B

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 8.2L6.84615 10L13 4" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 210 B

@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.59 9.17L5.41 4L4 5.41L9.17 10.58L10.59 9.17ZM14.5 4L16.54 6.04L4 18.59L5.41 20L17.96 7.46L20 9.5V4H14.5ZM14.83 13.41L13.42 14.82L16.55 17.95L14.5 20H20V14.5L17.96 16.54L14.83 13.41Z"
fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 335 B

@@ -0,0 +1,14 @@
<svg width="19" height="18" viewBox="0 0 19 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<g opacity="0.6">
<path d="M16.5 4.48535C14.0025 4.23785 11.49 4.11035 8.985 4.11035C7.5 4.11035 6.015 4.18535 4.53 4.33535L3 4.48535"
stroke="currentColor" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7.125 3.7275L7.29 2.745C7.41 2.0325 7.5 1.5 8.7675 1.5H10.7325C12 1.5 12.0975 2.0625 12.21 2.7525L12.375 3.7275"
stroke="currentColor" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14.8844 6.85449L14.3969 14.407C14.3144 15.5845 14.2469 16.4995 12.1544 16.4995H7.33937C5.24687 16.4995 5.17938 15.5845 5.09688 14.407L4.60938 6.85449"
stroke="currentColor" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.49609 12.375H10.9936" stroke="currentColor" stroke-width="1.125" stroke-linecap="round"
stroke-linejoin="round"/>
<path d="M7.875 9.375H11.625" stroke="currentColor" stroke-width="1.125" stroke-linecap="round"
stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,175 @@
import { MAX_FREQUENTLY_ROW_COUNT, PER_ROW_EMOJI_COUNT } from '@/components/_shared/emoji-picker/const';
import { loadEmojiData } from '@/utils/emoji';
import { EmojiMartData } from '@emoji-mart/data';
import { PopoverProps } from '@mui/material/Popover';
import { PopoverOrigin } from '@mui/material/Popover/Popover';
import { FrequentlyUsed, getEmojiDataFromNative, init, Store } from 'emoji-mart';
import chunk from 'lodash-es/chunk';
import React, { useCallback, useEffect, useState } from 'react';
export interface EmojiCategory {
id: string;
emojis: Emoji[];
}
interface Emoji {
id: string;
name: string;
native: string;
}
export function useLoadEmojiData({ onEmojiSelect }: { onEmojiSelect: (emoji: string) => void }) {
const [searchValue, setSearchValue] = useState('');
const [emojiCategories, setEmojiCategories] = useState<EmojiCategory[]>([]);
const [skin, setSkin] = useState<number>(() => {
return Number(Store.get('skin')) || 0;
});
const onSkinChange = useCallback((val: number) => {
setSkin(val);
Store.set('skin', String(val));
}, []);
const searchEmojiData = useCallback(
async (searchVal?: string) => {
const emojiData = await loadEmojiData();
const { emojis, categories } = emojiData as EmojiMartData;
const filteredCategories = categories
.map((category) => {
const { id, emojis: categoryEmojis } = category;
return {
id,
emojis: categoryEmojis
.filter((emojiId) => {
const emoji = emojis[emojiId];
if (!searchVal) return true;
return filterSearchValue(emoji, searchVal);
})
.map((emojiId) => {
const emoji = emojis[emojiId];
const { name, skins } = emoji;
return {
id: emojiId,
name,
native: skins[skin] ? skins[skin].native : skins[0].native,
};
}),
};
})
.filter((category) => category.emojis.length > 0);
setEmojiCategories(filteredCategories);
},
[skin]
);
useEffect(() => {
void (async () => {
await init({
maxFrequentRows: MAX_FREQUENTLY_ROW_COUNT,
perLine: PER_ROW_EMOJI_COUNT,
});
await searchEmojiData();
})();
}, [searchEmojiData]);
useEffect(() => {
void searchEmojiData(searchValue);
}, [searchEmojiData, searchValue]);
const onSelect = useCallback(
async (native: string) => {
onEmojiSelect(native);
if (!native) {
return;
}
try {
const data = await getEmojiDataFromNative(native);
FrequentlyUsed.add(data);
} catch (e) {
// do nothing
}
},
[onEmojiSelect]
);
return {
emojiCategories,
setSearchValue,
searchValue,
onSelect,
onSkinChange,
skin,
};
}
export function useSelectSkinPopoverProps(): PopoverProps & {
onOpen: (event: React.MouseEvent<HTMLButtonElement>) => void;
onClose: () => void;
} {
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | undefined>(undefined);
const onOpen = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
}, []);
const onClose = useCallback(() => {
setAnchorEl(undefined);
}, []);
const open = Boolean(anchorEl);
const anchorOrigin = { vertical: 'bottom', horizontal: 'center' } as PopoverOrigin;
const transformOrigin = { vertical: 'top', horizontal: 'center' } as PopoverOrigin;
return {
anchorEl,
onOpen,
onClose,
open,
anchorOrigin,
transformOrigin,
};
}
function filterSearchValue(
emoji: {
name: string;
keywords?: string[];
},
searchValue: string
) {
const { name, keywords } = emoji;
const searchValueLowerCase = searchValue.toLowerCase();
return (
name.toLowerCase().includes(searchValueLowerCase) ||
(keywords && keywords.some((keyword) => keyword.toLowerCase().includes(searchValueLowerCase)))
);
}
export function getRowsWithCategories(emojiCategories: EmojiCategory[], rowSize: number) {
const rows: {
id: string;
type: 'category' | 'emojis';
emojis?: Emoji[];
}[] = [];
emojiCategories.forEach((category) => {
rows.push({
id: category.id,
type: 'category',
});
chunk(category.emojis, rowSize).forEach((chunk, index) => {
rows.push({
type: 'emojis',
emojis: chunk,
id: `${category.id}-${index}`,
});
});
});
return rows;
}
@@ -0,0 +1,37 @@
import React from 'react';
import { useLoadEmojiData } from './EmojiPicker.hooks';
import EmojiPickerHeader from './EmojiPickerHeader';
import EmojiPickerCategories from './EmojiPickerCategories';
interface Props {
onEmojiSelect: (emoji: string) => void;
onEscape?: () => void;
defaultEmoji?: string;
hideRemove?: boolean;
}
export function EmojiPicker({ defaultEmoji, onEscape, ...props }: Props) {
const { skin, onSkinChange, emojiCategories, setSearchValue, searchValue, onSelect } = useLoadEmojiData(props);
return (
<div tabIndex={0} className={'emoji-picker flex h-[360px] max-h-[70vh] flex-col p-4 pt-2'}>
<EmojiPickerHeader
onEmojiSelect={onSelect}
skin={skin}
hideRemove={props.hideRemove}
onSkinSelect={onSkinChange}
searchValue={searchValue}
onSearchChange={setSearchValue}
/>
<EmojiPickerCategories
defaultEmoji={defaultEmoji}
onEscape={onEscape}
onEmojiSelect={onSelect}
emojiCategories={emojiCategories}
/>
</div>
);
}
export default EmojiPicker;
@@ -0,0 +1,354 @@
import { EMOJI_SIZE, PER_ROW_EMOJI_COUNT } from '@/components/_shared/emoji-picker/const';
import { AFScroller } from '@/components/_shared/scroller';
import { getDistanceEdge, inView } from '@/utils/position';
import { Tooltip } from '@mui/material';
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import AutoSizer from 'react-virtualized-auto-sizer';
import { FixedSizeList } from 'react-window';
import { EmojiCategory, getRowsWithCategories } from './EmojiPicker.hooks';
function EmojiPickerCategories({
emojiCategories,
onEmojiSelect,
onEscape,
defaultEmoji,
}: {
emojiCategories: EmojiCategory[];
onEmojiSelect: (emoji: string) => void;
onEscape?: () => void;
defaultEmoji?: string;
}) {
const scrollRef = React.useRef<HTMLDivElement>(null);
const { t } = useTranslation();
const [selectCell, setSelectCell] = React.useState({
row: 1,
column: 0,
});
const rows = useMemo(() => {
return getRowsWithCategories(emojiCategories, PER_ROW_EMOJI_COUNT);
}, [emojiCategories]);
const mouseY = useRef<number | null>(null);
const mouseX = useRef<number | null>(null);
const ref = React.useRef<HTMLDivElement>(null);
const getCategoryName = useCallback(
(id: string) => {
const i18nName: Record<string, string> = {
frequent: t('emoji.categories.frequentlyUsed'),
people: t('emoji.categories.people'),
nature: t('emoji.categories.nature'),
foods: t('emoji.categories.food'),
activity: t('emoji.categories.activities'),
places: t('emoji.categories.places'),
objects: t('emoji.categories.objects'),
symbols: t('emoji.categories.symbols'),
flags: t('emoji.categories.flags'),
};
return i18nName[id];
},
[t]
);
useEffect(() => {
scrollRef.current?.scrollTo({
top: 0,
});
setSelectCell({
row: 1,
column: 0,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rows]);
const renderRow = useCallback(
({ index, style }: { index: number; style: React.CSSProperties }) => {
const item = rows[index];
return (
<div style={style} data-index={index}>
{item.type === 'category' ? (
<div className={'pt-2 text-xs font-medium text-text-caption'}>{getCategoryName(item.id)}</div>
) : null}
<div className={'flex'}>
{item.emojis?.map((emoji, columnIndex) => {
const isSelected = selectCell.row === index && selectCell.column === columnIndex;
const isDefaultEmoji = defaultEmoji === emoji.native;
return (
<Tooltip key={emoji.id} title={emoji.name} placement={'top'} enterDelay={500}>
<div
data-key={emoji.id}
style={{
width: EMOJI_SIZE,
height: EMOJI_SIZE,
}}
onClick={() => {
onEmojiSelect(emoji.native);
}}
onMouseMove={(e) => {
mouseY.current = e.clientY;
mouseX.current = e.clientX;
}}
onMouseEnter={(e) => {
if (mouseY.current === null || mouseY.current !== e.clientY || mouseX.current !== e.clientX) {
setSelectCell({
row: index,
column: columnIndex,
});
}
mouseX.current = e.clientX;
mouseY.current = e.clientY;
}}
className={`icon flex cursor-pointer items-center justify-center rounded hover:bg-fill-list-hover ${
isSelected ? 'bg-fill-list-hover' : 'hover:bg-transparent'
} ${isDefaultEmoji ? 'bg-fill-list-active' : ''}`}
>
{emoji.native}
</div>
</Tooltip>
);
})}
</div>
</div>
);
},
[defaultEmoji, getCategoryName, onEmojiSelect, rows, selectCell.column, selectCell.row]
);
const getNewColumnIndex = useCallback(
(rowIndex: number, columnIndex: number): number => {
const row = rows[rowIndex];
const length = row.emojis?.length;
let newColumnIndex = columnIndex;
if (length && length <= columnIndex) {
newColumnIndex = length - 1 || 0;
}
return newColumnIndex;
},
[rows]
);
const findNextRow = useCallback(
(rowIndex: number, columnIndex: number): { row: number; column: number } => {
const rowLength = rows.length;
let nextRowIndex = rowIndex + 1;
if (nextRowIndex >= rowLength - 1) {
nextRowIndex = rowLength - 1;
} else if (rows[nextRowIndex].type === 'category') {
nextRowIndex = findNextRow(nextRowIndex, columnIndex).row;
}
const newColumnIndex = getNewColumnIndex(nextRowIndex, columnIndex);
return {
row: nextRowIndex,
column: newColumnIndex,
};
},
[getNewColumnIndex, rows]
);
const findPrevRow = useCallback(
(rowIndex: number, columnIndex: number): { row: number; column: number } => {
let prevRowIndex = rowIndex - 1;
if (prevRowIndex < 1) {
prevRowIndex = 1;
} else if (rows[prevRowIndex].type === 'category') {
prevRowIndex = findPrevRow(prevRowIndex, columnIndex).row;
}
const newColumnIndex = getNewColumnIndex(prevRowIndex, columnIndex);
return {
row: prevRowIndex,
column: newColumnIndex,
};
},
[getNewColumnIndex, rows]
);
const findPrevCell = useCallback(
(row: number, column: number): { row: number; column: number } => {
const prevColumn = column - 1;
if (prevColumn < 0) {
const prevRow = findPrevRow(row, column).row;
if (prevRow === row) return { row, column };
const length = rows[prevRow].emojis?.length || 0;
return {
row: prevRow,
column: length > 0 ? length - 1 : 0,
};
}
return {
row,
column: prevColumn,
};
},
[findPrevRow, rows]
);
const findNextCell = useCallback(
(row: number, column: number): { row: number; column: number } => {
const nextColumn = column + 1;
const rowLength = rows[row].emojis?.length || 0;
if (nextColumn >= rowLength) {
const nextRow = findNextRow(row, column).row;
if (nextRow === row) return { row, column };
return {
row: nextRow,
column: 0,
};
}
return {
row,
column: nextColumn,
};
},
[findNextRow, rows]
);
useEffect(() => {
if (!selectCell || !scrollRef.current) return;
const emojiKey = rows[selectCell.row]?.emojis?.[selectCell.column]?.id;
const emojiDom = document.querySelector(`[data-key="${emojiKey}"]`);
if (emojiDom && !inView(emojiDom as HTMLElement, scrollRef.current as HTMLElement)) {
const distance = getDistanceEdge(emojiDom as HTMLElement, scrollRef.current as HTMLElement);
scrollRef.current?.scrollTo({
top: scrollRef.current?.scrollTop + distance,
});
}
}, [selectCell, rows]);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
e.stopPropagation();
switch (e.key) {
case 'Escape':
e.preventDefault();
onEscape?.();
break;
case 'ArrowUp': {
e.preventDefault();
setSelectCell(findPrevRow(selectCell.row, selectCell.column));
break;
}
case 'ArrowDown': {
e.preventDefault();
setSelectCell(findNextRow(selectCell.row, selectCell.column));
break;
}
case 'ArrowLeft': {
e.preventDefault();
const prevCell = findPrevCell(selectCell.row, selectCell.column);
setSelectCell(prevCell);
break;
}
case 'ArrowRight': {
e.preventDefault();
const nextCell = findNextCell(selectCell.row, selectCell.column);
setSelectCell(nextCell);
break;
}
case 'Enter': {
e.preventDefault();
const currentRow = rows[selectCell.row];
const emoji = currentRow.emojis?.[selectCell.column];
if (emoji) {
onEmojiSelect(emoji.native);
}
break;
}
default:
break;
}
},
[
findNextCell,
findPrevCell,
findPrevRow,
findNextRow,
onEmojiSelect,
onEscape,
rows,
selectCell.column,
selectCell.row,
]
);
useEffect(() => {
const focusElement = document.querySelector('.emoji-picker .search-emoji-input') as HTMLInputElement;
const parentElement = ref.current?.parentElement;
focusElement?.addEventListener('keydown', handleKeyDown);
parentElement?.addEventListener('keydown', handleKeyDown);
return () => {
focusElement?.removeEventListener('keydown', handleKeyDown);
parentElement?.removeEventListener('keydown', handleKeyDown);
};
}, [handleKeyDown]);
return (
<div
ref={ref}
className={`mt-2 w-[${
EMOJI_SIZE * PER_ROW_EMOJI_COUNT
}px] flex-1 transform items-center justify-center overflow-y-auto overflow-x-hidden`}
>
<AutoSizer>
{({ height, width }: { height: number; width: number }) => (
<FixedSizeList
overscanCount={10}
height={height}
width={width}
outerRef={scrollRef}
itemCount={rows.length}
itemSize={EMOJI_SIZE}
itemData={rows}
outerElementType={AFScroller}
>
{renderRow}
</FixedSizeList>
)}
</AutoSizer>
</div>
);
}
export default EmojiPickerCategories;
@@ -0,0 +1,139 @@
import { useSelectSkinPopoverProps } from './EmojiPicker.hooks';
import React from 'react';
import { Box, IconButton } from '@mui/material';
import { Circle } from '@mui/icons-material';
import TextField from '@mui/material/TextField';
import Tooltip from '@mui/material/Tooltip';
import { randomEmoji } from '@/utils/emoji';
import { ReactComponent as ShuffleIcon } from '@/assets/shuffle.svg';
import Popover from '@mui/material/Popover';
import { useTranslation } from 'react-i18next';
import { ReactComponent as DeleteOutlineRounded } from '@/assets/trash.svg';
import { ReactComponent as SearchOutlined } from '@/assets/search.svg';
const skinTones = [
{
value: 0,
color: '#ffc93a',
},
{
color: '#ffdab7',
value: 1,
},
{
color: '#e7b98f',
value: 2,
},
{
color: '#c88c61',
value: 3,
},
{
color: '#a46134',
value: 4,
},
{
color: '#5d4437',
value: 5,
},
];
interface Props {
onEmojiSelect: (emoji: string) => void;
skin: number;
onSkinSelect: (skin: number) => void;
searchValue: string;
onSearchChange: (value: string) => void;
hideRemove?: boolean;
}
function EmojiPickerHeader({ hideRemove, onEmojiSelect, onSkinSelect, searchValue, onSearchChange, skin }: Props) {
const { onOpen, ...popoverProps } = useSelectSkinPopoverProps();
const { t } = useTranslation();
return (
<div className={'px-0.5 py-2'}>
<div className={'search-input flex items-end justify-between gap-2'}>
<Box className={'mr-1 flex flex-1 items-center gap-2'}>
<SearchOutlined className={'h-5 h-5'} />
<TextField
value={searchValue}
onChange={(e) => {
onSearchChange(e.target.value);
}}
autoFocus={true}
fullWidth={true}
autoCorrect={'off'}
autoComplete={'off'}
spellCheck={false}
className={'search-emoji-input'}
placeholder={t('search.label')}
variant='standard'
/>
</Box>
<div className={'flex gap-1'}>
<Tooltip title={t('emoji.random')}>
<IconButton
size={'small'}
onClick={async () => {
const emoji = await randomEmoji();
onEmojiSelect(emoji);
}}
>
<ShuffleIcon className={'h-5 h-5'} />
</IconButton>
</Tooltip>
<Tooltip title={t('emoji.selectSkinTone')}>
<IconButton size={'small'} className={'h-[25px] w-[25px]'} onClick={onOpen}>
<Circle
style={{
fill: skinTones[skin].color,
}}
className={'h-5 h-5'}
/>
</IconButton>
</Tooltip>
{hideRemove ? null : (
<Tooltip title={t('emoji.remove')}>
<IconButton
size={'small'}
onClick={() => {
onEmojiSelect('');
}}
>
<DeleteOutlineRounded className={'h-5 h-5'} />
</IconButton>
</Tooltip>
)}
</div>
</div>
<Popover {...popoverProps}>
<div className={'flex items-center p-2'}>
{skinTones.map((skinTone) => (
<div className={'mx-0.5'} key={skinTone.value}>
<IconButton
style={{
backgroundColor: skinTone.value === skin ? 'var(--fill-list-hover)' : undefined,
}}
size={'small'}
onClick={() => {
onSkinSelect(skinTone.value);
popoverProps.onClose?.();
}}
>
<Circle
style={{
fill: skinTone.color,
}}
/>
</IconButton>
</div>
))}
</div>
</Popover>
</div>
);
}
export default EmojiPickerHeader;
@@ -0,0 +1,3 @@
export const EMOJI_SIZE = 32;
export const PER_ROW_EMOJI_COUNT = 13;
export const MAX_FREQUENTLY_ROW_COUNT = 2;
@@ -0,0 +1,3 @@
import { lazy } from 'react';
export const EmojiPicker = lazy(() => import('./EmojiPicker'));
@@ -0,0 +1,3 @@
import { lazy } from 'react';
export const KatexMath = lazy(() => import('./KatexMath'));
@@ -0,0 +1,82 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Button, ButtonProps, CircularProgress, Dialog, DialogProps, IconButton } from '@mui/material';
import { ReactComponent as CloseIcon } from '@/assets/close.svg';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
export interface NormalModalProps extends DialogProps {
okText?: string;
cancelText?: string;
onOk?: () => void;
onCancel?: () => void;
danger?: boolean;
onClose?: () => void;
title: string | React.ReactNode;
okButtonProps?: ButtonProps;
cancelButtonProps?: ButtonProps;
okLoading?: boolean;
}
export function NormalModal({
okText,
title,
cancelText,
onOk,
onCancel,
danger,
onClose,
children,
okButtonProps,
cancelButtonProps,
okLoading,
...dialogProps
}: NormalModalProps) {
const { t } = useTranslation();
const modalOkText = okText || t('button.ok');
const modalCancelText = cancelText || t('button.cancel');
const buttonColor = danger ? 'var(--function-error)' : undefined;
return (
<Dialog
onKeyDown={(e) => {
if (e.key === 'Escape') {
onClose?.();
}
}}
{...dialogProps}
>
<div className={'relative flex flex-col gap-4 p-5'}>
<div className={'flex w-full items-center justify-between text-base font-medium'}>
<div className={'flex-1 text-center '}>{title}</div>
<div className={'relative -right-1.5'}>
<IconButton size={'small'} color={'inherit'} className={'h-6 w-6'} onClick={onClose || onCancel}>
<CloseIcon className={'h-4 w-4'} />
</IconButton>
</div>
</div>
<div className={'flex-1'}>{children}</div>
<div className={'flex w-full justify-end gap-4'}>
<Button color={'inherit'} variant={'outlined'} onClick={onCancel} {...cancelButtonProps}>
{modalCancelText}
</Button>
<Button
color={'primary'}
variant={'contained'}
style={{ backgroundColor: buttonColor }}
onClick={() => {
if (okLoading) return;
onOk?.();
}}
{...okButtonProps}
>
{okLoading ? <CircularProgress size={24} /> : modalOkText}
</Button>
</div>
</div>
</Dialog>
);
}
export default NormalModal;
@@ -0,0 +1 @@
export * from './NormalModal';
@@ -0,0 +1,44 @@
import React, { forwardRef } from 'react';
import { Button, IconButton, Paper } from '@mui/material';
import { useTranslation } from 'react-i18next';
import { ReactComponent as CloseIcon } from '@/assets/close.svg';
import { CustomContentProps, SnackbarContent } from 'notistack';
export interface InfoProps {
onOk?: () => void;
okText?: string;
title?: string;
message?: JSX.Element | string;
onClose?: () => void;
autoHideDuration?: number | null;
}
export type InfoSnackbarProps = InfoProps & CustomContentProps;
const InfoSnackbar = forwardRef<HTMLDivElement, InfoSnackbarProps>(({ onOk, okText, title, message, onClose }, ref) => {
const { t } = useTranslation();
return (
<SnackbarContent ref={ref}>
<Paper className={'relative flex flex-col gap-4 p-5'}>
<div className={'flex w-full items-center justify-between text-base font-medium'}>
<div className={'flex-1 text-left '}>{title}</div>
<div className={'relative -right-1.5'}>
<IconButton size={'small'} color={'inherit'} className={'h-6 w-6'} onClick={onClose}>
<CloseIcon className={'h-4 w-4'} />
</IconButton>
</div>
</div>
<div className={'flex-1'}>{message}</div>
<div className={'flex w-full justify-end gap-4'}>
<Button color={'primary'} variant={'contained'} onClick={onOk}>
{okText || t('button.ok')}
</Button>
</div>
</Paper>
</SnackbarContent>
);
});
export default InfoSnackbar;
@@ -1,3 +1,8 @@
import { InfoProps } from '@/components/_shared/notify/InfoSnackbar';
import { lazy } from 'react';
export const InfoSnackbar = lazy(() => import('./InfoSnackbar'));
export const notify = {
success: (message: string) => {
window.toast.success(message);
@@ -11,10 +16,19 @@ export const notify = {
warning: (message: string) => {
window.toast.warning(message);
},
info: (message: string) => {
window.toast.info(message);
info: (props: InfoProps) => {
window.toast.info({
...props,
variant: 'info',
anchorOrigin: {
vertical: 'bottom',
horizontal: 'center',
},
});
},
clear: () => {
window.toast.clear();
},
};
export * from './InfoSnackbar';
@@ -17,3 +17,5 @@ export function Popover({ children, ...props }: PopoverComponentProps) {
</PopoverComponent>
);
}
export default Popover;
@@ -2,10 +2,13 @@ import { clearData } from '@/application/db';
import { EventType, on } from '@/application/session';
import { isTokenValid } from '@/application/session/token';
import { useAppLanguage } from '@/components/app/useAppLanguage';
import { LoginModal } from '@/components/login';
import { useSnackbar } from 'notistack';
import React, { createContext, useEffect, useState } from 'react';
import React, { createContext, useCallback, useEffect, useState } from 'react';
import { AFService, AFServiceConfig } from '@/application/services/services.type';
import { getService } from '@/application/services';
import { InfoSnackbarProps } from '@/components/_shared/notify';
import { User } from '@/application/types';
const baseURL = import.meta.env.AF_BASE_URL || 'https://test.appflowy.cloud';
const gotrueURL = import.meta.env.AF_GOTRUE_URL || 'https://test.appflowy.cloud/gotrue';
@@ -23,6 +26,8 @@ export const AFConfigContext = createContext<
| {
service: AFService | undefined;
isAuthenticated: boolean;
currentUser?: User;
openLoginModal: (redirectTo?: string) => void;
}
| undefined
>(undefined);
@@ -31,6 +36,14 @@ function AppConfig({ children }: { children: React.ReactNode }) {
const [appConfig] = useState<AFServiceConfig>(defaultConfig);
const [service, setService] = useState<AFService>();
const [isAuthenticated, setIsAuthenticated] = React.useState<boolean>(isTokenValid());
const [currentUser, setCurrentUser] = React.useState<User>();
const [loginOpen, setLoginOpen] = React.useState(false);
const [loginCompletedRedirectTo, setLoginCompletedRedirectTo] = React.useState<string>('');
const openLoginModal = useCallback((redirectTo?: string) => {
setLoginOpen(true);
setLoginCompletedRedirectTo(redirectTo || '');
}, []);
useEffect(() => {
return on(EventType.SESSION_VALID, () => {
@@ -38,6 +51,24 @@ function AppConfig({ children }: { children: React.ReactNode }) {
});
}, []);
useEffect(() => {
if (!isAuthenticated) {
setCurrentUser(undefined);
return;
}
void (async () => {
if (!service) return;
try {
const user = await service.getCurrentUser();
setCurrentUser(user);
} catch (e) {
console.error(e);
}
})();
}, [isAuthenticated, service]);
useEffect(() => {
const handleStorageChange = (event: StorageEvent) => {
if (event.key === 'token') setIsAuthenticated(isTokenValid());
@@ -79,8 +110,9 @@ function AppConfig({ children }: { children: React.ReactNode }) {
default: (message: string) => {
enqueueSnackbar(message, { variant: 'default' });
},
info: (message: string) => {
enqueueSnackbar(message, { variant: 'info' });
info: (props: InfoSnackbarProps) => {
enqueueSnackbar(props.message, props);
},
clear: () => {
@@ -111,9 +143,20 @@ function AppConfig({ children }: { children: React.ReactNode }) {
value={{
service,
isAuthenticated,
currentUser,
openLoginModal,
}}
>
{children}
{loginOpen && (
<LoginModal
redirectTo={loginCompletedRedirectTo}
open={loginOpen}
onClose={() => {
setLoginOpen(false);
}}
/>
)}
</AFConfigContext.Provider>
);
}
@@ -41,20 +41,42 @@ function AppTheme({ children }: { children: React.ReactNode }) {
},
borderRadius: '4px',
padding: '2px',
'&.MuiIconButton-colorInherit': {
color: 'var(--icon-primary)',
},
},
},
},
MuiButton: {
styleOverrides: {
text: {
borderRadius: '8px',
'&:hover': {
backgroundColor: 'var(--fill-list-hover)',
},
},
contained: {
color: 'var(--content-on-fill)',
boxShadow: 'none',
'&:hover': {
backgroundColor: 'var(--content-blue-600)',
},
borderRadius: '8px',
'&.Mui-disabled': {
backgroundColor: 'var(--content-blue-400)',
opacity: 0.3,
color: 'var(--content-on-fill)',
},
},
outlined: {
'&.MuiButton-outlinedInherit': {
borderColor: 'var(--line-divider)',
},
borderRadius: '8px',
},
},
},
MuiButtonBase: {
styleOverrides: {
root: {
@@ -78,10 +100,16 @@ function AppTheme({ children }: { children: React.ReactNode }) {
root: {
backgroundImage: 'none',
boxShadow: 'var(--shadow)',
borderRadius: '10px',
},
},
},
MuiDialog: {
styleOverrides: {
paper: {
borderRadius: '12px',
},
},
defaultProps: {
sx: {
'& .MuiBackdrop-root': {
@@ -112,6 +140,7 @@ function AppTheme({ children }: { children: React.ReactNode }) {
color: 'var(--text-caption)',
WebkitTextFillColor: 'var(--text-caption) !important',
},
borderRadius: '8px',
},
},
styleOverrides: {
@@ -5,6 +5,7 @@ import AppConfig from '@/components/app/AppConfig';
import { Suspense } from 'react';
import { SnackbarProvider } from 'notistack';
import { styled } from '@mui/material';
import { InfoSnackbar } from '../_shared/notify';
const StyledSnackbarProvider = styled(SnackbarProvider)`
&.notistack-MuiContent-default {
@@ -39,6 +40,9 @@ export default function withAppWrapper(Component: React.FC): React.FC {
horizontal: 'center',
}}
preventDuplicate
Components={{
info: InfoSnackbar,
}}
>
<AppConfig>
<Suspense>
@@ -8,15 +8,12 @@ export function Calendar() {
const { dayPropGetter, localizer, formats, events, emptyEvents } = useCalendarSetup();
return (
<div className={'database-calendar h-full max-h-[960px] pb-4 pt-4 text-sm'}>
<div className={'database-calendar h-fit max-h-[960px] pb-4 pt-4 text-sm'}>
<BigCalendar
components={{
toolbar: (props) => <Toolbar {...props} emptyEvents={emptyEvents} />,
eventWrapper: Event,
}}
style={{
marginBottom: '24px',
}}
events={events}
views={['month']}
localizer={localizer}
@@ -29,7 +29,7 @@ $today-highlight-bg: transparent;
.rbc-month-view {
border: none;
@apply h-full overflow-auto;
.rbc-month-row {
border: 1px solid var(--line-divider);
@@ -79,10 +79,6 @@ $today-highlight-bg: transparent;
height: fit-content;
table-layout: fixed;
width: 100%;
&:last-child {
margin-bottom: 150px;
}
}
.event-properties {
@@ -79,7 +79,6 @@ export const Column = memo(
return (
<VariableSizeList
ref={ref}
className={'pb-[150px]'}
height={height}
itemCount={rowCount}
itemSize={getItemSize}
@@ -2,7 +2,7 @@ import { FieldType } from '@/application/database-yjs';
import { useDateTypeCellDispatcher } from '@/components/database/components/cell/Cell.hooks';
import { CellProps, DateTimeCell as DateTimeCellType } from '@/application/database-yjs/cell.type';
import React, { useMemo } from 'react';
import { ReactComponent as ReminderSvg } from '$icons/16x/clock_alarm.svg';
import { ReactComponent as ReminderSvg } from '@/assets/clock_alarm.svg';
export function DateTimeCell({ cell, fieldId, style, placeholder }: CellProps<DateTimeCellType>) {
const { getDateTimeStr } = useDateTypeCellDispatcher(fieldId);
@@ -4,7 +4,7 @@ import Cell from '@/components/database/components/cell/Cell';
import React, { CSSProperties, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
function CardField({ rowId, fieldId }: { rowId: string; fieldId: string; index: number }) {
export function CardField({ rowId, fieldId }: { rowId: string; fieldId: string; index: number }) {
const { t } = useTranslation();
const { field } = useFieldSelector(fieldId);
const cell = useCellSelector({
@@ -21,7 +21,7 @@ function CardField({ rowId, fieldId }: { rowId: string; fieldId: string; index:
textAlign: 'left',
};
if ([FieldType.Relation, FieldType.SingleSelect, FieldType.MultiSelect].includes(Number(type))) {
if (isPrimary || [FieldType.Relation, FieldType.SingleSelect, FieldType.MultiSelect].includes(Number(type))) {
Object.assign(styleProperties, {
breakWord: 'break-word',
whiteSpace: 'normal',
@@ -126,7 +126,7 @@ export const GridTable = ({ scrollLeft, columnWidth, columns, onScrollLeft }: Gr
columnCount={columns.length}
columnWidth={(index) => columnWidth(index, width)}
rowHeight={rowHeight}
className={'grid-table pb-[150px]'}
className={'grid-table'}
overscanRowCount={5}
overscanColumnCount={5}
style={{
@@ -18,7 +18,7 @@ function DatabaseHeader({
return (
<div
className={
'my-10 flex w-full items-center gap-4 overflow-hidden whitespace-pre-wrap break-words break-all text-[2.25rem] font-bold leading-[1.5em] max-sm:text-[7vw]'
'my-10 flex w-full items-center gap-4 whitespace-pre-wrap break-words break-all text-[2.25rem] font-bold leading-[1.5em] max-sm:text-[7vw]'
}
>
<div className={'relative'}>
@@ -48,12 +48,14 @@ export const DatabaseTabs = forwardRef<HTMLDivElement, DatabaseTabBarProps>(
return classList.join(' ');
}, [layout]);
const showActions = !hideConditions && layout !== DatabaseViewLayout.Calendar;
if (viewIds.length === 0) return null;
return (
<div ref={ref} className={className}>
<div
style={{
width: 'calc(100% - 120px)',
width: showActions ? 'calc(100% - 120px)' : '100%',
}}
className='flex items-center '
>
@@ -90,7 +92,7 @@ export const DatabaseTabs = forwardRef<HTMLDivElement, DatabaseTabBarProps>(
})}
</ViewTabs>
</div>
{!hideConditions && layout !== DatabaseViewLayout.Calendar ? <DatabaseActions /> : null}
{showActions ? <DatabaseActions /> : null}
</div>
);
}
@@ -3,39 +3,49 @@ import { useEditorContext } from '@/components/editor/EditorContext';
import { useCallback, useEffect } from 'react';
import { ReactEditor, useSlateStatic } from 'slate-react';
import { Element as SlateElement, Transforms } from 'slate';
const Prism = window.Prism;
const hljs = window.hljs;
import Prism from 'prismjs';
export function useCodeBlock(node: CodeNode) {
const language = node.data.language;
const editor = useSlateStatic() as ReactEditor;
const addCodeGrammars = useEditorContext().addCodeGrammars;
useEffect(() => {
const path = ReactEditor.findPath(editor, node);
let detectedLanguage = language;
void (async () => {
const path = ReactEditor.findPath(editor, node);
let detectedLanguage = language;
if (!language) {
const codeSnippet = editor.string(path);
if (!language) {
const codeSnippet = editor.string(path);
const script = document.createElement('script');
detectedLanguage = hljs.highlightAuto(codeSnippet).language;
}
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.5.1/highlight.min.js';
document.body.appendChild(script);
const promise = new Promise((resolve) => {
script.onload = () => {
resolve(true);
};
});
const prismLanguage = Prism.languages[detectedLanguage.toLowerCase()];
await promise;
if (!prismLanguage) {
const script = document.createElement('script');
detectedLanguage = window.hljs.highlightAuto(codeSnippet).language || 'plaintext';
}
script.src = `https://cdnjs.cloudflare.com/ajax/libs/prism/1.26.0/components/prism-${detectedLanguage.toLowerCase()}.min.js`;
document.head.appendChild(script);
script.onload = () => {
const prismLanguage = Prism.languages[detectedLanguage.toLowerCase()];
if (!prismLanguage) {
const script = document.createElement('script');
script.src = `https://cdnjs.cloudflare.com/ajax/libs/prism/1.26.0/components/prism-${detectedLanguage.toLowerCase()}.min.js`;
document.body.appendChild(script);
script.onload = () => {
addCodeGrammars?.(node.blockId, detectedLanguage);
};
} else {
addCodeGrammars?.(node.blockId, detectedLanguage);
};
} else {
addCodeGrammars?.(node.blockId, detectedLanguage);
}
}
})();
}, [addCodeGrammars, editor, language, node]);
const handleChangeLanguage = useCallback(
@@ -1,8 +1,5 @@
import { BaseRange, NodeEntry, Text, Path } from 'slate';
const Prism = window.Prism;
Prism.plugins.autoloader.languages_path = 'https://cdnjs.cloudflare.com/ajax/libs/prism/1.26.0/components/';
import Prism, { Grammar } from 'prismjs';
const push_string = (
token: string | Prism.Token,
@@ -86,7 +83,7 @@ export const decorateCode = ([node, path]: NodeEntry, language: string) => {
return ranges;
}
const highlightCode = (code: string, language: string) => {
const highlightCode = (code: string, language: Grammar) => {
try {
const tokens = Prism.tokenize(code, language);
@@ -5,7 +5,7 @@ import { copyTextToClipboard } from '@/utils/copy';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { CircularProgress } from '@mui/material';
import { ErrorOutline } from '@mui/icons-material';
import { ReactComponent as ErrorOutline } from '@/assets/error.svg';
const MIN_WIDTH = 100;
@@ -35,7 +35,11 @@ function ImageRender({
}, [hasError, initialWidth, loading]);
const imageProps: React.ImgHTMLAttributes<HTMLImageElement> = useMemo(() => {
return {
style: { width: loading || hasError ? '0' : newWidth ?? '100%', opacity: selected ? 0.8 : 1 },
style: {
width: loading || hasError ? '0' : newWidth ?? '100%',
opacity: selected ? 0.8 : 1,
height: hasError ? 0 : 'auto',
},
className: 'object-cover',
ref: imgRef,
src: url,
@@ -54,7 +58,9 @@ function ImageRender({
const renderErrorNode = useCallback(() => {
return (
<div
className={'flex h-full w-full items-center justify-center gap-2 rounded border border-function-error bg-red-50'}
className={
'flex h-[48px] w-full items-center justify-center gap-2 rounded border border-function-error bg-red-50'
}
>
<ErrorOutline className={'text-function-error'} />
<div className={'text-function-error'}>{t('editor.imageLoadFailed')}</div>
@@ -68,7 +74,7 @@ function ImageRender({
<div
style={{
minWidth: MIN_WIDTH,
width: 'fit-content',
width: loading || hasError ? '100%' : 'fit-content',
}}
className={`image-render relative min-h-[48px] ${hasError ? 'w-full' : ''}`}
>
@@ -1,4 +1,4 @@
import KatexMath from '@/components/_shared/katex-math/KatexMath';
import { KatexMath } from '@/components/_shared/katex-math';
import { notify } from '@/components/_shared/notify';
import RightTopActionsToolbar from '@/components/editor/components/block-actions/RightTopActionsToolbar';
import { EditorElementProps, MathEquationNode } from '@/components/editor/editor.type';
@@ -1,3 +1 @@
import { lazy } from 'react';
export const MathEquation = lazy(() => import('./MathEquation'));
export * from './MathEquation';
@@ -1,4 +1,4 @@
import KatexMath from '@/components/_shared/katex-math/KatexMath';
import { KatexMath } from '@/components/_shared/katex-math';
import { EditorElementProps, FormulaNode } from '@/components/editor/editor.type';
import React, { memo, forwardRef } from 'react';
import { useSelected } from 'slate-react';
@@ -1,5 +1 @@
import { lazy } from 'react';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
export const Formula = lazy(() => import('./Formula?chunkName=formula'));
export * from './Formula';
@@ -1,7 +1,7 @@
import { renderDate } from '@/utils/time';
import React, { useMemo } from 'react';
import { ReactComponent as DateSvg } from '@/assets/date.svg';
import { ReactComponent as ReminderSvg } from '$icons/16x/clock_alarm.svg';
import { ReactComponent as ReminderSvg } from '@/assets/clock_alarm.svg';
function MentionDate({ date, reminder }: { date: string; reminder?: { id: string; option: string } }) {
const dateFormat = useMemo(() => {
@@ -0,0 +1,175 @@
import { PublishContext } from '@/application/publish';
import { notify } from '@/components/_shared/notify';
import { AFConfigContext } from '@/components/app/AppConfig';
import { useGlobalCommentContext } from '@/components/global-comment/GlobalComment.hooks';
import ReplyComment from '@/components/global-comment/ReplyComment';
import { Button, TextareaAutosize } from '@mui/material';
import CircularProgress from '@mui/material/CircularProgress';
import React, { memo, useCallback, useContext, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import smoothScrollIntoViewIfNeeded from 'smooth-scroll-into-view-if-needed';
import { ReactComponent as CloseIcon } from '@/assets/close.svg';
function AddComment() {
const { reload, replyCommentId, replyComment: setReplyCommentId } = useGlobalCommentContext();
const { t } = useTranslation();
const isAuthenticated = useContext(AFConfigContext)?.isAuthenticated;
const openLoginModal = useContext(AFConfigContext)?.openLoginModal;
const createCommentOnPublishView = useContext(AFConfigContext)?.service?.createCommentOnPublishView;
const viewId = useContext(PublishContext)?.viewMeta?.view_id;
const [content, setContent] = React.useState('');
const [loading, setLoading] = React.useState(false);
const [focus, setFocus] = React.useState(false);
const url = window.location.href + '#addComment';
const ref = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const handleOnFocus = () => {
setFocus(true);
};
useEffect(() => {
const element = ref.current;
if (!element) {
return;
}
const hashHasComment = window.location.hash.includes('#addComment');
const duration = hashHasComment ? 1000 : 200;
if (hashHasComment || replyCommentId) {
setTimeout(() => {
void (async () => {
window.location.hash = '';
await smoothScrollIntoViewIfNeeded(element, {
behavior: 'smooth',
block: 'nearest',
});
inputRef.current?.focus();
})();
}, duration);
}
}, [replyCommentId]);
const handleSubmit = useCallback(async () => {
if (!createCommentOnPublishView || !viewId || loading) {
return;
}
setLoading(true);
try {
await createCommentOnPublishView(viewId, content, replyCommentId || undefined);
await reload();
setContent('');
setReplyCommentId(null);
} catch (_e) {
notify.error('Failed to create comment');
} finally {
setLoading(false);
}
}, [loading, content, createCommentOnPublishView, viewId, replyCommentId, reload, setReplyCommentId]);
return (
<div className={'my-2 flex flex-col gap-2'}>
<div
style={{
backgroundColor: replyCommentId ? 'var(--fill-list-hover)' : undefined,
}}
className={'flex flex-col rounded-[8px]'}
>
{replyCommentId && (
<div className={'relative flex items-center gap-2 py-2 pl-2 pr-6'}>
<span className={'text-sm text-text-caption'}>{t('globalComment.replyingTo')}</span>
<div className={'flex-1 overflow-hidden'}> {<ReplyComment commentId={replyCommentId} />}</div>
<div className={'absolute right-2 top-2 cursor-pointer rounded-full p-1 hover:bg-fill-list-hover'}>
<CloseIcon className={'h-3 w-3 '} onClick={() => setReplyCommentId(null)} />
</div>
</div>
)}
<div
ref={ref}
id={'addComment'}
style={{
borderColor: focus ? 'var(--content-blue-400)' : undefined,
borderWidth: '1.5px',
scrollMarginTop: '100px',
borderTopLeftRadius: replyCommentId ? 0 : undefined,
borderTopRightRadius: replyCommentId ? 0 : undefined,
}}
className={
'flex w-full w-full transform flex-col gap-4 rounded-[8px] border border-line-divider bg-bg-body px-3 py-1.5 '
}
>
<TextareaAutosize
minRows={1}
ref={inputRef}
autoComplete={'off'}
spellCheck={false}
onMouseDown={() => {
if (!isAuthenticated && openLoginModal) {
openLoginModal(url);
}
}}
readOnly={!isAuthenticated}
onFocus={handleOnFocus}
onBlur={() => setFocus(false)}
value={content}
className={'w-full resize-none'}
onChange={(e) => {
setContent(e.target.value);
}}
placeholder={t('globalComment.addComment')}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey) {
e.preventDefault();
if (!content) return;
void handleSubmit();
}
if (e.key === 'Escape') {
setContent('');
setReplyCommentId(null);
}
}}
/>
</div>
</div>
{!!content && (
<div className={'flex items-center justify-end gap-2'}>
<Button
onClick={() => {
inputRef.current?.blur();
setContent('');
setReplyCommentId(null);
}}
className={'h-7'}
size={'small'}
color={'inherit'}
variant={'outlined'}
>
{t('button.cancel')}
</Button>
<Button
className={'h-7'}
size={'small'}
disabled={!content || loading}
onClick={handleSubmit}
variant={'contained'}
>
{loading ? <CircularProgress color={'inherit'} size={20} /> : t('button.save')}
</Button>
</div>
)}
</div>
);
}
export default memo(AddComment);
@@ -0,0 +1,38 @@
import { CommentWrap } from '@/components/global-comment/comment';
import { useGlobalCommentContext } from '@/components/global-comment/GlobalComment.hooks';
import React, { memo } from 'react';
function CommentList() {
const { comments } = useGlobalCommentContext();
const isEmpty = !comments || comments.length === 0;
const [hoverId, setHoverId] = React.useState<string | null>(null);
if (isEmpty) {
return null;
}
return (
<div
onMouseLeave={() => {
setHoverId(null);
}}
className={'flex w-full flex-col gap-2'}
>
{comments?.map((comment) => (
<CommentWrap
isHovered={comment.commentId === hoverId}
onHovered={() => {
setHoverId(comment.commentId);
}}
key={comment.commentId}
commentId={comment.commentId}
/>
))}
</div>
);
}
export default memo(CommentList);
@@ -0,0 +1,242 @@
import { CommentUser, GlobalComment, Reaction } from '@/application/comment.type';
import { PublishContext } from '@/application/publish';
import { AFConfigContext } from '@/components/app/AppConfig';
import { stringAvatar } from '@/utils/color';
import dayjs from 'dayjs';
import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
export const GlobalCommentContext = React.createContext<{
reload: () => Promise<void>;
getComment: (commentId: string) => GlobalComment | undefined;
loading: boolean;
comments: GlobalComment[] | null;
replyComment: (commentId: string | null) => void;
replyCommentId: string | null;
reactions: Record<string, Reaction[]> | null;
toggleReaction: (commentId: string, reactionType: string) => void;
}>({
reload: () => Promise.resolve(),
getComment: () => undefined,
loading: false,
comments: null,
replyComment: () => undefined,
replyCommentId: null,
reactions: null,
toggleReaction: () => undefined,
});
export function useGlobalCommentContext() {
return useContext(GlobalCommentContext);
}
export function useLoadReactions() {
const viewId = useContext(PublishContext)?.viewMeta?.view_id;
const service = useContext(AFConfigContext)?.service;
const currentUser = useContext(AFConfigContext)?.currentUser;
const [reactions, setReactions] = useState<Record<string, Reaction[]> | null>(null);
const fetchReactions = useCallback(async () => {
if (!viewId || !service) return;
try {
const reactions = await service.getPublishViewReactions(viewId);
setReactions(reactions);
} catch (e) {
console.error(e);
}
}, [service, viewId]);
useEffect(() => {
void fetchReactions();
}, [fetchReactions]);
const toggleReaction = useCallback(
async (commentId: string, reactionType: string) => {
try {
if (!service || !viewId) return;
let isAdded = true;
setReactions((prev) => {
const commentReactions = prev?.[commentId] || [];
const reaction = commentReactions.find((reaction) => reaction.reactionType === reactionType);
const reactUsers = reaction?.reactUsers || [];
const hasReacted = reactUsers.some((user) => user.uuid === currentUser?.uuid);
let newReaction: Reaction | null = null;
let index = -1;
const reactUser = {
uuid: currentUser?.uuid || '',
name: currentUser?.name || '',
avatarUrl: currentUser?.avatar || null,
};
// If the reaction does not exist, create a new reaction.
if (!reaction) {
index = commentReactions.length;
newReaction = {
reactionType,
reactUsers: [reactUser],
commentId,
};
} else {
let newReactUsers: CommentUser[] = [];
// If the user has not reacted, add the user to the reaction.
if (!hasReacted) {
newReactUsers = [...reactUsers, reactUser];
// If the user has reacted, remove the user from the reaction.
} else {
isAdded = false;
newReactUsers = reactUsers.filter((user) => user.uuid !== currentUser?.uuid);
}
newReaction = {
reactionType,
reactUsers: newReactUsers,
commentId,
};
index = commentReactions.findIndex((reaction) => reaction.reactionType === reactionType);
}
const newReactions = [...commentReactions];
if (!newReaction) return prev;
// If the reaction does not exist, add the reaction to the list.
if (index === -1) {
newReactions.push(newReaction);
// If the reaction exists, update the reaction.
} else {
newReactions.splice(index, 1, newReaction);
}
return {
...prev,
[commentId]: newReactions,
};
});
try {
if (isAdded) {
await service.addPublishViewReaction(viewId, commentId, reactionType);
} else {
await service.removePublishViewReaction(viewId, commentId, reactionType);
}
} catch (e) {
console.error(e);
}
} catch (e) {
console.error(e);
}
},
[currentUser, service, viewId]
);
return { reactions, toggleReaction };
}
export function useLoadComments() {
const viewId = useContext(PublishContext)?.viewMeta?.view_id;
const service = useContext(AFConfigContext)?.service;
const [comments, setComments] = useState<GlobalComment[] | null>(null);
const [loading, setLoading] = useState(false);
const fetchComments = useCallback(async () => {
if (!viewId || !service) return;
setLoading(true);
try {
const comments = await service.getPublishViewGlobalComments(viewId);
setComments(comments);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
}, [viewId, service]);
useEffect(() => {
void fetchComments();
}, [fetchComments]);
return { comments, loading, reload: fetchComments };
}
export function getAvatar(comment: GlobalComment) {
if (comment.user?.avatarUrl) {
return {
children: <span>{comment.user.avatarUrl}</span>,
};
}
return stringAvatar(comment.user?.name || '');
}
export function useCommentRender(comment: GlobalComment) {
const { t } = useTranslation();
const avatar = useMemo(() => {
return getAvatar(comment);
}, [comment]);
const timeFormat = useMemo(() => {
const time = dayjs.unix(Number(comment.lastUpdatedAt));
return time.format('YYYY-MM-DD HH:mm:ss');
}, [comment.lastUpdatedAt]);
const time = useMemo(() => {
if (!comment.lastUpdatedAt) return '';
const now = dayjs();
const past = dayjs.unix(Number(comment.lastUpdatedAt));
const diffSec = now.diff(past, 'second');
const diffMin = now.diff(past, 'minute');
const diffHour = now.diff(past, 'hour');
const diffDay = now.diff(past, 'day');
const diffMonth = now.diff(past, 'month');
const diffYear = now.diff(past, 'year');
if (diffSec < 5) {
return t('globalComment.showSeconds', {
count: 0,
});
}
if (diffMin < 1) {
return t('globalComment.showSeconds', {
count: diffSec,
});
}
if (diffHour < 1) {
return t('globalComment.showMinutes', {
count: diffMin,
});
}
if (diffDay < 1) {
return t('globalComment.showHours', {
count: diffHour,
});
}
if (diffMonth < 1) {
return t('globalComment.showDays', {
count: diffDay,
});
}
if (diffYear < 1) {
return t('globalComment.showMonths', {
count: diffMonth,
});
}
return t('globalComment.showYears', {
count: diffYear,
});
}, [t, comment]);
return { avatar, time, timeFormat };
}
@@ -0,0 +1,35 @@
import AddComment from '@/components/global-comment/AddComment';
import CommentList from '@/components/global-comment/CommentList';
import { useGlobalCommentContext } from '@/components/global-comment/GlobalComment.hooks';
import { Divider } from '@mui/material';
import CircularProgress from '@mui/material/CircularProgress';
import React from 'react';
import { useTranslation } from 'react-i18next';
function GlobalComment() {
const { t } = useTranslation();
const { loading, comments } = useGlobalCommentContext();
return (
<div className={'mb-[100px] mt-16 flex h-fit w-full justify-center'}>
<div
className={
'flex w-[964px] min-w-0 max-w-full transform flex-col gap-2 px-16 transition-all duration-300 ease-in-out max-sm:px-4'
}
>
<div className={'text-[24px]'}>{t('globalComment.comments')}</div>
<Divider />
<AddComment />
{loading && !comments?.length ? (
<div className={'flex h-[200px] w-full items-center justify-center'}>
<CircularProgress />
</div>
) : (
<CommentList />
)}
</div>
</div>
);
}
export default GlobalComment;
@@ -0,0 +1,43 @@
import GlobalComment from '@/components/global-comment/GlobalComment';
import {
GlobalCommentContext,
useLoadComments,
useLoadReactions,
} from '@/components/global-comment/GlobalComment.hooks';
import React, { useCallback, useState } from 'react';
export function GlobalCommentProvider() {
const { comments, loading, reload } = useLoadComments();
const { reactions, toggleReaction } = useLoadReactions();
const [replyCommentId, setReplyCommentId] = useState<string | null>(null);
const getComment = useCallback(
(commentId: string) => {
return comments?.find((comment) => comment.commentId === commentId);
},
[comments]
);
const replyComment = useCallback((commentId: string | null) => {
setReplyCommentId(commentId);
}, []);
return (
<GlobalCommentContext.Provider
value={{
reactions,
replyCommentId,
reload,
getComment,
loading,
comments,
replyComment,
toggleReaction,
}}
>
<GlobalComment />
</GlobalCommentContext.Provider>
);
}
export default GlobalCommentProvider;
@@ -0,0 +1,30 @@
import { getAvatar, useGlobalCommentContext } from '@/components/global-comment/GlobalComment.hooks';
import { Avatar } from '@mui/material';
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
function ReplyComment({ commentId }: { commentId?: string | null }) {
const { getComment } = useGlobalCommentContext();
const { t } = useTranslation();
const replyComment = useMemo(() => {
if (!commentId) return;
return getComment(commentId);
}, [commentId, getComment]);
if (!replyComment) return null;
return (
<div className={'flex items-center gap-1 text-sm text-text-caption'}>
<Avatar {...getAvatar(replyComment)} className={'h-4 w-4 text-xs'} />
<div className={'text-xs font-medium'}>@{replyComment.user?.name}</div>
<div className={'truncate px-1'}>
{replyComment.isDeleted ? (
<span className={'text-xs'}>{`[${t('globalComment.hasBeenDeleted')}]`}</span>
) : (
replyComment.content
)}
</div>
</div>
);
}
export default ReplyComment;
@@ -0,0 +1,17 @@
import { GlobalComment } from '@/application/comment.type';
import MoreActions from '@/components/global-comment/actions/MoreActions';
import ReactAction from '@/components/global-comment/actions/ReactAction';
import ReplyAction from '@/components/global-comment/actions/ReplyAction';
import React, { memo } from 'react';
function CommentActions({ comment }: { comment: GlobalComment }) {
return (
<div className={'flex gap-2'}>
<ReactAction comment={comment} />
<ReplyAction comment={comment} />
<MoreActions comment={comment} />
</div>
);
}
export default memo(CommentActions);
@@ -0,0 +1,154 @@
import { GlobalComment } from '@/application/comment.type';
import { PublishContext } from '@/application/publish';
import { NormalModal } from '@/components/_shared/modal';
import { notify } from '@/components/_shared/notify';
import { RichTooltip } from '@/components/_shared/popover';
import { AFConfigContext } from '@/components/app/AppConfig';
import { useGlobalCommentContext } from '@/components/global-comment/GlobalComment.hooks';
import { Button, IconButton, Tooltip, TooltipProps } from '@mui/material';
import { debounce } from 'lodash-es';
import React, { memo, useCallback, useContext, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { ReactComponent as MoreIcon } from '@/assets/more.svg';
import { ReactComponent as TrashIcon } from '@/assets/trash.svg';
interface Item {
Icon: React.FC<React.SVGProps<SVGSVGElement>>;
label: string;
disabled: boolean;
onClick: () => void;
danger?: boolean;
tooltip?: TooltipProps;
}
function MoreActions({ comment }: { comment: GlobalComment }) {
const { reload } = useGlobalCommentContext();
const canDeleted = comment.canDeleted;
const [open, setOpen] = React.useState(false);
const [deleteModalOpen, setDeleteModalOpen] = React.useState(false);
const handleClose = useCallback(() => {
setOpen(false);
}, []);
const debounceClose = useMemo(() => {
return debounce(handleClose, 200);
}, [handleClose]);
const handleOpen = () => {
debounceClose.cancel();
setOpen(true);
};
const { t } = useTranslation();
const service = useContext(AFConfigContext)?.service;
const viewId = useContext(PublishContext)?.viewMeta?.view_id;
const handleDeleteAction = useCallback(async () => {
if (!viewId || !service) return;
try {
await service?.deleteCommentOnPublishView(viewId, comment.commentId);
await reload();
} catch (e) {
console.error(e);
notify.error('Failed to delete comment');
} finally {
setDeleteModalOpen(false);
}
}, [comment.commentId, reload, service, viewId]);
const actions = useMemo(() => {
return [
{
Icon: TrashIcon,
label: t('button.delete'),
disabled: !canDeleted,
tooltip: canDeleted
? undefined
: {
title: <div className={'text-center'}>{t('globalComment.noAccessDeleteComment')}</div>,
placement: 'top',
},
onClick: () => {
setDeleteModalOpen(true);
},
danger: true,
},
] as Item[];
}, [t, canDeleted]);
const renderItem = useCallback((action: Item) => {
return (
<Button
size={'small'}
color={'inherit'}
disabled={action.disabled}
onClick={action.onClick}
className={`w-full items-center justify-start gap-2 p-1 ${action.danger ? 'hover:text-function-error' : ''}`}
>
<action.Icon className={'h-4 w-4'} />
<div>{action.label}</div>
</Button>
);
}, []);
return (
<>
<RichTooltip
content={
<div
className={'flex min-w-[150px] flex-col items-start p-2'}
onMouseEnter={handleOpen}
onMouseLeave={debounceClose}
>
{actions.map((action, index) => {
if (action.tooltip) {
return (
<Tooltip key={index} {...action.tooltip}>
<div className={'w-full'}>{renderItem(action)}</div>
</Tooltip>
);
}
return (
<div key={index} className={'w-full'}>
{renderItem(action)}
</div>
);
})}
</div>
}
open={open}
onClose={handleClose}
>
<IconButton size={'small'} onMouseEnter={handleOpen} onMouseLeave={debounceClose}>
<MoreIcon className={'h-4 w-4'} />
</IconButton>
</RichTooltip>
{deleteModalOpen && (
<NormalModal
PaperProps={{
sx: {
maxWidth: 420,
},
}}
okText={t('button.delete')}
danger={true}
onOk={handleDeleteAction}
onCancel={() => {
setDeleteModalOpen(false);
}}
onClose={() => setDeleteModalOpen(false)}
open={deleteModalOpen}
title={<div className={'text-left'}>{t('globalComment.deleteComment')}</div>}
>
<div className={'w-full whitespace-pre-wrap break-words pb-1 text-text-caption'}>
{t('globalComment.confirmDeleteDescription')}
</div>
</NormalModal>
)}
</>
);
}
export default memo(MoreActions);
@@ -0,0 +1,60 @@
import { GlobalComment } from '@/application/comment.type';
import { EmojiPicker } from '@/components/_shared/emoji-picker';
import { EMOJI_SIZE, PER_ROW_EMOJI_COUNT } from '@/components/_shared/emoji-picker/const';
import { Popover } from '@/components/_shared/popover';
import ComponentLoading from '@/components/_shared/progress/ComponentLoading';
import { useGlobalCommentContext } from '@/components/global-comment/GlobalComment.hooks';
import { ReactComponent as AddReactionRounded } from '@/assets/add_reaction.svg';
import { IconButton, Tooltip } from '@mui/material';
import React, { memo, Suspense, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
function ReactAction({ comment }: { comment: GlobalComment }) {
const { toggleReaction } = useGlobalCommentContext();
const { t } = useTranslation();
const ref = React.useRef<HTMLButtonElement>(null);
const [open, setOpen] = React.useState(false);
const handleClose = useCallback(() => {
setOpen(false);
}, []);
const handleOpen = () => {
setOpen(true);
};
const handlePickEmoji = useCallback(
(emoji: string) => {
toggleReaction(comment.commentId, emoji);
handleClose();
},
[comment.commentId, handleClose, toggleReaction]
);
return (
<>
<Tooltip title={t('globalComment.addReaction')}>
<IconButton ref={ref} onClick={handleOpen} size='small'>
<AddReactionRounded className={'h-4 w-4'} />
</IconButton>
</Tooltip>
{open && (
<Popover
anchorEl={ref.current}
open={open}
onClose={handleClose}
sx={{
'& .MuiPopover-paper': {
width: PER_ROW_EMOJI_COUNT * EMOJI_SIZE,
},
}}
>
<Suspense fallback={<ComponentLoading />}>
<EmojiPicker hideRemove onEscape={handleClose} onEmojiSelect={handlePickEmoji} />
</Suspense>
</Popover>
)}
</>
);
}
export default memo(ReactAction);
@@ -0,0 +1,27 @@
import { GlobalComment } from '@/application/comment.type';
import { useGlobalCommentContext } from '@/components/global-comment/GlobalComment.hooks';
import { ReactComponent as ReplyOutlined } from '@/assets/reply.svg';
import { Tooltip } from '@mui/material';
import React, { memo } from 'react';
import { useTranslation } from 'react-i18next';
import IconButton from '@mui/material/IconButton';
function ReplyAction({ comment }: { comment: GlobalComment }) {
const { t } = useTranslation();
const replyComment = useGlobalCommentContext().replyComment;
return (
<Tooltip title={t('globalComment.reply')}>
<IconButton
onClick={() => {
replyComment(comment.commentId);
}}
size='small'
>
<ReplyOutlined className={'h-4 w-4'} />
</IconButton>
</Tooltip>
);
}
export default memo(ReplyAction);
@@ -0,0 +1,39 @@
import { GlobalComment } from '@/application/comment.type';
import { useCommentRender } from '@/components/global-comment/GlobalComment.hooks';
import { Reactions } from '@/components/global-comment/reactions';
import { Avatar, Tooltip } from '@mui/material';
import React, { memo } from 'react';
import { useTranslation } from 'react-i18next';
interface CommentProps {
comment: GlobalComment;
}
function Comment({ comment }: CommentProps) {
const { avatar, time, timeFormat } = useCommentRender(comment);
const { t } = useTranslation();
return (
<div className={' flex gap-4 '}>
<Avatar {...avatar} />
<div className={'flex flex-col gap-1'}>
<div className={'flex items-center gap-2'}>
<div className={'font-semibold'}>{comment.user?.name}</div>
<Tooltip title={timeFormat} enterNextDelay={500} enterDelay={1000} placement={'top-start'}>
<div className={'text-sm text-text-caption'}>{time}</div>
</Tooltip>
</div>
<div className={'whitespace-pre-wrap break-words'}>
{comment.isDeleted ? (
<span className={'text-text-caption'}>{`[${t('globalComment.hasBeenDeleted')}]`}</span>
) : (
comment.content
)}
</div>
{!comment.isDeleted && <Reactions comment={comment} />}
</div>
</div>
);
}
export default memo(Comment);
@@ -0,0 +1,87 @@
import { AFConfigContext } from '@/components/app/AppConfig';
import CommentActions from '@/components/global-comment/actions/CommentActions';
import Comment from './Comment';
import { useGlobalCommentContext } from '@/components/global-comment/GlobalComment.hooks';
import ReplyComment from '@/components/global-comment/ReplyComment';
import React, { useCallback, useContext, useEffect, useMemo } from 'react';
import smoothScrollIntoViewIfNeeded from 'smooth-scroll-into-view-if-needed';
export interface CommentWrapProps {
commentId: string;
isHovered: boolean;
onHovered: () => void;
}
export function CommentWrap({ commentId, isHovered, onHovered }: CommentWrapProps) {
const { getComment } = useGlobalCommentContext();
const comment = useMemo(() => getComment(commentId), [commentId, getComment]);
const ref = React.useRef<HTMLDivElement>(null);
const [highLight, setHighLight] = React.useState(false);
const isAuthenticated = useContext(AFConfigContext)?.isAuthenticated;
useEffect(() => {
const hashHasComment = window.location.hash.includes(`#comment-${commentId}`);
if (!hashHasComment) return;
const element = ref.current;
if (!element) return;
let timeout: NodeJS.Timeout | null = null;
void (async () => {
window.location.hash = '';
timeout = setTimeout(() => {
void smoothScrollIntoViewIfNeeded(element, {
behavior: 'smooth',
block: 'center',
});
setHighLight(true);
timeout = setTimeout(() => {
setHighLight(false);
}, 10000);
}, 500);
})();
return () => {
timeout && clearTimeout(timeout);
};
}, [commentId]);
const renderReplyComment = useCallback((replyCommentId: string) => {
return (
<div className={'relative flex w-full items-center gap-2'}>
<div className={'reply-line relative top-2 ml-[1.75em] min-w-[28px]'} />
<div className={'flex-1 overflow-hidden '}> {<ReplyComment commentId={replyCommentId} />}</div>
</div>
);
}, []);
if (!comment) {
return null;
}
return (
<div ref={ref} className={'flex flex-col gap-2'} data-comment-id={comment.commentId}>
{comment.replyCommentId && renderReplyComment(comment.replyCommentId)}
<div
className={`relative rounded-[8px] p-2 hover:bg-fill-list-hover ${highLight ? 'blink' : ''}`}
{...(comment.isDeleted ? { style: { opacity: 0.5 } } : {})}
onMouseEnter={() => {
onHovered();
setHighLight(false);
}}
>
<Comment comment={comment} />
{isHovered && isAuthenticated && !comment.isDeleted && (
<div className={'absolute right-2 top-2'}>
<CommentActions comment={comment} />
</div>
)}
</div>
</div>
);
}
export default CommentWrap;
@@ -0,0 +1 @@
export * from './CommentWrap';
@@ -0,0 +1 @@
export * from './GlobalCommentProvider';
@@ -0,0 +1,90 @@
import { Reaction as ReactionType } from '@/application/comment.type';
import { AFConfigContext } from '@/components/app/AppConfig';
import { Tooltip } from '@mui/material';
import React, { memo, useContext, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
function Reaction({ reaction, onClick }: { reaction: ReactionType; onClick: (reaction: ReactionType) => void }) {
const { t } = useTranslation();
const isAuthenticated = useContext(AFConfigContext)?.isAuthenticated;
const openLoginModal = useContext(AFConfigContext)?.openLoginModal;
const url = window.location.href + '#comment-' + reaction.commentId;
const reactCount = useMemo(() => {
return reaction.reactUsers.length;
}, [reaction.reactUsers]);
const userNames = useMemo(() => {
let sliceOffset = reactCount;
let suffix = '';
if (reactCount > 20) {
sliceOffset = 20;
suffix = ` ${t('globalComment.reactedByMore', { count: reactCount - 20 })}`;
}
return (
reaction.reactUsers
.slice(0, sliceOffset)
.map((user) => user.name)
.join(', ') + suffix
);
}, [reaction.reactUsers, t, reactCount]);
const currentUser = useContext(AFConfigContext)?.currentUser;
const currentUid = currentUser?.uuid;
const isCurrentUserReacted = useMemo(() => {
return reaction.reactUsers.some((user) => user.uuid === currentUid);
}, [currentUid, reaction.reactUsers]);
const [hover, setHover] = React.useState(false);
const style = useMemo(() => {
const styleProperties: React.CSSProperties = {};
if (hover) {
Object.assign(styleProperties, {
borderColor: 'var(--line-border)',
backgroundColor: 'var(--bg-body)',
});
} else if (isCurrentUserReacted) {
Object.assign(styleProperties, {
borderColor: 'var(--content-blue-400)',
backgroundColor: 'var(--content-blue-100)',
});
}
return styleProperties;
}, [hover, isCurrentUserReacted]);
return (
<Tooltip
title={
<div className={'break-word overflow-hidden whitespace-pre-wrap text-xs'}>
{t('globalComment.reactedBy')}
{` `}
{userNames}
</div>
}
>
<div
style={style}
onClick={() => {
if (!isAuthenticated && openLoginModal) {
openLoginModal(url);
return;
}
onClick(reaction);
}}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
className={
'flex cursor-pointer items-center gap-1 rounded-full border border-transparent bg-fill-list-hover px-1 py-0.5 text-sm'
}
>
<span className={'icon'}>{reaction.reactionType}</span>
{<div className={'text-xs font-medium'}>{reactCount}</div>}
</div>
</Tooltip>
);
}
export default memo(Reaction);
@@ -0,0 +1,28 @@
import { GlobalComment, Reaction as ReactionType } from '@/application/comment.type';
import { useGlobalCommentContext } from '@/components/global-comment/GlobalComment.hooks';
import Reaction from '@/components/global-comment/reactions/Reaction';
import React, { memo, useCallback, useMemo } from 'react';
export function Reactions({ comment }: { comment: GlobalComment }) {
const { reactions, toggleReaction } = useGlobalCommentContext();
const commentReactions = useMemo(() => {
return reactions?.[comment.commentId]?.filter((reaction) => reaction.reactUsers.length > 0) || [];
}, [reactions, comment.commentId]);
const handleReactionClick = useCallback(
(reaction: ReactionType) => {
toggleReaction(comment.commentId, reaction.reactionType);
},
[comment.commentId, toggleReaction]
);
return (
<div className={'flex w-full flex-wrap items-center gap-2 overflow-hidden'}>
{commentReactions.map((reaction) => {
return <Reaction reaction={reaction} onClick={handleReactionClick} key={reaction.reactionType} />;
})}
</div>
);
}
export default memo(Reactions);
@@ -0,0 +1 @@
export * from './Reactions';
@@ -1,28 +1,18 @@
import { AFConfigContext } from '@/components/app/AppConfig';
import LoginProvider from '@/components/login/LoginProvider';
import MagicLink from '@/components/login/MagicLink';
import { Divider } from '@mui/material';
import React, { useContext, useEffect } from 'react';
import React from 'react';
import { ReactComponent as Logo } from '@/assets/logo.svg';
import { useTranslation } from 'react-i18next';
import { useSearchParams } from 'react-router-dom';
export function Login() {
export function Login({ redirectTo }: { redirectTo: string }) {
const { t } = useTranslation();
const [search] = useSearchParams();
const redirectTo = search.get('redirectTo') || '';
const isAuthenticated = useContext(AFConfigContext)?.isAuthenticated || false;
useEffect(() => {
if (isAuthenticated && redirectTo && encodeURIComponent(redirectTo) !== window.location.href) {
window.location.href = redirectTo;
}
}, [isAuthenticated, redirectTo]);
return (
<div className={'my-10 flex flex-col items-center justify-center gap-[24px] px-4'}>
<div className={'flex flex-col items-center justify-center gap-[14px]'}>
<div className={'flex w-full flex-col items-center justify-center gap-[14px]'}>
<Logo className={'h-10 w-10'} />
<div className={'text-[24px] font-semibold'}>{t('welcomeTo')} AppFlowy</div>
<div className={'text-[24px] font-semibold max-sm:text-[20px]'}>{t('welcomeTo')} AppFlowy</div>
</div>
<MagicLink redirectTo={redirectTo} />
<div className={'flex w-full items-center justify-center gap-2 text-text-caption'}>
@@ -0,0 +1,21 @@
import React from 'react';
import { Dialog, IconButton } from '@mui/material';
import { Login } from './Login';
import { ReactComponent as CloseIcon } from '@/assets/close.svg';
export function LoginModal({ redirectTo, open, onClose }: { redirectTo: string; open: boolean; onClose: () => void }) {
return (
<Dialog open={open} onClose={onClose}>
<div className={'relative px-6'}>
<Login redirectTo={redirectTo} />
<div className={'absolute top-2 right-2'}>
<IconButton size={'small'} color={'inherit'} className={'h-6 w-6'} onClick={onClose}>
<CloseIcon className={'h-4 w-4'} />
</IconButton>
</div>
</div>
</Dialog>
);
}
export default LoginModal;
@@ -50,7 +50,7 @@ function LoginProvider({ redirectTo }: { redirectTo: string }) {
};
return (
<div className={'flex flex-col items-center justify-center gap-[10px]'}>
<div className={'flex w-full flex-col items-center justify-center gap-[10px]'}>
{options.map((option) => (
<Button
key={option.value}
@@ -58,7 +58,7 @@ function LoginProvider({ redirectTo }: { redirectTo: string }) {
variant={'outlined'}
onClick={() => handleClick(option.value)}
className={
'flex h-[46px] w-[380px] items-center justify-center gap-[10px] rounded-[12px] border border-line-divider text-sm font-medium'
'flex h-[46px] w-[380px] items-center justify-center gap-[10px] rounded-[12px] border border-line-divider text-sm font-medium text-text-title max-sm:w-full'
}
>
<option.Icon className={'h-[20px] w-[20px]'} />
@@ -3,7 +3,7 @@ import { AFConfigContext } from '@/components/app/AppConfig';
import { Button, CircularProgress, OutlinedInput } from '@mui/material';
import React, { useContext } from 'react';
import { useTranslation } from 'react-i18next';
import validator from 'validator';
import isEmail from 'validator/lib/isEmail';
function MagicLink({ redirectTo }: { redirectTo: string }) {
const { t } = useTranslation();
@@ -11,7 +11,7 @@ function MagicLink({ redirectTo }: { redirectTo: string }) {
const [loading, setLoading] = React.useState<boolean>(false);
const service = useContext(AFConfigContext)?.service;
const handleSubmit = async () => {
const isValidEmail = validator.isEmail(email);
const isValidEmail = isEmail(email);
if (!isValidEmail) {
notify.error(t('signIn.invalidEmail'));
@@ -34,11 +34,11 @@ function MagicLink({ redirectTo }: { redirectTo: string }) {
};
return (
<div className={'flex flex-col items-center justify-center gap-[12px]'}>
<div className={'flex w-full flex-col items-center justify-center gap-[12px]'}>
<OutlinedInput
value={email}
type={'email'}
className={'h-[46px] w-[380px] rounded-[12px] py-[15px] px-[20px] text-base'}
className={'h-[46px] w-[380px] rounded-[12px] py-[15px] px-[20px] text-base max-sm:w-full'}
placeholder={t('signIn.pleaseInputYourEmail')}
inputProps={{
className: 'px-0 py-0',
@@ -49,7 +49,7 @@ function MagicLink({ redirectTo }: { redirectTo: string }) {
onClick={handleSubmit}
disabled={loading}
variant={'contained'}
className={'flex h-[46px] w-[380px] items-center justify-center gap-2 rounded-[12px] text-base'}
className={'flex h-[46px] w-[380px] items-center justify-center gap-2 rounded-[12px] text-base max-sm:w-full'}
>
{loading ? (
<>
@@ -1 +1,3 @@
export * from './LoginModal';
export * from './Login';
@@ -45,7 +45,7 @@ function DatabaseView({ viewMeta, ...props }: DatabaseProps) {
return (
<div
style={{
height: 'calc(100vh - 48px)',
minHeight: 'calc(100vh - 48px)',
}}
className={'relative flex h-full w-full flex-col px-16 max-md:px-4'}
>
@@ -1,12 +1,14 @@
import { YDoc } from '@/application/collab.type';
import { PublishProvider } from '@/application/publish';
import ComponentLoading from '@/components/_shared/progress/ComponentLoading';
import { AFScroller } from '@/components/_shared/scroller';
import { AFConfigContext } from '@/components/app/AppConfig';
import { GlobalCommentProvider } from '@/components/global-comment';
import CollabView from '@/components/publish/CollabView';
import OutlineDrawer from '@/components/publish/outline/OutlineDrawer';
import { OutlineDrawer } from '@/components/publish/outline';
import { createHotkey, HOT_KEY_NAME } from '@/utils/hotkeys';
import React, { useCallback, useContext, useEffect, useState } from 'react';
import { PublishViewHeader } from 'src/components/publish/header';
import React, { Suspense, useCallback, useContext, useEffect, useState } from 'react';
import { PublishViewHeader } from '@/components/publish/header';
import NotFound from '@/components/error/NotFound';
export interface PublishViewProps {
@@ -84,6 +86,11 @@ export function PublishView({ namespace, publishName }: PublishViewProps) {
/>
<CollabView doc={doc} />
{doc && (
<Suspense fallback={<ComponentLoading />}>
<GlobalCommentProvider />
</Suspense>
)}
</AFScroller>
{open && <OutlineDrawer width={drawerWidth} open={open} onClose={() => setOpen(false)} />}
</div>
@@ -1,20 +1,20 @@
// import { invalidToken } from '@/application/session/token';
import { invalidToken } from '@/application/session/token';
import { Popover } from '@/components/_shared/popover';
// import { AFConfigContext } from '@/components/app/AppConfig';
import { AFConfigContext } from '@/components/app/AppConfig';
import { ThemeModeContext } from '@/components/app/useAppThemeMode';
import { openUrl } from '@/utils/url';
import { IconButton } from '@mui/material';
import React, { useContext, useMemo } from 'react';
import React, { useCallback, useContext, useMemo } from 'react';
import { ReactComponent as MoreIcon } from '@/assets/more.svg';
import { ReactComponent as MoonIcon } from '@/assets/moon.svg';
import { ReactComponent as SunIcon } from '@/assets/sun.svg';
// import { ReactComponent as LoginIcon } from '@/assets/login.svg';
import { ReactComponent as LoginIcon } from '@/assets/login.svg';
import { ReactComponent as ReportIcon } from '@/assets/report.svg';
import { useTranslation } from 'react-i18next';
import { ReactComponent as Logo } from '@/assets/logo.svg';
import { ReactComponent as AppflowyLogo } from '@/assets/appflowy.svg';
// import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router-dom';
function MoreActions() {
const { isDark, setDark } = useContext(ThemeModeContext) || {};
@@ -31,21 +31,21 @@ function MoreActions() {
const { t } = useTranslation();
// const navigate = useNavigate();
const navigate = useNavigate();
// const isAuthenticated = useContext(AFConfigContext)?.isAuthenticated || false;
//
// const handleLogin = useCallback(() => {
// invalidToken();
// navigate('/login?redirectTo=' + encodeURIComponent(window.location.href));
// }, [navigate]);
const isAuthenticated = useContext(AFConfigContext)?.isAuthenticated || false;
const handleLogin = useCallback(() => {
invalidToken();
navigate('/login?redirectTo=' + encodeURIComponent(window.location.href));
}, [navigate]);
const actions = useMemo(() => {
return [
// {
// Icon: LoginIcon,
// label: isAuthenticated ? t('button.logout') : t('web.login'),
// onClick: handleLogin,
// },
{
Icon: LoginIcon,
label: isAuthenticated ? t('button.logout') : t('web.login'),
onClick: handleLogin,
},
isDark
? {
Icon: SunIcon,
@@ -69,54 +69,58 @@ function MoreActions() {
},
},
];
}, [t, isDark, setDark]);
}, [isAuthenticated, t, handleLogin, isDark, setDark]);
return (
<>
<IconButton onClick={handleClick}>
<MoreIcon className={'text-text-caption'} />
</IconButton>
<Popover
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
open={open}
anchorEl={anchorEl}
onClose={handleClose}
>
<div className={'flex w-[240px] flex-col gap-2 px-2 py-2'}>
{actions.map((action, index) => (
<button
{open && (
<Popover
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
open={open}
anchorEl={anchorEl}
onClose={handleClose}
>
<div className={'flex w-[240px] flex-col gap-2 px-2 py-2'}>
{actions.map((action, index) => (
<button
onClick={() => {
action.onClick();
handleClose();
}}
key={index}
className={
'flex items-center gap-2 rounded-[8px] p-1.5 text-sm hover:bg-content-blue-50 focus:bg-content-blue-50 focus:outline-none'
}
>
<action.Icon />
<span>{action.label}</span>
</button>
))}
<div
onClick={() => {
action.onClick();
handleClose();
window.open('https://appflowy.io', '_blank');
}}
key={index}
className={
'flex items-center gap-2 rounded-[8px] p-1.5 text-sm hover:bg-content-blue-50 focus:bg-content-blue-50 focus:outline-none'
'flex w-full cursor-pointer items-center justify-center py-2 text-sm text-text-title opacity-50'
}
>
<action.Icon />
<span>{action.label}</span>
</button>
))}
<div
onClick={() => {
window.open('https://appflowy.io', '_blank');
}}
className={'flex w-full cursor-pointer items-center justify-center py-2 text-sm text-text-title opacity-50'}
>
Powered by
<Logo className={'ml-3 h-4 w-4'} />
<AppflowyLogo className={'w-20'} />
Powered by
<Logo className={'ml-3 h-4 w-4'} />
<AppflowyLogo className={'w-20'} />
</div>
</div>
</div>
</Popover>
</Popover>
)}
</>
);
}
@@ -2,13 +2,14 @@ import { usePublishContext } from '@/application/publish';
import { openOrDownload } from '@/components/publish/header/utils';
import { Divider, IconButton, Tooltip } from '@mui/material';
import { debounce } from 'lodash-es';
import React, { useCallback, useMemo } from 'react';
import OutlinePopover from '@/components/publish/outline/OutlinePopover';
import React, { Suspense, useCallback, useMemo } from 'react';
import { OutlinePopover } from '@/components/publish/outline';
import { useTranslation } from 'react-i18next';
import Breadcrumb from './Breadcrumb';
import { ReactComponent as Logo } from '@/assets/logo.svg';
import MoreActions from './MoreActions';
import { ReactComponent as SideOutlined } from '@/assets/side_outlined.svg';
import { Duplicate } from './duplicate';
export const HEADER_HEIGHT = 48;
@@ -65,7 +66,7 @@ export function PublishViewHeader({ onOpenDrawer, openDrawer }: { onOpenDrawer:
className={'appflowy-top-bar sticky top-0 z-10 flex px-5'}
>
<div className={'flex w-full items-center justify-between gap-2 overflow-hidden'}>
{!openDrawer && (
{!openDrawer && openPopover && (
<OutlinePopover
onMouseEnter={handleOpenPopover}
onMouseLeave={debounceClosePopover}
@@ -92,6 +93,9 @@ export function PublishViewHeader({ onOpenDrawer, openDrawer }: { onOpenDrawer:
<div className={'flex items-center gap-2'}>
<MoreActions />
<Suspense fallback={null}>
<Duplicate />
</Suspense>
<Divider orientation={'vertical'} className={'mx-2'} flexItem />
<Tooltip title={t('publish.downloadApp')}>
<button onClick={openOrDownload}>
@@ -0,0 +1,31 @@
import React, { useCallback } from 'react';
import { Button } from '@mui/material';
import { useTranslation } from 'react-i18next';
import { LoginModal } from '@/components/login';
import { useSearchParams } from 'react-router-dom';
import { useDuplicate } from '@/components/publish/header/duplicate/useDuplicate';
import DuplicateModal from '@/components/publish/header/duplicate/DuplicateModal';
export function Duplicate() {
const { t } = useTranslation();
const { loginOpen, duplicateOpen, handleDuplicateClose, handleLoginClose, url } = useDuplicate();
const [search, setSearch] = useSearchParams();
const handleClick = useCallback(() => {
setSearch({
...search,
action: 'duplicate',
});
}, [search, setSearch]);
return (
<>
<Button onClick={handleClick} size={'small'} variant={'outlined'} color={'inherit'}>
{t('publish.saveThisPage')}
</Button>
<LoginModal redirectTo={url} open={loginOpen} onClose={handleLoginClose} />
{duplicateOpen && <DuplicateModal open={duplicateOpen} onClose={handleDuplicateClose} />}
</>
);
}
export default Duplicate;
@@ -0,0 +1,129 @@
import React, { useCallback, useContext, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { NormalModal } from '@/components/_shared/modal';
import SelectWorkspace from '@/components/publish/header/duplicate/SelectWorkspace';
import { useLoadWorkspaces } from '@/components/publish/header/duplicate/useDuplicate';
import SpaceList from '@/components/publish/header/duplicate/SpaceList';
import { downloadPage, openAppFlowySchema } from '@/utils/url';
import { AFConfigContext } from '@/components/app/AppConfig';
import { PublishContext } from '@/application/publish';
import { CollabType, ViewLayout } from '@/application/collab.type';
import { notify } from '@/components/_shared/notify';
function getCollabTypeFromViewLayout(layout: ViewLayout) {
switch (layout) {
case ViewLayout.Document:
return CollabType.Document;
case ViewLayout.Grid:
case ViewLayout.Board:
case ViewLayout.Calendar:
return CollabType.Database;
default:
return null;
}
}
function DuplicateModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const { t } = useTranslation();
const service = useContext(AFConfigContext)?.service;
const viewMeta = useContext(PublishContext)?.viewMeta;
const viewId = viewMeta?.view_id;
const layout = viewMeta?.layout as ViewLayout;
const [loading, setLoading] = React.useState<boolean>(false);
const [successModalOpen, setSuccessModalOpen] = React.useState<boolean>(false);
const {
workspaceList,
spaceList,
setSelectedSpaceId,
setSelectedWorkspaceId,
selectedWorkspaceId,
selectedSpaceId,
workspaceLoading,
spaceLoading,
} = useLoadWorkspaces();
useEffect(() => {
if (!open) {
setSelectedWorkspaceId(workspaceList[0]?.id || '');
setSelectedSpaceId('');
}
}, [open, setSelectedSpaceId, setSelectedWorkspaceId, workspaceList]);
const handleDuplicate = useCallback(async () => {
if (!viewId) return;
const collabType = getCollabTypeFromViewLayout(layout);
if (collabType === null) return;
setLoading(true);
try {
await service?.duplicatePublishView({
workspaceId: selectedWorkspaceId,
spaceViewId: selectedSpaceId,
viewId,
collabType,
});
onClose();
setSuccessModalOpen(true);
} catch (e) {
notify.error(t('publish.duplicateFailed'));
} finally {
setLoading(false);
}
}, [viewId, layout, service, selectedWorkspaceId, selectedSpaceId, onClose, t]);
return (
<>
<NormalModal
okButtonProps={{
disabled: !selectedWorkspaceId || !selectedSpaceId,
}}
onCancel={onClose}
okText={t('button.add')}
title={t('publish.duplicateTitle')}
open={open}
onClose={onClose}
classes={{ container: 'items-start max-md:mt-auto max-md:items-center mt-[10%] ' }}
onOk={handleDuplicate}
okLoading={loading}
>
<div className={'flex flex-col gap-4'}>
<SelectWorkspace
loading={workspaceLoading}
workspaceList={workspaceList}
value={selectedWorkspaceId}
onChange={setSelectedWorkspaceId}
/>
<SpaceList
loading={spaceLoading}
spaceList={spaceList}
value={selectedSpaceId}
onChange={setSelectedSpaceId}
/>
</div>
</NormalModal>
<NormalModal
PaperProps={{
sx: {
maxWidth: 420,
},
}}
okText={t('publish.openApp')}
cancelText={t('publish.downloadIt')}
onOk={() => window.open(openAppFlowySchema, '_self')}
onCancel={() => {
window.open(downloadPage, '_blank');
}}
onClose={() => setSuccessModalOpen(false)}
open={successModalOpen}
title={<div className={'text-left'}>{t('publish.duplicateSuccessfully')}</div>}
>
<div className={'w-full whitespace-pre-wrap break-words pb-1 text-text-caption'}>
{t('publish.duplicateSuccessfullyDescription')}
</div>
</NormalModal>
</>
);
}
export default DuplicateModal;
@@ -0,0 +1,143 @@
import React, { useCallback, useContext, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Avatar, Button, CircularProgress, Divider, Tooltip } from '@mui/material';
import { Workspace } from '@/application/types';
import { ReactComponent as RightIcon } from '@/assets/arrow_right.svg';
import { ReactComponent as CheckIcon } from '@/assets/selected.svg';
import { Popover } from '@/components/_shared/popover';
import { stringToColor } from '@/utils/color';
import { AFConfigContext } from '@/components/app/AppConfig';
export interface SelectWorkspaceProps {
value: string;
onChange?: (value: string) => void;
workspaceList: Workspace[];
loading?: boolean;
}
function stringAvatar(name: string) {
return {
sx: {
bgcolor: stringToColor(name),
},
children: `${name.split('')[0]}`,
};
}
function SelectWorkspace({ loading, value, onChange, workspaceList }: SelectWorkspaceProps) {
const { t } = useTranslation();
const email = useContext(AFConfigContext)?.currentUser?.email || '';
const selectedWorkspace = useMemo(() => {
return workspaceList.find((workspace) => workspace.id === value);
}, [value, workspaceList]);
const ref = useRef<HTMLButtonElement | null>(null);
const [selectOpen, setSelectOpen] = useState<boolean>(false);
const renderWorkspace = useCallback(
(workspace: Workspace) => {
return (
<div className={'flex items-center gap-[10px] overflow-hidden'}>
{workspace.icon ? (
<div className={'h-10 w-10 text-2xl'}>{workspace.icon}</div>
) : (
<Avatar
className={'border border-line-border'}
sizes={'24px'}
variant={'rounded'}
{...stringAvatar(workspace.name)}
/>
)}
<div className={'flex flex-1 flex-col items-start gap-0.5 overflow-hidden'}>
<div className={'w-full truncate text-left text-sm font-medium'}>{workspace.name}</div>
<div className={'text-xs text-text-caption'}>
{t('publish.membersCount', {
count: workspace.memberCount || 0,
})}
</div>
</div>
</div>
);
},
[t]
);
return (
<div className={'flex w-[360px] flex-col gap-2 max-sm:w-full'}>
<div className={'text-sm text-text-caption'}>{t('publish.selectWorkspace')}</div>
<Button
ref={ref}
onClick={() => {
setSelectOpen(true);
}}
className={'px-3 py-2'}
variant={'outlined'}
color={'inherit'}
>
<div className={'flex w-full items-center gap-[10px]'}>
{loading ? (
<div className={'flex w-full items-center justify-center'}>
<CircularProgress size={24} />
</div>
) : (
<>
<div className={'flex-1 overflow-hidden'}>
{selectedWorkspace ? renderWorkspace(selectedWorkspace) : null}
</div>
<span className={`h-4 w-4 ${selectOpen ? '-rotate-90' : 'rotate-90'} transform`}>
<RightIcon className={'h-full w-full'} />
</span>
</>
)}
</div>
</Button>
<Popover
anchorEl={ref.current}
open={selectOpen}
transformOrigin={{
vertical: -8,
horizontal: 'left',
}}
onClose={() => {
setSelectOpen(false);
}}
>
<div className={'flex max-h-[340px] w-[360px] flex-col gap-1 p-2 max-sm:w-full'}>
<div className={'w-full px-3 py-2 text-sm font-medium text-text-caption'}>{email}</div>
<Divider />
<div className={'appflowy-scroller flex flex-1 flex-col overflow-y-auto overflow-x-hidden'}>
{workspaceList.map((workspace) => {
const isSelected = workspace.id === selectedWorkspace?.id;
return (
<Tooltip
title={workspace.name}
key={workspace.id}
placement={'bottom'}
enterDelay={1000}
enterNextDelay={1000}
>
<Button
onClick={() => {
onChange?.(workspace.id);
setSelectOpen(false);
}}
className={'w-full px-3 py-2'}
variant={'text'}
color={'inherit'}
>
<div className={'flex-1 overflow-hidden'}>{renderWorkspace(workspace)}</div>
<div className={'h-6 w-6'}>
{isSelected && <CheckIcon className={'h-6 w-6 text-content-blue-400'} />}
</div>
</Button>
</Tooltip>
);
})}
</div>
</div>
</Popover>
</div>
);
}
export default SelectWorkspace;
@@ -0,0 +1,95 @@
import React, { useCallback } from 'react';
import { SpaceView } from '@/application/types';
import { useTranslation } from 'react-i18next';
import { ReactComponent as CheckIcon } from '@/assets/selected.svg';
import { renderColor } from '@/utils/color';
import SpaceIcon from '@/components/publish/header/SpaceIcon';
import { Button, CircularProgress, Tooltip } from '@mui/material';
import { ReactComponent as LockSvg } from '@/assets/lock.svg';
export interface SpaceListProps {
value: string;
onChange?: (value: string) => void;
spaceList: SpaceView[];
loading?: boolean;
}
function SpaceList({ loading, spaceList, value, onChange }: SpaceListProps) {
const { t } = useTranslation();
const getExtraObj = useCallback((extra: string) => {
try {
return extra
? (JSON.parse(extra) as {
is_space?: boolean;
space_icon?: string;
space_icon_color?: string;
})
: {};
} catch (e) {
return {};
}
}, []);
const renderSpace = useCallback(
(space: SpaceView) => {
const extraObj = getExtraObj(space.extra || '');
return (
<div className={'flex items-center gap-[10px] overflow-hidden text-sm'}>
<span
className={'icon h-5 w-5'}
style={{
backgroundColor: extraObj.space_icon_color ? renderColor(extraObj.space_icon_color) : undefined,
borderRadius: '8px',
}}
>
<SpaceIcon value={extraObj.space_icon || ''} />
</span>
<div className={'flex flex-1 items-center gap-2 truncate'}>
{space.name}
{space.isPrivate && <LockSvg className={'h-3.5 w-3.5 text-icon-primary'} />}
</div>
</div>
);
},
[getExtraObj]
);
return (
<div className={'flex max-h-[280px] w-[360px] flex-col gap-2 overflow-hidden max-sm:w-full'}>
<div className={'text-sm text-text-caption'}>{t('publish.addTo')}</div>
{loading ? (
<div className={'flex w-full items-center justify-center'}>
<CircularProgress size={24} />
</div>
) : (
<div className={'appflowy-scroller flex w-full flex-1 flex-col gap-1 overflow-y-auto overflow-x-hidden'}>
{spaceList.map((space) => {
const isSelected = value === space.id;
return (
<Tooltip title={space.name} key={space.id} placement={'bottom'} enterDelay={1000} enterNextDelay={1000}>
<Button
variant={'text'}
color={'inherit'}
className={'flex items-center p-1 font-normal'}
onClick={() => {
onChange?.(space.id);
}}
>
<div className={'flex-1 overflow-hidden text-left'}>{renderSpace(space)}</div>
<div className={'h-6 w-6'}>
{isSelected && <CheckIcon className={'h-6 w-6 text-content-blue-400'} />}
</div>
</Button>
</Tooltip>
);
})}
</div>
)}
</div>
);
}
export default SpaceList;
@@ -0,0 +1 @@
export * from './Duplicate';
@@ -0,0 +1,131 @@
import React, { useCallback, useContext, useEffect, useState } from 'react';
import { AFConfigContext } from '@/components/app/AppConfig';
import { useSearchParams } from 'react-router-dom';
import { SpaceView, Workspace } from '@/application/types';
import { notify } from '@/components/_shared/notify';
export function useDuplicate() {
const isAuthenticated = useContext(AFConfigContext)?.isAuthenticated || false;
const [search, setSearch] = useSearchParams();
const [loginOpen, setLoginOpen] = React.useState(false);
const [duplicateOpen, setDuplicateOpen] = React.useState(false);
useEffect(() => {
const isDuplicate = search.get('action') === 'duplicate';
if (!isDuplicate) return;
setLoginOpen(!isAuthenticated);
setDuplicateOpen(isAuthenticated);
}, [isAuthenticated, search, setSearch]);
const url = window.location.href;
const handleLoginClose = useCallback(() => {
setLoginOpen(false);
setSearch((prev) => {
prev.delete('action');
return prev;
});
}, [setSearch]);
const handleDuplicateClose = useCallback(() => {
setDuplicateOpen(false);
setSearch((prev) => {
prev.delete('action');
return prev;
});
}, [setSearch]);
return {
loginOpen,
handleLoginClose,
url,
duplicateOpen,
handleDuplicateClose,
};
}
export function useLoadWorkspaces() {
const [spaceLoading, setSpaceLoading] = useState<boolean>(false);
const [workspaceLoading, setWorkspaceLoading] = useState<boolean>(false);
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState<string>('1');
const [selectedSpaceId, setSelectedSpaceId] = useState<string>('1');
const [workspaceList, setWorkspaceList] = useState<Workspace[]>([]);
const [spaceList, setSpaceList] = useState<SpaceView[]>([]);
const service = useContext(AFConfigContext)?.service;
useEffect(() => {
void (async () => {
setWorkspaceLoading(true);
try {
const workspaces = await service?.getWorkspaces();
if (workspaces) {
setWorkspaceList(workspaces);
setSelectedWorkspaceId(workspaces[0].id);
} else {
setWorkspaceList([]);
setSelectedWorkspaceId('');
}
} catch (e) {
notify.error('Failed to load workspaces');
} finally {
setWorkspaceLoading(false);
}
})();
}, [service]);
useEffect(() => {
if (workspaceList.length === 0 || !selectedWorkspaceId || workspaceLoading) {
setSpaceList([]);
setSelectedSpaceId('');
return;
}
setSpaceLoading(true);
void (async () => {
try {
const folder = await service?.getWorkspaceFolder(selectedWorkspaceId);
if (folder) {
const spaces = [];
for (const child of folder.children) {
if (child.isSpace) {
spaces.push({
id: child.id,
name: child.name,
isPrivate: child.isPrivate,
extra: child.extra,
});
}
}
setSpaceList(spaces);
} else {
setSpaceList([]);
}
} catch (e) {
notify.error('Failed to load spaces');
} finally {
setSelectedSpaceId('');
setSpaceLoading(false);
}
})();
}, [selectedWorkspaceId, service, workspaceList.length, workspaceLoading]);
return {
workspaceList,
spaceList,
selectedWorkspaceId,
setSelectedWorkspaceId,
selectedSpaceId,
setSelectedSpaceId,
workspaceLoading,
spaceLoading,
};
}
@@ -7,7 +7,7 @@ import { createHotKeyLabel, HOT_KEY_NAME } from '@/utils/hotkeys';
import { Drawer, IconButton, Tooltip } from '@mui/material';
import { useTranslation } from 'react-i18next';
function OutlineDrawer({ open, width, onClose }: { open: boolean; width: number; onClose: () => void }) {
export function OutlineDrawer({ open, width, onClose }: { open: boolean; width: number; onClose: () => void }) {
const { t } = useTranslation();
const viewMeta = usePublishContext()?.viewMeta;
@@ -2,7 +2,7 @@ import { usePublishContext } from '@/application/publish';
import Outline from '@/components/publish/outline/Outline';
import { Divider, PopperPlacementType } from '@mui/material';
import React, { ReactElement, useMemo } from 'react';
import RichTooltip from 'src/components/_shared/popover/RichTooltip';
import { RichTooltip } from '@/components/_shared/popover';
import { ReactComponent as Logo } from '@/assets/logo.svg';
import { ReactComponent as AppflowyLogo } from '@/assets/appflowy.svg';
@@ -1 +1,2 @@
export * from './OutlinePopover';
export * from './OutlineDrawer';
@@ -1,6 +1,7 @@
import { usePublishContext } from '@/application/publish';
import { EditorLayoutStyle } from '@/components/editor/EditorContext';
import { ViewMetaCover } from '@/components/view-meta';
import { getFontFamily } from '@/utils/font';
import { useEffect, useMemo } from 'react';
export function useViewMeta() {
@@ -21,7 +22,7 @@ export function useViewMeta() {
lineHeightLayout: extra?.lineHeightLayout,
};
}, [extra]);
const layout = viewMeta?.layout;
const style = useMemo(() => {
const fontSizeMap = {
@@ -56,11 +57,7 @@ export function useViewMeta() {
useEffect(() => {
if (!layoutStyle.font) return;
void window.WebFont?.load({
google: {
families: [layoutStyle.font],
},
});
void getFontFamily(layoutStyle.font);
}, [layoutStyle.font]);
const icon = viewMeta?.icon || undefined;
@@ -1,10 +1,21 @@
import { Login } from '@/components/login';
import React from 'react';
import React, { useContext, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { AFConfigContext } from '@/components/app/AppConfig';
function LoginPage() {
const [search] = useSearchParams();
const redirectTo = search.get('redirectTo') || '';
const isAuthenticated = useContext(AFConfigContext)?.isAuthenticated || false;
useEffect(() => {
if (isAuthenticated && redirectTo && encodeURIComponent(redirectTo) !== window.location.href) {
window.location.href = redirectTo;
}
}, [isAuthenticated, redirectTo]);
return (
<div className={'bg-body flex h-screen w-screen items-center justify-center'}>
<Login />
<Login redirectTo={redirectTo} />
</div>
);
}
@@ -24,6 +24,7 @@ body {
}
@apply bg-bg-body text-text-title;
&[data-os="windows"]:not([data-browser="firefox"]) {
.appflowy-custom-scroller {
@include mixin.hidden-scrollbar
@@ -45,12 +46,23 @@ body {
}
.appflowy-scroller {
@include mixin.scrollbar-style;
}
.appflowy-scrollbar-thumb-horizontal, .appflowy-scrollbar-thumb-vertical {
background-color: var(--scrollbar-thumb);
border-radius: 4px;
opacity: 60%;
}
.appflowy-scrollbar-track-horizontal, .appflowy-scrollbar-track-vertical {
background-color: var(--scrollbar-track);
}
.icon {
font-family: 'Apple Color Emoji', 'Noto Color Emoji', 'Segoe UI Emoji', 'Twemoji Mozilla', sans-serif;
}
@@ -111,3 +123,25 @@ body {
@apply flex-wrap py-2;
}
}
@keyframes blink {
0%, 100% {
background-color: var(--content-blue-100);
}
50% {
background-color: unset;
}
}
.blink {
animation: blink 2s linear infinite;
}
.reply-line {
width: 28px;
height: 16px;
border-left: 2px solid;
border-top: 2px solid;
border-color: var(--line-border);
border-top-left-radius: 6px;
}
@@ -1,3 +1,13 @@
::-webkit-scrollbar-thumb:hover {
border-radius: 4px;
background-color: var(--scrollbar-thumb);
}
::-webkit-scrollbar-track:hover {
background-color: var(--scrollbar-track);
}
@mixin hidden-scrollbar {
&::-webkit-scrollbar {
display: none;
@@ -7,6 +17,7 @@
scrollbar-width: none; // For Firefox
}
@mixin scrollbar-style {
::-webkit-scrollbar, &::-webkit-scrollbar {
width: 8px;
@@ -72,3 +72,34 @@ export function renderColor(color: string) {
return argbToRgba(color);
}
export function stringToColor(string: string) {
let hash = 0;
let i;
/* eslint-disable no-bitwise */
for (i = 0; i < string.length; i += 1) {
hash = string.charCodeAt(i) + ((hash << 5) - hash);
}
let color = '#';
for (i = 0; i < 3; i += 1) {
const value = (hash >> (i * 8)) & 0xff;
color += `00${value.toString(16)}`.slice(-2);
}
/* eslint-enable no-bitwise */
return color;
}
export function stringAvatar(name: string) {
return {
sx: {
bgcolor: stringToColor(name),
},
children: `${name.split('')[0]}`,
};
}
@@ -0,0 +1,14 @@
import { EmojiMartData } from '@emoji-mart/data';
export async function randomEmoji(skin = 0) {
const emojiData = await loadEmojiData();
const emojis = (emojiData as EmojiMartData).emojis;
const keys = Object.keys(emojis);
const randomKey = keys[Math.floor(Math.random() * keys.length)];
return emojis[randomKey].skins[skin].native;
}
export async function loadEmojiData() {
return import('@emoji-mart/data/sets/15/all.json');
}
+17 -5
View File
@@ -7,10 +7,22 @@ export function getFontFamily(attribute: string) {
return fontFamily;
}
window.WebFont?.load({
google: {
families: [fontFamily],
},
});
void (async () => {
const script = document.createElement('script');
script.src = 'https://ajax.googleapis.com/ajax/libs/webfont/1.6.26/webfont.js';
document.body.appendChild(script);
await new Promise((resolve) => {
script.onload = () => {
resolve(true);
};
});
window.WebFont?.load({
google: {
families: [fontFamily],
},
});
})();
return fontFamily;
}
@@ -0,0 +1,20 @@
export function inView(dom: HTMLElement, container: HTMLElement) {
const domRect = dom.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
if (!domRect || !containerRect) return true;
return domRect?.bottom <= containerRect?.bottom && domRect?.top >= containerRect?.top;
}
export function getDistanceEdge(dom: HTMLElement, container: HTMLElement) {
const domRect = dom.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
if (!domRect || !containerRect) return 0;
const distanceTop = domRect?.top - containerRect?.top;
const distanceBottom = domRect?.bottom - containerRect?.bottom;
return Math.abs(distanceTop) < Math.abs(distanceBottom) ? distanceTop : distanceBottom;
}
+2 -1
View File
@@ -13,7 +13,8 @@ interface Window {
toast: {
success: (message: string) => void;
error: (message: string) => void;
info: (message: string) => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
info: (props: any) => void;
clear: () => void;
default: (message: string) => void;
warning: (message: string) => void;
+6 -4
View File
@@ -112,8 +112,12 @@ export default defineConfig({
id.includes('/react-is@') ||
id.includes('/yjs@') ||
id.includes('/y-indexeddb@') ||
id.includes('/dexie') ||
id.includes('/redux') ||
id.includes('/react-custom-scrollbars')
id.includes('/react-custom-scrollbars') ||
id.includes('/dayjs') ||
id.includes('/smooth-scroll-into-view-if-needed') ||
id.includes('/react-virtualized-auto-sizer')
) {
return 'common';
}
@@ -140,9 +144,7 @@ export default defineConfig({
include: [
'react',
'react-dom',
'@mui/icons-material/ErrorOutline',
'@mui/icons-material/CheckCircleOutline',
'@mui/icons-material/FunctionsOutlined',
'@mui/icons-material/Circle',
'react-katex',
// 'react-custom-scrollbars-2',
// 'react-window',
+61 -1
View File
@@ -2176,7 +2176,22 @@
},
"mustSelectPrimaryDatabase": "The primary view must be selected",
"noDatabaseSelected": "No database selected, please select at least one database.",
"unableToDeselectPrimaryDatabase": "Unable to deselect primary database"
"unableToDeselectPrimaryDatabase": "Unable to deselect primary database",
"saveThisPage": "Save this page",
"duplicateTitle": "Where would you like to add",
"selectWorkspace": "Select a workspace",
"addTo": "Add to",
"duplicateSuccessfully": "Duplicated success. Want to view documents?",
"duplicateSuccessfullyDescription": "Don't have the app? Your download will begin automatically after clicking the 'Download'.",
"downloadIt": "Download",
"openApp": "Open in app",
"duplicateFailed": "Duplicated failed",
"membersCount": {
"zero": "No members",
"one": "1 member",
"many": "{count} members",
"other": "{count} members"
}
},
"web": {
"continue": "Continue",
@@ -2190,5 +2205,50 @@
"privacyPolicy": "Privacy Policy",
"signInError": "Sign in error",
"login": "Sign up or log in"
},
"globalComment": {
"comments": "Comments",
"addComment": "Add a comment",
"reactedBy": "reacted by",
"addReaction": "Add reaction",
"reactedByMore": "and {count} others",
"showSeconds": {
"one": "1 second ago",
"other": "{count} seconds ago",
"zero": "Just now",
"many": "{count} seconds ago"
},
"showMinutes": {
"one": "1 minute ago",
"other": "{count} minutes ago",
"many": "{count} minutes ago"
},
"showHours": {
"one": "1 hour ago",
"other": "{count} hours ago",
"many": "{count} hours ago"
},
"showDays": {
"one": "1 day ago",
"other": "{count} days ago",
"many": "{count} days ago"
},
"showMonths": {
"one": "1 month ago",
"other": "{count} months ago",
"many": "{count} months ago"
},
"showYears": {
"one": "1 year ago",
"other": "{count} years ago",
"many": "{count} years ago"
},
"reply": "Reply",
"deleteComment": "Delete comment",
"youAreNotOwner": "You are not the owner of this comment",
"confirmDeleteDescription": "Are you sure you want to delete this comment?",
"hasBeenDeleted": "Deleted",
"replyingTo": "Replying to",
"noAccessDeleteComment": "You're not allowed to delete this comment"
}
}