aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorcrupest <crupest@outlook.com>2020-06-11 17:27:15 +0800
committercrupest <crupest@outlook.com>2020-06-11 17:27:15 +0800
commitcf6cfe87b46a2a3eb2913209092ab4c5639e75c3 (patch)
treeeba7504d04dad89f67524b48d88a3b5eb27de6de
parent4b8abdd1921935ebb29d18961534db04a2e58fbb (diff)
downloadtimeline-cf6cfe87b46a2a3eb2913209092ab4c5639e75c3.tar.gz
timeline-cf6cfe87b46a2a3eb2913209092ab4c5639e75c3.tar.bz2
timeline-cf6cfe87b46a2a3eb2913209092ab4c5639e75c3.zip
feat(front): Service worker is coming!
-rw-r--r--Timeline/ClientApp/.eslintrc.js2
-rw-r--r--Timeline/ClientApp/.yarn/plugins/@yarnpkg/plugin-typescript.js1
-rw-r--r--Timeline/ClientApp/.yarnrc.yml2
-rw-r--r--Timeline/ClientApp/package.json6
-rw-r--r--Timeline/ClientApp/src/app/App.tsx (renamed from Timeline/ClientApp/src/App.tsx)0
-rw-r--r--Timeline/ClientApp/src/app/about/About.tsx (renamed from Timeline/ClientApp/src/about/About.tsx)360
-rw-r--r--Timeline/ClientApp/src/app/about/about.sass (renamed from Timeline/ClientApp/src/about/about.sass)6
-rw-r--r--Timeline/ClientApp/src/app/about/author-avatar.png (renamed from Timeline/ClientApp/src/about/author-avatar.png)bin12038 -> 12038 bytes
-rw-r--r--Timeline/ClientApp/src/app/admin/Admin.tsx (renamed from Timeline/ClientApp/src/admin/Admin.tsx)156
-rw-r--r--Timeline/ClientApp/src/app/admin/UserAdmin.tsx (renamed from Timeline/ClientApp/src/admin/UserAdmin.tsx)926
-rw-r--r--Timeline/ClientApp/src/app/common.ts (renamed from Timeline/ClientApp/src/common.ts)8
-rw-r--r--Timeline/ClientApp/src/app/common/AlertHost.tsx (renamed from Timeline/ClientApp/src/common/AlertHost.tsx)150
-rw-r--r--Timeline/ClientApp/src/app/common/AppBar.tsx (renamed from Timeline/ClientApp/src/common/AppBar.tsx)214
-rw-r--r--Timeline/ClientApp/src/app/common/CollapseButton.tsx (renamed from Timeline/ClientApp/src/common/CollapseButton.tsx)202
-rw-r--r--Timeline/ClientApp/src/app/common/FileInput.tsx (renamed from Timeline/ClientApp/src/common/FileInput.tsx)82
-rw-r--r--Timeline/ClientApp/src/app/common/ImageCropper.tsx (renamed from Timeline/ClientApp/src/common/ImageCropper.tsx)612
-rw-r--r--Timeline/ClientApp/src/app/common/LoadingPage.tsx (renamed from Timeline/ClientApp/src/common/LoadingPage.tsx)24
-rw-r--r--Timeline/ClientApp/src/app/common/OperationDialog.tsx (renamed from Timeline/ClientApp/src/common/OperationDialog.tsx)762
-rw-r--r--Timeline/ClientApp/src/app/common/SearchInput.tsx (renamed from Timeline/ClientApp/src/common/SearchInput.tsx)126
-rw-r--r--Timeline/ClientApp/src/app/common/TimelineLogo.tsx (renamed from Timeline/ClientApp/src/common/TimelineLogo.tsx)52
-rw-r--r--Timeline/ClientApp/src/app/common/UserTimelineLogo.tsx (renamed from Timeline/ClientApp/src/common/UserTimelineLogo.tsx)52
-rw-r--r--Timeline/ClientApp/src/app/common/alert-service.ts (renamed from Timeline/ClientApp/src/common/alert-service.ts)118
-rw-r--r--Timeline/ClientApp/src/app/common/alert.sass (renamed from Timeline/ClientApp/src/common/alert.sass)30
-rw-r--r--Timeline/ClientApp/src/app/common/common.sass (renamed from Timeline/ClientApp/src/common/common.sass)66
-rw-r--r--Timeline/ClientApp/src/app/config.ts (renamed from Timeline/ClientApp/src/config.ts)2
-rw-r--r--Timeline/ClientApp/src/app/data/base64.ts (renamed from Timeline/ClientApp/src/data/base64.ts)18
-rw-r--r--Timeline/ClientApp/src/app/data/common.ts (renamed from Timeline/ClientApp/src/data/common.ts)50
-rw-r--r--Timeline/ClientApp/src/app/data/timeline.ts (renamed from Timeline/ClientApp/src/data/timeline.ts)690
-rw-r--r--Timeline/ClientApp/src/app/data/user.ts (renamed from Timeline/ClientApp/src/data/user.ts)448
-rw-r--r--Timeline/ClientApp/src/app/helper.ts (renamed from Timeline/ClientApp/src/helper.ts)70
-rw-r--r--Timeline/ClientApp/src/app/home/Home.tsx (renamed from Timeline/ClientApp/src/home/Home.tsx)310
-rw-r--r--Timeline/ClientApp/src/app/home/TimelineBoard.tsx (renamed from Timeline/ClientApp/src/home/TimelineBoard.tsx)108
-rw-r--r--Timeline/ClientApp/src/app/home/TimelineBoardAreaWithUser.tsx (renamed from Timeline/ClientApp/src/home/TimelineBoardAreaWithUser.tsx)72
-rw-r--r--Timeline/ClientApp/src/app/home/TimelineBoardAreaWithoutUser.tsx (renamed from Timeline/ClientApp/src/home/TimelineBoardAreaWithoutUser.tsx)52
-rw-r--r--Timeline/ClientApp/src/app/home/TimelineCreateDialog.tsx (renamed from Timeline/ClientApp/src/home/TimelineCreateDialog.tsx)120
-rw-r--r--Timeline/ClientApp/src/app/home/home.sass (renamed from Timeline/ClientApp/src/home/home.sass)26
-rw-r--r--Timeline/ClientApp/src/app/i18n.ts (renamed from Timeline/ClientApp/src/i18n.ts)158
-rw-r--r--Timeline/ClientApp/src/app/index.sass (renamed from Timeline/ClientApp/src/index.sass)0
-rw-r--r--Timeline/ClientApp/src/app/index.tsx (renamed from Timeline/ClientApp/src/index.tsx)7
-rw-r--r--Timeline/ClientApp/src/app/locales/en/translation.ts (renamed from Timeline/ClientApp/src/locales/en/translation.ts)366
-rw-r--r--Timeline/ClientApp/src/app/locales/scheme.ts (renamed from Timeline/ClientApp/src/locales/scheme.ts)334
-rw-r--r--Timeline/ClientApp/src/app/locales/zh/translation.ts (renamed from Timeline/ClientApp/src/locales/zh/translation.ts)356
-rw-r--r--Timeline/ClientApp/src/app/settings/Settings.tsx (renamed from Timeline/ClientApp/src/settings/Settings.tsx)506
-rw-r--r--Timeline/ClientApp/src/app/timeline/Timeline.tsx (renamed from Timeline/ClientApp/src/timeline/Timeline.tsx)198
-rw-r--r--Timeline/ClientApp/src/app/timeline/TimelineDeleteDialog.tsx (renamed from Timeline/ClientApp/src/timeline/TimelineDeleteDialog.tsx)118
-rw-r--r--Timeline/ClientApp/src/app/timeline/TimelineInfoCard.tsx (renamed from Timeline/ClientApp/src/timeline/TimelineInfoCard.tsx)218
-rw-r--r--Timeline/ClientApp/src/app/timeline/TimelineItem.tsx (renamed from Timeline/ClientApp/src/timeline/TimelineItem.tsx)350
-rw-r--r--Timeline/ClientApp/src/app/timeline/TimelineMember.tsx (renamed from Timeline/ClientApp/src/timeline/TimelineMember.tsx)392
-rw-r--r--Timeline/ClientApp/src/app/timeline/TimelinePage.tsx (renamed from Timeline/ClientApp/src/timeline/TimelinePage.tsx)78
-rw-r--r--Timeline/ClientApp/src/app/timeline/TimelinePageTemplate.tsx (renamed from Timeline/ClientApp/src/timeline/TimelinePageTemplate.tsx)550
-rw-r--r--Timeline/ClientApp/src/app/timeline/TimelinePageTemplateUI.tsx (renamed from Timeline/ClientApp/src/timeline/TimelinePageTemplateUI.tsx)424
-rw-r--r--Timeline/ClientApp/src/app/timeline/TimelinePageUI.tsx (renamed from Timeline/ClientApp/src/timeline/TimelinePageUI.tsx)44
-rw-r--r--Timeline/ClientApp/src/app/timeline/TimelinePostEdit.tsx (renamed from Timeline/ClientApp/src/timeline/TimelinePostEdit.tsx)448
-rw-r--r--Timeline/ClientApp/src/app/timeline/TimelinePropertyChangeDialog.tsx (renamed from Timeline/ClientApp/src/timeline/TimelinePropertyChangeDialog.tsx)140
-rw-r--r--Timeline/ClientApp/src/app/timeline/timeline-ui.sass (renamed from Timeline/ClientApp/src/timeline/timeline-ui.sass)36
-rw-r--r--Timeline/ClientApp/src/app/timeline/timeline.sass (renamed from Timeline/ClientApp/src/timeline/timeline.sass)250
-rw-r--r--Timeline/ClientApp/src/app/tsconfig.json13
-rw-r--r--Timeline/ClientApp/src/app/type-utilities.ts (renamed from Timeline/ClientApp/src/type-utilities.ts)2
-rw-r--r--Timeline/ClientApp/src/app/typings.d.ts (renamed from Timeline/ClientApp/src/typings.d.ts)48
-rw-r--r--Timeline/ClientApp/src/app/user/ChangeAvatarDialog.tsx (renamed from Timeline/ClientApp/src/user/ChangeAvatarDialog.tsx)612
-rw-r--r--Timeline/ClientApp/src/app/user/ChangeNicknameDialog.tsx (renamed from Timeline/ClientApp/src/user/ChangeNicknameDialog.tsx)56
-rw-r--r--Timeline/ClientApp/src/app/user/Login.tsx (renamed from Timeline/ClientApp/src/user/Login.tsx)294
-rw-r--r--Timeline/ClientApp/src/app/user/User.tsx (renamed from Timeline/ClientApp/src/user/User.tsx)158
-rw-r--r--Timeline/ClientApp/src/app/user/UserInfoCard.tsx (renamed from Timeline/ClientApp/src/user/UserInfoCard.tsx)232
-rw-r--r--Timeline/ClientApp/src/app/user/UserPage.tsx (renamed from Timeline/ClientApp/src/user/UserPage.tsx)40
-rw-r--r--Timeline/ClientApp/src/app/user/api.ts (renamed from Timeline/ClientApp/src/user/api.ts)158
-rw-r--r--Timeline/ClientApp/src/app/user/user-page.sass (renamed from Timeline/ClientApp/src/user/user-page.sass)22
-rw-r--r--Timeline/ClientApp/src/sw/sw.ts22
-rw-r--r--Timeline/ClientApp/src/sw/tsconfig.json12
-rw-r--r--Timeline/ClientApp/src/tsconfig.json (renamed from Timeline/ClientApp/tsconfig.json)42
-rw-r--r--Timeline/ClientApp/webpack.config.dev.js25
-rw-r--r--Timeline/ClientApp/webpack.config.prod.js14
-rw-r--r--Timeline/ClientApp/yarn.lock627
73 files changed, 6979 insertions, 6294 deletions
diff --git a/Timeline/ClientApp/.eslintrc.js b/Timeline/ClientApp/.eslintrc.js
index 73c94bf8..5232e5d8 100644
--- a/Timeline/ClientApp/.eslintrc.js
+++ b/Timeline/ClientApp/.eslintrc.js
@@ -20,7 +20,7 @@ module.exports = {
},
parser: '@typescript-eslint/parser',
parserOptions: {
- project: ['./tsconfig.json'],
+ project: ['./src/app/tsconfig.json', './src/sw/tsconfig.json'],
ecmaFeatures: {
jsx: true,
},
diff --git a/Timeline/ClientApp/.yarn/plugins/@yarnpkg/plugin-typescript.js b/Timeline/ClientApp/.yarn/plugins/@yarnpkg/plugin-typescript.js
new file mode 100644
index 00000000..1eee87af
--- /dev/null
+++ b/Timeline/ClientApp/.yarn/plugins/@yarnpkg/plugin-typescript.js
@@ -0,0 +1 @@
+module.exports={name:"@yarnpkg/plugin-typescript",factory:function(e){return function(e){var t={};function r(n){if(t[n])return t[n].exports;var s=t[n]={i:n,l:!1,exports:{}};return e[n].call(s.exports,s,s.exports,r),s.l=!0,s.exports}return r.m=e,r.c=t,r.d=function(e,t,n){r.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:n})},r.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},r.t=function(e,t){if(1&t&&(e=r(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var n=Object.create(null);if(r.r(n),Object.defineProperty(n,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var s in e)r.d(n,s,function(t){return e[t]}.bind(null,s));return n},r.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return r.d(t,"a",t),t},r.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},r.p="",r(r.s=19)}([function(e,t,r){"use strict";function n(e,t,r){const n={"x-algolia-api-key":r,"x-algolia-application-id":t};return{headers:()=>e===l.WithinHeaders?n:{},queryParameters:()=>e===l.WithinQueryParameters?n:{}}}function s(e){let t=0;const r=()=>(t++,new Promise(n=>{setTimeout(()=>{n(e(r))},Math.min(100*t,1e3))}));return e(r)}function a(e,t=((e,t)=>Promise.resolve())){return Object.assign(e,{wait:r=>a(e.then(e=>Promise.all([t(e,r),e])).then(e=>e[1]))})}function o(e){let t=e.length-1;for(;t>0;t--){const r=Math.floor(Math.random()*(t+1)),n=e[t];e[t]=e[r],e[r]=n}return e}function i(e,t){return Object.keys(void 0!==t?t:{}).forEach(r=>{e[r]=t[r](e)}),e}function c(e,...t){let r=0;return e.replace(/%s/g,()=>encodeURIComponent(t[r++]))}r.r(t),r.d(t,"AuthMode",(function(){return l})),r.d(t,"addMethods",(function(){return i})),r.d(t,"createAuth",(function(){return n})),r.d(t,"createRetryablePromise",(function(){return s})),r.d(t,"createWaitablePromise",(function(){return a})),r.d(t,"destroy",(function(){return d})),r.d(t,"encode",(function(){return c})),r.d(t,"shuffle",(function(){return o})),r.d(t,"version",(function(){return u}));const u="4.2.0",d=e=>()=>e.transporter.requester.destroy(),l={WithinQueryParameters:0,WithinHeaders:1}},function(e,t,r){"use strict";r.d(t,"a",(function(){return n}));const n={Delete:"DELETE",Get:"GET",Post:"POST",Put:"PUT"}},function(e,t,r){"use strict";r.r(t),r.d(t,"CallEnum",(function(){return a})),r.d(t,"HostStatusEnum",(function(){return o})),r.d(t,"createApiError",(function(){return x})),r.d(t,"createDeserializationError",(function(){return S})),r.d(t,"createMappedRequestOptions",(function(){return s})),r.d(t,"createRetryError",(function(){return D})),r.d(t,"createStatefulHost",(function(){return c})),r.d(t,"createStatelessHost",(function(){return l})),r.d(t,"createTransporter",(function(){return h})),r.d(t,"createUserAgent",(function(){return f})),r.d(t,"deserializeFailure",(function(){return y})),r.d(t,"deserializeSuccess",(function(){return b})),r.d(t,"isStatefulHostTimeouted",(function(){return d})),r.d(t,"isStatefulHostUp",(function(){return u})),r.d(t,"serializeData",(function(){return j})),r.d(t,"serializeHeaders",(function(){return P})),r.d(t,"serializeQueryParameters",(function(){return O})),r.d(t,"serializeUrl",(function(){return g})),r.d(t,"stackFrameWithoutCredentials",(function(){return v})),r.d(t,"stackTraceWithoutCredentials",(function(){return I}));var n=r(1);function s(e,t){const r=e||{},n=r.data||{};return Object.keys(r).forEach(e=>{-1===["timeout","headers","queryParameters","data","cacheable"].indexOf(e)&&(n[e]=r[e])}),{data:Object.entries(n).length>0?n:void 0,timeout:r.timeout||t,headers:r.headers||{},queryParameters:r.queryParameters||{},cacheable:r.cacheable}}const a={Read:1,Write:2,Any:3},o={Up:1,Down:2,Timeouted:3},i=12e4;function c(e,t=o.Up){return{...e,status:t,lastUpdate:Date.now()}}function u(e){return e.status===o.Up||Date.now()-e.lastUpdate>i}function d(e){return e.status===o.Timeouted&&Date.now()-e.lastUpdate<=i}function l(e){return{protocol:e.protocol||"https",url:e.url,accept:e.accept||a.Any}}const p=(e,t)=>(e=>{const t=e.status;return e.isTimedOut||(({isTimedOut:e,status:t})=>!e&&0==~~t)(e)||2!=~~(t/100)&&4!=~~(t/100)})(e)?t.onRetry(e):(({status:e})=>2==~~(e/100))(e)?t.onSucess(e):t.onFail(e);function m(e,t,r,s){const a=[],i=j(r,s),m=P(e,s),h=r.method,f=r.method!==n.a.Get?{}:{...r.data,...s.data},O={"x-algolia-agent":e.userAgent.value,...e.queryParameters,...f,...s.queryParameters};let x=0;const S=(t,n)=>{const u=t.pop();if(void 0===u)throw D(I(a));const d={data:i,headers:m,method:h,url:g(u,r.path,O),connectTimeout:n(x,e.timeouts.connect),responseTimeout:n(x,s.timeout)},l=e=>{const r={request:d,response:e,host:u,triesLeft:t.length};return a.push(r),r},f={onSucess:e=>b(e),onRetry(r){const s=l(r);return r.isTimedOut&&x++,Promise.all([e.logger.info("Retryable failure",v(s)),e.hostsCache.set(u,c(u,r.isTimedOut?o.Timeouted:o.Down))]).then(()=>S(t,n))},onFail(e){throw l(e),y(e,I(a))}};return e.requester.send(d).then(e=>p(e,f))};return function(e,t){return Promise.all(t.map(t=>e.get(t,()=>Promise.resolve(c(t))))).then(e=>{const r=e.filter(e=>u(e)),n=e.filter(e=>d(e)),s=[...r,...n];return{getTimeout:(e,t)=>(0===n.length&&0===e?1:n.length+3+e)*t,statelessHosts:s.length>0?s.map(e=>l(e)):t}})}(e.hostsCache,t).then(e=>S([...e.statelessHosts].reverse(),e.getTimeout))}function h(e){const{hostsCache:t,logger:r,requester:n,requestsCache:o,responsesCache:i,timeouts:c,userAgent:u,hosts:d,queryParameters:p,headers:h}=e,f={hostsCache:t,logger:r,requester:n,requestsCache:o,responsesCache:i,timeouts:c,userAgent:u,headers:h,queryParameters:p,hosts:d.map(e=>l(e)),read(e,t){const r=s(t,f.timeouts.read),n=()=>m(f,f.hosts.filter(e=>0!=(e.accept&a.Read)),e,r);if(!0!==(void 0!==r.cacheable?r.cacheable:e.cacheable))return n();const o={request:e,mappedRequestOptions:r,transporter:{queryParameters:f.queryParameters,headers:f.headers}};return f.responsesCache.get(o,()=>f.requestsCache.get(o,()=>f.requestsCache.set(o,n()).then(e=>Promise.all([f.requestsCache.delete(o),e]),e=>Promise.all([f.requestsCache.delete(o),Promise.reject(e)])).then(([e,t])=>t)),{miss:e=>f.responsesCache.set(o,e)})},write:(e,t)=>m(f,f.hosts.filter(e=>0!=(e.accept&a.Write)),e,s(t,f.timeouts.write))};return f}function f(e){const t={value:`Algolia for JavaScript (${e})`,add(e){const r=`; ${e.segment}${void 0!==e.version?` (${e.version})`:""}`;return-1===t.value.indexOf(r)&&(t.value=`${t.value}${r}`),t}};return t}function b(e){try{return JSON.parse(e.content)}catch(t){throw S(t.message,e)}}function y({content:e,status:t},r){let n=e;try{n=JSON.parse(e).message}catch(e){}return x(n,t,r)}function g(e,t,r){const n=O(r);let s=`${e.protocol}://${e.url}/${"/"===t.charAt(0)?t.substr(1):t}`;return n.length&&(s+=`?${n}`),s}function O(e){return Object.keys(e).map(t=>(function(e,...t){let r=0;return e.replace(/%s/g,()=>encodeURIComponent(t[r++]))})("%s=%s",t,(e=>"[object Object]"===Object.prototype.toString.call(e)||"[object Array]"===Object.prototype.toString.call(e))(e[t])?JSON.stringify(e[t]):e[t])).join("&")}function j(e,t){if(e.method===n.a.Get||void 0===e.data&&void 0===t.data)return;const r=Array.isArray(e.data)?e.data:{...e.data,...t.data};return JSON.stringify(r)}function P(e,t){const r={...e.headers,...t.headers},n={};return Object.keys(r).forEach(e=>{const t=r[e];n[e.toLowerCase()]=t}),n}function I(e){return e.map(e=>v(e))}function v(e){const t=e.request.headers["x-algolia-api-key"]?{"x-algolia-api-key":"*****"}:{};return{...e,request:{...e.request,headers:{...e.request.headers,...t}}}}function x(e,t,r){return{name:"ApiError",message:e,status:t,transporterStackTrace:r}}function S(e,t){return{name:"DeserializationError",message:e,response:t}}function D(e){return{name:"RetryError",message:"Unreachable hosts - your application id may be incorrect. If the error persists, contact support@algolia.com.",transporterStackTrace:e}}},function(t,r){t.exports=e("@yarnpkg/core")},function(t,r){t.exports=e("http")},function(t,r){t.exports=e("https")},function(t,r){t.exports=e("@yarnpkg/plugin-essentials")},function(t,r){t.exports=e("semver")},function(e,t,r){const n=r(11);e.exports=n,e.exports.default=n},function(t,r){t.exports=e("crypto")},function(t,r){t.exports=e("url")},function(e,t,r){"use strict";var n=r(12),s=r(13),a=r(14),o=r(0),i=r(15),c=r(16),u=r(17),d=r(18),l=r(2);function p(e,t,r){const p={appId:e,apiKey:t,timeouts:{connect:2,read:5,write:30},requester:d.createNodeHttpRequester(),logger:u.createNullLogger(),responsesCache:n.createNullCache(),requestsCache:n.createNullCache(),hostsCache:s.createInMemoryCache(),userAgent:l.createUserAgent(o.version).add({segment:"Node.js",version:process.versions.node})};return c.createSearchClient({...p,...r,methods:{search:c.multipleQueries,searchForFacetValues:c.multipleSearchForFacetValues,multipleBatch:c.multipleBatch,multipleGetObjects:c.multipleGetObjects,multipleQueries:c.multipleQueries,copyIndex:c.copyIndex,copySettings:c.copySettings,copyRules:c.copyRules,copySynonyms:c.copySynonyms,moveIndex:c.moveIndex,listIndices:c.listIndices,getLogs:c.getLogs,listClusters:c.listClusters,multipleSearchForFacetValues:c.multipleSearchForFacetValues,getApiKey:c.getApiKey,addApiKey:c.addApiKey,listApiKeys:c.listApiKeys,updateApiKey:c.updateApiKey,deleteApiKey:c.deleteApiKey,restoreApiKey:c.restoreApiKey,assignUserID:c.assignUserID,assignUserIDs:c.assignUserIDs,getUserID:c.getUserID,searchUserIDs:c.searchUserIDs,listUserIDs:c.listUserIDs,getTopUserIDs:c.getTopUserIDs,removeUserID:c.removeUserID,hasPendingMappings:c.hasPendingMappings,generateSecuredApiKey:c.generateSecuredApiKey,getSecuredApiKeyRemainingValidity:c.getSecuredApiKeyRemainingValidity,destroy:o.destroy,initIndex:e=>t=>c.initIndex(e)(t,{methods:{batch:c.batch,delete:c.deleteIndex,getObject:c.getObject,getObjects:c.getObjects,saveObject:c.saveObject,saveObjects:c.saveObjects,search:c.search,searchForFacetValues:c.searchForFacetValues,waitTask:c.waitTask,setSettings:c.setSettings,getSettings:c.getSettings,partialUpdateObject:c.partialUpdateObject,partialUpdateObjects:c.partialUpdateObjects,deleteObject:c.deleteObject,deleteObjects:c.deleteObjects,deleteBy:c.deleteBy,clearObjects:c.clearObjects,browseObjects:c.browseObjects,getObjectPosition:c.getObjectPosition,findObject:c.findObject,exists:c.exists,saveSynonym:c.saveSynonym,saveSynonyms:c.saveSynonyms,getSynonym:c.getSynonym,searchSynonyms:c.searchSynonyms,browseSynonyms:c.browseSynonyms,deleteSynonym:c.deleteSynonym,clearSynonyms:c.clearSynonyms,replaceAllObjects:c.replaceAllObjects,replaceAllSynonyms:c.replaceAllSynonyms,searchRules:c.searchRules,getRule:c.getRule,deleteRule:c.deleteRule,saveRule:c.saveRule,saveRules:c.saveRules,replaceAllRules:c.replaceAllRules,browseRules:c.browseRules,clearRules:c.clearRules}}),initAnalytics:()=>e=>a.createAnalyticsClient({...p,...e,methods:{addABTest:a.addABTest,getABTest:a.getABTest,getABTests:a.getABTests,stopABTest:a.stopABTest,deleteABTest:a.deleteABTest}}),initRecommendation:()=>e=>i.createRecommendationClient({...p,...e,methods:{getPersonalizationStrategy:i.getPersonalizationStrategy,setPersonalizationStrategy:i.setPersonalizationStrategy}})}})}p.version=o.version,e.exports=p},function(e,t,r){"use strict";function n(e){const t=[...e.caches],r=t.shift();return void 0===r?s():{get:(e,s,a={miss:()=>Promise.resolve()})=>r.get(e,s,a).catch(()=>n({caches:t}).get(e,s,a)),set:(e,s)=>r.set(e,s).catch(()=>n({caches:t}).set(e,s)),delete:e=>r.delete(e).catch(()=>n({caches:t}).delete(e)),clear:()=>r.clear().catch(()=>n({caches:t}).clear())}}function s(){return{get:(e,t,r={miss:()=>Promise.resolve()})=>t().then(e=>Promise.all([e,r.miss(e)])).then(([e])=>e),set:(e,t)=>Promise.resolve(t),delete:e=>Promise.resolve(),clear:()=>Promise.resolve()}}r.r(t),r.d(t,"createFallbackableCache",(function(){return n})),r.d(t,"createNullCache",(function(){return s}))},function(e,t,r){"use strict";function n(e={serializable:!0}){let t={};return{get(r,n,s={miss:()=>Promise.resolve()}){const a=JSON.stringify(r);if(a in t)return Promise.resolve(e.serializable?JSON.parse(t[a]):t[a]);const o=n(),i=s&&s.miss||(()=>Promise.resolve());return o.then(e=>i(e)).then(()=>o)},set:(r,n)=>(t[JSON.stringify(r)]=e.serializable?JSON.stringify(n):n,Promise.resolve(n)),delete:e=>(delete t[JSON.stringify(e)],Promise.resolve()),clear:()=>(t={},Promise.resolve())}}r.r(t),r.d(t,"createInMemoryCache",(function(){return n}))},function(e,t,r){"use strict";r.r(t),r.d(t,"addABTest",(function(){return i})),r.d(t,"createAnalyticsClient",(function(){return o})),r.d(t,"deleteABTest",(function(){return c})),r.d(t,"getABTest",(function(){return u})),r.d(t,"getABTests",(function(){return d})),r.d(t,"stopABTest",(function(){return l}));var n=r(0),s=r(2),a=r(1);const o=e=>{const t=e.region||"us",r=Object(n.createAuth)(n.AuthMode.WithinHeaders,e.appId,e.apiKey),a=Object(s.createTransporter)({hosts:[{url:`analytics.${t}.algolia.com`}],...e,headers:{...r.headers(),"content-type":"application/json",...e.headers},queryParameters:{...r.queryParameters(),...e.queryParameters}}),o=e.appId;return Object(n.addMethods)({appId:o,transporter:a},e.methods)},i=e=>(t,r)=>e.transporter.write({method:a.a.Post,path:"2/abtests",data:t},r),c=e=>(t,r)=>e.transporter.write({method:a.a.Delete,path:Object(n.encode)("2/abtests/%s",t)},r),u=e=>(t,r)=>e.transporter.read({method:a.a.Get,path:Object(n.encode)("2/abtests/%s",t)},r),d=e=>t=>e.transporter.read({method:a.a.Get,path:"2/abtests"},t),l=e=>(t,r)=>e.transporter.write({method:a.a.Post,path:Object(n.encode)("2/abtests/%s/stop",t)},r)},function(e,t,r){"use strict";r.r(t),r.d(t,"createRecommendationClient",(function(){return o})),r.d(t,"getPersonalizationStrategy",(function(){return i})),r.d(t,"setPersonalizationStrategy",(function(){return c}));var n=r(0),s=r(2),a=r(1);const o=e=>{const t=e.region||"us",r=Object(n.createAuth)(n.AuthMode.WithinHeaders,e.appId,e.apiKey),a=Object(s.createTransporter)({hosts:[{url:`recommendation.${t}.algolia.com`}],...e,headers:{...r.headers(),"content-type":"application/json",...e.headers},queryParameters:{...r.queryParameters(),...e.queryParameters}});return Object(n.addMethods)({appId:e.appId,transporter:a},e.methods)},i=e=>t=>e.transporter.read({method:a.a.Get,path:"1/strategies/personalization"},t),c=e=>(t,r)=>e.transporter.write({method:a.a.Post,path:"1/strategies/personalization",data:t},r)},function(e,t,r){"use strict";r.r(t),r.d(t,"ApiKeyACLEnum",(function(){return we})),r.d(t,"BatchActionEnum",(function(){return Ae})),r.d(t,"ScopeEnum",(function(){return Re})),r.d(t,"StrategyEnum",(function(){return qe})),r.d(t,"SynonymEnum",(function(){return Te})),r.d(t,"addApiKey",(function(){return p})),r.d(t,"assignUserID",(function(){return m})),r.d(t,"assignUserIDs",(function(){return h})),r.d(t,"batch",(function(){return H})),r.d(t,"browseObjects",(function(){return K})),r.d(t,"browseRules",(function(){return B})),r.d(t,"browseSynonyms",(function(){return $})),r.d(t,"chunkedBatch",(function(){return z})),r.d(t,"clearObjects",(function(){return V})),r.d(t,"clearRules",(function(){return L})),r.d(t,"clearSynonyms",(function(){return Q})),r.d(t,"copyIndex",(function(){return f})),r.d(t,"copyRules",(function(){return b})),r.d(t,"copySettings",(function(){return y})),r.d(t,"copySynonyms",(function(){return g})),r.d(t,"createBrowsablePromise",(function(){return i})),r.d(t,"createMissingObjectIDError",(function(){return u})),r.d(t,"createObjectNotFoundError",(function(){return d})),r.d(t,"createSearchClient",(function(){return c})),r.d(t,"createValidUntilNotFoundError",(function(){return l})),r.d(t,"deleteApiKey",(function(){return O})),r.d(t,"deleteBy",(function(){return _})),r.d(t,"deleteIndex",(function(){return J})),r.d(t,"deleteObject",(function(){return X})),r.d(t,"deleteObjects",(function(){return Y})),r.d(t,"deleteRule",(function(){return Z})),r.d(t,"deleteSynonym",(function(){return ee})),r.d(t,"exists",(function(){return te})),r.d(t,"findObject",(function(){return re})),r.d(t,"generateSecuredApiKey",(function(){return j})),r.d(t,"getApiKey",(function(){return P})),r.d(t,"getLogs",(function(){return I})),r.d(t,"getObject",(function(){return ne})),r.d(t,"getObjectPosition",(function(){return se})),r.d(t,"getObjects",(function(){return ae})),r.d(t,"getRule",(function(){return oe})),r.d(t,"getSecuredApiKeyRemainingValidity",(function(){return v})),r.d(t,"getSettings",(function(){return ie})),r.d(t,"getSynonym",(function(){return ce})),r.d(t,"getTask",(function(){return ue})),r.d(t,"getTopUserIDs",(function(){return x})),r.d(t,"getUserID",(function(){return S})),r.d(t,"hasPendingMappings",(function(){return D})),r.d(t,"initIndex",(function(){return w})),r.d(t,"listApiKeys",(function(){return A})),r.d(t,"listClusters",(function(){return R})),r.d(t,"listIndices",(function(){return q})),r.d(t,"listUserIDs",(function(){return T})),r.d(t,"moveIndex",(function(){return k})),r.d(t,"multipleBatch",(function(){return U})),r.d(t,"multipleGetObjects",(function(){return C})),r.d(t,"multipleQueries",(function(){return N})),r.d(t,"multipleSearchForFacetValues",(function(){return E})),r.d(t,"partialUpdateObject",(function(){return de})),r.d(t,"partialUpdateObjects",(function(){return le})),r.d(t,"removeUserID",(function(){return M})),r.d(t,"replaceAllObjects",(function(){return pe})),r.d(t,"replaceAllRules",(function(){return me})),r.d(t,"replaceAllSynonyms",(function(){return he})),r.d(t,"restoreApiKey",(function(){return W})),r.d(t,"saveObject",(function(){return fe})),r.d(t,"saveObjects",(function(){return be})),r.d(t,"saveRule",(function(){return ye})),r.d(t,"saveRules",(function(){return ge})),r.d(t,"saveSynonym",(function(){return Oe})),r.d(t,"saveSynonyms",(function(){return je})),r.d(t,"search",(function(){return Pe})),r.d(t,"searchForFacetValues",(function(){return Ie})),r.d(t,"searchRules",(function(){return ve})),r.d(t,"searchSynonyms",(function(){return xe})),r.d(t,"searchUserIDs",(function(){return F})),r.d(t,"setSettings",(function(){return Se})),r.d(t,"updateApiKey",(function(){return G})),r.d(t,"waitTask",(function(){return De}));var n=r(0),s=r(2),a=r(1),o=r(9);function i(e){const t=r=>e.request(r).then(n=>{if(void 0!==e.batch&&e.batch(n.hits),!e.shouldStop(n))return n.cursor?t({cursor:n.cursor}):t({page:(r.page||0)+1})});return t({})}const c=e=>{const t=e.appId,r=Object(n.createAuth)(void 0!==e.authMode?e.authMode:n.AuthMode.WithinHeaders,t,e.apiKey),a=Object(s.createTransporter)({hosts:[{url:`${t}-dsn.algolia.net`,accept:s.CallEnum.Read},{url:`${t}.algolia.net`,accept:s.CallEnum.Write}].concat(Object(n.shuffle)([{url:`${t}-1.algolianet.com`},{url:`${t}-2.algolianet.com`},{url:`${t}-3.algolianet.com`}])),...e,headers:{...r.headers(),"content-type":"application/x-www-form-urlencoded",...e.headers},queryParameters:{...r.queryParameters(),...e.queryParameters}}),o={transporter:a,appId:t,addAlgoliaAgent(e,t){a.userAgent.add({segment:e,version:t})},clearCache:()=>Promise.all([a.requestsCache.clear(),a.responsesCache.clear()]).then(()=>void 0)};return Object(n.addMethods)(o,e.methods)};function u(){return{name:"MissingObjectIDError",message:"All objects must have an unique objectID (like a primary key) to be valid. Algolia is also able to generate objectIDs automatically but *it's not recommended*. To do it, use the `{'autoGenerateObjectIDIfNotExist': true}` option."}}function d(){return{name:"ObjectNotFoundError",message:"Object not found."}}function l(){return{name:"ValidUntilNotFoundError",message:"ValidUntil not found in given secured api key."}}const p=e=>(t,r)=>{const{queryParameters:s,...o}=r||{},i={acl:t,...void 0!==s?{queryParameters:s}:{}};return Object(n.createWaitablePromise)(e.transporter.write({method:a.a.Post,path:"1/keys",data:i},o),(t,r)=>Object(n.createRetryablePromise)(n=>P(e)(t.key,r).catch(e=>{if(404!==e.status)throw e;return n()})))},m=e=>(t,r,n)=>{const o=Object(s.createMappedRequestOptions)(n);return o.queryParameters["X-Algolia-User-ID"]=t,e.transporter.write({method:a.a.Post,path:"1/clusters/mapping",data:{cluster:r}},o)},h=e=>(t,r,n)=>e.transporter.write({method:a.a.Post,path:"1/clusters/mapping/batch",data:{users:t,cluster:r}},n),f=e=>(t,r,s)=>{return Object(n.createWaitablePromise)(e.transporter.write({method:a.a.Post,path:Object(n.encode)("1/indexes/%s/operation",t),data:{operation:"copy",destination:r}},s),(r,n)=>w(e)(t,{methods:{waitTask:De}}).waitTask(r.taskID,n))},b=e=>(t,r,n)=>f(e)(t,r,{...n,scope:[Re.Rules]}),y=e=>(t,r,n)=>f(e)(t,r,{...n,scope:[Re.Settings]}),g=e=>(t,r,n)=>f(e)(t,r,{...n,scope:[Re.Synonyms]}),O=e=>(t,r)=>{return Object(n.createWaitablePromise)(e.transporter.write({method:a.a.Delete,path:Object(n.encode)("1/keys/%s",t)},r),(r,s)=>Object(n.createRetryablePromise)(r=>P(e)(t,s).then(r).catch(e=>{if(404!==e.status)throw e})))},j=()=>(e,t)=>{const r=Object(s.serializeQueryParameters)(t),n=Object(o.createHmac)("sha256",e).update(r).digest("hex");return Buffer.from(n+r).toString("base64")},P=e=>(t,r)=>e.transporter.read({method:a.a.Get,path:Object(n.encode)("1/keys/%s",t)},r),I=e=>t=>e.transporter.read({method:a.a.Get,path:"1/logs"},t),v=()=>e=>{const t=Buffer.from(e,"base64").toString("ascii").match(/validUntil=(\d+)/);if(null===t)throw{name:"ValidUntilNotFoundError",message:"ValidUntil not found in given secured api key."};return parseInt(t[1],10)-Math.round((new Date).getTime()/1e3)},x=e=>t=>e.transporter.read({method:a.a.Get,path:"1/clusters/mapping/top"},t),S=e=>(t,r)=>e.transporter.read({method:a.a.Get,path:Object(n.encode)("1/clusters/mapping/%s",t)},r),D=e=>t=>{const{retrieveMappings:r,...n}=t||{};return!0===r&&(n.getClusters=!0),e.transporter.read({method:a.a.Get,path:"1/clusters/mapping/pending"},n)},w=e=>(t,r={})=>{const s={transporter:e.transporter,appId:e.appId,indexName:t};return Object(n.addMethods)(s,r.methods)},A=e=>t=>e.transporter.read({method:a.a.Get,path:"1/keys"},t),R=e=>t=>e.transporter.read({method:a.a.Get,path:"1/clusters"},t),q=e=>t=>e.transporter.read({method:a.a.Get,path:"1/indexes"},t),T=e=>t=>e.transporter.read({method:a.a.Get,path:"1/clusters/mapping"},t),k=e=>(t,r,s)=>{return Object(n.createWaitablePromise)(e.transporter.write({method:a.a.Post,path:Object(n.encode)("1/indexes/%s/operation",t),data:{operation:"move",destination:r}},s),(r,n)=>w(e)(t,{methods:{waitTask:De}}).waitTask(r.taskID,n))},U=e=>(t,r)=>{return Object(n.createWaitablePromise)(e.transporter.write({method:a.a.Post,path:"1/indexes/*/batch",data:{requests:t}},r),(t,r)=>Promise.all(Object.keys(t.taskID).map(n=>w(e)(n,{methods:{waitTask:De}}).waitTask(t.taskID[n],r))))},C=e=>(t,r)=>e.transporter.read({method:a.a.Post,path:"1/indexes/*/objects",data:{requests:t}},r),N=e=>(t,r)=>{const n=t.map(e=>({...e,params:Object(s.serializeQueryParameters)(e.params||{})}));return e.transporter.read({method:a.a.Post,path:"1/indexes/*/queries",data:{requests:n},cacheable:!0},r)},E=e=>(t,r)=>Promise.all(t.map(t=>{const{facetName:n,facetQuery:s,...a}=t.params;return w(e)(t.indexName,{methods:{searchForFacetValues:Ie}}).searchForFacetValues(n,s,{...r,...a})})),M=e=>(t,r)=>{const n=Object(s.createMappedRequestOptions)(r);return n.queryParameters["X-Algolia-User-ID"]=t,e.transporter.write({method:a.a.Delete,path:"1/clusters/mapping"},n)},W=e=>(t,r)=>{return Object(n.createWaitablePromise)(e.transporter.write({method:a.a.Post,path:Object(n.encode)("1/keys/%s/restore",t)},r),(r,s)=>Object(n.createRetryablePromise)(r=>P(e)(t,s).catch(e=>{if(404!==e.status)throw e;return r()})))},F=e=>(t,r)=>e.transporter.read({method:a.a.Post,path:"1/clusters/mapping/search",data:{query:t}},r),G=e=>(t,r)=>{const s=Object.assign({},r),{queryParameters:o,...i}=r||{},c=o?{queryParameters:o}:{},u=["acl","indexes","referers","restrictSources","queryParameters","description","maxQueriesPerIPPerHour","maxHitsPerQuery"];return Object(n.createWaitablePromise)(e.transporter.write({method:a.a.Put,path:Object(n.encode)("1/keys/%s",t),data:c},i),(r,a)=>Object(n.createRetryablePromise)(r=>P(e)(t,a).then(e=>(e=>Object.keys(s).filter(e=>-1!==u.indexOf(e)).every(t=>e[t]===s[t]))(e)?Promise.resolve():r())))},H=e=>(t,r)=>{return Object(n.createWaitablePromise)(e.transporter.write({method:a.a.Post,path:Object(n.encode)("1/indexes/%s/batch",e.indexName),data:{requests:t}},r),(t,r)=>De(e)(t.taskID,r))},K=e=>t=>i({...t,shouldStop:e=>void 0===e.cursor,request:r=>e.transporter.read({method:a.a.Post,path:Object(n.encode)("1/indexes/%s/browse",e.indexName),data:r},t)}),B=e=>t=>{const r={hitsPerPage:1e3,...t};return i({...r,shouldStop:e=>e.hits.length<r.hitsPerPage,request:t=>ve(e)("",{...r,...t}).then(e=>({...e,hits:e.hits.map(e=>(delete e._highlightResult,e))}))})},$=e=>t=>{const r={hitsPerPage:1e3,...t};return i({...r,shouldStop:e=>e.hits.length<r.hitsPerPage,request:t=>xe(e)("",{...r,...t}).then(e=>({...e,hits:e.hits.map(e=>(delete e._highlightResult,e))}))})},z=e=>(t,r,s)=>{const{batchSize:a,...o}=s||{},i={taskIDs:[],objectIDs:[]},c=(n=0)=>{const s=[];let u;for(u=n;u<t.length&&(s.push(t[u]),s.length!==(a||1e3));u++);return 0===s.length?Promise.resolve(i):H(e)(s.map(e=>({action:r,body:e})),o).then(e=>(i.objectIDs=i.objectIDs.concat(e.objectIDs),i.taskIDs.push(e.taskID),c(++u)))};return Object(n.createWaitablePromise)(c(),(t,r)=>Promise.all(t.taskIDs.map(t=>De(e)(t,r))))},V=e=>t=>Object(n.createWaitablePromise)(e.transporter.write({method:a.a.Post,path:Object(n.encode)("1/indexes/%s/clear",e.indexName)},t),(t,r)=>De(e)(t.taskID,r)),L=e=>t=>{const{forwardToReplicas:r,...o}=t||{},i=Object(s.createMappedRequestOptions)(o);return r&&(i.queryParameters.forwardToReplicas=1),Object(n.createWaitablePromise)(e.transporter.write({method:a.a.Post,path:Object(n.encode)("1/indexes/%s/rules/clear",e.indexName)},i),(t,r)=>De(e)(t.taskID,r))},Q=e=>t=>{const{forwardToReplicas:r,...o}=t||{},i=Object(s.createMappedRequestOptions)(o);return r&&(i.queryParameters.forwardToReplicas=1),Object(n.createWaitablePromise)(e.transporter.write({method:a.a.Post,path:Object(n.encode)("1/indexes/%s/synonyms/clear",e.indexName)},i),(t,r)=>De(e)(t.taskID,r))},_=e=>(t,r)=>Object(n.createWaitablePromise)(e.transporter.write({method:a.a.Post,path:Object(n.encode)("1/indexes/%s/deleteByQuery",e.indexName),data:t},r),(t,r)=>De(e)(t.taskID,r)),J=e=>t=>Object(n.createWaitablePromise)(e.transporter.write({method:a.a.Delete,path:Object(n.encode)("1/indexes/%s",e.indexName)},t),(t,r)=>De(e)(t.taskID,r)),X=e=>(t,r)=>Object(n.createWaitablePromise)(Y(e)([t],r).then(e=>({taskID:e.taskIDs[0]})),(t,r)=>De(e)(t.taskID,r)),Y=e=>(t,r)=>{const n=t.map(e=>({objectID:e}));return z(e)(n,Ae.DeleteObject,r)},Z=e=>(t,r)=>{const{forwardToReplicas:o,...i}=r||{},c=Object(s.createMappedRequestOptions)(i);return o&&(c.queryParameters.forwardToReplicas=1),Object(n.createWaitablePromise)(e.transporter.write({method:a.a.Delete,path:Object(n.encode)("1/indexes/%s/rules/%s",e.indexName,t)},c),(t,r)=>De(e)(t.taskID,r))},ee=e=>(t,r)=>{const{forwardToReplicas:o,...i}=r||{},c=Object(s.createMappedRequestOptions)(i);return o&&(c.queryParameters.forwardToReplicas=1),Object(n.createWaitablePromise)(e.transporter.write({method:a.a.Delete,path:Object(n.encode)("1/indexes/%s/synonyms/%s",e.indexName,t)},c),(t,r)=>De(e)(t.taskID,r))},te=e=>t=>ie(e)(t).then(()=>!0).catch(e=>{if(404!==e.status)throw e;return!1}),re=e=>(t,r)=>{const{query:n,paginate:s,...a}=r||{};let o=0;const i=()=>Pe(e)(n||"",{...a,page:o}).then(e=>{for(const[r,n]of Object.entries(e.hits))if(t(n))return{object:n,position:parseInt(r,10),page:o};if(o++,!1===s||o>=e.nbPages)throw{name:"ObjectNotFoundError",message:"Object not found."};return i()});return i()},ne=e=>(t,r)=>e.transporter.read({method:a.a.Get,path:Object(n.encode)("1/indexes/%s/%s",e.indexName,t)},r),se=()=>(e,t)=>{for(const[r,n]of Object.entries(e.hits))if(n.objectID===t)return parseInt(r,10);return-1},ae=e=>(t,r)=>{const{attributesToRetrieve:n,...s}=r||{},o=t.map(t=>({indexName:e.indexName,objectID:t,...n?{attributesToRetrieve:n}:{}}));return e.transporter.read({method:a.a.Post,path:"1/indexes/*/objects",data:{requests:o}},s)},oe=e=>(t,r)=>e.transporter.read({method:a.a.Get,path:Object(n.encode)("1/indexes/%s/rules/%s",e.indexName,t)},r),ie=e=>t=>e.transporter.read({method:a.a.Get,path:Object(n.encode)("1/indexes/%s/settings",e.indexName),data:{getVersion:2}},t),ce=e=>(t,r)=>e.transporter.read({method:a.a.Get,path:Object(n.encode)("1/indexes/%s/synonyms/%s",e.indexName,t)},r),ue=e=>(t,r)=>e.transporter.read({method:a.a.Get,path:Object(n.encode)("1/indexes/%s/task/%s",e.indexName,t.toString())},r),de=e=>(t,r)=>Object(n.createWaitablePromise)(le(e)([t],r).then(e=>({objectID:e.objectIDs[0],taskID:e.taskIDs[0]})),(t,r)=>De(e)(t.taskID,r)),le=e=>(t,r)=>{const{createIfNotExists:n,...s}=r||{},a=n?Ae.PartialUpdateObject:Ae.PartialUpdateObjectNoCreate;return z(e)(t,a,s)},pe=e=>(t,r)=>{const{safe:s,autoGenerateObjectIDIfNotExist:o,batchSize:i,...c}=r||{},u=(t,r,s,o)=>Object(n.createWaitablePromise)(e.transporter.write({method:a.a.Post,path:Object(n.encode)("1/indexes/%s/operation",t),data:{operation:s,destination:r}},o),(t,r)=>De(e)(t.taskID,r)),d=Math.random().toString(36).substring(7),l=`${e.indexName}_tmp_${d}`,p=be({appId:e.appId,transporter:e.transporter,indexName:l});let m=[];const h=u(e.indexName,l,"copy",{...c,scope:["settings","synonyms","rules"]});m.push(h);const f=(s?h.wait(c):h).then(()=>{const e=p(t,{...c,autoGenerateObjectIDIfNotExist:o,batchSize:i});return m.push(e),s?e.wait(c):e}).then(()=>{const t=u(l,e.indexName,"move",c);return m.push(t),s?t.wait(c):t}).then(()=>Promise.all(m)).then(([e,t,r])=>({objectIDs:t.objectIDs,taskIDs:[e.taskID,...t.taskIDs,r.taskID]}));return Object(n.createWaitablePromise)(f,(e,t)=>Promise.all(m.map(e=>e.wait(t))))},me=e=>(t,r)=>ge(e)(t,{...r,clearExistingRules:!0}),he=e=>(t,r)=>je(e)(t,{...r,replaceExistingSynonyms:!0}),fe=e=>(t,r)=>Object(n.createWaitablePromise)(be(e)([t],r).then(e=>({objectID:e.objectIDs[0],taskID:e.taskIDs[0]})),(t,r)=>De(e)(t.taskID,r)),be=e=>(t,r)=>{const{autoGenerateObjectIDIfNotExist:s,...a}=r||{},o=s?Ae.AddObject:Ae.UpdateObject;if(o===Ae.UpdateObject)for(const e of t)if(void 0===e.objectID)return Object(n.createWaitablePromise)(Promise.reject({name:"MissingObjectIDError",message:"All objects must have an unique objectID (like a primary key) to be valid. Algolia is also able to generate objectIDs automatically but *it's not recommended*. To do it, use the `{'autoGenerateObjectIDIfNotExist': true}` option."}));return z(e)(t,o,a)},ye=e=>(t,r)=>ge(e)([t],r),ge=e=>(t,r)=>{const{forwardToReplicas:o,clearExistingRules:i,...c}=r||{},u=Object(s.createMappedRequestOptions)(c);return o&&(u.queryParameters.forwardToReplicas=1),i&&(u.queryParameters.clearExistingRules=1),Object(n.createWaitablePromise)(e.transporter.write({method:a.a.Post,path:Object(n.encode)("1/indexes/%s/rules/batch",e.indexName),data:t},u),(t,r)=>De(e)(t.taskID,r))},Oe=e=>(t,r)=>je(e)([t],r),je=e=>(t,r)=>{const{forwardToReplicas:o,replaceExistingSynonyms:i,...c}=r||{},u=Object(s.createMappedRequestOptions)(c);return o&&(u.queryParameters.forwardToReplicas=1),i&&(u.queryParameters.replaceExistingSynonyms=1),Object(n.createWaitablePromise)(e.transporter.write({method:a.a.Post,path:Object(n.encode)("1/indexes/%s/synonyms/batch",e.indexName),data:t},u),(t,r)=>De(e)(t.taskID,r))},Pe=e=>(t,r)=>e.transporter.read({method:a.a.Post,path:Object(n.encode)("1/indexes/%s/query",e.indexName),data:{query:t},cacheable:!0},r),Ie=e=>(t,r,s)=>e.transporter.read({method:a.a.Post,path:Object(n.encode)("1/indexes/%s/facets/%s/query",e.indexName,t),data:{facetQuery:r},cacheable:!0},s),ve=e=>(t,r)=>e.transporter.read({method:a.a.Post,path:Object(n.encode)("1/indexes/%s/rules/search",e.indexName),data:{query:t}},r),xe=e=>(t,r)=>e.transporter.read({method:a.a.Post,path:Object(n.encode)("1/indexes/%s/synonyms/search",e.indexName),data:{query:t}},r),Se=e=>(t,r)=>{const{forwardToReplicas:o,...i}=r||{},c=Object(s.createMappedRequestOptions)(i);return o&&(c.queryParameters.forwardToReplicas=1),Object(n.createWaitablePromise)(e.transporter.write({method:a.a.Put,path:Object(n.encode)("1/indexes/%s/settings",e.indexName),data:t},c),(t,r)=>De(e)(t.taskID,r))},De=e=>(t,r)=>Object(n.createRetryablePromise)(n=>ue(e)(t,r).then(e=>"published"!==e.status?n():void 0)),we={AddObject:"addObject",Analytics:"analytics",Browser:"browse",DeleteIndex:"deleteIndex",DeleteObject:"deleteObject",EditSettings:"editSettings",ListIndexes:"listIndexes",Logs:"logs",Recommendation:"recommendation",Search:"search",SeeUnretrievableAttributes:"seeUnretrievableAttributes",Settings:"settings",Usage:"usage"},Ae={AddObject:"addObject",UpdateObject:"updateObject",PartialUpdateObject:"partialUpdateObject",PartialUpdateObjectNoCreate:"partialUpdateObjectNoCreate",DeleteObject:"deleteObject"},Re={Settings:"settings",Synonyms:"synonyms",Rules:"rules"},qe={None:"none",StopIfEnoughMatches:"stopIfEnoughMatches"},Te={Synonym:"synonym",OneWaySynonym:"oneWaySynonym",AltCorrection1:"altCorrection1",AltCorrection2:"altCorrection2",Placeholder:"placeholder"}},function(e,t,r){"use strict";function n(){return{debug:(e,t)=>Promise.resolve(),info:(e,t)=>Promise.resolve(),error:(e,t)=>Promise.resolve()}}r.r(t),r.d(t,"LogLevelEnum",(function(){return s})),r.d(t,"createNullLogger",(function(){return n}));const s={Debug:1,Info:2,Error:3}},function(e,t,r){"use strict";r.r(t),r.d(t,"createNodeHttpRequester",(function(){return o}));var n=r(4),s=r(5),a=r(10);function o(){const e={keepAlive:!0},t=new n.Agent(e),r=new s.Agent(e);return{send:e=>new Promise(o=>{const i=Object(a.parse)(e.url),c=null===i.query?i.pathname:`${i.pathname}?${i.query}`,u={agent:"https:"===i.protocol?r:t,hostname:i.hostname,path:c,method:e.method,headers:e.headers,...void 0!==i.port?{port:i.port||""}:{}},d=("https:"===i.protocol?s:n).request(u,e=>{let t="";e.on("data",e=>t+=e),e.on("end",()=>{clearTimeout(p),clearTimeout(m),o({status:e.statusCode||0,content:t,isTimedOut:!1})})}),l=(e,t)=>setTimeout(()=>{d.abort(),o({status:0,content:t,isTimedOut:!0})},1e3*e),p=l(e.connectTimeout,"Connection timeout");let m;d.on("error",e=>{clearTimeout(p),clearTimeout(m),o({status:0,content:e.message,isTimedOut:!1})}),d.once("response",()=>{clearTimeout(p),m=l(e.responseTimeout,"Socket timeout")}),void 0!==e.data&&d.write(e.data),d.end()}),destroy:()=>(t.destroy(),r.destroy(),Promise.resolve())}}},function(e,t,r){"use strict";r.r(t);var n=r(3),s=r(6),a=r(7),o=r.n(a),i=r(8),c=r.n(i);const u=e=>{const t={async send(t){try{const r=await n.httpUtils.request(t.url,t.data||null,{configuration:e,headers:t.headers});return{content:r.body,isTimedOut:!1,status:r.statusCode}}catch(e){return{content:e.response.body,isTimedOut:!1,status:e.response.statusCode}}}};return c()("OFCNCOG2CU","e8e1bd300d860104bb8c58453ffa1eb4",{requester:t})},d=e=>e.scope?`${e.scope}__${e.name}`:`${e.name}`,l={hooks:{afterWorkspaceDependencyAddition:async(e,t,r,a)=>{if("types"===r.scope)return;const{project:i}=e,{configuration:c}=i,l=c.makeResolver(),p={project:i,resolver:l,report:new n.ThrowReport};if(!await(async(e,t)=>{var r;const s=n.structUtils.stringifyIdent(e),a=u(t).initIndex("npm-search");try{return"definitely-typed"===(null===(r=(await a.getObject(s,{attributesToRetrieve:["types"]})).types)||void 0===r?void 0:r.ts)}catch(e){return!1}})(r,c))return;const m=d(r);let h=n.structUtils.parseRange(r.range).selector;if(!o.a.validRange(h)){const e=await l.getCandidates(r,new Map,p);h=n.structUtils.parseRange(e[0].reference).selector}const f=o.a.coerce(h);if(null===f)return;const b=`${s.suggestUtils.Modifier.CARET}${f.major}`,y=n.structUtils.makeDescriptor(n.structUtils.makeIdent("types",m),b),g=n.miscUtils.mapAndFind(i.workspaces,e=>{var t,s;const a=null===(t=e.manifest.dependencies.get(r.identHash))||void 0===t?void 0:t.descriptorHash,o=null===(s=e.manifest.devDependencies.get(r.identHash))||void 0===s?void 0:s.descriptorHash;if(a!==r.descriptorHash&&o!==r.descriptorHash)return n.miscUtils.mapAndFind.skip;const i=[];for(const t of n.Manifest.allDependencies){const r=e.manifest[t].get(y.identHash);void 0!==r&&i.push([t,r])}return 0===i.length?n.miscUtils.mapAndFind.skip:i});if(void 0!==g)for(const[t,r]of g)e.manifest[t].set(r.identHash,r);else{try{if(0===(await l.getCandidates(y,new Map,p)).length)return}catch(e){return}e.manifest[s.suggestUtils.Target.DEVELOPMENT].set(y.identHash,y)}},afterWorkspaceDependencyRemoval:async(e,t,r)=>{if("types"===r.scope)return;const s=d(r),a=n.structUtils.makeIdent("types",s);for(const t of n.Manifest.allDependencies){void 0!==e.manifest[t].get(a.identHash)&&e.manifest[t].delete(a.identHash)}},beforeWorkspacePacking:(e,t)=>{t.publishConfig&&t.publishConfig.typings&&(t.typings=t.publishConfig.typings),t.publishConfig&&t.publishConfig.types&&(t.types=t.publishConfig.types)}}};t.default=l}])}}; \ No newline at end of file
diff --git a/Timeline/ClientApp/.yarnrc.yml b/Timeline/ClientApp/.yarnrc.yml
index 0e4fbd7d..1fe52728 100644
--- a/Timeline/ClientApp/.yarnrc.yml
+++ b/Timeline/ClientApp/.yarnrc.yml
@@ -1,5 +1,7 @@
plugins:
- path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.js
spec: "@yarnpkg/plugin-interactive-tools"
+ - path: .yarn/plugins/@yarnpkg/plugin-typescript.js
+ spec: "@yarnpkg/plugin-typescript"
yarnPath: .yarn/releases/yarn-berry.js
diff --git a/Timeline/ClientApp/package.json b/Timeline/ClientApp/package.json
index 5718505d..ac8f189b 100644
--- a/Timeline/ClientApp/package.json
+++ b/Timeline/ClientApp/package.json
@@ -24,6 +24,9 @@
"reactstrap": "^8.4.1",
"regenerator-runtime": "^0.13.5",
"rxjs": "^6.5.5",
+ "workbox-precaching": "^5.1.3",
+ "workbox-routing": "^5.1.3",
+ "workbox-strategies": "^5.1.3",
"xregexp": "^4.3.0"
},
"scripts": {
@@ -94,6 +97,7 @@
"url-loader": "^4.1.0",
"webpack": "^4.43.0",
"webpack-cli": "^3.3.11",
- "webpack-dev-server": "^3.11.0"
+ "webpack-dev-server": "^3.11.0",
+ "workbox-webpack-plugin": "^5.1.3"
}
}
diff --git a/Timeline/ClientApp/src/App.tsx b/Timeline/ClientApp/src/app/App.tsx
index befa2a9c..befa2a9c 100644
--- a/Timeline/ClientApp/src/App.tsx
+++ b/Timeline/ClientApp/src/app/App.tsx
diff --git a/Timeline/ClientApp/src/about/About.tsx b/Timeline/ClientApp/src/app/about/About.tsx
index 031c46cb..bce4c06e 100644
--- a/Timeline/ClientApp/src/about/About.tsx
+++ b/Timeline/ClientApp/src/app/about/About.tsx
@@ -1,180 +1,180 @@
-import React from 'react';
-import { useTranslation, Trans } from 'react-i18next';
-
-import authorAvatarUrl from './author-avatar.png';
-
-import AppBar from '../common/AppBar';
-
-const frontendCredits: {
- name: string;
- url: string;
- icon?: string;
-}[] = [
- {
- name: 'reactjs',
- url: 'https://reactjs.org',
- icon: 'react',
- },
- {
- name: 'typescript',
- url: 'https://www.typescriptlang.org',
- },
- {
- name: 'bootstrap',
- url: 'https://getbootstrap.com',
- icon: 'bootstrap',
- },
- {
- name: 'reactstrap',
- url: 'https://reactstrap.github.io',
- },
- {
- name: 'babeljs',
- url: 'https://babeljs.io',
- },
- {
- name: 'webpack',
- url: 'https://webpack.js.org',
- },
- {
- name: 'sass',
- url: 'https://sass-lang.com',
- icon: 'sass',
- },
- {
- name: 'fontawesome',
- url: 'https://fontawesome.com',
- icon: 'font-awesome-flag',
- },
- {
- name: 'eslint',
- url: 'https://eslint.org',
- },
- {
- name: 'prettier',
- url: 'https://prettier.io',
- },
- {
- name: 'pepjs',
- url: 'https://github.com/jquery/PEP',
- },
-];
-
-const backendCredits: {
- name: string;
- url: string;
- icon?: string;
-}[] = [
- {
- name: 'ASP.NET Core',
- url: 'https://dotnet.microsoft.com/learn/aspnet/what-is-aspnet-core',
- },
- { name: 'sqlite', url: 'https://sqlite.org' },
- {
- name: 'ImageSharp',
- url: 'https://github.com/SixLabors/ImageSharp',
- },
-];
-
-const About: React.FC = () => {
- const { t } = useTranslation();
-
- return (
- <>
- <AppBar />
- <div className="mt-appbar px-2 mb-4">
- <div className="container mt-4 py-3 shadow border border-primary rounded bg-light">
- <h4 id="author-info">{t('about.author.title')}</h4>
- <div>
- <div className="d-flex">
- <img
- src={authorAvatarUrl}
- className="align-self-start avatar large rounded-circle"
- />
- <div>
- <p>
- <small>{t('about.author.fullname')}</small>
- <span className="text-primary">杨宇千</span>
- </p>
- <p>
- <small>{t('about.author.nickname')}</small>
- <span className="text-primary">crupest</span>
- </p>
- <p>
- <small>{t('about.author.introduction')}</small>
- {t('about.author.introductionContent')}
- </p>
- </div>
- </div>
- <p>
- <small>{t('about.author.links')}</small>
- <a
- href="https://github.com/crupest"
- target="_blank"
- rel="noopener noreferrer"
- >
- <i className="fab fa-github about-link-icon text-body" />
- </a>
- </p>
- </div>
- </div>
- <div className="container mt-4 py-3 shadow border border-primary rounded bg-light">
- <h4>{t('about.site.title')}</h4>
- <p>
- <Trans i18nKey="about.site.content">
- 0<span className="text-primary">1</span>2<b>3</b>4
- <a href="#author-info">5</a>6
- </Trans>
- </p>
- <p>
- <a
- href="https://github.com/crupest/Timeline"
- target="_blank"
- rel="noopener noreferrer"
- >
- {t('about.site.repo')}
- </a>
- </p>
- </div>
- <div className="container mt-4 py-3 shadow border border-primary rounded bg-light">
- <h4>{t('about.credits.title')}</h4>
- <p>{t('about.credits.content')}</p>
- <p>{t('about.credits.frontend')}</p>
- <ul>
- {frontendCredits.map((item, index) => {
- return (
- <li key={index}>
- <a href={item.url} target="_blank" rel="noopener noreferrer">
- {item.icon != null && (
- <i className={'fab fa-' + item.icon + ' mx-2'} />
- )}
- {item.name}
- </a>
- </li>
- );
- })}
- <li>...</li>
- </ul>
- <p>{t('about.credits.backend')}</p>
- <ul>
- {backendCredits.map((item, index) => {
- return (
- <li key={index}>
- <a href={item.url} target="_blank" rel="noopener noreferrer">
- {item.icon != null && (
- <i className={'fab fa-' + item.icon + ' mx-2'} />
- )}
- {item.name}
- </a>
- </li>
- );
- })}
- <li>...</li>
- </ul>
- </div>
- </div>
- </>
- );
-};
-
-export default About;
+import React from 'react';
+import { useTranslation, Trans } from 'react-i18next';
+
+import authorAvatarUrl from './author-avatar.png';
+
+import AppBar from '../common/AppBar';
+
+const frontendCredits: {
+ name: string;
+ url: string;
+ icon?: string;
+}[] = [
+ {
+ name: 'reactjs',
+ url: 'https://reactjs.org',
+ icon: 'react',
+ },
+ {
+ name: 'typescript',
+ url: 'https://www.typescriptlang.org',
+ },
+ {
+ name: 'bootstrap',
+ url: 'https://getbootstrap.com',
+ icon: 'bootstrap',
+ },
+ {
+ name: 'reactstrap',
+ url: 'https://reactstrap.github.io',
+ },
+ {
+ name: 'babeljs',
+ url: 'https://babeljs.io',
+ },
+ {
+ name: 'webpack',
+ url: 'https://webpack.js.org',
+ },
+ {
+ name: 'sass',
+ url: 'https://sass-lang.com',
+ icon: 'sass',
+ },
+ {
+ name: 'fontawesome',
+ url: 'https://fontawesome.com',
+ icon: 'font-awesome-flag',
+ },
+ {
+ name: 'eslint',
+ url: 'https://eslint.org',
+ },
+ {
+ name: 'prettier',
+ url: 'https://prettier.io',
+ },
+ {
+ name: 'pepjs',
+ url: 'https://github.com/jquery/PEP',
+ },
+];
+
+const backendCredits: {
+ name: string;
+ url: string;
+ icon?: string;
+}[] = [
+ {
+ name: 'ASP.NET Core',
+ url: 'https://dotnet.microsoft.com/learn/aspnet/what-is-aspnet-core',
+ },
+ { name: 'sqlite', url: 'https://sqlite.org' },
+ {
+ name: 'ImageSharp',
+ url: 'https://github.com/SixLabors/ImageSharp',
+ },
+];
+
+const About: React.FC = () => {
+ const { t } = useTranslation();
+
+ return (
+ <>
+ <AppBar />
+ <div className="mt-appbar px-2 mb-4">
+ <div className="container mt-4 py-3 shadow border border-primary rounded bg-light">
+ <h4 id="author-info">{t('about.author.title')}</h4>
+ <div>
+ <div className="d-flex">
+ <img
+ src={authorAvatarUrl}
+ className="align-self-start avatar large rounded-circle"
+ />
+ <div>
+ <p>
+ <small>{t('about.author.fullname')}</small>
+ <span className="text-primary">杨宇千</span>
+ </p>
+ <p>
+ <small>{t('about.author.nickname')}</small>
+ <span className="text-primary">crupest</span>
+ </p>
+ <p>
+ <small>{t('about.author.introduction')}</small>
+ {t('about.author.introductionContent')}
+ </p>
+ </div>
+ </div>
+ <p>
+ <small>{t('about.author.links')}</small>
+ <a
+ href="https://github.com/crupest"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ <i className="fab fa-github about-link-icon text-body" />
+ </a>
+ </p>
+ </div>
+ </div>
+ <div className="container mt-4 py-3 shadow border border-primary rounded bg-light">
+ <h4>{t('about.site.title')}</h4>
+ <p>
+ <Trans i18nKey="about.site.content">
+ 0<span className="text-primary">1</span>2<b>3</b>4
+ <a href="#author-info">5</a>6
+ </Trans>
+ </p>
+ <p>
+ <a
+ href="https://github.com/crupest/Timeline"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ {t('about.site.repo')}
+ </a>
+ </p>
+ </div>
+ <div className="container mt-4 py-3 shadow border border-primary rounded bg-light">
+ <h4>{t('about.credits.title')}</h4>
+ <p>{t('about.credits.content')}</p>
+ <p>{t('about.credits.frontend')}</p>
+ <ul>
+ {frontendCredits.map((item, index) => {
+ return (
+ <li key={index}>
+ <a href={item.url} target="_blank" rel="noopener noreferrer">
+ {item.icon != null && (
+ <i className={'fab fa-' + item.icon + ' mx-2'} />
+ )}
+ {item.name}
+ </a>
+ </li>
+ );
+ })}
+ <li>...</li>
+ </ul>
+ <p>{t('about.credits.backend')}</p>
+ <ul>
+ {backendCredits.map((item, index) => {
+ return (
+ <li key={index}>
+ <a href={item.url} target="_blank" rel="noopener noreferrer">
+ {item.icon != null && (
+ <i className={'fab fa-' + item.icon + ' mx-2'} />
+ )}
+ {item.name}
+ </a>
+ </li>
+ );
+ })}
+ <li>...</li>
+ </ul>
+ </div>
+ </div>
+ </>
+ );
+};
+
+export default About;
diff --git a/Timeline/ClientApp/src/about/about.sass b/Timeline/ClientApp/src/app/about/about.sass
index 24af9dee..0b0f89f5 100644
--- a/Timeline/ClientApp/src/about/about.sass
+++ b/Timeline/ClientApp/src/app/about/about.sass
@@ -1,3 +1,3 @@
-.about-link-icon
- @extend .mx-2
- font-size: 1.2em
+.about-link-icon
+ @extend .mx-2
+ font-size: 1.2em
diff --git a/Timeline/ClientApp/src/about/author-avatar.png b/Timeline/ClientApp/src/app/about/author-avatar.png
index d890d8d0..d890d8d0 100644
--- a/Timeline/ClientApp/src/about/author-avatar.png
+++ b/Timeline/ClientApp/src/app/about/author-avatar.png
Binary files differ
diff --git a/Timeline/ClientApp/src/admin/Admin.tsx b/Timeline/ClientApp/src/app/admin/Admin.tsx
index af07dd2b..9e7d592f 100644
--- a/Timeline/ClientApp/src/admin/Admin.tsx
+++ b/Timeline/ClientApp/src/app/admin/Admin.tsx
@@ -1,78 +1,78 @@
-import React, { Fragment } from 'react';
-import { Nav, NavItem, NavLink } from 'reactstrap';
-import {
- Redirect,
- Route,
- Switch,
- useRouteMatch,
- useHistory
-} from 'react-router';
-import classnames from 'classnames';
-
-import AppBar from '../common/AppBar';
-import UserAdmin from './UserAdmin';
-
-import { UserWithToken } from '../data/user';
-
-interface AdminProps {
- user: UserWithToken;
-}
-
-const Admin: React.FC<AdminProps> = props => {
- const match = useRouteMatch();
- const history = useHistory();
- type TabNames = 'users' | 'more';
-
- const tabName = history.location.pathname.replace(match.path + '/', '');
-
- function toggle(newTab: TabNames): void {
- history.push(`${match.url}/${newTab}`);
- }
-
- const createRoute = (
- name: string,
- body: React.ReactNode
- ): React.ReactNode => {
- return (
- <Route path={`${match.path}/${name}`}>
- <AppBar />
- <div style={{ height: 56 }} className="flex-fix-length" />
- <Nav tabs>
- <NavItem>
- <NavLink
- className={classnames({ active: tabName === 'users' })}
- onClick={() => {
- toggle('users');
- }}
- >
- Users
- </NavLink>
- </NavItem>
- <NavItem>
- <NavLink
- className={classnames({ active: tabName === 'more' })}
- onClick={() => {
- toggle('more');
- }}
- >
- More
- </NavLink>
- </NavItem>
- </Nav>
- {body}
- </Route>
- );
- };
-
- return (
- <Fragment>
- <Switch>
- <Redirect from={match.path} to={`${match.path}/users`} exact />
- {createRoute('users', <UserAdmin user={props.user} />)}
- {createRoute('more', <div>More Page Works</div>)}
- </Switch>
- </Fragment>
- );
-};
-
-export default Admin;
+import React, { Fragment } from 'react';
+import { Nav, NavItem, NavLink } from 'reactstrap';
+import {
+ Redirect,
+ Route,
+ Switch,
+ useRouteMatch,
+ useHistory
+} from 'react-router';
+import classnames from 'classnames';
+
+import AppBar from '../common/AppBar';
+import UserAdmin from './UserAdmin';
+
+import { UserWithToken } from '../data/user';
+
+interface AdminProps {
+ user: UserWithToken;
+}
+
+const Admin: React.FC<AdminProps> = props => {
+ const match = useRouteMatch();
+ const history = useHistory();
+ type TabNames = 'users' | 'more';
+
+ const tabName = history.location.pathname.replace(match.path + '/', '');
+
+ function toggle(newTab: TabNames): void {
+ history.push(`${match.url}/${newTab}`);
+ }
+
+ const createRoute = (
+ name: string,
+ body: React.ReactNode
+ ): React.ReactNode => {
+ return (
+ <Route path={`${match.path}/${name}`}>
+ <AppBar />
+ <div style={{ height: 56 }} className="flex-fix-length" />
+ <Nav tabs>
+ <NavItem>
+ <NavLink
+ className={classnames({ active: tabName === 'users' })}
+ onClick={() => {
+ toggle('users');
+ }}
+ >
+ Users
+ </NavLink>
+ </NavItem>
+ <NavItem>
+ <NavLink
+ className={classnames({ active: tabName === 'more' })}
+ onClick={() => {
+ toggle('more');
+ }}
+ >
+ More
+ </NavLink>
+ </NavItem>
+ </Nav>
+ {body}
+ </Route>
+ );
+ };
+
+ return (
+ <Fragment>
+ <Switch>
+ <Redirect from={match.path} to={`${match.path}/users`} exact />
+ {createRoute('users', <UserAdmin user={props.user} />)}
+ {createRoute('more', <div>More Page Works</div>)}
+ </Switch>
+ </Fragment>
+ );
+};
+
+export default Admin;
diff --git a/Timeline/ClientApp/src/admin/UserAdmin.tsx b/Timeline/ClientApp/src/app/admin/UserAdmin.tsx
index fc752011..ba14fc84 100644
--- a/Timeline/ClientApp/src/admin/UserAdmin.tsx
+++ b/Timeline/ClientApp/src/app/admin/UserAdmin.tsx
@@ -1,463 +1,463 @@
-import React, { useState, useEffect } from 'react';
-import {
- ListGroupItem,
- Row,
- Col,
- UncontrolledDropdown,
- DropdownToggle,
- DropdownMenu,
- DropdownItem,
- Spinner,
- Button,
-} from 'reactstrap';
-import axios from 'axios';
-
-import OperationDialog from '../common/OperationDialog';
-
-import { User, UserWithToken } from '../data/user';
-import { apiBaseUrl } from '../config';
-
-async function fetchUserList(_token: string): Promise<User[]> {
- const res = await axios.get<User[]>(`${apiBaseUrl}/users`);
- return res.data;
-}
-
-interface CreateUserInfo {
- username: string;
- password: string;
- administrator: boolean;
-}
-
-async function createUser(user: CreateUserInfo, token: string): Promise<User> {
- const res = await axios.post<User>(
- `${apiBaseUrl}/userop/createuser?token=${token}`,
- user
- );
- return res.data;
-}
-
-function deleteUser(username: string, token: string): Promise<void> {
- return axios.delete(`${apiBaseUrl}/users/${username}?token=${token}`);
-}
-
-function changeUsername(
- oldUsername: string,
- newUsername: string,
- token: string
-): Promise<void> {
- return axios.patch(`${apiBaseUrl}/users/${oldUsername}?token=${token}`, {
- username: newUsername,
- });
-}
-
-function changePassword(
- username: string,
- newPassword: string,
- token: string
-): Promise<void> {
- return axios.patch(`${apiBaseUrl}/users/${username}?token=${token}`, {
- password: newPassword,
- });
-}
-
-function changePermission(
- username: string,
- newPermission: boolean,
- token: string
-): Promise<void> {
- return axios.patch(`${apiBaseUrl}/users/${username}?token=${token}`, {
- administrator: newPermission,
- });
-}
-
-const kChangeUsername = 'changeusername';
-const kChangePassword = 'changepassword';
-const kChangePermission = 'changepermission';
-const kDelete = 'delete';
-
-type TChangeUsername = typeof kChangeUsername;
-type TChangePassword = typeof kChangePassword;
-type TChangePermission = typeof kChangePermission;
-type TDelete = typeof kDelete;
-
-type ContextMenuItem =
- | TChangeUsername
- | TChangePassword
- | TChangePermission
- | TDelete;
-
-interface UserCardProps {
- onContextMenu: (item: ContextMenuItem) => void;
- user: User;
-}
-
-const UserItem: React.FC<UserCardProps> = (props) => {
- const user = props.user;
-
- const createClickCallback = (item: ContextMenuItem): (() => void) => {
- return () => {
- props.onContextMenu(item);
- };
- };
-
- return (
- <ListGroupItem className="container">
- <Row className="align-items-center">
- <Col>
- <p className="mb-0 text-primary">{user.username}</p>
- <small
- className={user.administrator ? 'text-danger' : 'text-secondary'}
- >
- {user.administrator ? 'administrator' : 'user'}
- </small>
- </Col>
- <Col className="col-auto">
- <UncontrolledDropdown>
- <DropdownToggle color="warning" className="text-light" caret>
- Manage
- </DropdownToggle>
- <DropdownMenu>
- <DropdownItem onClick={createClickCallback(kChangeUsername)}>
- Change Username
- </DropdownItem>
- <DropdownItem onClick={createClickCallback(kChangePassword)}>
- Change Password
- </DropdownItem>
- <DropdownItem onClick={createClickCallback(kChangePermission)}>
- Change Permission
- </DropdownItem>
- <DropdownItem
- className="text-danger"
- onClick={createClickCallback(kDelete)}
- >
- Delete
- </DropdownItem>
- </DropdownMenu>
- </UncontrolledDropdown>
- </Col>
- </Row>
- </ListGroupItem>
- );
-};
-
-interface DialogProps {
- open: boolean;
- close: () => void;
-}
-
-interface CreateUserDialogProps extends DialogProps {
- process: (user: CreateUserInfo) => Promise<void>;
-}
-
-const CreateUserDialog: React.FC<CreateUserDialogProps> = (props) => {
- return (
- <OperationDialog
- title="Create"
- titleColor="create"
- inputPrompt="You are creating a new user."
- inputScheme={[
- { type: 'text', label: 'Username' },
- { type: 'text', label: 'Password' },
- { type: 'bool', label: 'Administrator' },
- ]}
- onProcess={([username, password, administrator]) =>
- props.process({
- username: username as string,
- password: password as string,
- administrator: administrator as boolean,
- })
- }
- close={props.close}
- open={props.open}
- />
- );
-};
-
-const UsernameLabel: React.FC = (props) => {
- return <span style={{ color: 'blue' }}>{props.children}</span>;
-};
-
-interface UserDeleteDialogProps extends DialogProps {
- username: string;
- process: () => Promise<void>;
-}
-
-const UserDeleteDialog: React.FC<UserDeleteDialogProps> = (props) => {
- return (
- <OperationDialog
- open={props.open}
- close={props.close}
- title="Dangerous"
- titleColor="dangerous"
- inputPrompt={() => (
- <>
- {'You are deleting user '}
- <UsernameLabel>{props.username}</UsernameLabel>
- {' !'}
- </>
- )}
- onProcess={props.process}
- />
- );
-};
-
-interface UserModifyDialogProps<T> extends DialogProps {
- username: string;
- process: (value: T) => Promise<void>;
-}
-
-const UserChangeUsernameDialog: React.FC<UserModifyDialogProps<string>> = (
- props
-) => {
- return (
- <OperationDialog
- open={props.open}
- close={props.close}
- title="Caution"
- titleColor="dangerous"
- inputPrompt={() => (
- <>
- {'You are change the username of user '}
- <UsernameLabel>{props.username}</UsernameLabel>
- {' !'}
- </>
- )}
- inputScheme={[{ type: 'text', label: 'New Username' }]}
- onProcess={([newUsername]) => {
- return props.process(newUsername as string);
- }}
- />
- );
-};
-
-const UserChangePasswordDialog: React.FC<UserModifyDialogProps<string>> = (
- props
-) => {
- return (
- <OperationDialog
- open={props.open}
- close={props.close}
- title="Caution"
- titleColor="dangerous"
- inputPrompt={() => (
- <>
- {'You are change the password of user '}
- <UsernameLabel>{props.username}</UsernameLabel>
- {' !'}
- </>
- )}
- inputScheme={[{ type: 'text', label: 'New Password' }]}
- onProcess={([newPassword]) => {
- return props.process(newPassword as string);
- }}
- />
- );
-};
-
-interface UserChangePermissionDialogProps extends DialogProps {
- username: string;
- newPermission: boolean;
- process: () => Promise<void>;
-}
-
-const UserChangePermissionDialog: React.FC<UserChangePermissionDialogProps> = (
- props
-) => {
- return (
- <OperationDialog
- open={props.open}
- close={props.close}
- title="Caution"
- titleColor="dangerous"
- inputPrompt={() => (
- <>
- {'You are change user '}
- <UsernameLabel>{props.username}</UsernameLabel>
- {' to '}
- <span style={{ color: 'orange' }}>
- {props.newPermission ? 'administrator' : 'normal user'}
- </span>
- {' !'}
- </>
- )}
- onProcess={props.process}
- />
- );
-};
-
-interface UserAdminProps {
- user: UserWithToken;
-}
-
-const UserAdmin: React.FC<UserAdminProps> = (props) => {
- type DialogInfo =
- | null
- | {
- type: 'create';
- }
- | { type: TDelete; username: string }
- | {
- type: TChangeUsername;
- username: string;
- }
- | {
- type: TChangePassword;
- username: string;
- }
- | {
- type: TChangePermission;
- username: string;
- newPermission: boolean;
- };
-
- const [users, setUsers] = useState<User[] | null>(null);
- const [dialog, setDialog] = useState<DialogInfo>(null);
-
- const token = props.user.token;
-
- useEffect(() => {
- let subscribe = true;
- void fetchUserList(props.user.token).then((us) => {
- if (subscribe) {
- setUsers(us);
- }
- });
- return () => {
- subscribe = false;
- };
- }, [props.user]);
-
- let dialogNode: React.ReactNode;
- if (dialog)
- switch (dialog.type) {
- case 'create':
- dialogNode = (
- <CreateUserDialog
- open
- close={() => setDialog(null)}
- process={async (user) => {
- const u = await createUser(user, token);
- setUsers((oldUsers) => [...(oldUsers ?? []), u]);
- }}
- />
- );
- break;
- case 'delete':
- dialogNode = (
- <UserDeleteDialog
- open
- close={() => setDialog(null)}
- username={dialog.username}
- process={async () => {
- await deleteUser(dialog.username, token);
- setUsers((oldUsers) =>
- (oldUsers ?? []).filter((u) => u.username !== dialog.username)
- );
- }}
- />
- );
- break;
- case kChangeUsername:
- dialogNode = (
- <UserChangeUsernameDialog
- open
- close={() => setDialog(null)}
- username={dialog.username}
- process={async (newUsername) => {
- await changeUsername(dialog.username, newUsername, token);
- setUsers((oldUsers) => {
- const users = (oldUsers ?? []).slice();
- const findedUser = users.find(
- (u) => u.username === dialog.username
- );
- if (findedUser) findedUser.username = newUsername;
- return users;
- });
- }}
- />
- );
- break;
- case kChangePassword:
- dialogNode = (
- <UserChangePasswordDialog
- open
- close={() => setDialog(null)}
- username={dialog.username}
- process={async (newPassword) => {
- await changePassword(dialog.username, newPassword, token);
- }}
- />
- );
- break;
- case kChangePermission: {
- const newPermission = dialog.newPermission;
- dialogNode = (
- <UserChangePermissionDialog
- open
- close={() => setDialog(null)}
- username={dialog.username}
- newPermission={newPermission}
- process={async () => {
- await changePermission(dialog.username, newPermission, token);
- setUsers((oldUsers) => {
- const users = (oldUsers ?? []).slice();
- const findedUser = users.find(
- (u) => u.username === dialog.username
- );
- if (findedUser) findedUser.administrator = newPermission;
- return users;
- });
- }}
- />
- );
- break;
- }
- }
-
- if (users) {
- const userComponents = users.map((user) => {
- return (
- <UserItem
- key={user.username}
- user={user}
- onContextMenu={(item) => {
- setDialog(
- item === kChangePermission
- ? {
- type: kChangePermission,
- username: user.username,
- newPermission: !user.administrator,
- }
- : {
- type: item,
- username: user.username,
- }
- );
- }}
- />
- );
- });
-
- return (
- <>
- <Button
- color="success"
- onClick={() =>
- setDialog({
- type: 'create',
- })
- }
- className="align-self-end"
- >
- Create User
- </Button>
- {userComponents}
- {dialogNode}
- </>
- );
- } else {
- return <Spinner />;
- }
-};
-
-export default UserAdmin;
+import React, { useState, useEffect } from 'react';
+import {
+ ListGroupItem,
+ Row,
+ Col,
+ UncontrolledDropdown,
+ DropdownToggle,
+ DropdownMenu,
+ DropdownItem,
+ Spinner,
+ Button,
+} from 'reactstrap';
+import axios from 'axios';
+
+import OperationDialog from '../common/OperationDialog';
+
+import { User, UserWithToken } from '../data/user';
+import { apiBaseUrl } from '../config';
+
+async function fetchUserList(_token: string): Promise<User[]> {
+ const res = await axios.get<User[]>(`${apiBaseUrl}/users`);
+ return res.data;
+}
+
+interface CreateUserInfo {
+ username: string;
+ password: string;
+ administrator: boolean;
+}
+
+async function createUser(user: CreateUserInfo, token: string): Promise<User> {
+ const res = await axios.post<User>(
+ `${apiBaseUrl}/userop/createuser?token=${token}`,
+ user
+ );
+ return res.data;
+}
+
+function deleteUser(username: string, token: string): Promise<void> {
+ return axios.delete(`${apiBaseUrl}/users/${username}?token=${token}`);
+}
+
+function changeUsername(
+ oldUsername: string,
+ newUsername: string,
+ token: string
+): Promise<void> {
+ return axios.patch(`${apiBaseUrl}/users/${oldUsername}?token=${token}`, {
+ username: newUsername,
+ });
+}
+
+function changePassword(
+ username: string,
+ newPassword: string,
+ token: string
+): Promise<void> {
+ return axios.patch(`${apiBaseUrl}/users/${username}?token=${token}`, {
+ password: newPassword,
+ });
+}
+
+function changePermission(
+ username: string,
+ newPermission: boolean,
+ token: string
+): Promise<void> {
+ return axios.patch(`${apiBaseUrl}/users/${username}?token=${token}`, {
+ administrator: newPermission,
+ });
+}
+
+const kChangeUsername = 'changeusername';
+const kChangePassword = 'changepassword';
+const kChangePermission = 'changepermission';
+const kDelete = 'delete';
+
+type TChangeUsername = typeof kChangeUsername;
+type TChangePassword = typeof kChangePassword;
+type TChangePermission = typeof kChangePermission;
+type TDelete = typeof kDelete;
+
+type ContextMenuItem =
+ | TChangeUsername
+ | TChangePassword
+ | TChangePermission
+ | TDelete;
+
+interface UserCardProps {
+ onContextMenu: (item: ContextMenuItem) => void;
+ user: User;
+}
+
+const UserItem: React.FC<UserCardProps> = (props) => {
+ const user = props.user;
+
+ const createClickCallback = (item: ContextMenuItem): (() => void) => {
+ return () => {
+ props.onContextMenu(item);
+ };
+ };
+
+ return (
+ <ListGroupItem className="container">
+ <Row className="align-items-center">
+ <Col>
+ <p className="mb-0 text-primary">{user.username}</p>
+ <small
+ className={user.administrator ? 'text-danger' : 'text-secondary'}
+ >
+ {user.administrator ? 'administrator' : 'user'}
+ </small>
+ </Col>
+ <Col className="col-auto">
+ <UncontrolledDropdown>
+ <DropdownToggle color="warning" className="text-light" caret>
+ Manage
+ </DropdownToggle>
+ <DropdownMenu>
+ <DropdownItem onClick={createClickCallback(kChangeUsername)}>
+ Change Username
+ </DropdownItem>
+ <DropdownItem onClick={createClickCallback(kChangePassword)}>
+ Change Password
+ </DropdownItem>
+ <DropdownItem onClick={createClickCallback(kChangePermission)}>
+ Change Permission
+ </DropdownItem>
+ <DropdownItem
+ className="text-danger"
+ onClick={createClickCallback(kDelete)}
+ >
+ Delete
+ </DropdownItem>
+ </DropdownMenu>
+ </UncontrolledDropdown>
+ </Col>
+ </Row>
+ </ListGroupItem>
+ );
+};
+
+interface DialogProps {
+ open: boolean;
+ close: () => void;
+}
+
+interface CreateUserDialogProps extends DialogProps {
+ process: (user: CreateUserInfo) => Promise<void>;
+}
+
+const CreateUserDialog: React.FC<CreateUserDialogProps> = (props) => {
+ return (
+ <OperationDialog
+ title="Create"
+ titleColor="create"
+ inputPrompt="You are creating a new user."
+ inputScheme={[
+ { type: 'text', label: 'Username' },
+ { type: 'text', label: 'Password' },
+ { type: 'bool', label: 'Administrator' },
+ ]}
+ onProcess={([username, password, administrator]) =>
+ props.process({
+ username: username as string,
+ password: password as string,
+ administrator: administrator as boolean,
+ })
+ }
+ close={props.close}
+ open={props.open}
+ />
+ );
+};
+
+const UsernameLabel: React.FC = (props) => {
+ return <span style={{ color: 'blue' }}>{props.children}</span>;
+};
+
+interface UserDeleteDialogProps extends DialogProps {
+ username: string;
+ process: () => Promise<void>;
+}
+
+const UserDeleteDialog: React.FC<UserDeleteDialogProps> = (props) => {
+ return (
+ <OperationDialog
+ open={props.open}
+ close={props.close}
+ title="Dangerous"
+ titleColor="dangerous"
+ inputPrompt={() => (
+ <>
+ {'You are deleting user '}
+ <UsernameLabel>{props.username}</UsernameLabel>
+ {' !'}
+ </>
+ )}
+ onProcess={props.process}
+ />
+ );
+};
+
+interface UserModifyDialogProps<T> extends DialogProps {
+ username: string;
+ process: (value: T) => Promise<void>;
+}
+
+const UserChangeUsernameDialog: React.FC<UserModifyDialogProps<string>> = (
+ props
+) => {
+ return (
+ <OperationDialog
+ open={props.open}
+ close={props.close}
+ title="Caution"
+ titleColor="dangerous"
+ inputPrompt={() => (
+ <>
+ {'You are change the username of user '}
+ <UsernameLabel>{props.username}</UsernameLabel>
+ {' !'}
+ </>
+ )}
+ inputScheme={[{ type: 'text', label: 'New Username' }]}
+ onProcess={([newUsername]) => {
+ return props.process(newUsername as string);
+ }}
+ />
+ );
+};
+
+const UserChangePasswordDialog: React.FC<UserModifyDialogProps<string>> = (
+ props
+) => {
+ return (
+ <OperationDialog
+ open={props.open}
+ close={props.close}
+ title="Caution"
+ titleColor="dangerous"
+ inputPrompt={() => (
+ <>
+ {'You are change the password of user '}
+ <UsernameLabel>{props.username}</UsernameLabel>
+ {' !'}
+ </>
+ )}
+ inputScheme={[{ type: 'text', label: 'New Password' }]}
+ onProcess={([newPassword]) => {
+ return props.process(newPassword as string);
+ }}
+ />
+ );
+};
+
+interface UserChangePermissionDialogProps extends DialogProps {
+ username: string;
+ newPermission: boolean;
+ process: () => Promise<void>;
+}
+
+const UserChangePermissionDialog: React.FC<UserChangePermissionDialogProps> = (
+ props
+) => {
+ return (
+ <OperationDialog
+ open={props.open}
+ close={props.close}
+ title="Caution"
+ titleColor="dangerous"
+ inputPrompt={() => (
+ <>
+ {'You are change user '}
+ <UsernameLabel>{props.username}</UsernameLabel>
+ {' to '}
+ <span style={{ color: 'orange' }}>
+ {props.newPermission ? 'administrator' : 'normal user'}
+ </span>
+ {' !'}
+ </>
+ )}
+ onProcess={props.process}
+ />
+ );
+};
+
+interface UserAdminProps {
+ user: UserWithToken;
+}
+
+const UserAdmin: React.FC<UserAdminProps> = (props) => {
+ type DialogInfo =
+ | null
+ | {
+ type: 'create';
+ }
+ | { type: TDelete; username: string }
+ | {
+ type: TChangeUsername;
+ username: string;
+ }
+ | {
+ type: TChangePassword;
+ username: string;
+ }
+ | {
+ type: TChangePermission;
+ username: string;
+ newPermission: boolean;
+ };
+
+ const [users, setUsers] = useState<User[] | null>(null);
+ const [dialog, setDialog] = useState<DialogInfo>(null);
+
+ const token = props.user.token;
+
+ useEffect(() => {
+ let subscribe = true;
+ void fetchUserList(props.user.token).then((us) => {
+ if (subscribe) {
+ setUsers(us);
+ }
+ });
+ return () => {
+ subscribe = false;
+ };
+ }, [props.user]);
+
+ let dialogNode: React.ReactNode;
+ if (dialog)
+ switch (dialog.type) {
+ case 'create':
+ dialogNode = (
+ <CreateUserDialog
+ open
+ close={() => setDialog(null)}
+ process={async (user) => {
+ const u = await createUser(user, token);
+ setUsers((oldUsers) => [...(oldUsers ?? []), u]);
+ }}
+ />
+ );
+ break;
+ case 'delete':
+ dialogNode = (
+ <UserDeleteDialog
+ open
+ close={() => setDialog(null)}
+ username={dialog.username}
+ process={async () => {
+ await deleteUser(dialog.username, token);
+ setUsers((oldUsers) =>
+ (oldUsers ?? []).filter((u) => u.username !== dialog.username)
+ );
+ }}
+ />
+ );
+ break;
+ case kChangeUsername:
+ dialogNode = (
+ <UserChangeUsernameDialog
+ open
+ close={() => setDialog(null)}
+ username={dialog.username}
+ process={async (newUsername) => {
+ await changeUsername(dialog.username, newUsername, token);
+ setUsers((oldUsers) => {
+ const users = (oldUsers ?? []).slice();
+ const findedUser = users.find(
+ (u) => u.username === dialog.username
+ );
+ if (findedUser) findedUser.username = newUsername;
+ return users;
+ });
+ }}
+ />
+ );
+ break;
+ case kChangePassword:
+ dialogNode = (
+ <UserChangePasswordDialog
+ open
+ close={() => setDialog(null)}
+ username={dialog.username}
+ process={async (newPassword) => {
+ await changePassword(dialog.username, newPassword, token);
+ }}
+ />
+ );
+ break;
+ case kChangePermission: {
+ const newPermission = dialog.newPermission;
+ dialogNode = (
+ <UserChangePermissionDialog
+ open
+ close={() => setDialog(null)}
+ username={dialog.username}
+ newPermission={newPermission}
+ process={async () => {
+ await changePermission(dialog.username, newPermission, token);
+ setUsers((oldUsers) => {
+ const users = (oldUsers ?? []).slice();
+ const findedUser = users.find(
+ (u) => u.username === dialog.username
+ );
+ if (findedUser) findedUser.administrator = newPermission;
+ return users;
+ });
+ }}
+ />
+ );
+ break;
+ }
+ }
+
+ if (users) {
+ const userComponents = users.map((user) => {
+ return (
+ <UserItem
+ key={user.username}
+ user={user}
+ onContextMenu={(item) => {
+ setDialog(
+ item === kChangePermission
+ ? {
+ type: kChangePermission,
+ username: user.username,
+ newPermission: !user.administrator,
+ }
+ : {
+ type: item,
+ username: user.username,
+ }
+ );
+ }}
+ />
+ );
+ });
+
+ return (
+ <>
+ <Button
+ color="success"
+ onClick={() =>
+ setDialog({
+ type: 'create',
+ })
+ }
+ className="align-self-end"
+ >
+ Create User
+ </Button>
+ {userComponents}
+ {dialogNode}
+ </>
+ );
+ } else {
+ return <Spinner />;
+ }
+};
+
+export default UserAdmin;
diff --git a/Timeline/ClientApp/src/common.ts b/Timeline/ClientApp/src/app/common.ts
index 673f1c98..0b11791a 100644
--- a/Timeline/ClientApp/src/common.ts
+++ b/Timeline/ClientApp/src/app/common.ts
@@ -1,4 +1,4 @@
-// This error is thrown when ui goes wrong with bad logic.
-// Such as am variable should not be null, but it does.
-// This error should never occur. If it does, it indicates there is some logic bug in codes.
-export class UiLogicError extends Error {}
+// This error is thrown when ui goes wrong with bad logic.
+// Such as am variable should not be null, but it does.
+// This error should never occur. If it does, it indicates there is some logic bug in codes.
+export class UiLogicError extends Error {}
diff --git a/Timeline/ClientApp/src/common/AlertHost.tsx b/Timeline/ClientApp/src/app/common/AlertHost.tsx
index 2d9dbb13..c815db2b 100644
--- a/Timeline/ClientApp/src/common/AlertHost.tsx
+++ b/Timeline/ClientApp/src/app/common/AlertHost.tsx
@@ -1,75 +1,75 @@
-import React, { useCallback } from 'react';
-import { Alert } from 'reactstrap';
-import without from 'lodash/without';
-import concat from 'lodash/concat';
-
-import {
- alertService,
- AlertInfoEx,
- kAlertHostId,
- AlertInfo,
-} from './alert-service';
-
-interface AutoCloseAlertProps {
- alert: AlertInfo;
- close: () => void;
-}
-
-export const AutoCloseAlert: React.FC<AutoCloseAlertProps> = (props) => {
- const { alert } = props;
-
- React.useEffect(() => {
- const tag = window.setTimeout(props.close, 5000);
- return () => window.clearTimeout(tag);
- }, [props.close]);
-
- return (
- <Alert className="m-3" color={alert.type ?? 'primary'} toggle={props.close}>
- {alert.message}
- </Alert>
- );
-};
-
-// oh what a bad name!
-interface AlertInfoExEx extends AlertInfoEx {
- close: () => void;
-}
-
-export const AlertHost: React.FC = () => {
- const [alerts, setAlerts] = React.useState<AlertInfoExEx[]>([]);
-
- // react guarantee that state setters are stable, so we don't need to add it to dependency list
-
- const consume = useCallback((alert: AlertInfoEx): void => {
- const alertEx: AlertInfoExEx = {
- ...alert,
- close: () => {
- setAlerts((oldAlerts) => {
- return without(oldAlerts, alertEx);
- });
- },
- };
- setAlerts((oldAlerts) => {
- return concat(oldAlerts, alertEx);
- });
- }, []);
-
- React.useEffect(() => {
- alertService.registerConsumer(consume);
- return () => {
- alertService.unregisterConsumer(consume);
- };
- }, [consume]);
-
- return (
- <div id={kAlertHostId} className="alert-container">
- {alerts.map((alert) => {
- return (
- <AutoCloseAlert key={alert.id} alert={alert} close={alert.close} />
- );
- })}
- </div>
- );
-};
-
-export default AlertHost;
+import React, { useCallback } from 'react';
+import { Alert } from 'reactstrap';
+import without from 'lodash/without';
+import concat from 'lodash/concat';
+
+import {
+ alertService,
+ AlertInfoEx,
+ kAlertHostId,
+ AlertInfo,
+} from './alert-service';
+
+interface AutoCloseAlertProps {
+ alert: AlertInfo;
+ close: () => void;
+}
+
+export const AutoCloseAlert: React.FC<AutoCloseAlertProps> = (props) => {
+ const { alert } = props;
+
+ React.useEffect(() => {
+ const tag = window.setTimeout(props.close, 5000);
+ return () => window.clearTimeout(tag);
+ }, [props.close]);
+
+ return (
+ <Alert className="m-3" color={alert.type ?? 'primary'} toggle={props.close}>
+ {alert.message}
+ </Alert>
+ );
+};
+
+// oh what a bad name!
+interface AlertInfoExEx extends AlertInfoEx {
+ close: () => void;
+}
+
+export const AlertHost: React.FC = () => {
+ const [alerts, setAlerts] = React.useState<AlertInfoExEx[]>([]);
+
+ // react guarantee that state setters are stable, so we don't need to add it to dependency list
+
+ const consume = useCallback((alert: AlertInfoEx): void => {
+ const alertEx: AlertInfoExEx = {
+ ...alert,
+ close: () => {
+ setAlerts((oldAlerts) => {
+ return without(oldAlerts, alertEx);
+ });
+ },
+ };
+ setAlerts((oldAlerts) => {
+ return concat(oldAlerts, alertEx);
+ });
+ }, []);
+
+ React.useEffect(() => {
+ alertService.registerConsumer(consume);
+ return () => {
+ alertService.unregisterConsumer(consume);
+ };
+ }, [consume]);
+
+ return (
+ <div id={kAlertHostId} className="alert-container">
+ {alerts.map((alert) => {
+ return (
+ <AutoCloseAlert key={alert.id} alert={alert} close={alert.close} />
+ );
+ })}
+ </div>
+ );
+};
+
+export default AlertHost;
diff --git a/Timeline/ClientApp/src/common/AppBar.tsx b/Timeline/ClientApp/src/app/common/AppBar.tsx
index 4f61798c..f75fe08f 100644
--- a/Timeline/ClientApp/src/common/AppBar.tsx
+++ b/Timeline/ClientApp/src/app/common/AppBar.tsx
@@ -1,107 +1,107 @@
-import React from 'react';
-import { useHistory, matchPath } from 'react-router';
-import { Link, NavLink } from 'react-router-dom';
-import { Navbar, NavbarToggler, Collapse, Nav, NavItem } from 'reactstrap';
-import { useMediaQuery } from 'react-responsive';
-import { useTranslation } from 'react-i18next';
-
-import { useUser } from '../data/user';
-import { useOptionalVersionedAvatarUrl } from '../user/api';
-
-import TimelineLogo from './TimelineLogo';
-
-const AppBar: React.FC = (_) => {
- const history = useHistory();
- const user = useUser();
- const avatarUrl = useOptionalVersionedAvatarUrl(user?._links?.avatar);
-
- const { t } = useTranslation();
-
- const isUpMd = useMediaQuery({
- minWidth: getComputedStyle(document.documentElement).getPropertyValue(
- '--breakpoint-md'
- ),
- });
-
- const [isMenuOpen, setIsMenuOpen] = React.useState(false);
-
- const toggleMenu = React.useCallback((): void => {
- setIsMenuOpen((oldIsMenuOpen) => !oldIsMenuOpen);
- }, []);
-
- const isAdministrator = user && user.administrator;
-
- const rightArea = (
- <div className="ml-auto mr-2">
- {user != null ? (
- <NavLink to={`/users/${user.username}`}>
- <img
- className="avatar small rounded-circle bg-white"
- src={avatarUrl}
- />
- </NavLink>
- ) : (
- <NavLink className="text-light" to="/login">
- {t('nav.login')}
- </NavLink>
- )}
- </div>
- );
-
- return (
- <Navbar dark className="fixed-top w-100 bg-primary app-bar" expand="md">
- <Link to="/" className="navbar-brand d-flex align-items-center">
- <TimelineLogo style={{ height: '1em' }} />
- Timeline
- </Link>
-
- {isUpMd ? null : rightArea}
-
- <NavbarToggler onClick={toggleMenu} />
- <Collapse isOpen={isMenuOpen} navbar>
- <Nav className="mr-auto" navbar>
- <NavItem
- className={
- matchPath(history.location.pathname, '/settings')
- ? 'active'
- : undefined
- }
- >
- <NavLink className="nav-link" to="/settings">
- {t('nav.settings')}
- </NavLink>
- </NavItem>
-
- <NavItem
- className={
- matchPath(history.location.pathname, '/about')
- ? 'active'
- : undefined
- }
- >
- <NavLink className="nav-link" to="/about">
- {t('nav.about')}
- </NavLink>
- </NavItem>
-
- {isAdministrator && (
- <NavItem
- className={
- matchPath(history.location.pathname, '/admin')
- ? 'active'
- : undefined
- }
- >
- <NavLink className="nav-link" to="/admin">
- Administration
- </NavLink>
- </NavItem>
- )}
- </Nav>
- {isUpMd ? rightArea : null}
- </Collapse>
- </Navbar>
- );
-};
-
-export default AppBar;
+import React from 'react';
+import { useHistory, matchPath } from 'react-router';
+import { Link, NavLink } from 'react-router-dom';
+import { Navbar, NavbarToggler, Collapse, Nav, NavItem } from 'reactstrap';
+import { useMediaQuery } from 'react-responsive';
+import { useTranslation } from 'react-i18next';
+
+import { useUser } from '../data/user';
+import { useOptionalVersionedAvatarUrl } from '../user/api';
+
+import TimelineLogo from './TimelineLogo';
+
+const AppBar: React.FC = (_) => {
+ const history = useHistory();
+ const user = useUser();
+ const avatarUrl = useOptionalVersionedAvatarUrl(user?._links?.avatar);
+
+ const { t } = useTranslation();
+
+ const isUpMd = useMediaQuery({
+ minWidth: getComputedStyle(document.documentElement).getPropertyValue(
+ '--breakpoint-md'
+ ),
+ });
+
+ const [isMenuOpen, setIsMenuOpen] = React.useState(false);
+
+ const toggleMenu = React.useCallback((): void => {
+ setIsMenuOpen((oldIsMenuOpen) => !oldIsMenuOpen);
+ }, []);
+
+ const isAdministrator = user && user.administrator;
+
+ const rightArea = (
+ <div className="ml-auto mr-2">
+ {user != null ? (
+ <NavLink to={`/users/${user.username}`}>
+ <img
+ className="avatar small rounded-circle bg-white"
+ src={avatarUrl}
+ />
+ </NavLink>
+ ) : (
+ <NavLink className="text-light" to="/login">
+ {t('nav.login')}
+ </NavLink>
+ )}
+ </div>
+ );
+
+ return (
+ <Navbar dark className="fixed-top w-100 bg-primary app-bar" expand="md">
+ <Link to="/" className="navbar-brand d-flex align-items-center">
+ <TimelineLogo style={{ height: '1em' }} />
+ Timeline
+ </Link>
+
+ {isUpMd ? null : rightArea}
+
+ <NavbarToggler onClick={toggleMenu} />
+ <Collapse isOpen={isMenuOpen} navbar>
+ <Nav className="mr-auto" navbar>
+ <NavItem
+ className={
+ matchPath(history.location.pathname, '/settings')
+ ? 'active'
+ : undefined
+ }
+ >
+ <NavLink className="nav-link" to="/settings">
+ {t('nav.settings')}
+ </NavLink>
+ </NavItem>
+
+ <NavItem
+ className={
+ matchPath(history.location.pathname, '/about')
+ ? 'active'
+ : undefined
+ }
+ >
+ <NavLink className="nav-link" to="/about">
+ {t('nav.about')}
+ </NavLink>
+ </NavItem>
+
+ {isAdministrator && (
+ <NavItem
+ className={
+ matchPath(history.location.pathname, '/admin')
+ ? 'active'
+ : undefined
+ }
+ >
+ <NavLink className="nav-link" to="/admin">
+ Administration
+ </NavLink>
+ </NavItem>
+ )}
+ </Nav>
+ {isUpMd ? rightArea : null}
+ </Collapse>
+ </Navbar>
+ );
+};
+
+export default AppBar;
diff --git a/Timeline/ClientApp/src/common/CollapseButton.tsx b/Timeline/ClientApp/src/app/common/CollapseButton.tsx
index 28385003..5307c4ac 100644
--- a/Timeline/ClientApp/src/common/CollapseButton.tsx
+++ b/Timeline/ClientApp/src/app/common/CollapseButton.tsx
@@ -1,101 +1,101 @@
-import React from 'react';
-
-export interface CollapseButtonProps {
- collapse: boolean;
- onClick: () => void;
- className?: string;
-}
-
-const CollapseButton: React.FC<CollapseButtonProps> = (props) => {
- const { onClick, collapse, className } = props;
-
- return (
- <svg
- width="25"
- height="25"
- viewBox="0 0 100 100"
- className={className}
- onClick={onClick}
- >
- {(() => {
- if (collapse) {
- return (
- <>
- <line
- stroke="currentcolor"
- strokeWidth="14"
- x1="50"
- x2="90"
- y1="17"
- y2="17"
- />
- <line
- stroke="currentcolor"
- strokeWidth="14"
- x1="10"
- x2="50"
- y1="83"
- y2="83"
- />
- <line
- stroke="currentcolor"
- strokeWidth="14"
- x1="17"
- x2="17"
- y1="50"
- y2="90"
- />
- <line
- stroke="currentcolor"
- strokeWidth="14"
- x1="83"
- x2="83"
- y1="10"
- y2="50"
- />
- </>
- );
- } else {
- return (
- <>
- <line
- stroke="currentcolor"
- strokeWidth="14"
- x1="55"
- x2="95"
- y1="38"
- y2="38"
- />
- <line
- stroke="currentcolor"
- strokeWidth="14"
- x1="5"
- x2="45"
- y1="62"
- y2="62"
- />
- <line
- stroke="currentcolor"
- strokeWidth="14"
- x1="38"
- x2="38"
- y1="55"
- y2="95"
- />
- <line
- stroke="currentcolor"
- strokeWidth="14"
- x1="62"
- x2="62"
- y1="5"
- y2="45"
- />
- </>
- );
- }
- })()}
- </svg>
- );
-};
-
-export default CollapseButton;
+import React from 'react';
+
+export interface CollapseButtonProps {
+ collapse: boolean;
+ onClick: () => void;
+ className?: string;
+}
+
+const CollapseButton: React.FC<CollapseButtonProps> = (props) => {
+ const { onClick, collapse, className } = props;
+
+ return (
+ <svg
+ width="25"
+ height="25"
+ viewBox="0 0 100 100"
+ className={className}
+ onClick={onClick}
+ >
+ {(() => {
+ if (collapse) {
+ return (
+ <>
+ <line
+ stroke="currentcolor"
+ strokeWidth="14"
+ x1="50"
+ x2="90"
+ y1="17"
+ y2="17"
+ />
+ <line
+ stroke="currentcolor"
+ strokeWidth="14"
+ x1="10"
+ x2="50"
+ y1="83"
+ y2="83"
+ />
+ <line
+ stroke="currentcolor"
+ strokeWidth="14"
+ x1="17"
+ x2="17"
+ y1="50"
+ y2="90"
+ />
+ <line
+ stroke="currentcolor"
+ strokeWidth="14"
+ x1="83"
+ x2="83"
+ y1="10"
+ y2="50"
+ />
+ </>
+ );
+ } else {
+ return (
+ <>
+ <line
+ stroke="currentcolor"
+ strokeWidth="14"
+ x1="55"
+ x2="95"
+ y1="38"
+ y2="38"
+ />
+ <line
+ stroke="currentcolor"
+ strokeWidth="14"
+ x1="5"
+ x2="45"
+ y1="62"
+ y2="62"
+ />
+ <line
+ stroke="currentcolor"
+ strokeWidth="14"
+ x1="38"
+ x2="38"
+ y1="55"
+ y2="95"
+ />
+ <line
+ stroke="currentcolor"
+ strokeWidth="14"
+ x1="62"
+ x2="62"
+ y1="5"
+ y2="45"
+ />
+ </>
+ );
+ }
+ })()}
+ </svg>
+ );
+};
+
+export default CollapseButton;
diff --git a/Timeline/ClientApp/src/common/FileInput.tsx b/Timeline/ClientApp/src/app/common/FileInput.tsx
index d434f0fa..20da7b71 100644
--- a/Timeline/ClientApp/src/common/FileInput.tsx
+++ b/Timeline/ClientApp/src/app/common/FileInput.tsx
@@ -1,41 +1,41 @@
-import React from 'react';
-import clsx from 'clsx';
-
-import { ExcludeKey } from '../type-utilities';
-
-export interface FileInputProps
- extends ExcludeKey<
- React.InputHTMLAttributes<HTMLInputElement>,
- 'type' | 'id'
- > {
- inputId?: string;
- labelText: string;
- color?: string;
- className?: string;
-}
-
-const FileInput: React.FC<FileInputProps> = props => {
- const { inputId, labelText, color, className, ...otherProps } = props;
-
- const realInputId = React.useMemo<string>(() => {
- if (inputId != null) return inputId;
- return (
- 'file-input-' +
- (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1)
- );
- }, [inputId]);
-
- return (
- <>
- <input className="d-none" type="file" id={realInputId} {...otherProps} />
- <label
- htmlFor={realInputId}
- className={clsx('btn', 'btn-' + (color ?? 'primary'), className)}
- >
- {labelText}
- </label>
- </>
- );
-};
-
-export default FileInput;
+import React from 'react';
+import clsx from 'clsx';
+
+import { ExcludeKey } from '../type-utilities';
+
+export interface FileInputProps
+ extends ExcludeKey<
+ React.InputHTMLAttributes<HTMLInputElement>,
+ 'type' | 'id'
+ > {
+ inputId?: string;
+ labelText: string;
+ color?: string;
+ className?: string;
+}
+
+const FileInput: React.FC<FileInputProps> = props => {
+ const { inputId, labelText, color, className, ...otherProps } = props;
+
+ const realInputId = React.useMemo<string>(() => {
+ if (inputId != null) return inputId;
+ return (
+ 'file-input-' +
+ (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1)
+ );
+ }, [inputId]);
+
+ return (
+ <>
+ <input className="d-none" type="file" id={realInputId} {...otherProps} />
+ <label
+ htmlFor={realInputId}
+ className={clsx('btn', 'btn-' + (color ?? 'primary'), className)}
+ >
+ {labelText}
+ </label>
+ </>
+ );
+};
+
+export default FileInput;
diff --git a/Timeline/ClientApp/src/common/ImageCropper.tsx b/Timeline/ClientApp/src/app/common/ImageCropper.tsx
index fb9bb4f9..7cb8d3cf 100644
--- a/Timeline/ClientApp/src/common/ImageCropper.tsx
+++ b/Timeline/ClientApp/src/app/common/ImageCropper.tsx
@@ -1,306 +1,306 @@
-import * as React from 'react';
-import clsx from 'clsx';
-
-import { UiLogicError } from '../common';
-
-export interface Clip {
- left: number;
- top: number;
- width: number;
-}
-
-interface NormailizedClip extends Clip {
- height: number;
-}
-
-interface ImageInfo {
- width: number;
- height: number;
- landscape: boolean;
- ratio: number;
- maxClipWidth: number;
- maxClipHeight: number;
-}
-
-interface ImageCropperSavedState {
- clip: NormailizedClip;
- x: number;
- y: number;
- pointerId: number;
-}
-
-export interface ImageCropperProps {
- clip: Clip | null;
- imageUrl: string;
- onChange: (clip: Clip) => void;
- imageElementCallback?: (element: HTMLImageElement | null) => void;
- className?: string;
-}
-
-const ImageCropper = (props: ImageCropperProps): React.ReactElement => {
- const { clip, imageUrl, onChange, imageElementCallback, className } = props;
-
- const [oldState, setOldState] = React.useState<ImageCropperSavedState | null>(
- null
- );
- const [imageInfo, setImageInfo] = React.useState<ImageInfo | null>(null);
-
- const normalizeClip = (c: Clip | null | undefined): NormailizedClip => {
- if (c == null) {
- return { left: 0, top: 0, width: 0, height: 0 };
- }
-
- return {
- left: c.left || 0,
- top: c.top || 0,
- width: c.width || 0,
- height: imageInfo != null ? (c.width || 0) / imageInfo.ratio : 0,
- };
- };
-
- const c = normalizeClip(clip);
-
- const imgElementRef = React.useRef<HTMLImageElement | null>(null);
-
- const onImageRef = React.useCallback(
- (e: HTMLImageElement | null) => {
- imgElementRef.current = e;
- if (imageElementCallback != null && e == null) {
- imageElementCallback(null);
- }
- },
- [imageElementCallback]
- );
-
- const onImageLoad = React.useCallback(
- (e: React.SyntheticEvent<HTMLImageElement>) => {
- const img = e.currentTarget;
- const landscape = img.naturalWidth >= img.naturalHeight;
-
- const info = {
- width: img.naturalWidth,
- height: img.naturalHeight,
- landscape,
- ratio: img.naturalHeight / img.naturalWidth,
- maxClipWidth: landscape ? img.naturalHeight / img.naturalWidth : 1,
- maxClipHeight: landscape ? 1 : img.naturalWidth / img.naturalHeight,
- };
- setImageInfo(info);
- onChange({ left: 0, top: 0, width: info.maxClipWidth });
- if (imageElementCallback != null) {
- imageElementCallback(img);
- }
- },
- [onChange, imageElementCallback]
- );
-
- const onPointerDown = React.useCallback(
- (e: React.PointerEvent) => {
- if (oldState != null) return;
- e.currentTarget.setPointerCapture(e.pointerId);
- setOldState({
- x: e.clientX,
- y: e.clientY,
- clip: c,
- pointerId: e.pointerId,
- });
- },
- [oldState, c]
- );
-
- const onPointerUp = React.useCallback(
- (e: React.PointerEvent) => {
- if (oldState == null || oldState.pointerId !== e.pointerId) return;
- e.currentTarget.releasePointerCapture(e.pointerId);
- setOldState(null);
- },
- [oldState]
- );
-
- const onPointerMove = React.useCallback(
- (e: React.PointerEvent) => {
- if (oldState == null) return;
-
- const oldClip = oldState.clip;
-
- const movement = { x: e.clientX - oldState.x, y: e.clientY - oldState.y };
-
- const { current: imgElement } = imgElementRef;
-
- if (imgElement == null) throw new UiLogicError('Image element is null.');
-
- const moveRatio = {
- x: movement.x / imgElement.width,
- y: movement.y / imgElement.height,
- };
-
- const newRatio = {
- x: oldClip.left + moveRatio.x,
- y: oldClip.top + moveRatio.y,
- };
- if (newRatio.x < 0) {
- newRatio.x = 0;
- } else if (newRatio.x > 1 - oldClip.width) {
- newRatio.x = 1 - oldClip.width;
- }
- if (newRatio.y < 0) {
- newRatio.y = 0;
- } else if (newRatio.y > 1 - oldClip.height) {
- newRatio.y = 1 - oldClip.height;
- }
-
- onChange({ left: newRatio.x, top: newRatio.y, width: oldClip.width });
- },
- [oldState, onChange]
- );
-
- const onHandlerPointerMove = React.useCallback(
- (e: React.PointerEvent) => {
- if (oldState == null) return;
-
- const oldClip = oldState.clip;
-
- const movement = { x: e.clientX - oldState.x, y: e.clientY - oldState.y };
-
- const ratio = imageInfo == null ? 1 : imageInfo.ratio;
-
- const { current: imgElement } = imgElementRef;
-
- if (imgElement == null) throw new UiLogicError('Image element is null.');
-
- const moveRatio = {
- x: movement.x / imgElement.width,
- y: movement.x / imgElement.width / ratio,
- };
-
- const newRatio = {
- x: oldClip.width + moveRatio.x,
- y: oldClip.height + moveRatio.y,
- };
-
- const maxRatio = {
- x: Math.min(1 - oldClip.left, newRatio.x),
- y: Math.min(1 - oldClip.top, newRatio.y),
- };
-
- const maxWidthRatio = Math.min(maxRatio.x, maxRatio.y * ratio);
-
- let newWidth;
- if (newRatio.x < 0) {
- newWidth = 0;
- } else if (newRatio.x > maxWidthRatio) {
- newWidth = maxWidthRatio;
- } else {
- newWidth = newRatio.x;
- }
-
- onChange({ left: oldClip.left, top: oldClip.top, width: newWidth });
- },
- [imageInfo, oldState, onChange]
- );
-
- const toPercentage = (n: number): string => `${n}%`;
-
- // fuck!!! I just can't find a better way to implement this in pure css
- const containerStyle: React.CSSProperties = (() => {
- if (imageInfo == null) {
- return { width: '100%', paddingTop: '100%', height: 0 };
- } else {
- if (imageInfo.ratio > 1) {
- return {
- width: toPercentage(100 / imageInfo.ratio),
- paddingTop: '100%',
- height: 0,
- };
- } else {
- return {
- width: '100%',
- paddingTop: toPercentage(100 * imageInfo.ratio),
- height: 0,
- };
- }
- }
- })();
-
- return (
- <div
- className={clsx('image-cropper-container', className)}
- style={containerStyle}
- >
- <img ref={onImageRef} src={imageUrl} onLoad={onImageLoad} alt="to crop" />
- <div className="image-cropper-mask-container">
- <div
- className="image-cropper-mask"
- touch-action="none"
- style={{
- left: toPercentage(c.left * 100),
- top: toPercentage(c.top * 100),
- width: toPercentage(c.width * 100),
- height: toPercentage(c.height * 100),
- }}
- onPointerMove={onPointerMove}
- onPointerDown={onPointerDown}
- onPointerUp={onPointerUp}
- />
- </div>
- <div
- className="image-cropper-handler"
- touch-action="none"
- style={{
- left: `calc(${(c.left + c.width) * 100}% - 15px)`,
- top: `calc(${(c.top + c.height) * 100}% - 15px)`,
- }}
- onPointerMove={onHandlerPointerMove}
- onPointerDown={onPointerDown}
- onPointerUp={onPointerUp}
- />
- </div>
- );
-};
-
-export default ImageCropper;
-
-export function applyClipToImage(
- image: HTMLImageElement,
- clip: Clip,
- mimeType: string
-): Promise<Blob> {
- return new Promise((resolve, reject) => {
- const naturalSize = {
- width: image.naturalWidth,
- height: image.naturalHeight,
- };
- const clipArea = {
- x: naturalSize.width * clip.left,
- y: naturalSize.height * clip.top,
- length: naturalSize.width * clip.width,
- };
-
- const canvas = document.createElement('canvas');
- canvas.width = clipArea.length;
- canvas.height = clipArea.length;
- const context = canvas.getContext('2d');
-
- if (context == null) throw new Error('Failed to create context.');
-
- context.drawImage(
- image,
- clipArea.x,
- clipArea.y,
- clipArea.length,
- clipArea.length,
- 0,
- 0,
- clipArea.length,
- clipArea.length
- );
-
- canvas.toBlob((blob) => {
- if (blob == null) {
- reject(new Error('canvas.toBlob returns null'));
- } else {
- resolve(blob);
- }
- }, mimeType);
- });
-}
+import * as React from 'react';
+import clsx from 'clsx';
+
+import { UiLogicError } from '../common';
+
+export interface Clip {
+ left: number;
+ top: number;
+ width: number;
+}
+
+interface NormailizedClip extends Clip {
+ height: number;
+}
+
+interface ImageInfo {
+ width: number;
+ height: number;
+ landscape: boolean;
+ ratio: number;
+ maxClipWidth: number;
+ maxClipHeight: number;
+}
+
+interface ImageCropperSavedState {
+ clip: NormailizedClip;
+ x: number;
+ y: number;
+ pointerId: number;
+}
+
+export interface ImageCropperProps {
+ clip: Clip | null;
+ imageUrl: string;
+ onChange: (clip: Clip) => void;
+ imageElementCallback?: (element: HTMLImageElement | null) => void;
+ className?: string;
+}
+
+const ImageCropper = (props: ImageCropperProps): React.ReactElement => {
+ const { clip, imageUrl, onChange, imageElementCallback, className } = props;
+
+ const [oldState, setOldState] = React.useState<ImageCropperSavedState | null>(
+ null
+ );
+ const [imageInfo, setImageInfo] = React.useState<ImageInfo | null>(null);
+
+ const normalizeClip = (c: Clip | null | undefined): NormailizedClip => {
+ if (c == null) {
+ return { left: 0, top: 0, width: 0, height: 0 };
+ }
+
+ return {
+ left: c.left || 0,
+ top: c.top || 0,
+ width: c.width || 0,
+ height: imageInfo != null ? (c.width || 0) / imageInfo.ratio : 0,
+ };
+ };
+
+ const c = normalizeClip(clip);
+
+ const imgElementRef = React.useRef<HTMLImageElement | null>(null);
+
+ const onImageRef = React.useCallback(
+ (e: HTMLImageElement | null) => {
+ imgElementRef.current = e;
+ if (imageElementCallback != null && e == null) {
+ imageElementCallback(null);
+ }
+ },
+ [imageElementCallback]
+ );
+
+ const onImageLoad = React.useCallback(
+ (e: React.SyntheticEvent<HTMLImageElement>) => {
+ const img = e.currentTarget;
+ const landscape = img.naturalWidth >= img.naturalHeight;
+
+ const info = {
+ width: img.naturalWidth,
+ height: img.naturalHeight,
+ landscape,
+ ratio: img.naturalHeight / img.naturalWidth,
+ maxClipWidth: landscape ? img.naturalHeight / img.naturalWidth : 1,
+ maxClipHeight: landscape ? 1 : img.naturalWidth / img.naturalHeight,
+ };
+ setImageInfo(info);
+ onChange({ left: 0, top: 0, width: info.maxClipWidth });
+ if (imageElementCallback != null) {
+ imageElementCallback(img);
+ }
+ },
+ [onChange, imageElementCallback]
+ );
+
+ const onPointerDown = React.useCallback(
+ (e: React.PointerEvent) => {
+ if (oldState != null) return;
+ e.currentTarget.setPointerCapture(e.pointerId);
+ setOldState({
+ x: e.clientX,
+ y: e.clientY,
+ clip: c,
+ pointerId: e.pointerId,
+ });
+ },
+ [oldState, c]
+ );
+
+ const onPointerUp = React.useCallback(
+ (e: React.PointerEvent) => {
+ if (oldState == null || oldState.pointerId !== e.pointerId) return;
+ e.currentTarget.releasePointerCapture(e.pointerId);
+ setOldState(null);
+ },
+ [oldState]
+ );
+
+ const onPointerMove = React.useCallback(
+ (e: React.PointerEvent) => {
+ if (oldState == null) return;
+
+ const oldClip = oldState.clip;
+
+ const movement = { x: e.clientX - oldState.x, y: e.clientY - oldState.y };
+
+ const { current: imgElement } = imgElementRef;
+
+ if (imgElement == null) throw new UiLogicError('Image element is null.');
+
+ const moveRatio = {
+ x: movement.x / imgElement.width,
+ y: movement.y / imgElement.height,
+ };
+
+ const newRatio = {
+ x: oldClip.left + moveRatio.x,
+ y: oldClip.top + moveRatio.y,
+ };
+ if (newRatio.x < 0) {
+ newRatio.x = 0;
+ } else if (newRatio.x > 1 - oldClip.width) {
+ newRatio.x = 1 - oldClip.width;
+ }
+ if (newRatio.y < 0) {
+ newRatio.y = 0;
+ } else if (newRatio.y > 1 - oldClip.height) {
+ newRatio.y = 1 - oldClip.height;
+ }
+
+ onChange({ left: newRatio.x, top: newRatio.y, width: oldClip.width });
+ },
+ [oldState, onChange]
+ );
+
+ const onHandlerPointerMove = React.useCallback(
+ (e: React.PointerEvent) => {
+ if (oldState == null) return;
+
+ const oldClip = oldState.clip;
+
+ const movement = { x: e.clientX - oldState.x, y: e.clientY - oldState.y };
+
+ const ratio = imageInfo == null ? 1 : imageInfo.ratio;
+
+ const { current: imgElement } = imgElementRef;
+
+ if (imgElement == null) throw new UiLogicError('Image element is null.');
+
+ const moveRatio = {
+ x: movement.x / imgElement.width,
+ y: movement.x / imgElement.width / ratio,
+ };
+
+ const newRatio = {
+ x: oldClip.width + moveRatio.x,
+ y: oldClip.height + moveRatio.y,
+ };
+
+ const maxRatio = {
+ x: Math.min(1 - oldClip.left, newRatio.x),
+ y: Math.min(1 - oldClip.top, newRatio.y),
+ };
+
+ const maxWidthRatio = Math.min(maxRatio.x, maxRatio.y * ratio);
+
+ let newWidth;
+ if (newRatio.x < 0) {
+ newWidth = 0;
+ } else if (newRatio.x > maxWidthRatio) {
+ newWidth = maxWidthRatio;
+ } else {
+ newWidth = newRatio.x;
+ }
+
+ onChange({ left: oldClip.left, top: oldClip.top, width: newWidth });
+ },
+ [imageInfo, oldState, onChange]
+ );
+
+ const toPercentage = (n: number): string => `${n}%`;
+
+ // fuck!!! I just can't find a better way to implement this in pure css
+ const containerStyle: React.CSSProperties = (() => {
+ if (imageInfo == null) {
+ return { width: '100%', paddingTop: '100%', height: 0 };
+ } else {
+ if (imageInfo.ratio > 1) {
+ return {
+ width: toPercentage(100 / imageInfo.ratio),
+ paddingTop: '100%',
+ height: 0,
+ };
+ } else {
+ return {
+ width: '100%',
+ paddingTop: toPercentage(100 * imageInfo.ratio),
+ height: 0,
+ };
+ }
+ }
+ })();
+
+ return (
+ <div
+ className={clsx('image-cropper-container', className)}
+ style={containerStyle}
+ >
+ <img ref={onImageRef} src={imageUrl} onLoad={onImageLoad} alt="to crop" />
+ <div className="image-cropper-mask-container">
+ <div
+ className="image-cropper-mask"
+ touch-action="none"
+ style={{
+ left: toPercentage(c.left * 100),
+ top: toPercentage(c.top * 100),
+ width: toPercentage(c.width * 100),
+ height: toPercentage(c.height * 100),
+ }}
+ onPointerMove={onPointerMove}
+ onPointerDown={onPointerDown}
+ onPointerUp={onPointerUp}
+ />
+ </div>
+ <div
+ className="image-cropper-handler"
+ touch-action="none"
+ style={{
+ left: `calc(${(c.left + c.width) * 100}% - 15px)`,
+ top: `calc(${(c.top + c.height) * 100}% - 15px)`,
+ }}
+ onPointerMove={onHandlerPointerMove}
+ onPointerDown={onPointerDown}
+ onPointerUp={onPointerUp}
+ />
+ </div>
+ );
+};
+
+export default ImageCropper;
+
+export function applyClipToImage(
+ image: HTMLImageElement,
+ clip: Clip,
+ mimeType: string
+): Promise<Blob> {
+ return new Promise((resolve, reject) => {
+ const naturalSize = {
+ width: image.naturalWidth,
+ height: image.naturalHeight,
+ };
+ const clipArea = {
+ x: naturalSize.width * clip.left,
+ y: naturalSize.height * clip.top,
+ length: naturalSize.width * clip.width,
+ };
+
+ const canvas = document.createElement('canvas');
+ canvas.width = clipArea.length;
+ canvas.height = clipArea.length;
+ const context = canvas.getContext('2d');
+
+ if (context == null) throw new Error('Failed to create context.');
+
+ context.drawImage(
+ image,
+ clipArea.x,
+ clipArea.y,
+ clipArea.length,
+ clipArea.length,
+ 0,
+ 0,
+ clipArea.length,
+ clipArea.length
+ );
+
+ canvas.toBlob((blob) => {
+ if (blob == null) {
+ reject(new Error('canvas.toBlob returns null'));
+ } else {
+ resolve(blob);
+ }
+ }, mimeType);
+ });
+}
diff --git a/Timeline/ClientApp/src/common/LoadingPage.tsx b/Timeline/ClientApp/src/app/common/LoadingPage.tsx
index c1bc7105..81bc74cf 100644
--- a/Timeline/ClientApp/src/common/LoadingPage.tsx
+++ b/Timeline/ClientApp/src/app/common/LoadingPage.tsx
@@ -1,12 +1,12 @@
-import React from 'react';
-import { Spinner } from 'reactstrap';
-
-const LoadingPage: React.FC = () => {
- return (
- <div className="position-fixed w-100 h-100 d-flex justify-content-center align-items-center">
- <Spinner style={{ height: '2.5rem', width: '2.5rem' }} color="primary" />
- </div>
- );
-};
-
-export default LoadingPage;
+import React from 'react';
+import { Spinner } from 'reactstrap';
+
+const LoadingPage: React.FC = () => {
+ return (
+ <div className="position-fixed w-100 h-100 d-flex justify-content-center align-items-center">
+ <Spinner style={{ height: '2.5rem', width: '2.5rem' }} color="primary" />
+ </div>
+ );
+};
+
+export default LoadingPage;
diff --git a/Timeline/ClientApp/src/common/OperationDialog.tsx b/Timeline/ClientApp/src/app/common/OperationDialog.tsx
index 501a353e..30db4053 100644
--- a/Timeline/ClientApp/src/common/OperationDialog.tsx
+++ b/Timeline/ClientApp/src/app/common/OperationDialog.tsx
@@ -1,381 +1,381 @@
-import React, { useState } from 'react';
-import { useTranslation } from 'react-i18next';
-import {
- Spinner,
- Container,
- ModalBody,
- Label,
- Input,
- FormGroup,
- FormFeedback,
- ModalFooter,
- Button,
- Modal,
- ModalHeader,
- FormText,
-} from 'reactstrap';
-
-import { UiLogicError } from '../common';
-
-const DefaultProcessPrompt: React.FC = (_) => {
- return (
- <Container className="justify-content-center align-items-center">
- <Spinner />
- </Container>
- );
-};
-
-interface DefaultErrorPromptProps {
- error?: string;
-}
-
-const DefaultErrorPrompt: React.FC<DefaultErrorPromptProps> = (props) => {
- const { t } = useTranslation();
-
- let result = <p className="text-danger">{t('operationDialog.error')}</p>;
-
- if (props.error != null) {
- result = (
- <>
- {result}
- <p className="text-danger">{props.error}</p>
- </>
- );
- }
-
- return result;
-};
-
-export type OperationInputOptionalError = undefined | null | string;
-
-export interface OperationInputErrorInfo {
- [index: number]: OperationInputOptionalError;
-}
-
-export type OperationInputValidator<TValue> = (
- value: TValue,
- values: (string | boolean)[]
-) => OperationInputOptionalError | OperationInputErrorInfo;
-
-export interface OperationTextInputInfo {
- type: 'text';
- password?: boolean;
- label?: string;
- initValue?: string;
- textFieldProps?: Omit<
- React.InputHTMLAttributes<HTMLInputElement>,
- 'type' | 'value' | 'onChange'
- >;
- helperText?: string;
- validator?: OperationInputValidator<string>;
-}
-
-export interface OperationBoolInputInfo {
- type: 'bool';
- label: string;
- initValue?: boolean;
-}
-
-export interface OperationSelectInputInfoOption {
- value: string;
- label: string;
- icon?: React.ReactElement;
-}
-
-export interface OperationSelectInputInfo {
- type: 'select';
- label: string;
- options: OperationSelectInputInfoOption[];
- initValue?: string;
-}
-
-export type OperationInputInfo =
- | OperationTextInputInfo
- | OperationBoolInputInfo
- | OperationSelectInputInfo;
-
-interface OperationResult {
- type: 'success' | 'failure';
- data: unknown;
-}
-
-interface OperationDialogProps {
- open: boolean;
- close: () => void;
- title: React.ReactNode;
- titleColor?: 'default' | 'dangerous' | 'create' | string;
- onProcess: (inputs: (string | boolean)[]) => Promise<unknown>;
- inputScheme?: OperationInputInfo[];
- inputPrompt?: string | (() => React.ReactNode);
- processPrompt?: () => React.ReactNode;
- successPrompt?: (data: unknown) => React.ReactNode;
- failurePrompt?: (error: unknown) => React.ReactNode;
- onSuccessAndClose?: () => void;
-}
-
-const OperationDialog: React.FC<OperationDialogProps> = (props) => {
- const inputScheme = props.inputScheme ?? [];
-
- const { t } = useTranslation();
-
- type Step = 'input' | 'process' | OperationResult;
- const [step, setStep] = useState<Step>('input');
- const [values, setValues] = useState<(boolean | string)[]>(
- inputScheme.map((i) => {
- if (i.type === 'bool') {
- return i.initValue ?? false;
- } else if (i.type === 'text' || i.type === 'select') {
- return i.initValue ?? '';
- } else {
- throw new UiLogicError('Unknown input scheme.');
- }
- })
- );
- const [inputError, setInputError] = useState<OperationInputErrorInfo>({});
-
- const close = (): void => {
- if (step !== 'process') {
- props.close();
- if (
- typeof step === 'object' &&
- step.type === 'success' &&
- props.onSuccessAndClose
- ) {
- props.onSuccessAndClose();
- }
- } else {
- console.log('Attempt to close modal when processing.');
- }
- };
-
- const onConfirm = (): void => {
- setStep('process');
- props.onProcess(values).then(
- (d: unknown) => {
- setStep({
- type: 'success',
- data: d,
- });
- },
- (e: unknown) => {
- setStep({
- type: 'failure',
- data: e,
- });
- }
- );
- };
-
- let body: React.ReactNode;
- if (step === 'input') {
- let inputPrompt =
- typeof props.inputPrompt === 'function'
- ? props.inputPrompt()
- : props.inputPrompt;
- inputPrompt = <h6>{inputPrompt}</h6>;
-
- const updateValue = (
- index: number,
- newValue: string | boolean
- ): (string | boolean)[] => {
- const oldValues = values;
- const newValues = oldValues.slice();
- newValues[index] = newValue;
- setValues(newValues);
- return newValues;
- };
-
- const testErrorInfo = (errorInfo: OperationInputErrorInfo): boolean => {
- for (let i = 0; i < inputScheme.length; i++) {
- if (inputScheme[i].type === 'text' && errorInfo[i] != null) {
- return true;
- }
- }
- return false;
- };
-
- const calculateError = (
- oldError: OperationInputErrorInfo,
- index: number,
- newError: OperationInputOptionalError | OperationInputErrorInfo
- ): OperationInputErrorInfo => {
- if (newError === undefined) {
- return oldError;
- } else if (newError === null || typeof newError === 'string') {
- return { ...oldError, [index]: newError };
- } else {
- const newInputError: OperationInputErrorInfo = { ...oldError };
- for (const [index, error] of Object.entries(newError)) {
- if (error !== undefined) {
- newInputError[+index] = error as OperationInputOptionalError;
- }
- }
- return newInputError;
- }
- };
-
- const validateAll = (): boolean => {
- let newInputError = inputError;
- for (let i = 0; i < inputScheme.length; i++) {
- const item = inputScheme[i];
- if (item.type === 'text') {
- newInputError = calculateError(
- newInputError,
- i,
- item.validator?.(values[i] as string, values)
- );
- }
- }
- const result = !testErrorInfo(newInputError);
- setInputError(newInputError);
- return result;
- };
-
- body = (
- <>
- <ModalBody>
- {inputPrompt}
- {inputScheme.map((item, index) => {
- const value = values[index];
- const error: string | undefined = ((e) =>
- typeof e === 'string' ? t(e) : undefined)(inputError?.[index]);
-
- if (item.type === 'text') {
- return (
- <FormGroup key={index}>
- {item.label && <Label>{t(item.label)}</Label>}
- <Input
- type={item.password === true ? 'password' : 'text'}
- value={value as string}
- onChange={(e) => {
- const v = e.target.value;
- const newValues = updateValue(index, v);
- setInputError(
- calculateError(
- inputError,
- index,
- item.validator?.(v, newValues)
- )
- );
- }}
- invalid={error != null}
- {...item.textFieldProps}
- />
- {error != null && <FormFeedback>{error}</FormFeedback>}
- {item.helperText && <FormText>{t(item.helperText)}</FormText>}
- </FormGroup>
- );
- } else if (item.type === 'bool') {
- return (
- <FormGroup check key={index}>
- <Input
- type="checkbox"
- value={value as string}
- onChange={(e) => {
- updateValue(
- index,
- (e.target as HTMLInputElement).checked
- );
- }}
- />
- <Label check>{t(item.label)}</Label>
- </FormGroup>
- );
- } else if (item.type === 'select') {
- return (
- <FormGroup key={index}>
- <Label>{t(item.label)}</Label>
- <Input
- type="select"
- value={value as string}
- onChange={(event) => {
- updateValue(index, event.target.value);
- }}
- >
- {item.options.map((option, i) => {
- return (
- <option value={option.value} key={i}>
- {option.icon}
- {t(option.label)}
- </option>
- );
- })}
- </Input>
- </FormGroup>
- );
- }
- })}
- </ModalBody>
- <ModalFooter>
- <Button color="secondary" onClick={close}>
- {t('operationDialog.cancel')}
- </Button>
- <Button
- color="primary"
- disabled={testErrorInfo(inputError)}
- onClick={() => {
- if (validateAll()) {
- onConfirm();
- }
- }}
- >
- {t('operationDialog.confirm')}
- </Button>
- </ModalFooter>
- </>
- );
- } else if (step === 'process') {
- body = (
- <ModalBody>
- {props.processPrompt?.() ?? <DefaultProcessPrompt />}
- </ModalBody>
- );
- } else {
- let content: React.ReactNode;
- const result = step;
- if (result.type === 'success') {
- content =
- props.successPrompt?.(result.data) ?? t('operationDialog.success');
- if (typeof content === 'string')
- content = <p className="text-success">{content}</p>;
- } else {
- content = props.failurePrompt?.(result.data) ?? <DefaultErrorPrompt />;
- if (typeof content === 'string')
- content = <DefaultErrorPrompt error={content} />;
- }
- body = (
- <>
- <ModalBody>{content}</ModalBody>
- <ModalFooter>
- <Button color="primary" onClick={close}>
- {t('operationDialog.ok')}
- </Button>
- </ModalFooter>
- </>
- );
- }
-
- const title = typeof props.title === 'string' ? t(props.title) : props.title;
-
- return (
- <Modal isOpen={props.open} toggle={close}>
- <ModalHeader
- className={
- props.titleColor != null
- ? 'text-' +
- (props.titleColor === 'create'
- ? 'success'
- : props.titleColor === 'dangerous'
- ? 'danger'
- : props.titleColor)
- : undefined
- }
- >
- {title}
- </ModalHeader>
- {body}
- </Modal>
- );
-};
-
-export default OperationDialog;
+import React, { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import {
+ Spinner,
+ Container,
+ ModalBody,
+ Label,
+ Input,
+ FormGroup,
+ FormFeedback,
+ ModalFooter,
+ Button,
+ Modal,
+ ModalHeader,
+ FormText,
+} from 'reactstrap';
+
+import { UiLogicError } from '../common';
+
+const DefaultProcessPrompt: React.FC = (_) => {
+ return (
+ <Container className="justify-content-center align-items-center">
+ <Spinner />
+ </Container>
+ );
+};
+
+interface DefaultErrorPromptProps {
+ error?: string;
+}
+
+const DefaultErrorPrompt: React.FC<DefaultErrorPromptProps> = (props) => {
+ const { t } = useTranslation();
+
+ let result = <p className="text-danger">{t('operationDialog.error')}</p>;
+
+ if (props.error != null) {
+ result = (
+ <>
+ {result}
+ <p className="text-danger">{props.error}</p>
+ </>
+ );
+ }
+
+ return result;
+};
+
+export type OperationInputOptionalError = undefined | null | string;
+
+export interface OperationInputErrorInfo {
+ [index: number]: OperationInputOptionalError;
+}
+
+export type OperationInputValidator<TValue> = (
+ value: TValue,
+ values: (string | boolean)[]
+) => OperationInputOptionalError | OperationInputErrorInfo;
+
+export interface OperationTextInputInfo {
+ type: 'text';
+ password?: boolean;
+ label?: string;
+ initValue?: string;
+ textFieldProps?: Omit<
+ React.InputHTMLAttributes<HTMLInputElement>,
+ 'type' | 'value' | 'onChange'
+ >;
+ helperText?: string;
+ validator?: OperationInputValidator<string>;
+}
+
+export interface OperationBoolInputInfo {
+ type: 'bool';
+ label: string;
+ initValue?: boolean;
+}
+
+export interface OperationSelectInputInfoOption {
+ value: string;
+ label: string;
+ icon?: React.ReactElement;
+}
+
+export interface OperationSelectInputInfo {
+ type: 'select';
+ label: string;
+ options: OperationSelectInputInfoOption[];
+ initValue?: string;
+}
+
+export type OperationInputInfo =
+ | OperationTextInputInfo
+ | OperationBoolInputInfo
+ | OperationSelectInputInfo;
+
+interface OperationResult {
+ type: 'success' | 'failure';
+ data: unknown;
+}
+
+interface OperationDialogProps {
+ open: boolean;
+ close: () => void;
+ title: React.ReactNode;
+ titleColor?: 'default' | 'dangerous' | 'create' | string;
+ onProcess: (inputs: (string | boolean)[]) => Promise<unknown>;
+ inputScheme?: OperationInputInfo[];
+ inputPrompt?: string | (() => React.ReactNode);
+ processPrompt?: () => React.ReactNode;
+ successPrompt?: (data: unknown) => React.ReactNode;
+ failurePrompt?: (error: unknown) => React.ReactNode;
+ onSuccessAndClose?: () => void;
+}
+
+const OperationDialog: React.FC<OperationDialogProps> = (props) => {
+ const inputScheme = props.inputScheme ?? [];
+
+ const { t } = useTranslation();
+
+ type Step = 'input' | 'process' | OperationResult;
+ const [step, setStep] = useState<Step>('input');
+ const [values, setValues] = useState<(boolean | string)[]>(
+ inputScheme.map((i) => {
+ if (i.type === 'bool') {
+ return i.initValue ?? false;
+ } else if (i.type === 'text' || i.type === 'select') {
+ return i.initValue ?? '';
+ } else {
+ throw new UiLogicError('Unknown input scheme.');
+ }
+ })
+ );
+ const [inputError, setInputError] = useState<OperationInputErrorInfo>({});
+
+ const close = (): void => {
+ if (step !== 'process') {
+ props.close();
+ if (
+ typeof step === 'object' &&
+ step.type === 'success' &&
+ props.onSuccessAndClose
+ ) {
+ props.onSuccessAndClose();
+ }
+ } else {
+ console.log('Attempt to close modal when processing.');
+ }
+ };
+
+ const onConfirm = (): void => {
+ setStep('process');
+ props.onProcess(values).then(
+ (d: unknown) => {
+ setStep({
+ type: 'success',
+ data: d,
+ });
+ },
+ (e: unknown) => {
+ setStep({
+ type: 'failure',
+ data: e,
+ });
+ }
+ );
+ };
+
+ let body: React.ReactNode;
+ if (step === 'input') {
+ let inputPrompt =
+ typeof props.inputPrompt === 'function'
+ ? props.inputPrompt()
+ : props.inputPrompt;
+ inputPrompt = <h6>{inputPrompt}</h6>;
+
+ const updateValue = (
+ index: number,
+ newValue: string | boolean
+ ): (string | boolean)[] => {
+ const oldValues = values;
+ const newValues = oldValues.slice();
+ newValues[index] = newValue;
+ setValues(newValues);
+ return newValues;
+ };
+
+ const testErrorInfo = (errorInfo: OperationInputErrorInfo): boolean => {
+ for (let i = 0; i < inputScheme.length; i++) {
+ if (inputScheme[i].type === 'text' && errorInfo[i] != null) {
+ return true;
+ }
+ }
+ return false;
+ };
+
+ const calculateError = (
+ oldError: OperationInputErrorInfo,
+ index: number,
+ newError: OperationInputOptionalError | OperationInputErrorInfo
+ ): OperationInputErrorInfo => {
+ if (newError === undefined) {
+ return oldError;
+ } else if (newError === null || typeof newError === 'string') {
+ return { ...oldError, [index]: newError };
+ } else {
+ const newInputError: OperationInputErrorInfo = { ...oldError };
+ for (const [index, error] of Object.entries(newError)) {
+ if (error !== undefined) {
+ newInputError[+index] = error as OperationInputOptionalError;
+ }
+ }
+ return newInputError;
+ }
+ };
+
+ const validateAll = (): boolean => {
+ let newInputError = inputError;
+ for (let i = 0; i < inputScheme.length; i++) {
+ const item = inputScheme[i];
+ if (item.type === 'text') {
+ newInputError = calculateError(
+ newInputError,
+ i,
+ item.validator?.(values[i] as string, values)
+ );
+ }
+ }
+ const result = !testErrorInfo(newInputError);
+ setInputError(newInputError);
+ return result;
+ };
+
+ body = (
+ <>
+ <ModalBody>
+ {inputPrompt}
+ {inputScheme.map((item, index) => {
+ const value = values[index];
+ const error: string | undefined = ((e) =>
+ typeof e === 'string' ? t(e) : undefined)(inputError?.[index]);
+
+ if (item.type === 'text') {
+ return (
+ <FormGroup key={index}>
+ {item.label && <Label>{t(item.label)}</Label>}
+ <Input
+ type={item.password === true ? 'password' : 'text'}
+ value={value as string}
+ onChange={(e) => {
+ const v = e.target.value;
+ const newValues = updateValue(index, v);
+ setInputError(
+ calculateError(
+ inputError,
+ index,
+ item.validator?.(v, newValues)
+ )
+ );
+ }}
+ invalid={error != null}
+ {...item.textFieldProps}
+ />
+ {error != null && <FormFeedback>{error}</FormFeedback>}
+ {item.helperText && <FormText>{t(item.helperText)}</FormText>}
+ </FormGroup>
+ );
+ } else if (item.type === 'bool') {
+ return (
+ <FormGroup check key={index}>
+ <Input
+ type="checkbox"
+ value={value as string}
+ onChange={(e) => {
+ updateValue(
+ index,
+ (e.target as HTMLInputElement).checked
+ );
+ }}
+ />
+ <Label check>{t(item.label)}</Label>
+ </FormGroup>
+ );
+ } else if (item.type === 'select') {
+ return (
+ <FormGroup key={index}>
+ <Label>{t(item.label)}</Label>
+ <Input
+ type="select"
+ value={value as string}
+ onChange={(event) => {
+ updateValue(index, event.target.value);
+ }}
+ >
+ {item.options.map((option, i) => {
+ return (
+ <option value={option.value} key={i}>
+ {option.icon}
+ {t(option.label)}
+ </option>
+ );
+ })}
+ </Input>
+ </FormGroup>
+ );
+ }
+ })}
+ </ModalBody>
+ <ModalFooter>
+ <Button color="secondary" onClick={close}>
+ {t('operationDialog.cancel')}
+ </Button>
+ <Button
+ color="primary"
+ disabled={testErrorInfo(inputError)}
+ onClick={() => {
+ if (validateAll()) {
+ onConfirm();
+ }
+ }}
+ >
+ {t('operationDialog.confirm')}
+ </Button>
+ </ModalFooter>
+ </>
+ );
+ } else if (step === 'process') {
+ body = (
+ <ModalBody>
+ {props.processPrompt?.() ?? <DefaultProcessPrompt />}
+ </ModalBody>
+ );
+ } else {
+ let content: React.ReactNode;
+ const result = step;
+ if (result.type === 'success') {
+ content =
+ props.successPrompt?.(result.data) ?? t('operationDialog.success');
+ if (typeof content === 'string')
+ content = <p className="text-success">{content}</p>;
+ } else {
+ content = props.failurePrompt?.(result.data) ?? <DefaultErrorPrompt />;
+ if (typeof content === 'string')
+ content = <DefaultErrorPrompt error={content} />;
+ }
+ body = (
+ <>
+ <ModalBody>{content}</ModalBody>
+ <ModalFooter>
+ <Button color="primary" onClick={close}>
+ {t('operationDialog.ok')}
+ </Button>
+ </ModalFooter>
+ </>
+ );
+ }
+
+ const title = typeof props.title === 'string' ? t(props.title) : props.title;
+
+ return (
+ <Modal isOpen={props.open} toggle={close}>
+ <ModalHeader
+ className={
+ props.titleColor != null
+ ? 'text-' +
+ (props.titleColor === 'create'
+ ? 'success'
+ : props.titleColor === 'dangerous'
+ ? 'danger'
+ : props.titleColor)
+ : undefined
+ }
+ >
+ {title}
+ </ModalHeader>
+ {body}
+ </Modal>
+ );
+};
+
+export default OperationDialog;
diff --git a/Timeline/ClientApp/src/common/SearchInput.tsx b/Timeline/ClientApp/src/app/common/SearchInput.tsx
index 46fb00d1..50c252fa 100644
--- a/Timeline/ClientApp/src/common/SearchInput.tsx
+++ b/Timeline/ClientApp/src/app/common/SearchInput.tsx
@@ -1,63 +1,63 @@
-import React, { useCallback } from 'react';
-import clsx from 'clsx';
-import { Spinner, Input, Button } from 'reactstrap';
-import { useTranslation } from 'react-i18next';
-
-export interface SearchInputProps {
- value: string;
- onChange: (value: string) => void;
- onButtonClick: () => void;
- className?: string;
- loading?: boolean;
- buttonText?: string;
- placeholder?: string;
- additionalButton?: React.ReactNode;
-}
-
-const SearchInput: React.FC<SearchInputProps> = (props) => {
- const { onChange, onButtonClick } = props;
-
- const { t } = useTranslation();
-
- const onInputChange = useCallback(
- (event: React.ChangeEvent<HTMLInputElement>): void => {
- onChange(event.currentTarget.value);
- },
- [onChange]
- );
-
- const onInputKeyPress = useCallback(
- (event: React.KeyboardEvent<HTMLInputElement>): void => {
- if (event.key === 'Enter') {
- onButtonClick();
- }
- },
- [onButtonClick]
- );
-
- return (
- <div className={clsx('form-inline my-2', props.className)}>
- <Input
- className="mr-sm-2"
- value={props.value}
- onChange={onInputChange}
- onKeyPress={onInputKeyPress}
- placeholder={props.placeholder}
- />
- <div className="mt-2 mt-sm-0 order-sm-last ml-sm-3">
- {props.additionalButton}
- </div>
- <div className="mt-2 mt-sm-0 ml-auto ml-sm-0">
- {props.loading ? (
- <Spinner />
- ) : (
- <Button outline color="primary" onClick={props.onButtonClick}>
- {props.buttonText ?? t('search')}
- </Button>
- )}
- </div>
- </div>
- );
-};
-
-export default SearchInput;
+import React, { useCallback } from 'react';
+import clsx from 'clsx';
+import { Spinner, Input, Button } from 'reactstrap';
+import { useTranslation } from 'react-i18next';
+
+export interface SearchInputProps {
+ value: string;
+ onChange: (value: string) => void;
+ onButtonClick: () => void;
+ className?: string;
+ loading?: boolean;
+ buttonText?: string;
+ placeholder?: string;
+ additionalButton?: React.ReactNode;
+}
+
+const SearchInput: React.FC<SearchInputProps> = (props) => {
+ const { onChange, onButtonClick } = props;
+
+ const { t } = useTranslation();
+
+ const onInputChange = useCallback(
+ (event: React.ChangeEvent<HTMLInputElement>): void => {
+ onChange(event.currentTarget.value);
+ },
+ [onChange]
+ );
+
+ const onInputKeyPress = useCallback(
+ (event: React.KeyboardEvent<HTMLInputElement>): void => {
+ if (event.key === 'Enter') {
+ onButtonClick();
+ }
+ },
+ [onButtonClick]
+ );
+
+ return (
+ <div className={clsx('form-inline my-2', props.className)}>
+ <Input
+ className="mr-sm-2"
+ value={props.value}
+ onChange={onInputChange}
+ onKeyPress={onInputKeyPress}
+ placeholder={props.placeholder}
+ />
+ <div className="mt-2 mt-sm-0 order-sm-last ml-sm-3">
+ {props.additionalButton}
+ </div>
+ <div className="mt-2 mt-sm-0 ml-auto ml-sm-0">
+ {props.loading ? (
+ <Spinner />
+ ) : (
+ <Button outline color="primary" onClick={props.onButtonClick}>
+ {props.buttonText ?? t('search')}
+ </Button>
+ )}
+ </div>
+ </div>
+ );
+};
+
+export default SearchInput;
diff --git a/Timeline/ClientApp/src/common/TimelineLogo.tsx b/Timeline/ClientApp/src/app/common/TimelineLogo.tsx
index 1ec62021..8dd9e97b 100644
--- a/Timeline/ClientApp/src/common/TimelineLogo.tsx
+++ b/Timeline/ClientApp/src/app/common/TimelineLogo.tsx
@@ -1,26 +1,26 @@
-import React, { SVGAttributes } from 'react';
-
-export interface TimelineLogoProps extends SVGAttributes<SVGElement> {
- color?: string;
-}
-
-const TimelineLogo: React.FC<TimelineLogoProps> = props => {
- const { color, ...forwardProps } = props;
- const coercedColor = color ?? 'currentcolor';
- return (
- <svg
- className={props.className}
- viewBox="0 0 100 100"
- fill="none"
- strokeWidth="12"
- stroke={coercedColor}
- {...forwardProps}
- >
- <line x1="50" y1="0" x2="50" y2="25" />
- <circle cx="50" cy="50" r="22" />
- <line x1="50" y1="75" x2="50" y2="100" />
- </svg>
- );
-};
-
-export default TimelineLogo;
+import React, { SVGAttributes } from 'react';
+
+export interface TimelineLogoProps extends SVGAttributes<SVGElement> {
+ color?: string;
+}
+
+const TimelineLogo: React.FC<TimelineLogoProps> = props => {
+ const { color, ...forwardProps } = props;
+ const coercedColor = color ?? 'currentcolor';
+ return (
+ <svg
+ className={props.className}
+ viewBox="0 0 100 100"
+ fill="none"
+ strokeWidth="12"
+ stroke={coercedColor}
+ {...forwardProps}
+ >
+ <line x1="50" y1="0" x2="50" y2="25" />
+ <circle cx="50" cy="50" r="22" />
+ <line x1="50" y1="75" x2="50" y2="100" />
+ </svg>
+ );
+};
+
+export default TimelineLogo;
diff --git a/Timeline/ClientApp/src/common/UserTimelineLogo.tsx b/Timeline/ClientApp/src/app/common/UserTimelineLogo.tsx
index 3b721985..58a429d8 100644
--- a/Timeline/ClientApp/src/common/UserTimelineLogo.tsx
+++ b/Timeline/ClientApp/src/app/common/UserTimelineLogo.tsx
@@ -1,26 +1,26 @@
-import React, { SVGAttributes } from 'react';
-
-export interface UserTimelineLogoProps extends SVGAttributes<SVGElement> {
- color?: string;
-}
-
-const UserTimelineLogo: React.FC<UserTimelineLogoProps> = props => {
- const { color, ...forwardProps } = props;
- const coercedColor = color ?? 'currentcolor';
-
- return (
- <svg viewBox="0 0 100 100" {...forwardProps}>
- <g fill="none" stroke={coercedColor} strokeWidth="12">
- <line x1="50" x2="50" y1="0" y2="25" />
- <circle cx="50" cy="50" r="22" />
- <line x1="50" x2="50" y1="75" y2="100" />
- </g>
- <g fill={color}>
- <circle cx="85" cy="75" r="10" />
- <path d="m70,100c0,0 15,-30 30,0.25" />
- </g>
- </svg>
- );
-};
-
-export default UserTimelineLogo;
+import React, { SVGAttributes } from 'react';
+
+export interface UserTimelineLogoProps extends SVGAttributes<SVGElement> {
+ color?: string;
+}
+
+const UserTimelineLogo: React.FC<UserTimelineLogoProps> = props => {
+ const { color, ...forwardProps } = props;
+ const coercedColor = color ?? 'currentcolor';
+
+ return (
+ <svg viewBox="0 0 100 100" {...forwardProps}>
+ <g fill="none" stroke={coercedColor} strokeWidth="12">
+ <line x1="50" x2="50" y1="0" y2="25" />
+ <circle cx="50" cy="50" r="22" />
+ <line x1="50" x2="50" y1="75" y2="100" />
+ </g>
+ <g fill={color}>
+ <circle cx="85" cy="75" r="10" />
+ <path d="m70,100c0,0 15,-30 30,0.25" />
+ </g>
+ </svg>
+ );
+};
+
+export default UserTimelineLogo;
diff --git a/Timeline/ClientApp/src/common/alert-service.ts b/Timeline/ClientApp/src/app/common/alert-service.ts
index 305752eb..6d3f8af8 100644
--- a/Timeline/ClientApp/src/common/alert-service.ts
+++ b/Timeline/ClientApp/src/app/common/alert-service.ts
@@ -1,59 +1,59 @@
-import pull from 'lodash/pull';
-
-export interface AlertInfo {
- type?: 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info';
- message: string;
-}
-
-export interface AlertInfoEx extends AlertInfo {
- id: number;
-}
-
-export type AlertConsumer = (alerts: AlertInfoEx) => void;
-
-export class AlertService {
- private consumers: AlertConsumer[] = [];
- private savedAlerts: AlertInfoEx[] = [];
- private currentId = 1;
-
- private produce(alert: AlertInfoEx): void {
- for (const consumer of this.consumers) {
- consumer(alert);
- }
- }
-
- registerConsumer(consumer: AlertConsumer): void {
- this.consumers.push(consumer);
- if (this.savedAlerts.length !== 0) {
- for (const alert of this.savedAlerts) {
- this.produce(alert);
- }
- this.savedAlerts = [];
- }
- }
-
- unregisterConsumer(consumer: AlertConsumer): void {
- pull(this.consumers, consumer);
- }
-
- push(alert: AlertInfo): void {
- const newAlert: AlertInfoEx = { ...alert, id: this.currentId++ };
- if (this.consumers.length === 0) {
- this.savedAlerts.push(newAlert);
- } else {
- this.produce(newAlert);
- }
- }
-}
-
-export const alertService = new AlertService();
-
-export function pushAlert(alert: AlertInfo): void {
- alertService.push(alert);
-}
-
-export const kAlertHostId = 'alert-host';
-
-export function getAlertHost(): HTMLElement | null {
- return document.getElementById(kAlertHostId);
-}
+import pull from 'lodash/pull';
+
+export interface AlertInfo {
+ type?: 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info';
+ message: string;
+}
+
+export interface AlertInfoEx extends AlertInfo {
+ id: number;
+}
+
+export type AlertConsumer = (alerts: AlertInfoEx) => void;
+
+export class AlertService {
+ private consumers: AlertConsumer[] = [];
+ private savedAlerts: AlertInfoEx[] = [];
+ private currentId = 1;
+
+ private produce(alert: AlertInfoEx): void {
+ for (const consumer of this.consumers) {
+ consumer(alert);
+ }
+ }
+
+ registerConsumer(consumer: AlertConsumer): void {
+ this.consumers.push(consumer);
+ if (this.savedAlerts.length !== 0) {
+ for (const alert of this.savedAlerts) {
+ this.produce(alert);
+ }
+ this.savedAlerts = [];
+ }
+ }
+
+ unregisterConsumer(consumer: AlertConsumer): void {
+ pull(this.consumers, consumer);
+ }
+
+ push(alert: AlertInfo): void {
+ const newAlert: AlertInfoEx = { ...alert, id: this.currentId++ };
+ if (this.consumers.length === 0) {
+ this.savedAlerts.push(newAlert);
+ } else {
+ this.produce(newAlert);
+ }
+ }
+}
+
+export const alertService = new AlertService();
+
+export function pushAlert(alert: AlertInfo): void {
+ alertService.push(alert);
+}
+
+export const kAlertHostId = 'alert-host';
+
+export function getAlertHost(): HTMLElement | null {
+ return document.getElementById(kAlertHostId);
+}
diff --git a/Timeline/ClientApp/src/common/alert.sass b/Timeline/ClientApp/src/app/common/alert.sass
index c3560b87..5b6e65c2 100644
--- a/Timeline/ClientApp/src/common/alert.sass
+++ b/Timeline/ClientApp/src/app/common/alert.sass
@@ -1,15 +1,15 @@
-.alert-container
- position: fixed
- z-index: $zindex-popover
-
-@include media-breakpoint-up(sm)
- .alert-container
- bottom: 0
- right: 0
-
-@include media-breakpoint-down(sm)
- .alert-container
- bottom: 0
- right: 0
- left: 0
- text-align: center
+.alert-container
+ position: fixed
+ z-index: $zindex-popover
+
+@include media-breakpoint-up(sm)
+ .alert-container
+ bottom: 0
+ right: 0
+
+@include media-breakpoint-down(sm)
+ .alert-container
+ bottom: 0
+ right: 0
+ left: 0
+ text-align: center
diff --git a/Timeline/ClientApp/src/common/common.sass b/Timeline/ClientApp/src/app/common/common.sass
index 78e6fd14..15d34d7c 100644
--- a/Timeline/ClientApp/src/common/common.sass
+++ b/Timeline/ClientApp/src/app/common/common.sass
@@ -1,33 +1,33 @@
-.image-cropper-container
- position: relative
- box-sizing: border-box
- user-select: none
-
-.image-cropper-container img
- position: absolute
- left: 0
- top: 0
- width: 100%
- height: 100%
-
-.image-cropper-mask-container
- position: absolute
- left: 0
- top: 0
- right: 0
- bottom: 0
- overflow: hidden
-
-.image-cropper-mask
- position: absolute
- box-shadow: 0 0 0 10000px rgba(255, 255, 255, 80%)
- touch-action: none
-
-.image-cropper-handler
- position: absolute
- width: 26px
- height: 26px
- border: black solid 2px
- border-radius: 50%
- background: white
- touch-action: none
+.image-cropper-container
+ position: relative
+ box-sizing: border-box
+ user-select: none
+
+.image-cropper-container img
+ position: absolute
+ left: 0
+ top: 0
+ width: 100%
+ height: 100%
+
+.image-cropper-mask-container
+ position: absolute
+ left: 0
+ top: 0
+ right: 0
+ bottom: 0
+ overflow: hidden
+
+.image-cropper-mask
+ position: absolute
+ box-shadow: 0 0 0 10000px rgba(255, 255, 255, 80%)
+ touch-action: none
+
+.image-cropper-handler
+ position: absolute
+ width: 26px
+ height: 26px
+ border: black solid 2px
+ border-radius: 50%
+ background: white
+ touch-action: none
diff --git a/Timeline/ClientApp/src/config.ts b/Timeline/ClientApp/src/app/config.ts
index d06c3332..081a2cf6 100644
--- a/Timeline/ClientApp/src/config.ts
+++ b/Timeline/ClientApp/src/app/config.ts
@@ -1 +1 @@
-export const apiBaseUrl = '/api';
+export const apiBaseUrl = '/api';
diff --git a/Timeline/ClientApp/src/data/base64.ts b/Timeline/ClientApp/src/app/data/base64.ts
index 6d846527..7f7c6fcc 100644
--- a/Timeline/ClientApp/src/data/base64.ts
+++ b/Timeline/ClientApp/src/app/data/base64.ts
@@ -1,9 +1,9 @@
-export function base64(blob: Blob): Promise<string> {
- return new Promise<string>(resolve => {
- const reader = new FileReader();
- reader.onload = function() {
- resolve((reader.result as string).replace(/^data:.+;base64,/, ''));
- };
- reader.readAsDataURL(blob);
- });
-}
+export function base64(blob: Blob): Promise<string> {
+ return new Promise<string>(resolve => {
+ const reader = new FileReader();
+ reader.onload = function() {
+ resolve((reader.result as string).replace(/^data:.+;base64,/, ''));
+ };
+ reader.readAsDataURL(blob);
+ });
+}
diff --git a/Timeline/ClientApp/src/data/common.ts b/Timeline/ClientApp/src/app/data/common.ts
index e2882a80..1cdf93f1 100644
--- a/Timeline/ClientApp/src/data/common.ts
+++ b/Timeline/ClientApp/src/app/data/common.ts
@@ -1,25 +1,25 @@
-import { AxiosError } from 'axios';
-
-export function extractStatusCode(error: AxiosError): number | null {
- const code = error.response && error.response.status;
- if (typeof code === 'number') {
- return code;
- } else {
- return null;
- }
-}
-
-export interface CommonErrorResponse {
- code: number;
- message: string;
-}
-
-export function extractErrorCode(error: AxiosError): number | null {
- const { response } = error as AxiosError<CommonErrorResponse>;
- const code = response && response.data && response.data.code;
- if (typeof code === 'number') {
- return code;
- } else {
- return null;
- }
-}
+import { AxiosError } from 'axios';
+
+export function extractStatusCode(error: AxiosError): number | null {
+ const code = error.response && error.response.status;
+ if (typeof code === 'number') {
+ return code;
+ } else {
+ return null;
+ }
+}
+
+export interface CommonErrorResponse {
+ code: number;
+ message: string;
+}
+
+export function extractErrorCode(error: AxiosError): number | null {
+ const { response } = error as AxiosError<CommonErrorResponse>;
+ const code = response && response.data && response.data.code;
+ if (typeof code === 'number') {
+ return code;
+ } else {
+ return null;
+ }
+}
diff --git a/Timeline/ClientApp/src/data/timeline.ts b/Timeline/ClientApp/src/app/data/timeline.ts
index 66d9e57a..19bb3d45 100644
--- a/Timeline/ClientApp/src/data/timeline.ts
+++ b/Timeline/ClientApp/src/app/data/timeline.ts
@@ -1,345 +1,345 @@
-import axios from 'axios';
-import XRegExp from 'xregexp';
-
-import { base64 } from './base64';
-import { apiBaseUrl } from '../config';
-import { User, UserAuthInfo, getCurrentUser, UserWithToken } from './user';
-import { UiLogicError } from '../common';
-
-export const kTimelineVisibilities = ['Public', 'Register', 'Private'] as const;
-
-export type TimelineVisibility = typeof kTimelineVisibilities[number];
-
-export const timelineVisibilityTooltipTranslationMap: Record<
- TimelineVisibility,
- string
-> = {
- Public: 'timeline.visibilityTooltip.public',
- Register: 'timeline.visibilityTooltip.register',
- Private: 'timeline.visibilityTooltip.private',
-};
-
-export interface TimelineInfo {
- name: string;
- description: string;
- owner: User;
- visibility: TimelineVisibility;
- members: User[];
- _links: {
- posts: string;
- };
-}
-
-export interface TimelinePostTextContent {
- type: 'text';
- text: string;
-}
-
-export interface TimelinePostImageContent {
- type: 'image';
- url: string;
-}
-
-export type TimelinePostContent =
- | TimelinePostTextContent
- | TimelinePostImageContent;
-
-export interface TimelinePostInfo {
- id: number;
- content: TimelinePostContent;
- time: Date;
- author: User;
-}
-
-export interface CreatePostRequestTextContent {
- type: 'text';
- text: string;
-}
-
-export interface CreatePostRequestImageContent {
- type: 'image';
- data: Blob;
-}
-
-export type CreatePostRequestContent =
- | CreatePostRequestTextContent
- | CreatePostRequestImageContent;
-
-export interface CreatePostRequest {
- content: CreatePostRequestContent;
- time?: Date;
-}
-
-// TODO: Remove in the future
-export interface TimelineChangePropertyRequest {
- visibility?: TimelineVisibility;
- description?: string;
-}
-
-export interface PersonalTimelineChangePropertyRequest {
- visibility?: TimelineVisibility;
- description?: string;
-}
-
-export interface OrdinaryTimelineChangePropertyRequest {
- // not supported by server now
- // name?: string;
- visibility?: TimelineVisibility;
- description?: string;
-}
-
-//-------------------- begin: internal model --------------------
-
-interface RawTimelinePostTextContent {
- type: 'text';
- text: string;
-}
-
-interface RawTimelinePostImageContent {
- type: 'image';
- url: string;
-}
-
-type RawTimelinePostContent =
- | RawTimelinePostTextContent
- | RawTimelinePostImageContent;
-
-interface RawTimelinePostInfo {
- id: number;
- content: RawTimelinePostContent;
- time: string;
- author: User;
-}
-
-interface RawCreatePostRequestTextContent {
- type: 'text';
- text: string;
-}
-
-interface RawCreatePostRequestImageContent {
- type: 'text';
- data: string;
-}
-
-type RawCreatePostRequestContent =
- | RawCreatePostRequestTextContent
- | RawCreatePostRequestImageContent;
-
-interface RawCreatePostRequest {
- content: RawCreatePostRequestContent;
- time?: string;
-}
-
-//-------------------- end: internal model --------------------
-
-function processRawTimelinePostInfo(
- raw: RawTimelinePostInfo,
- token?: string
-): TimelinePostInfo {
- return {
- ...raw,
- content: (() => {
- if (raw.content.type === 'image' && token != null) {
- return {
- ...raw.content,
- url: raw.content.url + '?token=' + token,
- };
- }
- return raw.content;
- })(),
- time: new Date(raw.time),
- };
-}
-
-type TimelineUrlResolver = (name: string) => string;
-
-export class TimelineServiceTemplate<
- TTimeline extends TimelineInfo,
- TChangePropertyRequest
-> {
- private checkUser(): UserWithToken {
- const user = getCurrentUser();
- if (user == null) {
- throw new UiLogicError('You must login to perform the operation.');
- }
- return user;
- }
-
- constructor(private urlResolver: TimelineUrlResolver) {}
-
- changeProperty(
- name: string,
- req: TChangePropertyRequest
- ): Promise<TTimeline> {
- const user = this.checkUser();
-
- return axios
- .patch<TTimeline>(`${this.urlResolver(name)}?token=${user.token}`, req)
- .then((res) => res.data);
- }
-
- fetch(name: string): Promise<TTimeline> {
- return axios
- .get<TTimeline>(`${this.urlResolver(name)}`)
- .then((res) => res.data);
- }
-
- fetchPosts(name: string): Promise<TimelinePostInfo[]> {
- const token = getCurrentUser()?.token;
- return axios
- .get<RawTimelinePostInfo[]>(
- token == null
- ? `${this.urlResolver(name)}/posts`
- : `${this.urlResolver(name)}/posts?token=${token}`
- )
- .then((res) => res.data.map((p) => processRawTimelinePostInfo(p, token)));
- }
-
- createPost(
- name: string,
- request: CreatePostRequest
- ): Promise<TimelinePostInfo> {
- const user = this.checkUser();
-
- const rawReq: Promise<RawCreatePostRequest> = new Promise<
- RawCreatePostRequestContent
- >((resolve) => {
- if (request.content.type === 'image') {
- void base64(request.content.data).then((d) =>
- resolve({
- ...request.content,
- data: d,
- } as RawCreatePostRequestImageContent)
- );
- } else {
- resolve(request.content);
- }
- }).then((content) => {
- const rawReq: RawCreatePostRequest = {
- content,
- };
- if (request.time != null) {
- rawReq.time = request.time.toISOString();
- }
- return rawReq;
- });
-
- return rawReq
- .then((req) =>
- axios.post<RawTimelinePostInfo>(
- `${this.urlResolver(name)}/posts?token=${user.token}`,
- req
- )
- )
- .then((res) => processRawTimelinePostInfo(res.data, user.token));
- }
-
- deletePost(name: string, id: number): Promise<void> {
- const user = this.checkUser();
-
- return axios.delete(
- `${this.urlResolver(name)}/posts/${id}?token=${user.token}`
- );
- }
-
- addMember(name: string, username: string): Promise<void> {
- const user = this.checkUser();
-
- return axios.put(
- `${this.urlResolver(name)}/members/${username}?token=${user.token}`
- );
- }
-
- removeMember(name: string, username: string): Promise<void> {
- const user = this.checkUser();
-
- return axios.delete(
- `${this.urlResolver(name)}/members/${username}?token=${user.token}`
- );
- }
-
- isMemberOf(username: string, timeline: TTimeline): boolean {
- return timeline.members.findIndex((m) => m.username == username) >= 0;
- }
-
- hasReadPermission(
- user: UserAuthInfo | null | undefined,
- timeline: TTimeline
- ): boolean {
- if (user != null && user.administrator) return true;
-
- const { visibility } = timeline;
- if (visibility === 'Public') {
- return true;
- } else if (visibility === 'Register') {
- if (user != null) return true;
- } else if (visibility === 'Private') {
- if (user != null && this.isMemberOf(user.username, timeline)) {
- return true;
- }
- }
- return false;
- }
-
- hasPostPermission(
- user: UserAuthInfo | null | undefined,
- timeline: TTimeline
- ): boolean {
- if (user != null && user.administrator) return true;
-
- return (
- user != null &&
- (timeline.owner.username === user.username ||
- this.isMemberOf(user.username, timeline))
- );
- }
-
- hasManagePermission(
- user: UserAuthInfo | null | undefined,
- timeline: TTimeline
- ): boolean {
- if (user != null && user.administrator) return true;
-
- return user != null && user.username == timeline.owner.username;
- }
-
- hasModifyPostPermission(
- user: UserAuthInfo | null | undefined,
- timeline: TTimeline,
- post: TimelinePostInfo
- ): boolean {
- if (user != null && user.administrator) return true;
-
- return (
- user != null &&
- (user.username === timeline.owner.username ||
- user.username === post.author.username)
- );
- }
-}
-
-export type PersonalTimelineService = TimelineServiceTemplate<
- TimelineInfo,
- PersonalTimelineChangePropertyRequest
->;
-
-export const personalTimelineService: PersonalTimelineService = new TimelineServiceTemplate<
- TimelineInfo,
- PersonalTimelineChangePropertyRequest
->((name) => `${apiBaseUrl}/timelines/@${name}`);
-
-export type OrdinaryTimelineService = TimelineServiceTemplate<
- TimelineInfo,
- OrdinaryTimelineChangePropertyRequest
->;
-
-export const ordinaryTimelineService: OrdinaryTimelineService = new TimelineServiceTemplate<
- TimelineInfo,
- TimelineChangePropertyRequest
->((name) => `${apiBaseUrl}/timelines/${name}`);
-
-const timelineNameReg = XRegExp('^[-_\\p{L}]*$', 'u');
-
-export function validateTimelineName(name: string): boolean {
- return timelineNameReg.test(name);
-}
+import axios from 'axios';
+import XRegExp from 'xregexp';
+
+import { base64 } from './base64';
+import { apiBaseUrl } from '../config';
+import { User, UserAuthInfo, getCurrentUser, UserWithToken } from './user';
+import { UiLogicError } from '../common';
+
+export const kTimelineVisibilities = ['Public', 'Register', 'Private'] as const;
+
+export type TimelineVisibility = typeof kTimelineVisibilities[number];
+
+export const timelineVisibilityTooltipTranslationMap: Record<
+ TimelineVisibility,
+ string
+> = {
+ Public: 'timeline.visibilityTooltip.public',
+ Register: 'timeline.visibilityTooltip.register',
+ Private: 'timeline.visibilityTooltip.private',
+};
+
+export interface TimelineInfo {
+ name: string;
+ description: string;
+ owner: User;
+ visibility: TimelineVisibility;
+ members: User[];
+ _links: {
+ posts: string;
+ };
+}
+
+export interface TimelinePostTextContent {
+ type: 'text';
+ text: string;
+}
+
+export interface TimelinePostImageContent {
+ type: 'image';
+ url: string;
+}
+
+export type TimelinePostContent =
+ | TimelinePostTextContent
+ | TimelinePostImageContent;
+
+export interface TimelinePostInfo {
+ id: number;
+ content: TimelinePostContent;
+ time: Date;
+ author: User;
+}
+
+export interface CreatePostRequestTextContent {
+ type: 'text';
+ text: string;
+}
+
+export interface CreatePostRequestImageContent {
+ type: 'image';
+ data: Blob;
+}
+
+export type CreatePostRequestContent =
+ | CreatePostRequestTextContent
+ | CreatePostRequestImageContent;
+
+export interface CreatePostRequest {
+ content: CreatePostRequestContent;
+ time?: Date;
+}
+
+// TODO: Remove in the future
+export interface TimelineChangePropertyRequest {
+ visibility?: TimelineVisibility;
+ description?: string;
+}
+
+export interface PersonalTimelineChangePropertyRequest {
+ visibility?: TimelineVisibility;
+ description?: string;
+}
+
+export interface OrdinaryTimelineChangePropertyRequest {
+ // not supported by server now
+ // name?: string;
+ visibility?: TimelineVisibility;
+ description?: string;
+}
+
+//-------------------- begin: internal model --------------------
+
+interface RawTimelinePostTextContent {
+ type: 'text';
+ text: string;
+}
+
+interface RawTimelinePostImageContent {
+ type: 'image';
+ url: string;
+}
+
+type RawTimelinePostContent =
+ | RawTimelinePostTextContent
+ | RawTimelinePostImageContent;
+
+interface RawTimelinePostInfo {
+ id: number;
+ content: RawTimelinePostContent;
+ time: string;
+ author: User;
+}
+
+interface RawCreatePostRequestTextContent {
+ type: 'text';
+ text: string;
+}
+
+interface RawCreatePostRequestImageContent {
+ type: 'text';
+ data: string;
+}
+
+type RawCreatePostRequestContent =
+ | RawCreatePostRequestTextContent
+ | RawCreatePostRequestImageContent;
+
+interface RawCreatePostRequest {
+ content: RawCreatePostRequestContent;
+ time?: string;
+}
+
+//-------------------- end: internal model --------------------
+
+function processRawTimelinePostInfo(
+ raw: RawTimelinePostInfo,
+ token?: string
+): TimelinePostInfo {
+ return {
+ ...raw,
+ content: (() => {
+ if (raw.content.type === 'image' && token != null) {
+ return {
+ ...raw.content,
+ url: raw.content.url + '?token=' + token,
+ };
+ }
+ return raw.content;
+ })(),
+ time: new Date(raw.time),
+ };
+}
+
+type TimelineUrlResolver = (name: string) => string;
+
+export class TimelineServiceTemplate<
+ TTimeline extends TimelineInfo,
+ TChangePropertyRequest
+> {
+ private checkUser(): UserWithToken {
+ const user = getCurrentUser();
+ if (user == null) {
+ throw new UiLogicError('You must login to perform the operation.');
+ }
+ return user;
+ }
+
+ constructor(private urlResolver: TimelineUrlResolver) {}
+
+ changeProperty(
+ name: string,
+ req: TChangePropertyRequest
+ ): Promise<TTimeline> {
+ const user = this.checkUser();
+
+ return axios
+ .patch<TTimeline>(`${this.urlResolver(name)}?token=${user.token}`, req)
+ .then((res) => res.data);
+ }
+
+ fetch(name: string): Promise<TTimeline> {
+ return axios
+ .get<TTimeline>(`${this.urlResolver(name)}`)
+ .then((res) => res.data);
+ }
+
+ fetchPosts(name: string): Promise<TimelinePostInfo[]> {
+ const token = getCurrentUser()?.token;
+ return axios
+ .get<RawTimelinePostInfo[]>(
+ token == null
+ ? `${this.urlResolver(name)}/posts`
+ : `${this.urlResolver(name)}/posts?token=${token}`
+ )
+ .then((res) => res.data.map((p) => processRawTimelinePostInfo(p, token)));
+ }
+
+ createPost(
+ name: string,
+ request: CreatePostRequest
+ ): Promise<TimelinePostInfo> {
+ const user = this.checkUser();
+
+ const rawReq: Promise<RawCreatePostRequest> = new Promise<
+ RawCreatePostRequestContent
+ >((resolve) => {
+ if (request.content.type === 'image') {
+ void base64(request.content.data).then((d) =>
+ resolve({
+ ...request.content,
+ data: d,
+ } as RawCreatePostRequestImageContent)
+ );
+ } else {
+ resolve(request.content);
+ }
+ }).then((content) => {
+ const rawReq: RawCreatePostRequest = {
+ content,
+ };
+ if (request.time != null) {
+ rawReq.time = request.time.toISOString();
+ }
+ return rawReq;
+ });
+
+ return rawReq
+ .then((req) =>
+ axios.post<RawTimelinePostInfo>(
+ `${this.urlResolver(name)}/posts?token=${user.token}`,
+ req
+ )
+ )
+ .then((res) => processRawTimelinePostInfo(res.data, user.token));
+ }
+
+ deletePost(name: string, id: number): Promise<void> {
+ const user = this.checkUser();
+
+ return axios.delete(
+ `${this.urlResolver(name)}/posts/${id}?token=${user.token}`
+ );
+ }
+
+ addMember(name: string, username: string): Promise<void> {
+ const user = this.checkUser();
+
+ return axios.put(
+ `${this.urlResolver(name)}/members/${username}?token=${user.token}`
+ );
+ }
+
+ removeMember(name: string, username: string): Promise<void> {
+ const user = this.checkUser();
+
+ return axios.delete(
+ `${this.urlResolver(name)}/members/${username}?token=${user.token}`
+ );
+ }
+
+ isMemberOf(username: string, timeline: TTimeline): boolean {
+ return timeline.members.findIndex((m) => m.username == username) >= 0;
+ }
+
+ hasReadPermission(
+ user: UserAuthInfo | null | undefined,
+ timeline: TTimeline
+ ): boolean {
+ if (user != null && user.administrator) return true;
+
+ const { visibility } = timeline;
+ if (visibility === 'Public') {
+ return true;
+ } else if (visibility === 'Register') {
+ if (user != null) return true;
+ } else if (visibility === 'Private') {
+ if (user != null && this.isMemberOf(user.username, timeline)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ hasPostPermission(
+ user: UserAuthInfo | null | undefined,
+ timeline: TTimeline
+ ): boolean {
+ if (user != null && user.administrator) return true;
+
+ return (
+ user != null &&
+ (timeline.owner.username === user.username ||
+ this.isMemberOf(user.username, timeline))
+ );
+ }
+
+ hasManagePermission(
+ user: UserAuthInfo | null | undefined,
+ timeline: TTimeline
+ ): boolean {
+ if (user != null && user.administrator) return true;
+
+ return user != null && user.username == timeline.owner.username;
+ }
+
+ hasModifyPostPermission(
+ user: UserAuthInfo | null | undefined,
+ timeline: TTimeline,
+ post: TimelinePostInfo
+ ): boolean {
+ if (user != null && user.administrator) return true;
+
+ return (
+ user != null &&
+ (user.username === timeline.owner.username ||
+ user.username === post.author.username)
+ );
+ }
+}
+
+export type PersonalTimelineService = TimelineServiceTemplate<
+ TimelineInfo,
+ PersonalTimelineChangePropertyRequest
+>;
+
+export const personalTimelineService: PersonalTimelineService = new TimelineServiceTemplate<
+ TimelineInfo,
+ PersonalTimelineChangePropertyRequest
+>((name) => `${apiBaseUrl}/timelines/@${name}`);
+
+export type OrdinaryTimelineService = TimelineServiceTemplate<
+ TimelineInfo,
+ OrdinaryTimelineChangePropertyRequest
+>;
+
+export const ordinaryTimelineService: OrdinaryTimelineService = new TimelineServiceTemplate<
+ TimelineInfo,
+ TimelineChangePropertyRequest
+>((name) => `${apiBaseUrl}/timelines/${name}`);
+
+const timelineNameReg = XRegExp('^[-_\\p{L}]*$', 'u');
+
+export function validateTimelineName(name: string): boolean {
+ return timelineNameReg.test(name);
+}
diff --git a/Timeline/ClientApp/src/data/user.ts b/Timeline/ClientApp/src/app/data/user.ts
index b85f4f27..8f787478 100644
--- a/Timeline/ClientApp/src/data/user.ts
+++ b/Timeline/ClientApp/src/app/data/user.ts
@@ -1,224 +1,224 @@
-import axios, { AxiosError } from 'axios';
-import { useState, useEffect } from 'react';
-import { BehaviorSubject, Observable } from 'rxjs';
-
-import { apiBaseUrl } from '../config';
-import { extractErrorCode } from './common';
-import { pushAlert } from '../common/alert-service';
-import { i18nPromise } from '../i18n';
-import { UiLogicError } from '../common';
-
-export interface UserAuthInfo {
- username: string;
- administrator: boolean;
-}
-
-export interface User {
- username: string;
- administrator: boolean;
- nickname: string;
- _links: {
- avatar: string;
- timeline: string;
- };
-}
-
-export interface UserWithToken extends User {
- token: string;
-}
-
-interface CreateTokenRequest {
- username: string;
- password: string;
-}
-
-interface CreateTokenResponse {
- token: string;
- user: User;
-}
-
-interface VerifyTokenRequest {
- token: string;
-}
-
-interface VerifyTokenResponse {
- user: User;
-}
-
-export type LoginCredentials = CreateTokenRequest;
-
-const userSubject = new BehaviorSubject<UserWithToken | null | undefined>(
- undefined
-);
-
-export const user$: Observable<UserWithToken | null | undefined> = userSubject;
-
-export function getCurrentUser(): UserWithToken | null | undefined {
- return userSubject.value;
-}
-
-const kCreateTokenUrl = '/token/create';
-const kVerifyTokenUrl = '/token/verify';
-const createTokenUrl = apiBaseUrl + kCreateTokenUrl;
-const verifyTokenUrl = apiBaseUrl + kVerifyTokenUrl;
-
-function verifyToken(token: string): Promise<User> {
- return axios
- .post<VerifyTokenResponse>(verifyTokenUrl, {
- token: token,
- } as VerifyTokenRequest)
- .then((res) => res.data.user);
-}
-
-const TOKEN_STORAGE_KEY = 'token';
-
-export function checkUserLoginState(): Promise<UserWithToken | null> {
- if (getCurrentUser() !== undefined)
- throw new UiLogicError("Already checked user. Can't check twice.");
-
- const savedToken = window.localStorage.getItem(TOKEN_STORAGE_KEY);
- if (savedToken) {
- return verifyToken(savedToken)
- .then(
- (u) => {
- const user: UserWithToken = {
- ...u,
- token: savedToken,
- };
- void i18nPromise.then((t) => {
- pushAlert({
- type: 'success',
- message: t('user.welcomeBack'),
- });
- });
- return user;
- },
- (e: AxiosError) => {
- if (e.response != null) {
- window.localStorage.removeItem(TOKEN_STORAGE_KEY);
- void i18nPromise.then((t) => {
- pushAlert({
- type: 'danger',
- message: t('user.verifyTokenFailed'),
- });
- });
- } else {
- void i18nPromise.then((t) => {
- pushAlert({
- type: 'danger',
- message: t('user.verifyTokenFailedNetwork'),
- });
- });
- }
-
- return null;
- }
- )
- .then((u) => {
- userSubject.next(u);
- return u;
- });
- }
- userSubject.next(null);
- return Promise.resolve(null);
-}
-
-export class BadCredentialError {
- constructor(public innerError: Error) {}
-
- message = 'login.badCredential';
-}
-
-export function userLogin(
- credentials: LoginCredentials,
- rememberMe: boolean
-): Promise<UserWithToken> {
- if (getCurrentUser()) {
- throw new UiLogicError('Already login.');
- }
- return axios
- .post<CreateTokenResponse>(createTokenUrl, { ...credentials, expire: 30 })
- .catch((e: AxiosError) => {
- if (extractErrorCode(e) === 11010101) {
- throw new BadCredentialError(e);
- }
- throw e;
- })
- .then((res) => {
- const body = res.data;
- const token = body.token;
- if (rememberMe) {
- window.localStorage.setItem(TOKEN_STORAGE_KEY, token);
- }
- const user = {
- ...body.user,
- token,
- };
- userSubject.next(user);
- return user;
- });
-}
-
-export function userLogout(): void {
- if (getCurrentUser() === undefined) {
- throw new UiLogicError('Please check user first.');
- }
- if (getCurrentUser() === null) {
- throw new UiLogicError('No login.');
- }
- window.localStorage.removeItem(TOKEN_STORAGE_KEY);
- userSubject.next(null);
-}
-
-export function useOptionalUser(): UserWithToken | null | undefined {
- const [user, setUser] = useState<UserWithToken | null | undefined>(
- userSubject.value
- );
- useEffect(() => {
- const sub = user$.subscribe((u) => setUser(u));
- return () => {
- sub.unsubscribe();
- };
- });
- return user;
-}
-
-export function useUser(): UserWithToken | null {
- const [user, setUser] = useState<UserWithToken | null>(() => {
- const initUser = userSubject.value;
- if (initUser === undefined) {
- throw new UiLogicError(
- "This is a logic error in user module. Current user can't be undefined in useUser."
- );
- }
- return initUser;
- });
- useEffect(() => {
- const sub = user$.subscribe((u) => {
- if (u === undefined) {
- throw new UiLogicError(
- "This is a logic error in user module. User emitted can't be undefined later."
- );
- }
- setUser(u);
- });
- return () => {
- sub.unsubscribe();
- };
- });
- return user;
-}
-
-export function useUserLoggedIn(): UserWithToken {
- const user = useUser();
- if (user == null) {
- throw new UiLogicError('You assert user has logged in but actually not.');
- }
- return user;
-}
-
-export function fetchUser(username: string): Promise<User> {
- return axios
- .get<User>(`${apiBaseUrl}/users/${username}`)
- .then((res) => res.data);
-}
+import axios, { AxiosError } from 'axios';
+import { useState, useEffect } from 'react';
+import { BehaviorSubject, Observable } from 'rxjs';
+
+import { apiBaseUrl } from '../config';
+import { extractErrorCode } from './common';
+import { pushAlert } from '../common/alert-service';
+import { i18nPromise } from '../i18n';
+import { UiLogicError } from '../common';
+
+export interface UserAuthInfo {
+ username: string;
+ administrator: boolean;
+}
+
+export interface User {
+ username: string;
+ administrator: boolean;
+ nickname: string;
+ _links: {
+ avatar: string;
+ timeline: string;
+ };
+}
+
+export interface UserWithToken extends User {
+ token: string;
+}
+
+interface CreateTokenRequest {
+ username: string;
+ password: string;
+}
+
+interface CreateTokenResponse {
+ token: string;
+ user: User;
+}
+
+interface VerifyTokenRequest {
+ token: string;
+}
+
+interface VerifyTokenResponse {
+ user: User;
+}
+
+export type LoginCredentials = CreateTokenRequest;
+
+const userSubject = new BehaviorSubject<UserWithToken | null | undefined>(
+ undefined
+);
+
+export const user$: Observable<UserWithToken | null | undefined> = userSubject;
+
+export function getCurrentUser(): UserWithToken | null | undefined {
+ return userSubject.value;
+}
+
+const kCreateTokenUrl = '/token/create';
+const kVerifyTokenUrl = '/token/verify';
+const createTokenUrl = apiBaseUrl + kCreateTokenUrl;
+const verifyTokenUrl = apiBaseUrl + kVerifyTokenUrl;
+
+function verifyToken(token: string): Promise<User> {
+ return axios
+ .post<VerifyTokenResponse>(verifyTokenUrl, {
+ token: token,
+ } as VerifyTokenRequest)
+ .then((res) => res.data.user);
+}
+
+const TOKEN_STORAGE_KEY = 'token';
+
+export function checkUserLoginState(): Promise<UserWithToken | null> {
+ if (getCurrentUser() !== undefined)
+ throw new UiLogicError("Already checked user. Can't check twice.");
+
+ const savedToken = window.localStorage.getItem(TOKEN_STORAGE_KEY);
+ if (savedToken) {
+ return verifyToken(savedToken)
+ .then(
+ (u) => {
+ const user: UserWithToken = {
+ ...u,
+ token: savedToken,
+ };
+ void i18nPromise.then((t) => {
+ pushAlert({
+ type: 'success',
+ message: t('user.welcomeBack'),
+ });
+ });
+ return user;
+ },
+ (e: AxiosError) => {
+ if (e.response != null) {
+ window.localStorage.removeItem(TOKEN_STORAGE_KEY);
+ void i18nPromise.then((t) => {
+ pushAlert({
+ type: 'danger',
+ message: t('user.verifyTokenFailed'),
+ });
+ });
+ } else {
+ void i18nPromise.then((t) => {
+ pushAlert({
+ type: 'danger',
+ message: t('user.verifyTokenFailedNetwork'),
+ });
+ });
+ }
+
+ return null;
+ }
+ )
+ .then((u) => {
+ userSubject.next(u);
+ return u;
+ });
+ }
+ userSubject.next(null);
+ return Promise.resolve(null);
+}
+
+export class BadCredentialError {
+ constructor(public innerError: Error) {}
+
+ message = 'login.badCredential';
+}
+
+export function userLogin(
+ credentials: LoginCredentials,
+ rememberMe: boolean
+): Promise<UserWithToken> {
+ if (getCurrentUser()) {
+ throw new UiLogicError('Already login.');
+ }
+ return axios
+ .post<CreateTokenResponse>(createTokenUrl, { ...credentials, expire: 30 })
+ .catch((e: AxiosError) => {
+ if (extractErrorCode(e) === 11010101) {
+ throw new BadCredentialError(e);
+ }
+ throw e;
+ })
+ .then((res) => {
+ const body = res.data;
+ const token = body.token;
+ if (rememberMe) {
+ window.localStorage.setItem(TOKEN_STORAGE_KEY, token);
+ }
+ const user = {
+ ...body.user,
+ token,
+ };
+ userSubject.next(user);
+ return user;
+ });
+}
+
+export function userLogout(): void {
+ if (getCurrentUser() === undefined) {
+ throw new UiLogicError('Please check user first.');
+ }
+ if (getCurrentUser() === null) {
+ throw new UiLogicError('No login.');
+ }
+ window.localStorage.removeItem(TOKEN_STORAGE_KEY);
+ userSubject.next(null);
+}
+
+export function useOptionalUser(): UserWithToken | null | undefined {
+ const [user, setUser] = useState<UserWithToken | null | undefined>(
+ userSubject.value
+ );
+ useEffect(() => {
+ const sub = user$.subscribe((u) => setUser(u));
+ return () => {
+ sub.unsubscribe();
+ };
+ });
+ return user;
+}
+
+export function useUser(): UserWithToken | null {
+ const [user, setUser] = useState<UserWithToken | null>(() => {
+ const initUser = userSubject.value;
+ if (initUser === undefined) {
+ throw new UiLogicError(
+ "This is a logic error in user module. Current user can't be undefined in useUser."
+ );
+ }
+ return initUser;
+ });
+ useEffect(() => {
+ const sub = user$.subscribe((u) => {
+ if (u === undefined) {
+ throw new UiLogicError(
+ "This is a logic error in user module. User emitted can't be undefined later."
+ );
+ }
+ setUser(u);
+ });
+ return () => {
+ sub.unsubscribe();
+ };
+ });
+ return user;
+}
+
+export function useUserLoggedIn(): UserWithToken {
+ const user = useUser();
+ if (user == null) {
+ throw new UiLogicError('You assert user has logged in but actually not.');
+ }
+ return user;
+}
+
+export function fetchUser(username: string): Promise<User> {
+ return axios
+ .get<User>(`${apiBaseUrl}/users/${username}`)
+ .then((res) => res.data);
+}
diff --git a/Timeline/ClientApp/src/helper.ts b/Timeline/ClientApp/src/app/helper.ts
index 904dcbbe..b48829b5 100644
--- a/Timeline/ClientApp/src/helper.ts
+++ b/Timeline/ClientApp/src/app/helper.ts
@@ -1,35 +1,35 @@
-//copied from https://stackoverflow.com/questions/5999118/how-can-i-add-or-update-a-query-string-parameter
-export function updateQueryString(
- key: string,
- value?: string | null,
- url?: string
-): string {
- if (!url) url = window.location.href;
- const re = new RegExp('([?&])' + key + '=.*?(&|#|$)(.*)', 'gi');
- let hash;
-
- if (re.test(url)) {
- if (typeof value !== 'undefined' && value !== null) {
- return url.replace(re, '$1' + key + '=' + value + '$2$3');
- } else {
- hash = url.split('#');
- url = hash[0].replace(re, '$1$3').replace(/(&|\?)$/, '');
- if (typeof hash[1] !== 'undefined' && hash[1] !== null) {
- url += '#' + hash[1];
- }
- return url;
- }
- } else {
- if (typeof value !== 'undefined' && value !== null) {
- const separator = url.includes('?') ? '&' : '?';
- hash = url.split('#');
- url = hash[0] + separator + key + '=' + value;
- if (typeof hash[1] !== 'undefined' && hash[1] !== null) {
- url += '#' + hash[1];
- }
- return url;
- } else {
- return url;
- }
- }
-}
+//copied from https://stackoverflow.com/questions/5999118/how-can-i-add-or-update-a-query-string-parameter
+export function updateQueryString(
+ key: string,
+ value?: string | null,
+ url?: string
+): string {
+ if (!url) url = window.location.href;
+ const re = new RegExp('([?&])' + key + '=.*?(&|#|$)(.*)', 'gi');
+ let hash;
+
+ if (re.test(url)) {
+ if (typeof value !== 'undefined' && value !== null) {
+ return url.replace(re, '$1' + key + '=' + value + '$2$3');
+ } else {
+ hash = url.split('#');
+ url = hash[0].replace(re, '$1$3').replace(/(&|\?)$/, '');
+ if (typeof hash[1] !== 'undefined' && hash[1] !== null) {
+ url += '#' + hash[1];
+ }
+ return url;
+ }
+ } else {
+ if (typeof value !== 'undefined' && value !== null) {
+ const separator = url.includes('?') ? '&' : '?';
+ hash = url.split('#');
+ url = hash[0] + separator + key + '=' + value;
+ if (typeof hash[1] !== 'undefined' && hash[1] !== null) {
+ url += '#' + hash[1];
+ }
+ return url;
+ } else {
+ return url;
+ }
+ }
+}
diff --git a/Timeline/ClientApp/src/home/Home.tsx b/Timeline/ClientApp/src/app/home/Home.tsx
index 495781d0..775a1a87 100644
--- a/Timeline/ClientApp/src/home/Home.tsx
+++ b/Timeline/ClientApp/src/app/home/Home.tsx
@@ -1,155 +1,155 @@
-import React from 'react';
-import { useHistory } from 'react-router';
-import { Row, Container, Button, Col } from 'reactstrap';
-import { useTranslation } from 'react-i18next';
-import axios from 'axios';
-
-import { apiBaseUrl } from '../config';
-import { useUser } from '../data/user';
-import { TimelineInfo } from '../data/timeline';
-
-import AppBar from '../common/AppBar';
-import SearchInput from '../common/SearchInput';
-import TimelineBoardAreaWithoutUser from './TimelineBoardAreaWithoutUser';
-import TimelineBoardAreaWithUser from './TimelineBoardAreaWithUser';
-import TimelineCreateDialog from './TimelineCreateDialog';
-
-const Home: React.FC = (_) => {
- const history = useHistory();
-
- const { t } = useTranslation();
-
- const user = useUser();
-
- const [navText, setNavText] = React.useState<string>('');
-
- const [publicTimelines, setPublicTimelines] = React.useState<
- TimelineInfo[] | undefined
- >(undefined);
- const [ownTimelines, setOwnTimelines] = React.useState<
- TimelineInfo[] | undefined
- >(undefined);
- const [joinTimelines, setJoinTimelines] = React.useState<
- TimelineInfo[] | undefined
- >(undefined);
-
- React.useEffect(() => {
- let subscribe = true;
- if (user == null) {
- setOwnTimelines(undefined);
- setJoinTimelines(undefined);
- void axios
- .get<TimelineInfo[]>(`${apiBaseUrl}/timelines?visibility=public`)
- .then((res) => {
- if (subscribe) {
- setPublicTimelines(res.data);
- }
- });
- } else {
- setPublicTimelines(undefined);
- void axios
- .get<TimelineInfo[]>(
- `${apiBaseUrl}/timelines?relate=${user.username}&relateType=own`
- )
- .then((res) => {
- if (subscribe) {
- setOwnTimelines(res.data);
- }
- });
- void axios
- .get<TimelineInfo[]>(
- `${apiBaseUrl}/timelines?relate=${user.username}&relateType=join`
- )
- .then((res) => {
- if (subscribe) {
- setJoinTimelines(res.data);
- }
- });
- }
- return () => {
- subscribe = false;
- };
- }, [user]);
-
- const [dialog, setDialog] = React.useState<'create' | null>(null);
-
- const goto = React.useCallback((): void => {
- if (navText === '') {
- history.push('users/crupest');
- } else if (navText.startsWith('@')) {
- history.push(`users/${navText.slice(1)}`);
- } else {
- history.push(`timelines/${navText}`);
- }
- }, [navText, history]);
-
- const openCreateDialog = React.useCallback(() => {
- setDialog('create');
- }, []);
-
- const closeDialog = React.useCallback(() => {
- setDialog(null);
- }, []);
-
- return (
- <>
- <AppBar />
- <Container fluid style={{ marginTop: '56px' }}>
- <Row>
- <Col>
- <SearchInput
- className="justify-content-center"
- value={navText}
- onChange={setNavText}
- onButtonClick={goto}
- buttonText={t('home.go')}
- placeholder="@crupest"
- additionalButton={
- user != null && (
- <Button color="success" outline onClick={openCreateDialog}>
- {t('home.createButton')}
- </Button>
- )
- }
- />
- </Col>
- </Row>
- {(() => {
- if (user == null) {
- return (
- <TimelineBoardAreaWithoutUser publicTimelines={publicTimelines} />
- );
- } else {
- return (
- <TimelineBoardAreaWithUser
- ownTimelines={ownTimelines}
- joinTimelines={joinTimelines}
- />
- );
- }
- })()}
- </Container>
- <footer className="text-right">
- <a
- className="mx-3 text-muted"
- href="http://beian.miit.gov.cn/"
- target="_blank"
- rel="noopener noreferrer"
- >
- <small>鄂ICP备18030913号-1</small>
- </a>
- <a
- className="mx-3 text-muted"
- href="http://www.beian.gov.cn/"
- target="_blank"
- rel="noopener noreferrer"
- >
- <small className="white-space-no-wrap">公安备案 42112102000124</small>
- </a>
- </footer>
- {dialog === 'create' && <TimelineCreateDialog open close={closeDialog} />}
- </>
- );
-};
-
-export default Home;
+import React from 'react';
+import { useHistory } from 'react-router';
+import { Row, Container, Button, Col } from 'reactstrap';
+import { useTranslation } from 'react-i18next';
+import axios from 'axios';
+
+import { apiBaseUrl } from '../config';
+import { useUser } from '../data/user';
+import { TimelineInfo } from '../data/timeline';
+
+import AppBar from '../common/AppBar';
+import SearchInput from '../common/SearchInput';
+import TimelineBoardAreaWithoutUser from './TimelineBoardAreaWithoutUser';
+import TimelineBoardAreaWithUser from './TimelineBoardAreaWithUser';
+import TimelineCreateDialog from './TimelineCreateDialog';
+
+const Home: React.FC = (_) => {
+ const history = useHistory();
+
+ const { t } = useTranslation();
+
+ const user = useUser();
+
+ const [navText, setNavText] = React.useState<string>('');
+
+ const [publicTimelines, setPublicTimelines] = React.useState<
+ TimelineInfo[] | undefined
+ >(undefined);
+ const [ownTimelines, setOwnTimelines] = React.useState<
+ TimelineInfo[] | undefined
+ >(undefined);
+ const [joinTimelines, setJoinTimelines] = React.useState<
+ TimelineInfo[] | undefined
+ >(undefined);
+
+ React.useEffect(() => {
+ let subscribe = true;
+ if (user == null) {
+ setOwnTimelines(undefined);
+ setJoinTimelines(undefined);
+ void axios
+ .get<TimelineInfo[]>(`${apiBaseUrl}/timelines?visibility=public`)
+ .then((res) => {
+ if (subscribe) {
+ setPublicTimelines(res.data);
+ }
+ });
+ } else {
+ setPublicTimelines(undefined);
+ void axios
+ .get<TimelineInfo[]>(
+ `${apiBaseUrl}/timelines?relate=${user.username}&relateType=own`
+ )
+ .then((res) => {
+ if (subscribe) {
+ setOwnTimelines(res.data);
+ }
+ });
+ void axios
+ .get<TimelineInfo[]>(
+ `${apiBaseUrl}/timelines?relate=${user.username}&relateType=join`
+ )
+ .then((res) => {
+ if (subscribe) {
+ setJoinTimelines(res.data);
+ }
+ });
+ }
+ return () => {
+ subscribe = false;
+ };
+ }, [user]);
+
+ const [dialog, setDialog] = React.useState<'create' | null>(null);
+
+ const goto = React.useCallback((): void => {
+ if (navText === '') {
+ history.push('users/crupest');
+ } else if (navText.startsWith('@')) {
+ history.push(`users/${navText.slice(1)}`);
+ } else {
+ history.push(`timelines/${navText}`);
+ }
+ }, [navText, history]);
+
+ const openCreateDialog = React.useCallback(() => {
+ setDialog('create');
+ }, []);
+
+ const closeDialog = React.useCallback(() => {
+ setDialog(null);
+ }, []);
+
+ return (
+ <>
+ <AppBar />
+ <Container fluid style={{ marginTop: '56px' }}>
+ <Row>
+ <Col>
+ <SearchInput
+ className="justify-content-center"
+ value={navText}
+ onChange={setNavText}
+ onButtonClick={goto}
+ buttonText={t('home.go')}
+ placeholder="@crupest"
+ additionalButton={
+ user != null && (
+ <Button color="success" outline onClick={openCreateDialog}>
+ {t('home.createButton')}
+ </Button>
+ )
+ }
+ />
+ </Col>
+ </Row>
+ {(() => {
+ if (user == null) {
+ return (
+ <TimelineBoardAreaWithoutUser publicTimelines={publicTimelines} />
+ );
+ } else {
+ return (
+ <TimelineBoardAreaWithUser
+ ownTimelines={ownTimelines}
+ joinTimelines={joinTimelines}
+ />
+ );
+ }
+ })()}
+ </Container>
+ <footer className="text-right">
+ <a
+ className="mx-3 text-muted"
+ href="http://beian.miit.gov.cn/"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ <small>鄂ICP备18030913号-1</small>
+ </a>
+ <a
+ className="mx-3 text-muted"
+ href="http://www.beian.gov.cn/"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ <small className="white-space-no-wrap">公安备案 42112102000124</small>
+ </a>
+ </footer>
+ {dialog === 'create' && <TimelineCreateDialog open close={closeDialog} />}
+ </>
+ );
+};
+
+export default Home;
diff --git a/Timeline/ClientApp/src/home/TimelineBoard.tsx b/Timeline/ClientApp/src/app/home/TimelineBoard.tsx
index 2e017bf7..ca77ab43 100644
--- a/Timeline/ClientApp/src/home/TimelineBoard.tsx
+++ b/Timeline/ClientApp/src/app/home/TimelineBoard.tsx
@@ -1,54 +1,54 @@
-import React from 'react';
-import clsx from 'clsx';
-import { Link } from 'react-router-dom';
-import { Spinner } from 'reactstrap';
-
-import { TimelineInfo } from '../data/timeline';
-
-import TimelineLogo from '../common/TimelineLogo';
-import UserTimelineLogo from '../common/UserTimelineLogo';
-
-export interface TimelineBoardProps {
- title?: string;
- timelines?: TimelineInfo[];
- className?: string;
-}
-
-const TimelineBoard: React.FC<TimelineBoardProps> = props => {
- const { title, timelines, className } = props;
-
- return (
- <div className={clsx('timeline-board', className)}>
- {title != null && <h3 className="text-center">{title}</h3>}
- {(() => {
- if (timelines == null) {
- return (
- <div className="d-flex flex-grow-1 justify-content-center align-items-center">
- <Spinner color="primary" />
- </div>
- );
- } else {
- return timelines.map(timeline => {
- const { name } = timeline;
- const isPersonal = name.startsWith('@');
- const url = isPersonal
- ? `/users/${timeline.owner.username}`
- : `/timelines/${name}`;
- return (
- <div key={name} className="timeline-board-item">
- {isPersonal ? (
- <UserTimelineLogo className="icon" />
- ) : (
- <TimelineLogo className="icon" />
- )}
- <Link to={url}>{name}</Link>
- </div>
- );
- });
- }
- })()}
- </div>
- );
-};
-
-export default TimelineBoard;
+import React from 'react';
+import clsx from 'clsx';
+import { Link } from 'react-router-dom';
+import { Spinner } from 'reactstrap';
+
+import { TimelineInfo } from '../data/timeline';
+
+import TimelineLogo from '../common/TimelineLogo';
+import UserTimelineLogo from '../common/UserTimelineLogo';
+
+export interface TimelineBoardProps {
+ title?: string;
+ timelines?: TimelineInfo[];
+ className?: string;
+}
+
+const TimelineBoard: React.FC<TimelineBoardProps> = props => {
+ const { title, timelines, className } = props;
+
+ return (
+ <div className={clsx('timeline-board', className)}>
+ {title != null && <h3 className="text-center">{title}</h3>}
+ {(() => {
+ if (timelines == null) {
+ return (
+ <div className="d-flex flex-grow-1 justify-content-center align-items-center">
+ <Spinner color="primary" />
+ </div>
+ );
+ } else {
+ return timelines.map(timeline => {
+ const { name } = timeline;
+ const isPersonal = name.startsWith('@');
+ const url = isPersonal
+ ? `/users/${timeline.owner.username}`
+ : `/timelines/${name}`;
+ return (
+ <div key={name} className="timeline-board-item">
+ {isPersonal ? (
+ <UserTimelineLogo className="icon" />
+ ) : (
+ <TimelineLogo className="icon" />
+ )}
+ <Link to={url}>{name}</Link>
+ </div>
+ );
+ });
+ }
+ })()}
+ </div>
+ );
+};
+
+export default TimelineBoard;
diff --git a/Timeline/ClientApp/src/home/TimelineBoardAreaWithUser.tsx b/Timeline/ClientApp/src/app/home/TimelineBoardAreaWithUser.tsx
index a8603b9e..ea1c6beb 100644
--- a/Timeline/ClientApp/src/home/TimelineBoardAreaWithUser.tsx
+++ b/Timeline/ClientApp/src/app/home/TimelineBoardAreaWithUser.tsx
@@ -1,36 +1,36 @@
-import React from 'react';
-import { Row, Col } from 'reactstrap';
-import { useTranslation } from 'react-i18next';
-
-import TimelineBoard from './TimelineBoard';
-import { TimelineInfo } from '../data/timeline';
-
-interface TimelineBoardAreaWithUserProps {
- ownTimelines?: TimelineInfo[];
- joinTimelines?: TimelineInfo[];
-}
-
-const TimelineBoardAreaWithUser: React.FC<TimelineBoardAreaWithUserProps> = (
- props
-) => {
- const { t } = useTranslation();
-
- return (
- <Row className="my-2 justify-content-center">
- <Col sm="6" lg="5" className="py-2">
- <TimelineBoard
- title={t('home.ownTimeline')}
- timelines={props.ownTimelines}
- />
- </Col>
- <Col sm="6" lg="5" className="py-2">
- <TimelineBoard
- title={t('home.joinTimeline')}
- timelines={props.joinTimelines}
- />
- </Col>
- </Row>
- );
-};
-
-export default TimelineBoardAreaWithUser;
+import React from 'react';
+import { Row, Col } from 'reactstrap';
+import { useTranslation } from 'react-i18next';
+
+import TimelineBoard from './TimelineBoard';
+import { TimelineInfo } from '../data/timeline';
+
+interface TimelineBoardAreaWithUserProps {
+ ownTimelines?: TimelineInfo[];
+ joinTimelines?: TimelineInfo[];
+}
+
+const TimelineBoardAreaWithUser: React.FC<TimelineBoardAreaWithUserProps> = (
+ props
+) => {
+ const { t } = useTranslation();
+
+ return (
+ <Row className="my-2 justify-content-center">
+ <Col sm="6" lg="5" className="py-2">
+ <TimelineBoard
+ title={t('home.ownTimeline')}
+ timelines={props.ownTimelines}
+ />
+ </Col>
+ <Col sm="6" lg="5" className="py-2">
+ <TimelineBoard
+ title={t('home.joinTimeline')}
+ timelines={props.joinTimelines}
+ />
+ </Col>
+ </Row>
+ );
+};
+
+export default TimelineBoardAreaWithUser;
diff --git a/Timeline/ClientApp/src/home/TimelineBoardAreaWithoutUser.tsx b/Timeline/ClientApp/src/app/home/TimelineBoardAreaWithoutUser.tsx
index dc05ff09..c79558b6 100644
--- a/Timeline/ClientApp/src/home/TimelineBoardAreaWithoutUser.tsx
+++ b/Timeline/ClientApp/src/app/home/TimelineBoardAreaWithoutUser.tsx
@@ -1,26 +1,26 @@
-import React from 'react';
-import { Row, Col } from 'reactstrap';
-
-import { TimelineInfo } from '../data/timeline';
-
-import TimelineBoard from './TimelineBoard';
-
-interface TimelineBoardAreaWithoutUserProps {
- publicTimelines?: TimelineInfo[];
-}
-
-const TimelineBoardAreaWithoutUser: React.FC<TimelineBoardAreaWithoutUserProps> = (
- props
-) => {
- const { publicTimelines } = props;
-
- return (
- <Row className="my-2 justify-content-center">
- <Col sm="8" lg="6">
- <TimelineBoard timelines={publicTimelines} />
- </Col>
- </Row>
- );
-};
-
-export default TimelineBoardAreaWithoutUser;
+import React from 'react';
+import { Row, Col } from 'reactstrap';
+
+import { TimelineInfo } from '../data/timeline';
+
+import TimelineBoard from './TimelineBoard';
+
+interface TimelineBoardAreaWithoutUserProps {
+ publicTimelines?: TimelineInfo[];
+}
+
+const TimelineBoardAreaWithoutUser: React.FC<TimelineBoardAreaWithoutUserProps> = (
+ props
+) => {
+ const { publicTimelines } = props;
+
+ return (
+ <Row className="my-2 justify-content-center">
+ <Col sm="8" lg="6">
+ <TimelineBoard timelines={publicTimelines} />
+ </Col>
+ </Row>
+ );
+};
+
+export default TimelineBoardAreaWithoutUser;
diff --git a/Timeline/ClientApp/src/home/TimelineCreateDialog.tsx b/Timeline/ClientApp/src/app/home/TimelineCreateDialog.tsx
index 27c9d0d6..925c6c76 100644
--- a/Timeline/ClientApp/src/home/TimelineCreateDialog.tsx
+++ b/Timeline/ClientApp/src/app/home/TimelineCreateDialog.tsx
@@ -1,60 +1,60 @@
-import React from 'react';
-import { useHistory } from 'react-router';
-import axios from 'axios';
-
-import { apiBaseUrl } from '../config';
-import { useUserLoggedIn } from '../data/user';
-import { validateTimelineName } from '../data/timeline';
-
-import OperationDialog from '../common/OperationDialog';
-
-interface TimelineCreateDialogProps {
- open: boolean;
- close: () => void;
-}
-
-const TimelineCreateDialog: React.FC<TimelineCreateDialogProps> = (props) => {
- const history = useHistory();
- const user = useUserLoggedIn();
-
- let nameSaved: string;
-
- return (
- <OperationDialog
- open={props.open}
- close={props.close}
- titleColor="success"
- title="home.createDialog.title"
- inputScheme={[
- {
- type: 'text',
- label: 'home.createDialog.name',
- helperText: 'home.createDialog.nameFormat',
- validator: (name) => {
- if (name.length === 0) {
- return 'home.createDialog.noEmpty';
- } else if (name.length > 26) {
- return 'home.createDialog.tooLong';
- } else if (!validateTimelineName(name)) {
- return 'home.createDialog.badFormat';
- } else {
- return null;
- }
- },
- },
- ]}
- onProcess={([name]) => {
- nameSaved = name as string;
- return axios.post(`${apiBaseUrl}/timelines?token=${user.token}`, {
- name,
- });
- }}
- onSuccessAndClose={() => {
- history.push(`timelines/${nameSaved}`);
- }}
- failurePrompt={(e) => `${e as string}`}
- />
- );
-};
-
-export default TimelineCreateDialog;
+import React from 'react';
+import { useHistory } from 'react-router';
+import axios from 'axios';
+
+import { apiBaseUrl } from '../config';
+import { useUserLoggedIn } from '../data/user';
+import { validateTimelineName } from '../data/timeline';
+
+import OperationDialog from '../common/OperationDialog';
+
+interface TimelineCreateDialogProps {
+ open: boolean;
+ close: () => void;
+}
+
+const TimelineCreateDialog: React.FC<TimelineCreateDialogProps> = (props) => {
+ const history = useHistory();
+ const user = useUserLoggedIn();
+
+ let nameSaved: string;
+
+ return (
+ <OperationDialog
+ open={props.open}
+ close={props.close}
+ titleColor="success"
+ title="home.createDialog.title"
+ inputScheme={[
+ {
+ type: 'text',
+ label: 'home.createDialog.name',
+ helperText: 'home.createDialog.nameFormat',
+ validator: (name) => {
+ if (name.length === 0) {
+ return 'home.createDialog.noEmpty';
+ } else if (name.length > 26) {
+ return 'home.createDialog.tooLong';
+ } else if (!validateTimelineName(name)) {
+ return 'home.createDialog.badFormat';
+ } else {
+ return null;
+ }
+ },
+ },
+ ]}
+ onProcess={([name]) => {
+ nameSaved = name as string;
+ return axios.post(`${apiBaseUrl}/timelines?token=${user.token}`, {
+ name,
+ });
+ }}
+ onSuccessAndClose={() => {
+ history.push(`timelines/${nameSaved}`);
+ }}
+ failurePrompt={(e) => `${e as string}`}
+ />
+ );
+};
+
+export default TimelineCreateDialog;
diff --git a/Timeline/ClientApp/src/home/home.sass b/Timeline/ClientApp/src/app/home/home.sass
index 28a2e5f3..f5d6ffc3 100644
--- a/Timeline/ClientApp/src/home/home.sass
+++ b/Timeline/ClientApp/src/app/home/home.sass
@@ -1,13 +1,13 @@
-.timeline-board-item
- font-size: 1.1em
- @extend .my-2
- .icon
- height: 1.3em
- @extend .mr-2
-
-.timeline-board
- @extend .cru-card
- @extend .d-flex
- @extend .flex-column
- @extend .p-3
- min-height: 200px
+.timeline-board-item
+ font-size: 1.1em
+ @extend .my-2
+ .icon
+ height: 1.3em
+ @extend .mr-2
+
+.timeline-board
+ @extend .cru-card
+ @extend .d-flex
+ @extend .flex-column
+ @extend .p-3
+ min-height: 200px
diff --git a/Timeline/ClientApp/src/i18n.ts b/Timeline/ClientApp/src/app/i18n.ts
index aa293819..bda33fe8 100644
--- a/Timeline/ClientApp/src/i18n.ts
+++ b/Timeline/ClientApp/src/app/i18n.ts
@@ -1,79 +1,79 @@
-import i18n, { BackendModule, ResourceKey } from 'i18next';
-import LanguageDetector from 'i18next-browser-languagedetector';
-import { initReactI18next } from 'react-i18next';
-
-const backend: BackendModule = {
- type: 'backend',
- async read(language, namespace, callback) {
- function error(message: string): void {
- callback(new Error(message), false);
- }
-
- function success(result: ResourceKey): void {
- callback(null, result);
- }
-
- if (namespace !== 'translation') {
- error("Namespace must be 'translation'.");
- }
-
- if (language === 'en') {
- const res = (
- await import(
- /* webpackChunkName: "locales-en" */ './locales/en/translation'
- )
- ).default;
- success(res);
- } else if (language === 'zh-cn' || language === 'zh') {
- const res = (
- await import(
- /* webpackChunkName: "locales-zh" */ './locales/zh/translation'
- )
- ).default;
- success(res);
- } else {
- error(`Language ${language} is not supported.`);
- }
- },
- init() {}, // eslint-disable-line @typescript-eslint/no-empty-function
- create() {}, // eslint-disable-line @typescript-eslint/no-empty-function
-};
-
-export const i18nPromise = i18n
- .use(LanguageDetector)
- .use(backend)
- .use(initReactI18next) // bind react-i18next to the instance
- .init({
- fallbackLng: false,
- lowerCaseLng: true,
-
- debug: process.env.NODE_ENV === 'development',
-
- interpolation: {
- escapeValue: false, // not needed for react!!
- },
-
- // react i18next special options (optional)
- // override if needed - omit if ok with defaults
- /*
- react: {
- bindI18n: 'languageChanged',
- bindI18nStore: '',
- transEmptyNodeValue: '',
- transSupportBasicHtmlNodes: true,
- transKeepBasicHtmlNodesFor: ['br', 'strong', 'i'],
- useSuspense: true,
- }
- */
- });
-
-if (module.hot) {
- module.hot.accept(
- ['./locales/en/translation', './locales/zh/translation'],
- () => {
- void i18n.reloadResources();
- }
- );
-}
-
-export default i18n;
+import i18n, { BackendModule, ResourceKey } from 'i18next';
+import LanguageDetector from 'i18next-browser-languagedetector';
+import { initReactI18next } from 'react-i18next';
+
+const backend: BackendModule = {
+ type: 'backend',
+ async read(language, namespace, callback) {
+ function error(message: string): void {
+ callback(new Error(message), false);
+ }
+
+ function success(result: ResourceKey): void {
+ callback(null, result);
+ }
+
+ if (namespace !== 'translation') {
+ error("Namespace must be 'translation'.");
+ }
+
+ if (language === 'en') {
+ const res = (
+ await import(
+ /* webpackChunkName: "locales-en" */ './locales/en/translation'
+ )
+ ).default;
+ success(res);
+ } else if (language === 'zh-cn' || language === 'zh') {
+ const res = (
+ await import(
+ /* webpackChunkName: "locales-zh" */ './locales/zh/translation'
+ )
+ ).default;
+ success(res);
+ } else {
+ error(`Language ${language} is not supported.`);
+ }
+ },
+ init() {}, // eslint-disable-line @typescript-eslint/no-empty-function
+ create() {}, // eslint-disable-line @typescript-eslint/no-empty-function
+};
+
+export const i18nPromise = i18n
+ .use(LanguageDetector)
+ .use(backend)
+ .use(initReactI18next) // bind react-i18next to the instance
+ .init({
+ fallbackLng: false,
+ lowerCaseLng: true,
+
+ debug: process.env.NODE_ENV === 'development',
+
+ interpolation: {
+ escapeValue: false, // not needed for react!!
+ },
+
+ // react i18next special options (optional)
+ // override if needed - omit if ok with defaults
+ /*
+ react: {
+ bindI18n: 'languageChanged',
+ bindI18nStore: '',
+ transEmptyNodeValue: '',
+ transSupportBasicHtmlNodes: true,
+ transKeepBasicHtmlNodesFor: ['br', 'strong', 'i'],
+ useSuspense: true,
+ }
+ */
+ });
+
+if (module.hot) {
+ module.hot.accept(
+ ['./locales/en/translation', './locales/zh/translation'],
+ () => {
+ void i18n.reloadResources();
+ }
+ );
+}
+
+export default i18n;
diff --git a/Timeline/ClientApp/src/index.sass b/Timeline/ClientApp/src/app/index.sass
index 86276701..86276701 100644
--- a/Timeline/ClientApp/src/index.sass
+++ b/Timeline/ClientApp/src/app/index.sass
diff --git a/Timeline/ClientApp/src/index.tsx b/Timeline/ClientApp/src/app/index.tsx
index 086bd6e8..73788c3a 100644
--- a/Timeline/ClientApp/src/index.tsx
+++ b/Timeline/ClientApp/src/app/index.tsx
@@ -13,3 +13,10 @@ import './i18n';
import App from './App';
ReactDOM.render(<App />, document.getElementById('app'));
+
+if ('serviceWorker' in navigator) {
+ // Use the window load event to keep the page load performant
+ window.addEventListener('load', () => {
+ void navigator.serviceWorker.register('/sw.js');
+ });
+}
diff --git a/Timeline/ClientApp/src/locales/en/translation.ts b/Timeline/ClientApp/src/app/locales/en/translation.ts
index 99783d04..00672f67 100644
--- a/Timeline/ClientApp/src/locales/en/translation.ts
+++ b/Timeline/ClientApp/src/app/locales/en/translation.ts
@@ -1,183 +1,183 @@
-import TranslationResource from '../scheme';
-
-const translation: TranslationResource = {
- welcome: 'Welcome!',
- search: 'Search',
- nav: {
- settings: 'Settings',
- login: 'Login',
- about: 'About',
- },
- chooseImage: 'Choose a image',
- loadImageError: 'Failed to load image.',
- home: {
- go: 'Go!',
- allTimeline: 'All Timelines',
- joinTimeline: 'Joined Timelines',
- ownTimeline: 'Owned Timelines',
- createButton: 'Create Timeline',
- createDialog: {
- title: 'Create Timeline!',
- name: 'Name',
- nameFormat:
- 'Name must consist of only letter including non-English letter, digit, hyphen(-) and underline(_) and be no longer than 26.',
- badFormat: 'Bad format.',
- noEmpty: 'Empty is not allowed.',
- tooLong: 'Too long.',
- },
- },
- operationDialog: {
- retry: 'Retry',
- nextStep: 'Next',
- previousStep: 'Previous',
- confirm: 'Confirm',
- cancel: 'Cancel',
- ok: 'OK!',
- processing: 'Processing...',
- success: 'Success!',
- error: 'An error occured.',
- },
- timeline: {
- messageCantSee: 'Sorry, you are not allowed to see this timeline.😅',
- userNotExist: 'The user does not exist!',
- timelineNotExist: 'The timeline does not exist!',
- manage: 'Manage',
- memberButton: 'Member',
- send: 'Send',
- deletePostFailed: 'Failed to delete post.',
- sendPostFailed: 'Failed to send post.',
- visibility: {
- public: 'public to everyone',
- register: 'only registed people can see',
- private: 'only members can see',
- },
- visibilityTooltip: {
- public:
- 'Everyone including those without accounts can see content of the timeline.',
- register:
- 'Only those who have an account and logined can see content of the timeline.',
- private: 'Only members of this timeline can see content of the timeline.',
- },
- dialogChangeProperty: {
- title: 'Change Timeline Properties',
- visibility: 'Visibility',
- description: 'Description',
- },
- member: {
- alreadyMember: 'The user is already a member.',
- add: 'Add',
- remove: 'Remove',
- },
- manageItem: {
- nickname: 'Nickname',
- avatar: 'Avatar',
- property: 'Timeline Property',
- member: 'Timeline Member',
- delete: 'Delete Timeline',
- },
- deleteDialog: {
- title: 'Delete Timeline',
- inputPrompt:
- 'This is a dangerous action. If you are sure to delete timeline<1>{{name}}</1>, please input its name below and click confirm button.',
- notMatch: 'Name does not match.',
- },
- post: {
- deleteDialog: {
- title: 'Confirm Delete',
- prompt:
- 'Are you sure to delete the post? This operation is not recoverable.',
- },
- },
- },
- user: {
- username: 'username',
- password: 'password',
- login: 'login',
- rememberMe: 'Remember Me',
- welcomeBack: 'Welcome back!',
- verifyTokenFailed: 'User login info is expired. Please login again!',
- verifyTokenFailedNetwork:
- 'Verifying user login info failed. Please check your network and refresh page!',
- },
- login: {
- emptyUsername: "Username can't be empty.",
- emptyPassword: "Password can't be empty.",
- badCredential: 'Username or password is invalid.',
- alreadyLogin: 'Already login! Redirect to home page in 3s!',
- },
- userPage: {
- dialogChangeNickname: {
- title: 'Change Nickname',
- inputLabel: 'New nickname',
- },
- dialogChangeAvatar: {
- title: 'Change Avatar',
- previewImgAlt: 'preview',
- prompt: {
- select: 'Please select a picture.',
- crop: 'Please crop the picture.',
- processingCrop: 'Cropping picture...',
- uploading: 'Uploading...',
- preview: 'Please preview avatar',
- },
- upload: 'upload',
- },
- },
- settings: {
- subheaders: {
- account: 'Account',
- customization: 'Customization',
- },
- languagePrimary: 'Choose display language.',
- languageSecondary:
- 'You language preference will be saved locally. Next time you visit this page, last language option will be used.',
- changePassword: "Change account's password.",
- logout: 'Log out this account.',
- gotoSelf:
- 'Click here to go to timeline of myself to change nickname and avatar.',
- dialogChangePassword: {
- title: 'Change Password',
- prompt:
- 'You are changing your password. You need to input the correct old password. After change, you need to login again and all old login will be invalid.',
- inputOldPassword: 'Old password',
- inputNewPassword: 'New password',
- inputRetypeNewPassword: 'Retype new password',
- errorEmptyOldPassword: "Old password can't be empty.",
- errorEmptyNewPassword: "New password can't be empty.",
- errorRetypeNotMatch: 'Password retyped does not match.',
- },
- dialogConfirmLogout: {
- title: 'Confirm Logout',
- prompt:
- 'Are you sure to log out? All cached data in the browser will be deleted.',
- },
- },
- about: {
- author: {
- title: 'Site Developer',
- fullname: 'Fullname: ',
- nickname: 'Nickname: ',
- introduction: 'Introduction: ',
- introductionContent: 'A programmer coding based on coincidence',
- links: 'Links: ',
- },
- site: {
- title: 'Site Information',
- content:
- 'The name of this site is <1>Timeline</1>, which is a Web App with <3>timeline</3> as its core concept. Its frontend and backend are both developed by <5>me</5>, and open source on GitHub. It is relatively easy to deploy it on your own server, which is also one of my goals. Welcome to comment anything in GitHub repository.',
- repo: 'GitHub Repo',
- },
- credits: {
- title: 'Credits',
- content:
- 'Timeline is works standing on shoulders of gaints. Special appreciation for many open source projects listed below or not. Related licenses could be found in GitHub repository.',
- frontend: 'Frontend: ',
- backend: 'Backend: ',
- },
- },
- admin: {
- title: 'admin',
- },
-};
-
-export default translation;
+import TranslationResource from '../scheme';
+
+const translation: TranslationResource = {
+ welcome: 'Welcome!',
+ search: 'Search',
+ nav: {
+ settings: 'Settings',
+ login: 'Login',
+ about: 'About',
+ },
+ chooseImage: 'Choose a image',
+ loadImageError: 'Failed to load image.',
+ home: {
+ go: 'Go!',
+ allTimeline: 'All Timelines',
+ joinTimeline: 'Joined Timelines',
+ ownTimeline: 'Owned Timelines',
+ createButton: 'Create Timeline',
+ createDialog: {
+ title: 'Create Timeline!',
+ name: 'Name',
+ nameFormat:
+ 'Name must consist of only letter including non-English letter, digit, hyphen(-) and underline(_) and be no longer than 26.',
+ badFormat: 'Bad format.',
+ noEmpty: 'Empty is not allowed.',
+ tooLong: 'Too long.',
+ },
+ },
+ operationDialog: {
+ retry: 'Retry',
+ nextStep: 'Next',
+ previousStep: 'Previous',
+ confirm: 'Confirm',
+ cancel: 'Cancel',
+ ok: 'OK!',
+ processing: 'Processing...',
+ success: 'Success!',
+ error: 'An error occured.',
+ },
+ timeline: {
+ messageCantSee: 'Sorry, you are not allowed to see this timeline.😅',
+ userNotExist: 'The user does not exist!',
+ timelineNotExist: 'The timeline does not exist!',
+ manage: 'Manage',
+ memberButton: 'Member',
+ send: 'Send',
+ deletePostFailed: 'Failed to delete post.',
+ sendPostFailed: 'Failed to send post.',
+ visibility: {
+ public: 'public to everyone',
+ register: 'only registed people can see',
+ private: 'only members can see',
+ },
+ visibilityTooltip: {
+ public:
+ 'Everyone including those without accounts can see content of the timeline.',
+ register:
+ 'Only those who have an account and logined can see content of the timeline.',
+ private: 'Only members of this timeline can see content of the timeline.',
+ },
+ dialogChangeProperty: {
+ title: 'Change Timeline Properties',
+ visibility: 'Visibility',
+ description: 'Description',
+ },
+ member: {
+ alreadyMember: 'The user is already a member.',
+ add: 'Add',
+ remove: 'Remove',
+ },
+ manageItem: {
+ nickname: 'Nickname',
+ avatar: 'Avatar',
+ property: 'Timeline Property',
+ member: 'Timeline Member',
+ delete: 'Delete Timeline',
+ },
+ deleteDialog: {
+ title: 'Delete Timeline',
+ inputPrompt:
+ 'This is a dangerous action. If you are sure to delete timeline<1>{{name}}</1>, please input its name below and click confirm button.',
+ notMatch: 'Name does not match.',
+ },
+ post: {
+ deleteDialog: {
+ title: 'Confirm Delete',
+ prompt:
+ 'Are you sure to delete the post? This operation is not recoverable.',
+ },
+ },
+ },
+ user: {
+ username: 'username',
+ password: 'password',
+ login: 'login',
+ rememberMe: 'Remember Me',
+ welcomeBack: 'Welcome back!',
+ verifyTokenFailed: 'User login info is expired. Please login again!',
+ verifyTokenFailedNetwork:
+ 'Verifying user login info failed. Please check your network and refresh page!',
+ },
+ login: {
+ emptyUsername: "Username can't be empty.",
+ emptyPassword: "Password can't be empty.",
+ badCredential: 'Username or password is invalid.',
+ alreadyLogin: 'Already login! Redirect to home page in 3s!',
+ },
+ userPage: {
+ dialogChangeNickname: {
+ title: 'Change Nickname',
+ inputLabel: 'New nickname',
+ },
+ dialogChangeAvatar: {
+ title: 'Change Avatar',
+ previewImgAlt: 'preview',
+ prompt: {
+ select: 'Please select a picture.',
+ crop: 'Please crop the picture.',
+ processingCrop: 'Cropping picture...',
+ uploading: 'Uploading...',
+ preview: 'Please preview avatar',
+ },
+ upload: 'upload',
+ },
+ },
+ settings: {
+ subheaders: {
+ account: 'Account',
+ customization: 'Customization',
+ },
+ languagePrimary: 'Choose display language.',
+ languageSecondary:
+ 'You language preference will be saved locally. Next time you visit this page, last language option will be used.',
+ changePassword: "Change account's password.",
+ logout: 'Log out this account.',
+ gotoSelf:
+ 'Click here to go to timeline of myself to change nickname and avatar.',
+ dialogChangePassword: {
+ title: 'Change Password',
+ prompt:
+ 'You are changing your password. You need to input the correct old password. After change, you need to login again and all old login will be invalid.',
+ inputOldPassword: 'Old password',
+ inputNewPassword: 'New password',
+ inputRetypeNewPassword: 'Retype new password',
+ errorEmptyOldPassword: "Old password can't be empty.",
+ errorEmptyNewPassword: "New password can't be empty.",
+ errorRetypeNotMatch: 'Password retyped does not match.',
+ },
+ dialogConfirmLogout: {
+ title: 'Confirm Logout',
+ prompt:
+ 'Are you sure to log out? All cached data in the browser will be deleted.',
+ },
+ },
+ about: {
+ author: {
+ title: 'Site Developer',
+ fullname: 'Fullname: ',
+ nickname: 'Nickname: ',
+ introduction: 'Introduction: ',
+ introductionContent: 'A programmer coding based on coincidence',
+ links: 'Links: ',
+ },
+ site: {
+ title: 'Site Information',
+ content:
+ 'The name of this site is <1>Timeline</1>, which is a Web App with <3>timeline</3> as its core concept. Its frontend and backend are both developed by <5>me</5>, and open source on GitHub. It is relatively easy to deploy it on your own server, which is also one of my goals. Welcome to comment anything in GitHub repository.',
+ repo: 'GitHub Repo',
+ },
+ credits: {
+ title: 'Credits',
+ content:
+ 'Timeline is works standing on shoulders of gaints. Special appreciation for many open source projects listed below or not. Related licenses could be found in GitHub repository.',
+ frontend: 'Frontend: ',
+ backend: 'Backend: ',
+ },
+ },
+ admin: {
+ title: 'admin',
+ },
+};
+
+export default translation;
diff --git a/Timeline/ClientApp/src/locales/scheme.ts b/Timeline/ClientApp/src/app/locales/scheme.ts
index 0bf08257..bb3f14df 100644
--- a/Timeline/ClientApp/src/locales/scheme.ts
+++ b/Timeline/ClientApp/src/app/locales/scheme.ts
@@ -1,167 +1,167 @@
-export default interface TranslationResource {
- welcome: string;
- search: string;
- chooseImage: string;
- loadImageError: string;
- nav: {
- settings: string;
- login: string;
- about: string;
- };
- home: {
- go: string;
- allTimeline: string;
- joinTimeline: string;
- ownTimeline: string;
- createButton: string;
- createDialog: {
- title: string;
- name: string;
- nameFormat: string;
- badFormat: string;
- noEmpty: string;
- tooLong: string;
- };
- };
- operationDialog: {
- retry: string;
- nextStep: string;
- previousStep: string;
- confirm: string;
- cancel: string;
- ok: string;
- processing: string;
- success: string;
- error: string;
- };
- timeline: {
- messageCantSee: string;
- userNotExist: string;
- timelineNotExist: string;
- manage: string;
- memberButton: string;
- send: string;
- deletePostFailed: string;
- sendPostFailed: string;
- visibility: {
- public: string;
- register: string;
- private: string;
- };
- visibilityTooltip: {
- public: string;
- register: string;
- private: string;
- };
- dialogChangeProperty: {
- title: string;
- visibility: string;
- description: string;
- };
- member: {
- alreadyMember: string;
- add: string;
- remove: string;
- };
- manageItem: {
- nickname: string;
- avatar: string;
- property: string;
- member: string;
- delete: string;
- };
- deleteDialog: {
- title: string;
- inputPrompt: string;
- notMatch: string;
- };
- post: {
- deleteDialog: {
- title: string;
- prompt: string;
- };
- };
- };
- user: {
- username: string;
- password: string;
- login: string;
- rememberMe: string;
- welcomeBack: string;
- verifyTokenFailed: string;
- verifyTokenFailedNetwork: string;
- };
- login: {
- emptyUsername: string;
- emptyPassword: string;
- badCredential: string;
- alreadyLogin: string;
- };
- userPage: {
- dialogChangeNickname: {
- title: string;
- inputLabel: string;
- };
- dialogChangeAvatar: {
- title: string;
- previewImgAlt: string;
- prompt: {
- select: string;
- crop: string;
- processingCrop: string;
- preview: string;
- uploading: string;
- };
- upload: string;
- };
- };
- settings: {
- subheaders: {
- account: string;
- customization: string;
- };
- languagePrimary: string;
- languageSecondary: string;
- changePassword: string;
- logout: string;
- gotoSelf: string;
- dialogChangePassword: {
- title: string;
- prompt: string;
- inputOldPassword: string;
- inputNewPassword: string;
- inputRetypeNewPassword: string;
- errorEmptyOldPassword: string;
- errorEmptyNewPassword: string;
- errorRetypeNotMatch: string;
- };
- dialogConfirmLogout: {
- title: string;
- prompt: string;
- };
- };
- about: {
- author: {
- title: string;
- fullname: string;
- nickname: string;
- introduction: string;
- introductionContent: string;
- links: string;
- };
- site: {
- title: string;
- content: string;
- repo: string;
- };
- credits: {
- title: string;
- content: string;
- frontend: string;
- backend: string;
- };
- };
- admin: {
- title: string;
- };
-}
+export default interface TranslationResource {
+ welcome: string;
+ search: string;
+ chooseImage: string;
+ loadImageError: string;
+ nav: {
+ settings: string;
+ login: string;
+ about: string;
+ };
+ home: {
+ go: string;
+ allTimeline: string;
+ joinTimeline: string;
+ ownTimeline: string;
+ createButton: string;
+ createDialog: {
+ title: string;
+ name: string;
+ nameFormat: string;
+ badFormat: string;
+ noEmpty: string;
+ tooLong: string;
+ };
+ };
+ operationDialog: {
+ retry: string;
+ nextStep: string;
+ previousStep: string;
+ confirm: string;
+ cancel: string;
+ ok: string;
+ processing: string;
+ success: string;
+ error: string;
+ };
+ timeline: {
+ messageCantSee: string;
+ userNotExist: string;
+ timelineNotExist: string;
+ manage: string;
+ memberButton: string;
+ send: string;
+ deletePostFailed: string;
+ sendPostFailed: string;
+ visibility: {
+ public: string;
+ register: string;
+ private: string;
+ };
+ visibilityTooltip: {
+ public: string;
+ register: string;
+ private: string;
+ };
+ dialogChangeProperty: {
+ title: string;
+ visibility: string;
+ description: string;
+ };
+ member: {
+ alreadyMember: string;
+ add: string;
+ remove: string;
+ };
+ manageItem: {
+ nickname: string;
+ avatar: string;
+ property: string;
+ member: string;
+ delete: string;
+ };
+ deleteDialog: {
+ title: string;
+ inputPrompt: string;
+ notMatch: string;
+ };
+ post: {
+ deleteDialog: {
+ title: string;
+ prompt: string;
+ };
+ };
+ };
+ user: {
+ username: string;
+ password: string;
+ login: string;
+ rememberMe: string;
+ welcomeBack: string;
+ verifyTokenFailed: string;
+ verifyTokenFailedNetwork: string;
+ };
+ login: {
+ emptyUsername: string;
+ emptyPassword: string;
+ badCredential: string;
+ alreadyLogin: string;
+ };
+ userPage: {
+ dialogChangeNickname: {
+ title: string;
+ inputLabel: string;
+ };
+ dialogChangeAvatar: {
+ title: string;
+ previewImgAlt: string;
+ prompt: {
+ select: string;
+ crop: string;
+ processingCrop: string;
+ preview: string;
+ uploading: string;
+ };
+ upload: string;
+ };
+ };
+ settings: {
+ subheaders: {
+ account: string;
+ customization: string;
+ };
+ languagePrimary: string;
+ languageSecondary: string;
+ changePassword: string;
+ logout: string;
+ gotoSelf: string;
+ dialogChangePassword: {
+ title: string;
+ prompt: string;
+ inputOldPassword: string;
+ inputNewPassword: string;
+ inputRetypeNewPassword: string;
+ errorEmptyOldPassword: string;
+ errorEmptyNewPassword: string;
+ errorRetypeNotMatch: string;
+ };
+ dialogConfirmLogout: {
+ title: string;
+ prompt: string;
+ };
+ };
+ about: {
+ author: {
+ title: string;
+ fullname: string;
+ nickname: string;
+ introduction: string;
+ introductionContent: string;
+ links: string;
+ };
+ site: {
+ title: string;
+ content: string;
+ repo: string;
+ };
+ credits: {
+ title: string;
+ content: string;
+ frontend: string;
+ backend: string;
+ };
+ };
+ admin: {
+ title: string;
+ };
+}
diff --git a/Timeline/ClientApp/src/locales/zh/translation.ts b/Timeline/ClientApp/src/app/locales/zh/translation.ts
index 4054893e..66421375 100644
--- a/Timeline/ClientApp/src/locales/zh/translation.ts
+++ b/Timeline/ClientApp/src/app/locales/zh/translation.ts
@@ -1,178 +1,178 @@
-import TranslationResource from '../scheme';
-
-const translation: TranslationResource = {
- welcome: '欢迎!',
- search: '搜索',
- nav: {
- settings: '设置',
- login: '登陆',
- about: '关于',
- },
- chooseImage: '选择一个图片',
- loadImageError: '加载图片失败',
- home: {
- go: '冲!',
- allTimeline: '所有的时间线',
- joinTimeline: '加入的时间线',
- ownTimeline: '拥有的时间线',
- createButton: '创建时间线',
- createDialog: {
- title: '创建时间线!',
- name: '名字',
- nameFormat:
- '名字只能由字母、汉字、数字、下划线(_)和连字符(-)构成,且长度不能超过26.',
- badFormat: '格式错误',
- noEmpty: '不能为空',
- tooLong: '太长了',
- },
- },
- operationDialog: {
- retry: '重试',
- nextStep: '下一步',
- previousStep: '上一步',
- confirm: '确定',
- cancel: '取消',
- ok: '好的!',
- processing: '处理中...',
- success: '成功!',
- error: '出错啦!',
- },
- timeline: {
- messageCantSee: '不好意思,你没有权限查看这个时间线。😅',
- userNotExist: '该用户不存在!',
- timelineNotExist: '该时间线不存在!',
- manage: '管理',
- memberButton: '成员',
- send: '发送',
- deletePostFailed: '删除消息失败。',
- sendPostFailed: '发送消息失败。',
- visibility: {
- public: '对所有人公开',
- register: '仅注册可见',
- private: '仅成员可见',
- },
- visibilityTooltip: {
- public: '所有人都可以看到这个时间线的内容,包括没有注册的人。',
- register: '只有拥有本网站的账号且登陆了的人才能看到这个时间线的内容。',
- private: '只有这个时间线的成员可以看到这个时间线的内容。',
- },
- dialogChangeProperty: {
- title: '修改时间线属性',
- visibility: '可见性',
- description: '描述',
- },
- member: {
- alreadyMember: '该用户已经是一个成员。',
- add: '添加',
- remove: '移除',
- },
- manageItem: {
- nickname: '昵称',
- avatar: '头像',
- property: '时间线属性',
- member: '时间线成员',
- delete: '删除时间线',
- },
- deleteDialog: {
- title: '删除时间线',
- inputPrompt:
- '这是一个危险的操作。如果您确认要删除时间线<1>{{name}}</1>,请在下面输入它的名字并点击确认。',
- notMatch: '名字不匹配',
- },
- post: {
- deleteDialog: {
- title: '确认删除',
- prompt: '确定删除这个消息?这个操作不可撤销。',
- },
- },
- },
- user: {
- username: '用户名',
- password: '密码',
- login: '登录',
- rememberMe: '记住我',
- welcomeBack: '欢迎回来!',
- verifyTokenFailed: '用户登录信息已过期,请重新登陆!',
- verifyTokenFailedNetwork:
- '验证用户登录信息失败,请检查网络连接并刷新页面!',
- },
- login: {
- emptyUsername: '用户名不能为空。',
- emptyPassword: '密码不能为空。',
- badCredential: '用户名或密码错误。',
- alreadyLogin: '已经登陆,三秒后导航到首页!',
- },
- userPage: {
- dialogChangeNickname: {
- title: '更改昵称',
- inputLabel: '新昵称',
- },
- dialogChangeAvatar: {
- title: '修改头像',
- previewImgAlt: '预览',
- prompt: {
- select: '请选择一个图片',
- crop: '请裁剪图片',
- processingCrop: '正在裁剪图片',
- uploading: '正在上传',
- preview: '请预览图片',
- },
- upload: '上传',
- },
- },
- settings: {
- subheaders: {
- account: '账户',
- customization: '个性化',
- },
- languagePrimary: '选择显示的语言。',
- languageSecondary:
- '您的语言偏好将会存储在本地,下次浏览时将自动使用上次保存的语言选项。',
- changePassword: '更改账号的密码。',
- logout: '注销此账号。',
- gotoSelf: '点击前往个人时间线修改昵称和头像!',
- dialogChangePassword: {
- title: '修改密码',
- prompt:
- '您正在修改密码,您需要输入正确的旧密码。成功修改后您需要重新登陆,而且以前所有的登录都会失效。',
- inputOldPassword: '旧密码',
- inputNewPassword: '新密码',
- inputRetypeNewPassword: '再次输入新密码',
- errorEmptyOldPassword: '旧密码不能为空。',
- errorEmptyNewPassword: '新密码不能为空',
- errorRetypeNotMatch: '两次输入的密码不一致',
- },
- dialogConfirmLogout: {
- title: '确定注销',
- prompt: '您确定注销此账号?这将删除所有已经缓存在浏览器的数据。',
- },
- },
- about: {
- author: {
- title: '网站作者',
- fullname: '姓名:',
- nickname: '昵称:',
- introduction: '简介:',
- introductionContent: '一个基于巧合编程的代码爱好者。',
- links: '链接:',
- },
- site: {
- title: '网站信息',
- content:
- '这个网站的名字叫 <1>Timeline</1>,是一个以<3>时间线</3>为核心概念的 Web App . 它的前端和后端都是由<5>我</5>开发,并且在 GitHub 上开源。大家可以相对轻松的把它们部署在自己的服务器上,这也是我的目标之一。欢迎大家前往 GitHub 仓库提出任何意见。',
- repo: 'GitHub 仓库',
- },
- credits: {
- title: '鸣谢',
- content:
- 'Timeline 是站在巨人肩膀上的作品,感谢以下列出的和其他未列出的许多开源项目,相关 License 请在 GitHub 仓库中查看。',
- frontend: '前端:',
- backend: '后端:',
- },
- },
- admin: {
- title: '管理',
- },
-};
-
-export default translation;
+import TranslationResource from '../scheme';
+
+const translation: TranslationResource = {
+ welcome: '欢迎!',
+ search: '搜索',
+ nav: {
+ settings: '设置',
+ login: '登陆',
+ about: '关于',
+ },
+ chooseImage: '选择一个图片',
+ loadImageError: '加载图片失败',
+ home: {
+ go: '冲!',
+ allTimeline: '所有的时间线',
+ joinTimeline: '加入的时间线',
+ ownTimeline: '拥有的时间线',
+ createButton: '创建时间线',
+ createDialog: {
+ title: '创建时间线!',
+ name: '名字',
+ nameFormat:
+ '名字只能由字母、汉字、数字、下划线(_)和连字符(-)构成,且长度不能超过26.',
+ badFormat: '格式错误',
+ noEmpty: '不能为空',
+ tooLong: '太长了',
+ },
+ },
+ operationDialog: {
+ retry: '重试',
+ nextStep: '下一步',
+ previousStep: '上一步',
+ confirm: '确定',
+ cancel: '取消',
+ ok: '好的!',
+ processing: '处理中...',
+ success: '成功!',
+ error: '出错啦!',
+ },
+ timeline: {
+ messageCantSee: '不好意思,你没有权限查看这个时间线。😅',
+ userNotExist: '该用户不存在!',
+ timelineNotExist: '该时间线不存在!',
+ manage: '管理',
+ memberButton: '成员',
+ send: '发送',
+ deletePostFailed: '删除消息失败。',
+ sendPostFailed: '发送消息失败。',
+ visibility: {
+ public: '对所有人公开',
+ register: '仅注册可见',
+ private: '仅成员可见',
+ },
+ visibilityTooltip: {
+ public: '所有人都可以看到这个时间线的内容,包括没有注册的人。',
+ register: '只有拥有本网站的账号且登陆了的人才能看到这个时间线的内容。',
+ private: '只有这个时间线的成员可以看到这个时间线的内容。',
+ },
+ dialogChangeProperty: {
+ title: '修改时间线属性',
+ visibility: '可见性',
+ description: '描述',
+ },
+ member: {
+ alreadyMember: '该用户已经是一个成员。',
+ add: '添加',
+ remove: '移除',
+ },
+ manageItem: {
+ nickname: '昵称',
+ avatar: '头像',
+ property: '时间线属性',
+ member: '时间线成员',
+ delete: '删除时间线',
+ },
+ deleteDialog: {
+ title: '删除时间线',
+ inputPrompt:
+ '这是一个危险的操作。如果您确认要删除时间线<1>{{name}}</1>,请在下面输入它的名字并点击确认。',
+ notMatch: '名字不匹配',
+ },
+ post: {
+ deleteDialog: {
+ title: '确认删除',
+ prompt: '确定删除这个消息?这个操作不可撤销。',
+ },
+ },
+ },
+ user: {
+ username: '用户名',
+ password: '密码',
+ login: '登录',
+ rememberMe: '记住我',
+ welcomeBack: '欢迎回来!',
+ verifyTokenFailed: '用户登录信息已过期,请重新登陆!',
+ verifyTokenFailedNetwork:
+ '验证用户登录信息失败,请检查网络连接并刷新页面!',
+ },
+ login: {
+ emptyUsername: '用户名不能为空。',
+ emptyPassword: '密码不能为空。',
+ badCredential: '用户名或密码错误。',
+ alreadyLogin: '已经登陆,三秒后导航到首页!',
+ },
+ userPage: {
+ dialogChangeNickname: {
+ title: '更改昵称',
+ inputLabel: '新昵称',
+ },
+ dialogChangeAvatar: {
+ title: '修改头像',
+ previewImgAlt: '预览',
+ prompt: {
+ select: '请选择一个图片',
+ crop: '请裁剪图片',
+ processingCrop: '正在裁剪图片',
+ uploading: '正在上传',
+ preview: '请预览图片',
+ },
+ upload: '上传',
+ },
+ },
+ settings: {
+ subheaders: {
+ account: '账户',
+ customization: '个性化',
+ },
+ languagePrimary: '选择显示的语言。',
+ languageSecondary:
+ '您的语言偏好将会存储在本地,下次浏览时将自动使用上次保存的语言选项。',
+ changePassword: '更改账号的密码。',
+ logout: '注销此账号。',
+ gotoSelf: '点击前往个人时间线修改昵称和头像!',
+ dialogChangePassword: {
+ title: '修改密码',
+ prompt:
+ '您正在修改密码,您需要输入正确的旧密码。成功修改后您需要重新登陆,而且以前所有的登录都会失效。',
+ inputOldPassword: '旧密码',
+ inputNewPassword: '新密码',
+ inputRetypeNewPassword: '再次输入新密码',
+ errorEmptyOldPassword: '旧密码不能为空。',
+ errorEmptyNewPassword: '新密码不能为空',
+ errorRetypeNotMatch: '两次输入的密码不一致',
+ },
+ dialogConfirmLogout: {
+ title: '确定注销',
+ prompt: '您确定注销此账号?这将删除所有已经缓存在浏览器的数据。',
+ },
+ },
+ about: {
+ author: {
+ title: '网站作者',
+ fullname: '姓名:',
+ nickname: '昵称:',
+ introduction: '简介:',
+ introductionContent: '一个基于巧合编程的代码爱好者。',
+ links: '链接:',
+ },
+ site: {
+ title: '网站信息',
+ content:
+ '这个网站的名字叫 <1>Timeline</1>,是一个以<3>时间线</3>为核心概念的 Web App . 它的前端和后端都是由<5>我</5>开发,并且在 GitHub 上开源。大家可以相对轻松的把它们部署在自己的服务器上,这也是我的目标之一。欢迎大家前往 GitHub 仓库提出任何意见。',
+ repo: 'GitHub 仓库',
+ },
+ credits: {
+ title: '鸣谢',
+ content:
+ 'Timeline 是站在巨人肩膀上的作品,感谢以下列出的和其他未列出的许多开源项目,相关 License 请在 GitHub 仓库中查看。',
+ frontend: '前端:',
+ backend: '后端:',
+ },
+ },
+ admin: {
+ title: '管理',
+ },
+};
+
+export default translation;
diff --git a/Timeline/ClientApp/src/settings/Settings.tsx b/Timeline/ClientApp/src/app/settings/Settings.tsx
index bf7ee4ef..96a3fab4 100644
--- a/Timeline/ClientApp/src/settings/Settings.tsx
+++ b/Timeline/ClientApp/src/app/settings/Settings.tsx
@@ -1,253 +1,253 @@
-import React, { useState } from 'react';
-import { useHistory } from 'react-router';
-import { useTranslation } from 'react-i18next';
-import axios, { AxiosError } from 'axios';
-import {
- Container,
- Row,
- Col,
- Input,
- Modal,
- ModalHeader,
- ModalBody,
- ModalFooter,
- Button,
-} from 'reactstrap';
-
-import { apiBaseUrl } from '../config';
-
-import { useUser, userLogout, useUserLoggedIn } from '../data/user';
-
-import AppBar from '../common/AppBar';
-import OperationDialog, {
- OperationInputErrorInfo,
-} from '../common/OperationDialog';
-import { CommonErrorResponse } from '../data/common';
-
-interface ChangePasswordDialogProps {
- open: boolean;
- close: () => void;
-}
-
-async function changePassword(
- oldPassword: string,
- newPassword: string,
- token: string
-): Promise<void> {
- const url = `${apiBaseUrl}/userop/changepassword?token=${token}`;
- try {
- await axios.post(url, {
- oldPassword,
- newPassword,
- });
- } catch (e) {
- const error = e as AxiosError<CommonErrorResponse>;
- if (
- error.response &&
- error.response.status === 400 &&
- error.response.data &&
- error.response.data.message
- ) {
- throw error.response.data.message;
- }
- throw e;
- }
-}
-
-const ChangePasswordDialog: React.FC<ChangePasswordDialogProps> = (props) => {
- const user = useUserLoggedIn();
- const history = useHistory();
- const { t } = useTranslation();
-
- const [redirect, setRedirect] = useState<boolean>(false);
-
- return (
- <OperationDialog
- open={props.open}
- title={t('settings.dialogChangePassword.title')}
- titleColor="dangerous"
- inputPrompt={t('settings.dialogChangePassword.prompt')}
- inputScheme={[
- {
- type: 'text',
- label: t('settings.dialogChangePassword.inputOldPassword'),
- password: true,
- validator: (v) =>
- v === ''
- ? 'settings.dialogChangePassword.errorEmptyOldPassword'
- : null,
- },
- {
- type: 'text',
- label: t('settings.dialogChangePassword.inputNewPassword'),
- password: true,
- validator: (v, values) => {
- const error: OperationInputErrorInfo = {};
- error[1] =
- v === ''
- ? 'settings.dialogChangePassword.errorEmptyNewPassword'
- : null;
- if (v === values[2]) {
- error[2] = null;
- } else {
- if (values[2] !== '') {
- error[2] = 'settings.dialogChangePassword.errorRetypeNotMatch';
- }
- }
- return error;
- },
- },
- {
- type: 'text',
- label: t('settings.dialogChangePassword.inputRetypeNewPassword'),
- password: true,
- validator: (v, values) =>
- v !== values[1]
- ? 'settings.dialogChangePassword.errorRetypeNotMatch'
- : null,
- },
- ]}
- onProcess={async ([oldPassword, newPassword]) => {
- await changePassword(
- oldPassword as string,
- newPassword as string,
- user.token
- );
- userLogout();
- setRedirect(true);
- }}
- close={() => {
- props.close();
- if (redirect) {
- history.push('/login');
- }
- }}
- />
- );
-};
-
-const ConfirmLogoutDialog: React.FC<{
- toggle: () => void;
- onConfirm: () => void;
-}> = ({ toggle, onConfirm }) => {
- const { t } = useTranslation();
-
- return (
- <Modal isOpen centered>
- <ModalHeader className="text-danger">
- {t('settings.dialogConfirmLogout.title')}
- </ModalHeader>
- <ModalBody>{t('settings.dialogConfirmLogout.prompt')}</ModalBody>
- <ModalFooter>
- <Button color="secondary" onClick={toggle}>
- {t('operationDialog.cancel')}
- </Button>
- <Button color="danger" onClick={onConfirm}>
- {t('operationDialog.confirm')}
- </Button>
- </ModalFooter>
- </Modal>
- );
-};
-
-const Settings: React.FC = (_) => {
- const { i18n, t } = useTranslation();
- const user = useUser();
- const history = useHistory();
-
- const [dialog, setDialog] = useState<null | 'changepassword' | 'logout'>(
- null
- );
-
- const language = i18n.language.slice(0, 2);
-
- return (
- <>
- <AppBar />
- <Container fluid className="mt-appbar">
- {user ? (
- <>
- <Row className="border-bottom p-3 cursor-pointer">
- <Col xs="12">
- <h5
- onClick={() => {
- history.push(`/users/${user.username}`);
- }}
- >
- {t('settings.gotoSelf')}
- </h5>
- </Col>
- </Row>
- <Row className="border-bottom p-3 cursor-pointer">
- <Col xs="12">
- <h5
- className="text-danger"
- onClick={() => setDialog('changepassword')}
- >
- {t('settings.changePassword')}
- </h5>
- </Col>
- </Row>
- <Row className="border-bottom p-3 cursor-pointer">
- <Col xs="12">
- <h5
- className="text-danger"
- onClick={() => {
- setDialog('logout');
- }}
- >
- {t('settings.logout')}
- </h5>
- </Col>
- </Row>
- </>
- ) : null}
- <Row className="align-items-center border-bottom p-3">
- <Col xs="12" sm="auto">
- <h5>{t('settings.languagePrimary')}</h5>
- <p>{t('settings.languageSecondary')}</p>
- </Col>
- <Col xs="auto" className="ml-auto">
- <Input
- type="select"
- value={language}
- onChange={(e) => {
- void i18n.changeLanguage(e.target.value);
- }}
- >
- <option value="zh">中文</option>
- <option value="en">English</option>
- </Input>
- </Col>
- </Row>
- {(() => {
- switch (dialog) {
- case 'changepassword':
- return (
- <ChangePasswordDialog
- open
- close={() => {
- setDialog(null);
- }}
- />
- );
- case 'logout':
- return (
- <ConfirmLogoutDialog
- toggle={() => setDialog(null)}
- onConfirm={() => {
- userLogout();
- history.push('/');
- }}
- />
- );
- default:
- return null;
- }
- })()}
- </Container>
- </>
- );
-};
-
-export default Settings;
+import React, { useState } from 'react';
+import { useHistory } from 'react-router';
+import { useTranslation } from 'react-i18next';
+import axios, { AxiosError } from 'axios';
+import {
+ Container,
+ Row,
+ Col,
+ Input,
+ Modal,
+ ModalHeader,
+ ModalBody,
+ ModalFooter,
+ Button,
+} from 'reactstrap';
+
+import { apiBaseUrl } from '../config';
+
+import { useUser, userLogout, useUserLoggedIn } from '../data/user';
+
+import AppBar from '../common/AppBar';
+import OperationDialog, {
+ OperationInputErrorInfo,
+} from '../common/OperationDialog';
+import { CommonErrorResponse } from '../data/common';
+
+interface ChangePasswordDialogProps {
+ open: boolean;
+ close: () => void;
+}
+
+async function changePassword(
+ oldPassword: string,
+ newPassword: string,
+ token: string
+): Promise<void> {
+ const url = `${apiBaseUrl}/userop/changepassword?token=${token}`;
+ try {
+ await axios.post(url, {
+ oldPassword,
+ newPassword,
+ });
+ } catch (e) {
+ const error = e as AxiosError<CommonErrorResponse>;
+ if (
+ error.response &&
+ error.response.status === 400 &&
+ error.response.data &&
+ error.response.data.message
+ ) {
+ throw error.response.data.message;
+ }
+ throw e;
+ }
+}
+
+const ChangePasswordDialog: React.FC<ChangePasswordDialogProps> = (props) => {
+ const user = useUserLoggedIn();
+ const history = useHistory();
+ const { t } = useTranslation();
+
+ const [redirect, setRedirect] = useState<boolean>(false);
+
+ return (
+ <OperationDialog
+ open={props.open}
+ title={t('settings.dialogChangePassword.title')}
+ titleColor="dangerous"
+ inputPrompt={t('settings.dialogChangePassword.prompt')}
+ inputScheme={[
+ {
+ type: 'text',
+ label: t('settings.dialogChangePassword.inputOldPassword'),
+ password: true,
+ validator: (v) =>
+ v === ''
+ ? 'settings.dialogChangePassword.errorEmptyOldPassword'
+ : null,
+ },
+ {
+ type: 'text',
+ label: t('settings.dialogChangePassword.inputNewPassword'),
+ password: true,
+ validator: (v, values) => {
+ const error: OperationInputErrorInfo = {};
+ error[1] =
+ v === ''
+ ? 'settings.dialogChangePassword.errorEmptyNewPassword'
+ : null;
+ if (v === values[2]) {
+ error[2] = null;
+ } else {
+ if (values[2] !== '') {
+ error[2] = 'settings.dialogChangePassword.errorRetypeNotMatch';
+ }
+ }
+ return error;
+ },
+ },
+ {
+ type: 'text',
+ label: t('settings.dialogChangePassword.inputRetypeNewPassword'),
+ password: true,
+ validator: (v, values) =>
+ v !== values[1]
+ ? 'settings.dialogChangePassword.errorRetypeNotMatch'
+ : null,
+ },
+ ]}
+ onProcess={async ([oldPassword, newPassword]) => {
+ await changePassword(
+ oldPassword as string,
+ newPassword as string,
+ user.token
+ );
+ userLogout();
+ setRedirect(true);
+ }}
+ close={() => {
+ props.close();
+ if (redirect) {
+ history.push('/login');
+ }
+ }}
+ />
+ );
+};
+
+const ConfirmLogoutDialog: React.FC<{
+ toggle: () => void;
+ onConfirm: () => void;
+}> = ({ toggle, onConfirm }) => {
+ const { t } = useTranslation();
+
+ return (
+ <Modal isOpen centered>
+ <ModalHeader className="text-danger">
+ {t('settings.dialogConfirmLogout.title')}
+ </ModalHeader>
+ <ModalBody>{t('settings.dialogConfirmLogout.prompt')}</ModalBody>
+ <ModalFooter>
+ <Button color="secondary" onClick={toggle}>
+ {t('operationDialog.cancel')}
+ </Button>
+ <Button color="danger" onClick={onConfirm}>
+ {t('operationDialog.confirm')}
+ </Button>
+ </ModalFooter>
+ </Modal>
+ );
+};
+
+const Settings: React.FC = (_) => {
+ const { i18n, t } = useTranslation();
+ const user = useUser();
+ const history = useHistory();
+
+ const [dialog, setDialog] = useState<null | 'changepassword' | 'logout'>(
+ null
+ );
+
+ const language = i18n.language.slice(0, 2);
+
+ return (
+ <>
+ <AppBar />
+ <Container fluid className="mt-appbar">
+ {user ? (
+ <>
+ <Row className="border-bottom p-3 cursor-pointer">
+ <Col xs="12">
+ <h5
+ onClick={() => {
+ history.push(`/users/${user.username}`);
+ }}
+ >
+ {t('settings.gotoSelf')}
+ </h5>
+ </Col>
+ </Row>
+ <Row className="border-bottom p-3 cursor-pointer">
+ <Col xs="12">
+ <h5
+ className="text-danger"
+ onClick={() => setDialog('changepassword')}
+ >
+ {t('settings.changePassword')}
+ </h5>
+ </Col>
+ </Row>
+ <Row className="border-bottom p-3 cursor-pointer">
+ <Col xs="12">
+ <h5
+ className="text-danger"
+ onClick={() => {
+ setDialog('logout');
+ }}
+ >
+ {t('settings.logout')}
+ </h5>
+ </Col>
+ </Row>
+ </>
+ ) : null}
+ <Row className="align-items-center border-bottom p-3">
+ <Col xs="12" sm="auto">
+ <h5>{t('settings.languagePrimary')}</h5>
+ <p>{t('settings.languageSecondary')}</p>
+ </Col>
+ <Col xs="auto" className="ml-auto">
+ <Input
+ type="select"
+ value={language}
+ onChange={(e) => {
+ void i18n.changeLanguage(e.target.value);
+ }}
+ >
+ <option value="zh">中文</option>
+ <option value="en">English</option>
+ </Input>
+ </Col>
+ </Row>
+ {(() => {
+ switch (dialog) {
+ case 'changepassword':
+ return (
+ <ChangePasswordDialog
+ open
+ close={() => {
+ setDialog(null);
+ }}
+ />
+ );
+ case 'logout':
+ return (
+ <ConfirmLogoutDialog
+ toggle={() => setDialog(null)}
+ onConfirm={() => {
+ userLogout();
+ history.push('/');
+ }}
+ />
+ );
+ default:
+ return null;
+ }
+ })()}
+ </Container>
+ </>
+ );
+};
+
+export default Settings;
diff --git a/Timeline/ClientApp/src/timeline/Timeline.tsx b/Timeline/ClientApp/src/app/timeline/Timeline.tsx
index 1ec3795f..35d6490b 100644
--- a/Timeline/ClientApp/src/timeline/Timeline.tsx
+++ b/Timeline/ClientApp/src/app/timeline/Timeline.tsx
@@ -1,99 +1,99 @@
-import React from 'react';
-import clsx from 'clsx';
-import { Container } from 'reactstrap';
-
-import { TimelinePostInfo } from '../data/timeline';
-import { useUser } from '../data/user';
-import { useAvatarVersion } from '../user/api';
-
-import TimelineItem from './TimelineItem';
-
-export interface TimelinePostInfoEx extends TimelinePostInfo {
- deletable: boolean;
-}
-
-export type TimelineDeleteCallback = (index: number, id: number) => void;
-
-export interface TimelineProps {
- className?: string;
- posts: TimelinePostInfoEx[];
- onDelete: TimelineDeleteCallback;
- onResize?: () => void;
-}
-
-const Timeline: React.FC<TimelineProps> = (props) => {
- const user = useUser();
- const avatarVersion = useAvatarVersion();
-
- const { posts, onDelete, onResize } = props;
-
- const [indexShowDeleteButton, setIndexShowDeleteButton] = React.useState<
- number
- >(-1);
-
- const onItemClick = React.useCallback(() => {
- setIndexShowDeleteButton(-1);
- }, []);
-
- const onToggleDelete = React.useMemo(() => {
- return posts.map((post, i) => {
- return post.deletable
- ? () => {
- setIndexShowDeleteButton((oldIndexShowDeleteButton) => {
- return oldIndexShowDeleteButton !== i ? i : -1;
- });
- }
- : undefined;
- });
- }, [posts]);
-
- const onItemDelete = React.useMemo(() => {
- return posts.map((post, i) => {
- return () => {
- onDelete(i, post.id);
- };
- });
- }, [posts, onDelete]);
-
- return (
- <Container
- fluid
- className={clsx('d-flex flex-column position-relative', props.className)}
- >
- <div className="timeline-enter-animation-mask" />
- {(() => {
- const length = posts.length;
- return posts.map((post, i) => {
- const av: number | undefined =
- user != null && user.username === post.author.username
- ? avatarVersion
- : undefined;
-
- const toggleMore = onToggleDelete[i];
-
- return (
- <TimelineItem
- post={post}
- key={post.id}
- current={length - 1 === i}
- more={
- toggleMore
- ? {
- isOpen: indexShowDeleteButton === i,
- toggle: toggleMore,
- onDelete: onItemDelete[i],
- }
- : undefined
- }
- onClick={onItemClick}
- avatarVersion={av}
- onResize={onResize}
- />
- );
- });
- })()}
- </Container>
- );
-};
-
-export default Timeline;
+import React from 'react';
+import clsx from 'clsx';
+import { Container } from 'reactstrap';
+
+import { TimelinePostInfo } from '../data/timeline';
+import { useUser } from '../data/user';
+import { useAvatarVersion } from '../user/api';
+
+import TimelineItem from './TimelineItem';
+
+export interface TimelinePostInfoEx extends TimelinePostInfo {
+ deletable: boolean;
+}
+
+export type TimelineDeleteCallback = (index: number, id: number) => void;
+
+export interface TimelineProps {
+ className?: string;
+ posts: TimelinePostInfoEx[];
+ onDelete: TimelineDeleteCallback;
+ onResize?: () => void;
+}
+
+const Timeline: React.FC<TimelineProps> = (props) => {
+ const user = useUser();
+ const avatarVersion = useAvatarVersion();
+
+ const { posts, onDelete, onResize } = props;
+
+ const [indexShowDeleteButton, setIndexShowDeleteButton] = React.useState<
+ number
+ >(-1);
+
+ const onItemClick = React.useCallback(() => {
+ setIndexShowDeleteButton(-1);
+ }, []);
+
+ const onToggleDelete = React.useMemo(() => {
+ return posts.map((post, i) => {
+ return post.deletable
+ ? () => {
+ setIndexShowDeleteButton((oldIndexShowDeleteButton) => {
+ return oldIndexShowDeleteButton !== i ? i : -1;
+ });
+ }
+ : undefined;
+ });
+ }, [posts]);
+
+ const onItemDelete = React.useMemo(() => {
+ return posts.map((post, i) => {
+ return () => {
+ onDelete(i, post.id);
+ };
+ });
+ }, [posts, onDelete]);
+
+ return (
+ <Container
+ fluid
+ className={clsx('d-flex flex-column position-relative', props.className)}
+ >
+ <div className="timeline-enter-animation-mask" />
+ {(() => {
+ const length = posts.length;
+ return posts.map((post, i) => {
+ const av: number | undefined =
+ user != null && user.username === post.author.username
+ ? avatarVersion
+ : undefined;
+
+ const toggleMore = onToggleDelete[i];
+
+ return (
+ <TimelineItem
+ post={post}
+ key={post.id}
+ current={length - 1 === i}
+ more={
+ toggleMore
+ ? {
+ isOpen: indexShowDeleteButton === i,
+ toggle: toggleMore,
+ onDelete: onItemDelete[i],
+ }
+ : undefined
+ }
+ onClick={onItemClick}
+ avatarVersion={av}
+ onResize={onResize}
+ />
+ );
+ });
+ })()}
+ </Container>
+ );
+};
+
+export default Timeline;
diff --git a/Timeline/ClientApp/src/timeline/TimelineDeleteDialog.tsx b/Timeline/ClientApp/src/app/timeline/TimelineDeleteDialog.tsx
index e2d8ad5a..5e7c6dd4 100644
--- a/Timeline/ClientApp/src/timeline/TimelineDeleteDialog.tsx
+++ b/Timeline/ClientApp/src/app/timeline/TimelineDeleteDialog.tsx
@@ -1,59 +1,59 @@
-import React from 'react';
-import axios from 'axios';
-import { useHistory } from 'react-router';
-import { Trans } from 'react-i18next';
-
-import { apiBaseUrl } from '../config';
-import { useUserLoggedIn } from '../data/user';
-import OperationDialog from '../common/OperationDialog';
-
-interface TimelineDeleteDialog {
- open: boolean;
- name: string;
- close: () => void;
-}
-
-const TimelineDeleteDialog: React.FC<TimelineDeleteDialog> = (props) => {
- const user = useUserLoggedIn();
- const history = useHistory();
-
- const { name } = props;
-
- return (
- <OperationDialog
- open={props.open}
- close={props.close}
- title="timeline.deleteDialog.title"
- titleColor="danger"
- inputPrompt={() => {
- return (
- <Trans i18nKey="timeline.deleteDialog.inputPrompt">
- 0<code className="mx-2">{{ name }}</code>2
- </Trans>
- );
- }}
- inputScheme={[
- {
- type: 'text',
- validator: (value) => {
- if (value !== name) {
- return 'timeline.deleteDialog.notMatch';
- } else {
- return null;
- }
- },
- },
- ]}
- onProcess={() => {
- return axios.delete(
- `${apiBaseUrl}/timelines/${name}?token=${user.token}`
- );
- }}
- onSuccessAndClose={() => {
- history.replace('/');
- }}
- />
- );
-};
-
-export default TimelineDeleteDialog;
+import React from 'react';
+import axios from 'axios';
+import { useHistory } from 'react-router';
+import { Trans } from 'react-i18next';
+
+import { apiBaseUrl } from '../config';
+import { useUserLoggedIn } from '../data/user';
+import OperationDialog from '../common/OperationDialog';
+
+interface TimelineDeleteDialog {
+ open: boolean;
+ name: string;
+ close: () => void;
+}
+
+const TimelineDeleteDialog: React.FC<TimelineDeleteDialog> = (props) => {
+ const user = useUserLoggedIn();
+ const history = useHistory();
+
+ const { name } = props;
+
+ return (
+ <OperationDialog
+ open={props.open}
+ close={props.close}
+ title="timeline.deleteDialog.title"
+ titleColor="danger"
+ inputPrompt={() => {
+ return (
+ <Trans i18nKey="timeline.deleteDialog.inputPrompt">
+ 0<code className="mx-2">{{ name }}</code>2
+ </Trans>
+ );
+ }}
+ inputScheme={[
+ {
+ type: 'text',
+ validator: (value) => {
+ if (value !== name) {
+ return 'timeline.deleteDialog.notMatch';
+ } else {
+ return null;
+ }
+ },
+ },
+ ]}
+ onProcess={() => {
+ return axios.delete(
+ `${apiBaseUrl}/timelines/${name}?token=${user.token}`
+ );
+ }}
+ onSuccessAndClose={() => {
+ history.replace('/');
+ }}
+ />
+ );
+};
+
+export default TimelineDeleteDialog;
diff --git a/Timeline/ClientApp/src/timeline/TimelineInfoCard.tsx b/Timeline/ClientApp/src/app/timeline/TimelineInfoCard.tsx
index b3af4cab..e4bc07d1 100644
--- a/Timeline/ClientApp/src/timeline/TimelineInfoCard.tsx
+++ b/Timeline/ClientApp/src/app/timeline/TimelineInfoCard.tsx
@@ -1,109 +1,109 @@
-import React from 'react';
-import clsx from 'clsx';
-import {
- Dropdown,
- DropdownToggle,
- DropdownMenu,
- DropdownItem,
- Button,
-} from 'reactstrap';
-import { useTranslation } from 'react-i18next';
-import { fromEvent } from 'rxjs';
-
-import {
- timelineVisibilityTooltipTranslationMap,
- TimelineInfo,
-} from '../data/timeline';
-import { TimelineCardComponentProps } from './TimelinePageTemplateUI';
-
-export type OrdinaryTimelineManageItem = 'delete';
-
-export type TimelineInfoCardProps = TimelineCardComponentProps<
- TimelineInfo,
- OrdinaryTimelineManageItem
->;
-
-const TimelineInfoCard: React.FC<TimelineInfoCardProps> = (props) => {
- const { onHeight, onManage } = props;
-
- const { t } = useTranslation();
-
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- const containerRef = React.useRef<HTMLDivElement>(null!);
-
- const notifyHeight = React.useCallback((): void => {
- if (onHeight) {
- onHeight(containerRef.current.getBoundingClientRect().height);
- }
- }, [onHeight]);
-
- React.useEffect(() => {
- const subscription = fromEvent(window, 'resize').subscribe(notifyHeight);
- return () => subscription.unsubscribe();
- });
-
- const [manageDropdownOpen, setManageDropdownOpen] = React.useState<boolean>(
- false
- );
- const toggleManageDropdown = React.useCallback(
- (): void => setManageDropdownOpen((old) => !old),
- []
- );
-
- return (
- <div
- ref={containerRef}
- className={clsx('rounded border p-2 bg-light', props.className)}
- onTransitionEnd={notifyHeight}
- >
- <h3 className="text-primary mx-3 d-inline-block align-middle">
- {props.timeline.name}
- </h3>
- <div className="d-inline-block align-middle">
- <img
- src={props.timeline.owner._links.avatar}
- onLoad={notifyHeight}
- className="avatar small rounded-circle"
- />
- {props.timeline.owner.nickname}
- <small className="ml-3 text-secondary">
- @{props.timeline.owner.username}
- </small>
- </div>
- <p className="mb-0">{props.timeline.description}</p>
- <small className="mt-1 d-block">
- {t(timelineVisibilityTooltipTranslationMap[props.timeline.visibility])}
- </small>
- <div className="text-right mt-2">
- {onManage != null ? (
- <Dropdown isOpen={manageDropdownOpen} toggle={toggleManageDropdown}>
- <DropdownToggle outline color="primary">
- {t('timeline.manage')}
- </DropdownToggle>
- <DropdownMenu>
- <DropdownItem onClick={() => onManage('property')}>
- {t('timeline.manageItem.property')}
- </DropdownItem>
- <DropdownItem onClick={props.onMember}>
- {t('timeline.manageItem.member')}
- </DropdownItem>
- <DropdownItem divider />
- <DropdownItem
- className="text-danger"
- onClick={() => onManage('delete')}
- >
- {t('timeline.manageItem.delete')}
- </DropdownItem>
- </DropdownMenu>
- </Dropdown>
- ) : (
- <Button color="primary" outline onClick={props.onMember}>
- {t('timeline.memberButton')}
- </Button>
- )}
- </div>
- </div>
- );
-};
-
-export default TimelineInfoCard;
+import React from 'react';
+import clsx from 'clsx';
+import {
+ Dropdown,
+ DropdownToggle,
+ DropdownMenu,
+ DropdownItem,
+ Button,
+} from 'reactstrap';
+import { useTranslation } from 'react-i18next';
+import { fromEvent } from 'rxjs';
+
+import {
+ timelineVisibilityTooltipTranslationMap,
+ TimelineInfo,
+} from '../data/timeline';
+import { TimelineCardComponentProps } from './TimelinePageTemplateUI';
+
+export type OrdinaryTimelineManageItem = 'delete';
+
+export type TimelineInfoCardProps = TimelineCardComponentProps<
+ TimelineInfo,
+ OrdinaryTimelineManageItem
+>;
+
+const TimelineInfoCard: React.FC<TimelineInfoCardProps> = (props) => {
+ const { onHeight, onManage } = props;
+
+ const { t } = useTranslation();
+
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ const containerRef = React.useRef<HTMLDivElement>(null!);
+
+ const notifyHeight = React.useCallback((): void => {
+ if (onHeight) {
+ onHeight(containerRef.current.getBoundingClientRect().height);
+ }
+ }, [onHeight]);
+
+ React.useEffect(() => {
+ const subscription = fromEvent(window, 'resize').subscribe(notifyHeight);
+ return () => subscription.unsubscribe();
+ });
+
+ const [manageDropdownOpen, setManageDropdownOpen] = React.useState<boolean>(
+ false
+ );
+ const toggleManageDropdown = React.useCallback(
+ (): void => setManageDropdownOpen((old) => !old),
+ []
+ );
+
+ return (
+ <div
+ ref={containerRef}
+ className={clsx('rounded border p-2 bg-light', props.className)}
+ onTransitionEnd={notifyHeight}
+ >
+ <h3 className="text-primary mx-3 d-inline-block align-middle">
+ {props.timeline.name}
+ </h3>
+ <div className="d-inline-block align-middle">
+ <img
+ src={props.timeline.owner._links.avatar}
+ onLoad={notifyHeight}
+ className="avatar small rounded-circle"
+ />
+ {props.timeline.owner.nickname}
+ <small className="ml-3 text-secondary">
+ @{props.timeline.owner.username}
+ </small>
+ </div>
+ <p className="mb-0">{props.timeline.description}</p>
+ <small className="mt-1 d-block">
+ {t(timelineVisibilityTooltipTranslationMap[props.timeline.visibility])}
+ </small>
+ <div className="text-right mt-2">
+ {onManage != null ? (
+ <Dropdown isOpen={manageDropdownOpen} toggle={toggleManageDropdown}>
+ <DropdownToggle outline color="primary">
+ {t('timeline.manage')}
+ </DropdownToggle>
+ <DropdownMenu>
+ <DropdownItem onClick={() => onManage('property')}>
+ {t('timeline.manageItem.property')}
+ </DropdownItem>
+ <DropdownItem onClick={props.onMember}>
+ {t('timeline.manageItem.member')}
+ </DropdownItem>
+ <DropdownItem divider />
+ <DropdownItem
+ className="text-danger"
+ onClick={() => onManage('delete')}
+ >
+ {t('timeline.manageItem.delete')}
+ </DropdownItem>
+ </DropdownMenu>
+ </Dropdown>
+ ) : (
+ <Button color="primary" outline onClick={props.onMember}>
+ {t('timeline.memberButton')}
+ </Button>
+ )}
+ </div>
+ </div>
+ );
+};
+
+export default TimelineInfoCard;
diff --git a/Timeline/ClientApp/src/timeline/TimelineItem.tsx b/Timeline/ClientApp/src/app/timeline/TimelineItem.tsx
index 9e08acc7..4737fd7d 100644
--- a/Timeline/ClientApp/src/timeline/TimelineItem.tsx
+++ b/Timeline/ClientApp/src/app/timeline/TimelineItem.tsx
@@ -1,175 +1,175 @@
-import React from 'react';
-import clsx from 'clsx';
-import {
- Row,
- Col,
- Modal,
- ModalHeader,
- ModalBody,
- ModalFooter,
- Button,
-} from 'reactstrap';
-import { Link } from 'react-router-dom';
-import { useTranslation } from 'react-i18next';
-
-import { TimelinePostInfo } from '../data/timeline';
-import { useAvatarUrlWithGivenVersion } from '../user/api';
-
-const TimelinePostDeleteConfirmDialog: React.FC<{
- toggle: () => void;
- onConfirm: () => void;
-}> = ({ toggle, onConfirm }) => {
- const { t } = useTranslation();
-
- return (
- <Modal toggle={toggle} isOpen centered>
- <ModalHeader className="text-danger">
- {t('timeline.post.deleteDialog.title')}
- </ModalHeader>
- <ModalBody>{t('timeline.post.deleteDialog.prompt')}</ModalBody>
- <ModalFooter>
- <Button color="secondary" onClick={toggle}>
- {t('operationDialog.cancel')}
- </Button>
- <Button
- color="danger"
- onClick={() => {
- onConfirm();
- toggle();
- }}
- >
- {t('operationDialog.confirm')}
- </Button>
- </ModalFooter>
- </Modal>
- );
-};
-
-export interface TimelineItemProps {
- post: TimelinePostInfo;
- current?: boolean;
- more?: {
- isOpen: boolean;
- toggle: () => void;
- onDelete: () => void;
- };
- onClick?: () => void;
- avatarVersion?: number;
- onResize?: () => void;
- className?: string;
- style?: React.CSSProperties;
-}
-
-const TimelineItem: React.FC<TimelineItemProps> = (props) => {
- const { i18n } = useTranslation();
-
- const current = props.current === true;
-
- const { more, onResize } = props;
-
- const avatarUrl = useAvatarUrlWithGivenVersion(
- props.avatarVersion,
- props.post.author._links.avatar
- );
-
- const [deleteDialog, setDeleteDialog] = React.useState<boolean>(false);
- const toggleDeleteDialog = React.useCallback(
- () => setDeleteDialog((old) => !old),
- []
- );
-
- return (
- <Row
- className={clsx(
- 'position-relative flex-nowrap',
- current && 'current',
- props.className
- )}
- onClick={props.onClick}
- style={props.style}
- >
- <Col className="timeline-line-area">
- <div className="timeline-line-segment start"></div>
- <div className="timeline-line-node-container">
- <div className="timeline-line-node"></div>
- </div>
- <div className="timeline-line-segment end"></div>
- {current && <div className="timeline-line-segment current-end" />}
- </Col>
- <Col className="timeline-pt-start">
- <Row className="flex-nowrap">
- <div className="col-auto flex-shrink-1 px-0">
- <Row className="ml-n3 mr-0 align-items-center">
- <span className="ml-3 text-primary white-space-no-wrap">
- {props.post.time.toLocaleString(i18n.languages)}
- </span>
- <small className="text-dark ml-3">
- {props.post.author.nickname}
- </small>
- </Row>
- </div>
- {more != null ? (
- <div className="col-auto px-2 d-flex justify-content-center align-items-center">
- <i
- className="fas fa-chevron-circle-down text-info icon-button"
- onClick={(e) => {
- more.toggle();
- e.stopPropagation();
- }}
- />
- </div>
- ) : null}
- </Row>
- <p className="row d-block timeline-content">
- <Link
- className="float-right float-sm-left mx-2"
- to={'/users/' + props.post.author.username}
- >
- <img onLoad={onResize} src={avatarUrl} className="avatar rounded" />
- </Link>
- {(() => {
- const { content } = props.post;
- if (content.type === 'text') {
- return content.text;
- } else {
- return (
- <img
- onLoad={onResize}
- src={content.url}
- className="timeline-content-image"
- />
- );
- }
- })()}
- </p>
- </Col>
- {more != null && more.isOpen ? (
- <>
- <div
- className="position-absolute position-lt w-100 h-100 mask d-flex justify-content-center align-items-center"
- onClick={more.toggle}
- >
- <i
- className="fas fa-trash text-danger large-icon"
- onClick={(e) => {
- toggleDeleteDialog();
- e.stopPropagation();
- }}
- />
- </div>
- {deleteDialog ? (
- <TimelinePostDeleteConfirmDialog
- toggle={() => {
- toggleDeleteDialog();
- more.toggle();
- }}
- onConfirm={more.onDelete}
- />
- ) : null}
- </>
- ) : null}
- </Row>
- );
-};
-
-export default TimelineItem;
+import React from 'react';
+import clsx from 'clsx';
+import {
+ Row,
+ Col,
+ Modal,
+ ModalHeader,
+ ModalBody,
+ ModalFooter,
+ Button,
+} from 'reactstrap';
+import { Link } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
+
+import { TimelinePostInfo } from '../data/timeline';
+import { useAvatarUrlWithGivenVersion } from '../user/api';
+
+const TimelinePostDeleteConfirmDialog: React.FC<{
+ toggle: () => void;
+ onConfirm: () => void;
+}> = ({ toggle, onConfirm }) => {
+ const { t } = useTranslation();
+
+ return (
+ <Modal toggle={toggle} isOpen centered>
+ <ModalHeader className="text-danger">
+ {t('timeline.post.deleteDialog.title')}
+ </ModalHeader>
+ <ModalBody>{t('timeline.post.deleteDialog.prompt')}</ModalBody>
+ <ModalFooter>
+ <Button color="secondary" onClick={toggle}>
+ {t('operationDialog.cancel')}
+ </Button>
+ <Button
+ color="danger"
+ onClick={() => {
+ onConfirm();
+ toggle();
+ }}
+ >
+ {t('operationDialog.confirm')}
+ </Button>
+ </ModalFooter>
+ </Modal>
+ );
+};
+
+export interface TimelineItemProps {
+ post: TimelinePostInfo;
+ current?: boolean;
+ more?: {
+ isOpen: boolean;
+ toggle: () => void;
+ onDelete: () => void;
+ };
+ onClick?: () => void;
+ avatarVersion?: number;
+ onResize?: () => void;
+ className?: string;
+ style?: React.CSSProperties;
+}
+
+const TimelineItem: React.FC<TimelineItemProps> = (props) => {
+ const { i18n } = useTranslation();
+
+ const current = props.current === true;
+
+ const { more, onResize } = props;
+
+ const avatarUrl = useAvatarUrlWithGivenVersion(
+ props.avatarVersion,
+ props.post.author._links.avatar
+ );
+
+ const [deleteDialog, setDeleteDialog] = React.useState<boolean>(false);
+ const toggleDeleteDialog = React.useCallback(
+ () => setDeleteDialog((old) => !old),
+ []
+ );
+
+ return (
+ <Row
+ className={clsx(
+ 'position-relative flex-nowrap',
+ current && 'current',
+ props.className
+ )}
+ onClick={props.onClick}
+ style={props.style}
+ >
+ <Col className="timeline-line-area">
+ <div className="timeline-line-segment start"></div>
+ <div className="timeline-line-node-container">
+ <div className="timeline-line-node"></div>
+ </div>
+ <div className="timeline-line-segment end"></div>
+ {current && <div className="timeline-line-segment current-end" />}
+ </Col>
+ <Col className="timeline-pt-start">
+ <Row className="flex-nowrap">
+ <div className="col-auto flex-shrink-1 px-0">
+ <Row className="ml-n3 mr-0 align-items-center">
+ <span className="ml-3 text-primary white-space-no-wrap">
+ {props.post.time.toLocaleString(i18n.languages)}
+ </span>
+ <small className="text-dark ml-3">
+ {props.post.author.nickname}
+ </small>
+ </Row>
+ </div>
+ {more != null ? (
+ <div className="col-auto px-2 d-flex justify-content-center align-items-center">
+ <i
+ className="fas fa-chevron-circle-down text-info icon-button"
+ onClick={(e) => {
+ more.toggle();
+ e.stopPropagation();
+ }}
+ />
+ </div>
+ ) : null}
+ </Row>
+ <p className="row d-block timeline-content">
+ <Link
+ className="float-right float-sm-left mx-2"
+ to={'/users/' + props.post.author.username}
+ >
+ <img onLoad={onResize} src={avatarUrl} className="avatar rounded" />
+ </Link>
+ {(() => {
+ const { content } = props.post;
+ if (content.type === 'text') {
+ return content.text;
+ } else {
+ return (
+ <img
+ onLoad={onResize}
+ src={content.url}
+ className="timeline-content-image"
+ />
+ );
+ }
+ })()}
+ </p>
+ </Col>
+ {more != null && more.isOpen ? (
+ <>
+ <div
+ className="position-absolute position-lt w-100 h-100 mask d-flex justify-content-center align-items-center"
+ onClick={more.toggle}
+ >
+ <i
+ className="fas fa-trash text-danger large-icon"
+ onClick={(e) => {
+ toggleDeleteDialog();
+ e.stopPropagation();
+ }}
+ />
+ </div>
+ {deleteDialog ? (
+ <TimelinePostDeleteConfirmDialog
+ toggle={() => {
+ toggleDeleteDialog();
+ more.toggle();
+ }}
+ onConfirm={more.onDelete}
+ />
+ ) : null}
+ </>
+ ) : null}
+ </Row>
+ );
+};
+
+export default TimelineItem;
diff --git a/Timeline/ClientApp/src/timeline/TimelineMember.tsx b/Timeline/ClientApp/src/app/timeline/TimelineMember.tsx
index b8bb49ee..f9747b4d 100644
--- a/Timeline/ClientApp/src/timeline/TimelineMember.tsx
+++ b/Timeline/ClientApp/src/app/timeline/TimelineMember.tsx
@@ -1,196 +1,196 @@
-import React, { useState } from 'react';
-import { useTranslation } from 'react-i18next';
-
-import { User } from '../data/user';
-
-import SearchInput from '../common/SearchInput';
-import {
- Container,
- ListGroup,
- ListGroupItem,
- Modal,
- Row,
- Col,
- Button,
-} from 'reactstrap';
-
-export interface TimelineMemberCallbacks {
- onCheckUser: (username: string) => Promise<User | null>;
- onAddUser: (user: User) => Promise<void>;
- onRemoveUser: (username: string) => void;
-}
-
-export interface TimelineMemberProps {
- members: User[];
- edit: TimelineMemberCallbacks | null | undefined;
-}
-
-const TimelineMember: React.FC<TimelineMemberProps> = (props) => {
- const { t } = useTranslation();
-
- const [userSearchText, setUserSearchText] = useState<string>('');
- const [userSearchState, setUserSearchState] = useState<
- | {
- type: 'user';
- data: User;
- }
- | { type: 'error'; data: string }
- | { type: 'loading' }
- | { type: 'init' }
- >({ type: 'init' });
-
- const members = props.members;
-
- return (
- <Container className="px-4">
- <ListGroup className="my-3">
- {members.map((member, index) => (
- <ListGroupItem key={member.username} className="container">
- <Row>
- <Col className="col-auto">
- <img src={member._links.avatar} className="avatar small" />
- </Col>
- <Col>
- <Row>{member.nickname}</Row>
- <Row>
- <small>{'@' + member.username}</small>
- </Row>
- </Col>
- {(() => {
- if (index === 0) {
- return null;
- }
- const onRemove = props.edit?.onRemoveUser;
- if (onRemove == null) {
- return null;
- }
- return (
- <Button
- className="align-self-center"
- color="danger"
- onClick={() => {
- onRemove(member.username);
- }}
- >
- {t('timeline.member.remove')}
- </Button>
- );
- })()}
- </Row>
- </ListGroupItem>
- ))}
- </ListGroup>
- {(() => {
- const edit = props.edit;
- if (edit != null) {
- return (
- <>
- <SearchInput
- value={userSearchText}
- onChange={(v) => {
- setUserSearchText(v);
- }}
- loading={userSearchState.type === 'loading'}
- onButtonClick={() => {
- if (userSearchText === '') {
- setUserSearchState({
- type: 'error',
- data: 'login.emptyUsername',
- });
- return;
- }
-
- setUserSearchState({ type: 'loading' });
- edit.onCheckUser(userSearchText).then(
- (u) => {
- if (u == null) {
- setUserSearchState({
- type: 'error',
- data: 'timeline.userNotExist',
- });
- } else {
- setUserSearchState({ type: 'user', data: u });
- }
- },
- (e) => {
- setUserSearchState({
- type: 'error',
- data: `${e as string}`,
- });
- }
- );
- }}
- />
- {(() => {
- if (userSearchState.type === 'user') {
- const u = userSearchState.data;
- const addable =
- members.findIndex((m) => m.username === u.username) === -1;
- return (
- <>
- {!addable ? (
- <p>{t('timeline.member.alreadyMember')}</p>
- ) : null}
- <Container className="mb-3">
- <Row>
- <Col className="col-auto">
- <img
- src={u._links.avatar}
- className="avatar small"
- />
- </Col>
- <Col>
- <Row>{u.nickname}</Row>
- <Row>
- <small>{'@' + u.username}</small>
- </Row>
- </Col>
- <Button
- color="primary"
- className="align-self-center"
- disabled={!addable}
- onClick={() => {
- void edit.onAddUser(u).then((_) => {
- setUserSearchText('');
- setUserSearchState({ type: 'init' });
- });
- }}
- >
- {t('timeline.member.add')}
- </Button>
- </Row>
- </Container>
- </>
- );
- } else if (userSearchState.type === 'error') {
- return (
- <p className="text-danger">{t(userSearchState.data)}</p>
- );
- }
- })()}
- </>
- );
- } else {
- return null;
- }
- })()}
- </Container>
- );
-};
-
-export default TimelineMember;
-
-export interface TimelineMemberDialogProps extends TimelineMemberProps {
- open: boolean;
- onClose: () => void;
-}
-
-export const TimelineMemberDialog: React.FC<TimelineMemberDialogProps> = (
- props
-) => {
- return (
- <Modal isOpen={props.open} toggle={props.onClose}>
- <TimelineMember {...props} />
- </Modal>
- );
-};
+import React, { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { User } from '../data/user';
+
+import SearchInput from '../common/SearchInput';
+import {
+ Container,
+ ListGroup,
+ ListGroupItem,
+ Modal,
+ Row,
+ Col,
+ Button,
+} from 'reactstrap';
+
+export interface TimelineMemberCallbacks {
+ onCheckUser: (username: string) => Promise<User | null>;
+ onAddUser: (user: User) => Promise<void>;
+ onRemoveUser: (username: string) => void;
+}
+
+export interface TimelineMemberProps {
+ members: User[];
+ edit: TimelineMemberCallbacks | null | undefined;
+}
+
+const TimelineMember: React.FC<TimelineMemberProps> = (props) => {
+ const { t } = useTranslation();
+
+ const [userSearchText, setUserSearchText] = useState<string>('');
+ const [userSearchState, setUserSearchState] = useState<
+ | {
+ type: 'user';
+ data: User;
+ }
+ | { type: 'error'; data: string }
+ | { type: 'loading' }
+ | { type: 'init' }
+ >({ type: 'init' });
+
+ const members = props.members;
+
+ return (
+ <Container className="px-4">
+ <ListGroup className="my-3">
+ {members.map((member, index) => (
+ <ListGroupItem key={member.username} className="container">
+ <Row>
+ <Col className="col-auto">
+ <img src={member._links.avatar} className="avatar small" />
+ </Col>
+ <Col>
+ <Row>{member.nickname}</Row>
+ <Row>
+ <small>{'@' + member.username}</small>
+ </Row>
+ </Col>
+ {(() => {
+ if (index === 0) {
+ return null;
+ }
+ const onRemove = props.edit?.onRemoveUser;
+ if (onRemove == null) {
+ return null;
+ }
+ return (
+ <Button
+ className="align-self-center"
+ color="danger"
+ onClick={() => {
+ onRemove(member.username);
+ }}
+ >
+ {t('timeline.member.remove')}
+ </Button>
+ );
+ })()}
+ </Row>
+ </ListGroupItem>
+ ))}
+ </ListGroup>
+ {(() => {
+ const edit = props.edit;
+ if (edit != null) {
+ return (
+ <>
+ <SearchInput
+ value={userSearchText}
+ onChange={(v) => {
+ setUserSearchText(v);
+ }}
+ loading={userSearchState.type === 'loading'}
+ onButtonClick={() => {
+ if (userSearchText === '') {
+ setUserSearchState({
+ type: 'error',
+ data: 'login.emptyUsername',
+ });
+ return;
+ }
+
+ setUserSearchState({ type: 'loading' });
+ edit.onCheckUser(userSearchText).then(
+ (u) => {
+ if (u == null) {
+ setUserSearchState({
+ type: 'error',
+ data: 'timeline.userNotExist',
+ });
+ } else {
+ setUserSearchState({ type: 'user', data: u });
+ }
+ },
+ (e) => {
+ setUserSearchState({
+ type: 'error',
+ data: `${e as string}`,
+ });
+ }
+ );
+ }}
+ />
+ {(() => {
+ if (userSearchState.type === 'user') {
+ const u = userSearchState.data;
+ const addable =
+ members.findIndex((m) => m.username === u.username) === -1;
+ return (
+ <>
+ {!addable ? (
+ <p>{t('timeline.member.alreadyMember')}</p>
+ ) : null}
+ <Container className="mb-3">
+ <Row>
+ <Col className="col-auto">
+ <img
+ src={u._links.avatar}
+ className="avatar small"
+ />
+ </Col>
+ <Col>
+ <Row>{u.nickname}</Row>
+ <Row>
+ <small>{'@' + u.username}</small>
+ </Row>
+ </Col>
+ <Button
+ color="primary"
+ className="align-self-center"
+ disabled={!addable}
+ onClick={() => {
+ void edit.onAddUser(u).then((_) => {
+ setUserSearchText('');
+ setUserSearchState({ type: 'init' });
+ });
+ }}
+ >
+ {t('timeline.member.add')}
+ </Button>
+ </Row>
+ </Container>
+ </>
+ );
+ } else if (userSearchState.type === 'error') {
+ return (
+ <p className="text-danger">{t(userSearchState.data)}</p>
+ );
+ }
+ })()}
+ </>
+ );
+ } else {
+ return null;
+ }
+ })()}
+ </Container>
+ );
+};
+
+export default TimelineMember;
+
+export interface TimelineMemberDialogProps extends TimelineMemberProps {
+ open: boolean;
+ onClose: () => void;
+}
+
+export const TimelineMemberDialog: React.FC<TimelineMemberDialogProps> = (
+ props
+) => {
+ return (
+ <Modal isOpen={props.open} toggle={props.onClose}>
+ <TimelineMember {...props} />
+ </Modal>
+ );
+};
diff --git a/Timeline/ClientApp/src/timeline/TimelinePage.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePage.tsx
index 5adebe1f..900d6e6a 100644
--- a/Timeline/ClientApp/src/timeline/TimelinePage.tsx
+++ b/Timeline/ClientApp/src/app/timeline/TimelinePage.tsx
@@ -1,39 +1,39 @@
-import React from 'react';
-import { useParams } from 'react-router';
-
-import { ordinaryTimelineService } from '../data/timeline';
-
-import TimelinePageUI from './TimelinePageUI';
-import TimelinePageTemplate from '../timeline/TimelinePageTemplate';
-import { OrdinaryTimelineManageItem } from './TimelineInfoCard';
-import TimelineDeleteDialog from './TimelineDeleteDialog';
-
-const TimelinePage: React.FC = _ => {
- const { name } = useParams<{ name: string }>();
-
- const [dialog, setDialog] = React.useState<OrdinaryTimelineManageItem | null>(
- null
- );
-
- let dialogElement: React.ReactElement | undefined;
- if (dialog === 'delete') {
- dialogElement = (
- <TimelineDeleteDialog open close={() => setDialog(null)} name={name} />
- );
- }
-
- return (
- <>
- <TimelinePageTemplate
- name={name}
- UiComponent={TimelinePageUI}
- onManage={item => setDialog(item)}
- service={ordinaryTimelineService}
- notFoundI18nKey="timeline.timelineNotExist"
- />
- {dialogElement}
- </>
- );
-};
-
-export default TimelinePage;
+import React from 'react';
+import { useParams } from 'react-router';
+
+import { ordinaryTimelineService } from '../data/timeline';
+
+import TimelinePageUI from './TimelinePageUI';
+import TimelinePageTemplate from '../timeline/TimelinePageTemplate';
+import { OrdinaryTimelineManageItem } from './TimelineInfoCard';
+import TimelineDeleteDialog from './TimelineDeleteDialog';
+
+const TimelinePage: React.FC = _ => {
+ const { name } = useParams<{ name: string }>();
+
+ const [dialog, setDialog] = React.useState<OrdinaryTimelineManageItem | null>(
+ null
+ );
+
+ let dialogElement: React.ReactElement | undefined;
+ if (dialog === 'delete') {
+ dialogElement = (
+ <TimelineDeleteDialog open close={() => setDialog(null)} name={name} />
+ );
+ }
+
+ return (
+ <>
+ <TimelinePageTemplate
+ name={name}
+ UiComponent={TimelinePageUI}
+ onManage={item => setDialog(item)}
+ service={ordinaryTimelineService}
+ notFoundI18nKey="timeline.timelineNotExist"
+ />
+ {dialogElement}
+ </>
+ );
+};
+
+export default TimelinePage;
diff --git a/Timeline/ClientApp/src/timeline/TimelinePageTemplate.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePageTemplate.tsx
index f8721985..38ecd8f9 100644
--- a/Timeline/ClientApp/src/timeline/TimelinePageTemplate.tsx
+++ b/Timeline/ClientApp/src/app/timeline/TimelinePageTemplate.tsx
@@ -1,275 +1,275 @@
-import React from 'react';
-import { useTranslation } from 'react-i18next';
-import { AxiosError } from 'axios';
-import concat from 'lodash/concat';
-import without from 'lodash/without';
-
-import { ExcludeKey } from '../type-utilities';
-import { useUser, fetchUser } from '../data/user';
-import { pushAlert } from '../common/alert-service';
-import { extractStatusCode, extractErrorCode } from '../data/common';
-import {
- TimelineServiceTemplate,
- TimelineInfo,
- TimelineChangePropertyRequest,
-} from '../data/timeline';
-
-import { TimelinePostInfoEx, TimelineDeleteCallback } from './Timeline';
-import { TimelineMemberDialog } from './TimelineMember';
-import TimelinePropertyChangeDialog from './TimelinePropertyChangeDialog';
-import { TimelinePageTemplateUIProps } from './TimelinePageTemplateUI';
-import { TimelinePostSendCallback } from './TimelinePostEdit';
-import { UiLogicError } from '../common';
-
-export interface TimelinePageTemplateProps<
- TManageItem,
- TTimeline extends TimelineInfo
-> {
- name: string;
- onManage: (item: TManageItem) => void;
- service: TimelineServiceTemplate<TTimeline, TimelineChangePropertyRequest>;
- UiComponent: React.ComponentType<
- ExcludeKey<
- TimelinePageTemplateUIProps<TTimeline, TManageItem>,
- 'CardComponent'
- >
- >;
- dataVersion?: number;
- notFoundI18nKey: string;
-}
-
-export default function TimelinePageTemplate<
- TManageItem,
- TTimeline extends TimelineInfo
->(
- props: TimelinePageTemplateProps<TManageItem, TTimeline>
-): React.ReactElement | null {
- const { t } = useTranslation();
-
- const { name } = props;
-
- const user = useUser();
-
- const [dialog, setDialog] = React.useState<null | 'property' | 'member'>(
- null
- );
- const [timeline, setTimeline] = React.useState<TTimeline | undefined>(
- undefined
- );
- const [posts, setPosts] = React.useState<
- TimelinePostInfoEx[] | 'forbid' | undefined
- >(undefined);
- const [error, setError] = React.useState<string | undefined>(undefined);
-
- const service = props.service;
-
- React.useEffect(() => {
- let subscribe = true;
- service.fetch(name).then(
- (ti) => {
- if (subscribe) {
- setTimeline(ti);
- if (!service.hasReadPermission(user, ti)) {
- setPosts('forbid');
- } else {
- service.fetchPosts(name).then(
- (data) => {
- if (subscribe) {
- setPosts(
- data.map((post) => ({
- ...post,
- deletable: service.hasModifyPostPermission(
- user,
- ti,
- post
- ),
- }))
- );
- }
- },
- (error) => {
- if (subscribe) {
- setError(`${error as string}`);
- }
- }
- );
- }
- }
- },
- (error: AxiosError) => {
- if (subscribe) {
- if (
- extractStatusCode(error) === 404 ||
- extractErrorCode(error) === 11020101
- ) {
- setError(t(props.notFoundI18nKey));
- } else {
- setError(error.toString());
- }
- }
- }
- );
- return () => {
- subscribe = false;
- };
- }, [name, service, user, t, props.dataVersion, props.notFoundI18nKey]);
-
- const closeDialog = React.useCallback((): void => {
- setDialog(null);
- }, []);
-
- let dialogElement: React.ReactElement | undefined;
-
- if (dialog === 'property') {
- if (timeline == null) {
- throw new UiLogicError(
- 'Timeline is null but attempt to open change property dialog.'
- );
- }
-
- dialogElement = (
- <TimelinePropertyChangeDialog
- open
- close={closeDialog}
- oldInfo={{
- visibility: timeline.visibility,
- description: timeline.description,
- }}
- onProcess={(req) => {
- return service.changeProperty(name, req).then((newTimeline) => {
- setTimeline(newTimeline);
- });
- }}
- />
- );
- } else if (dialog === 'member') {
- if (timeline == null) {
- throw new UiLogicError(
- 'Timeline is null but attempt to open change property dialog.'
- );
- }
-
- dialogElement = (
- <TimelineMemberDialog
- open
- onClose={closeDialog}
- members={[timeline.owner, ...timeline.members]}
- edit={
- service.hasManagePermission(user, timeline)
- ? {
- onCheckUser: (u) => {
- return fetchUser(u).catch((e) => {
- if (
- extractStatusCode(e) === 404 ||
- extractErrorCode(e) === 11020101
- ) {
- return Promise.resolve(null);
- } else {
- return Promise.reject(e);
- }
- });
- },
- onAddUser: (u) => {
- return service.addMember(name, u.username).then((_) => {
- setTimeline({
- ...timeline,
- members: concat(timeline.members, u),
- });
- });
- },
- onRemoveUser: (u) => {
- void service.removeMember(name, u).then((_) => {
- setTimeline({
- ...timeline,
- members: without(
- timeline.members,
- timeline.members.find((m) => m.username === u)
- ),
- });
- });
- },
- }
- : null
- }
- />
- );
- }
-
- const { UiComponent } = props;
-
- const onDelete: TimelineDeleteCallback = React.useCallback(
- (index, id) => {
- service.deletePost(name, id).then(
- (_) => {
- setPosts((oldPosts) =>
- without(
- oldPosts as TimelinePostInfoEx[],
- (oldPosts as TimelinePostInfoEx[])[index]
- )
- );
- },
- () => {
- pushAlert({
- type: 'danger',
- message: t('timeline.deletePostFailed'),
- });
- }
- );
- },
- [service, name, t]
- );
-
- const onPost: TimelinePostSendCallback = React.useCallback(
- (req) => {
- return service.createPost(name, req).then((newPost) => {
- setPosts((oldPosts) =>
- concat(oldPosts as TimelinePostInfoEx[], {
- ...newPost,
- deletable: true,
- })
- );
- });
- },
- [service, name]
- );
-
- const onManageProp = props.onManage;
-
- const onManage = React.useCallback(
- (item: 'property' | TManageItem) => {
- if (item === 'property') {
- setDialog(item);
- } else {
- onManageProp(item);
- }
- },
- [onManageProp]
- );
-
- const onMember = React.useCallback(() => {
- setDialog('member');
- }, []);
-
- return (
- <>
- <UiComponent
- error={error}
- timeline={timeline}
- posts={posts}
- onDelete={onDelete}
- onPost={
- timeline != null && service.hasPostPermission(user, timeline)
- ? onPost
- : undefined
- }
- onManage={
- timeline != null && service.hasManagePermission(user, timeline)
- ? onManage
- : undefined
- }
- onMember={onMember}
- />
- {dialogElement}
- </>
- );
-}
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { AxiosError } from 'axios';
+import concat from 'lodash/concat';
+import without from 'lodash/without';
+
+import { ExcludeKey } from '../type-utilities';
+import { useUser, fetchUser } from '../data/user';
+import { pushAlert } from '../common/alert-service';
+import { extractStatusCode, extractErrorCode } from '../data/common';
+import {
+ TimelineServiceTemplate,
+ TimelineInfo,
+ TimelineChangePropertyRequest,
+} from '../data/timeline';
+
+import { TimelinePostInfoEx, TimelineDeleteCallback } from './Timeline';
+import { TimelineMemberDialog } from './TimelineMember';
+import TimelinePropertyChangeDialog from './TimelinePropertyChangeDialog';
+import { TimelinePageTemplateUIProps } from './TimelinePageTemplateUI';
+import { TimelinePostSendCallback } from './TimelinePostEdit';
+import { UiLogicError } from '../common';
+
+export interface TimelinePageTemplateProps<
+ TManageItem,
+ TTimeline extends TimelineInfo
+> {
+ name: string;
+ onManage: (item: TManageItem) => void;
+ service: TimelineServiceTemplate<TTimeline, TimelineChangePropertyRequest>;
+ UiComponent: React.ComponentType<
+ ExcludeKey<
+ TimelinePageTemplateUIProps<TTimeline, TManageItem>,
+ 'CardComponent'
+ >
+ >;
+ dataVersion?: number;
+ notFoundI18nKey: string;
+}
+
+export default function TimelinePageTemplate<
+ TManageItem,
+ TTimeline extends TimelineInfo
+>(
+ props: TimelinePageTemplateProps<TManageItem, TTimeline>
+): React.ReactElement | null {
+ const { t } = useTranslation();
+
+ const { name } = props;
+
+ const user = useUser();
+
+ const [dialog, setDialog] = React.useState<null | 'property' | 'member'>(
+ null
+ );
+ const [timeline, setTimeline] = React.useState<TTimeline | undefined>(
+ undefined
+ );
+ const [posts, setPosts] = React.useState<
+ TimelinePostInfoEx[] | 'forbid' | undefined
+ >(undefined);
+ const [error, setError] = React.useState<string | undefined>(undefined);
+
+ const service = props.service;
+
+ React.useEffect(() => {
+ let subscribe = true;
+ service.fetch(name).then(
+ (ti) => {
+ if (subscribe) {
+ setTimeline(ti);
+ if (!service.hasReadPermission(user, ti)) {
+ setPosts('forbid');
+ } else {
+ service.fetchPosts(name).then(
+ (data) => {
+ if (subscribe) {
+ setPosts(
+ data.map((post) => ({
+ ...post,
+ deletable: service.hasModifyPostPermission(
+ user,
+ ti,
+ post
+ ),
+ }))
+ );
+ }
+ },
+ (error) => {
+ if (subscribe) {
+ setError(`${error as string}`);
+ }
+ }
+ );
+ }
+ }
+ },
+ (error: AxiosError) => {
+ if (subscribe) {
+ if (
+ extractStatusCode(error) === 404 ||
+ extractErrorCode(error) === 11020101
+ ) {
+ setError(t(props.notFoundI18nKey));
+ } else {
+ setError(error.toString());
+ }
+ }
+ }
+ );
+ return () => {
+ subscribe = false;
+ };
+ }, [name, service, user, t, props.dataVersion, props.notFoundI18nKey]);
+
+ const closeDialog = React.useCallback((): void => {
+ setDialog(null);
+ }, []);
+
+ let dialogElement: React.ReactElement | undefined;
+
+ if (dialog === 'property') {
+ if (timeline == null) {
+ throw new UiLogicError(
+ 'Timeline is null but attempt to open change property dialog.'
+ );
+ }
+
+ dialogElement = (
+ <TimelinePropertyChangeDialog
+ open
+ close={closeDialog}
+ oldInfo={{
+ visibility: timeline.visibility,
+ description: timeline.description,
+ }}
+ onProcess={(req) => {
+ return service.changeProperty(name, req).then((newTimeline) => {
+ setTimeline(newTimeline);
+ });
+ }}
+ />
+ );
+ } else if (dialog === 'member') {
+ if (timeline == null) {
+ throw new UiLogicError(
+ 'Timeline is null but attempt to open change property dialog.'
+ );
+ }
+
+ dialogElement = (
+ <TimelineMemberDialog
+ open
+ onClose={closeDialog}
+ members={[timeline.owner, ...timeline.members]}
+ edit={
+ service.hasManagePermission(user, timeline)
+ ? {
+ onCheckUser: (u) => {
+ return fetchUser(u).catch((e) => {
+ if (
+ extractStatusCode(e) === 404 ||
+ extractErrorCode(e) === 11020101
+ ) {
+ return Promise.resolve(null);
+ } else {
+ return Promise.reject(e);
+ }
+ });
+ },
+ onAddUser: (u) => {
+ return service.addMember(name, u.username).then((_) => {
+ setTimeline({
+ ...timeline,
+ members: concat(timeline.members, u),
+ });
+ });
+ },
+ onRemoveUser: (u) => {
+ void service.removeMember(name, u).then((_) => {
+ setTimeline({
+ ...timeline,
+ members: without(
+ timeline.members,
+ timeline.members.find((m) => m.username === u)
+ ),
+ });
+ });
+ },
+ }
+ : null
+ }
+ />
+ );
+ }
+
+ const { UiComponent } = props;
+
+ const onDelete: TimelineDeleteCallback = React.useCallback(
+ (index, id) => {
+ service.deletePost(name, id).then(
+ (_) => {
+ setPosts((oldPosts) =>
+ without(
+ oldPosts as TimelinePostInfoEx[],
+ (oldPosts as TimelinePostInfoEx[])[index]
+ )
+ );
+ },
+ () => {
+ pushAlert({
+ type: 'danger',
+ message: t('timeline.deletePostFailed'),
+ });
+ }
+ );
+ },
+ [service, name, t]
+ );
+
+ const onPost: TimelinePostSendCallback = React.useCallback(
+ (req) => {
+ return service.createPost(name, req).then((newPost) => {
+ setPosts((oldPosts) =>
+ concat(oldPosts as TimelinePostInfoEx[], {
+ ...newPost,
+ deletable: true,
+ })
+ );
+ });
+ },
+ [service, name]
+ );
+
+ const onManageProp = props.onManage;
+
+ const onManage = React.useCallback(
+ (item: 'property' | TManageItem) => {
+ if (item === 'property') {
+ setDialog(item);
+ } else {
+ onManageProp(item);
+ }
+ },
+ [onManageProp]
+ );
+
+ const onMember = React.useCallback(() => {
+ setDialog('member');
+ }, []);
+
+ return (
+ <>
+ <UiComponent
+ error={error}
+ timeline={timeline}
+ posts={posts}
+ onDelete={onDelete}
+ onPost={
+ timeline != null && service.hasPostPermission(user, timeline)
+ ? onPost
+ : undefined
+ }
+ onManage={
+ timeline != null && service.hasManagePermission(user, timeline)
+ ? onManage
+ : undefined
+ }
+ onMember={onMember}
+ />
+ {dialogElement}
+ </>
+ );
+}
diff --git a/Timeline/ClientApp/src/timeline/TimelinePageTemplateUI.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePageTemplateUI.tsx
index cc5bf509..d74fffc4 100644
--- a/Timeline/ClientApp/src/timeline/TimelinePageTemplateUI.tsx
+++ b/Timeline/ClientApp/src/app/timeline/TimelinePageTemplateUI.tsx
@@ -1,212 +1,212 @@
-import React from 'react';
-import { Spinner } from 'reactstrap';
-import { useTranslation } from 'react-i18next';
-import { Subject, fromEvent } from 'rxjs';
-
-import { getAlertHost } from '../common/alert-service';
-
-import Timeline, {
- TimelinePostInfoEx,
- TimelineDeleteCallback,
-} from './Timeline';
-import AppBar from '../common/AppBar';
-import TimelinePostEdit, { TimelinePostSendCallback } from './TimelinePostEdit';
-import CollapseButton from '../common/CollapseButton';
-
-export interface TimelineCardComponentProps<TTimeline, TManageItems> {
- timeline: TTimeline;
- onManage?: (item: TManageItems | 'property') => void;
- onMember: () => void;
- className?: string;
- onHeight?: (height: number) => void;
-}
-
-export interface TimelinePageTemplateUIProps<
- TTimeline extends { name: string },
- TManageItems
-> {
- avatarKey?: string | number;
- timeline?: TTimeline;
- posts?: TimelinePostInfoEx[] | 'forbid';
- CardComponent: React.ComponentType<
- TimelineCardComponentProps<TTimeline, TManageItems>
- >;
- onMember: () => void;
- onManage?: (item: TManageItems | 'property') => void;
- onPost?: TimelinePostSendCallback;
- onDelete: TimelineDeleteCallback;
- error?: string;
-}
-
-export default function TimelinePageTemplateUI<
- TTimeline extends { name: string },
- TEditItems
->(
- props: TimelinePageTemplateUIProps<TTimeline, TEditItems>
-): React.ReactElement | null {
- const { timeline } = props;
-
- const { t } = useTranslation();
-
- const bottomSpaceRef = React.useRef<HTMLDivElement | null>(null);
-
- const onPostEditHeightChange = React.useCallback((height: number): void => {
- const { current: bottomSpaceDiv } = bottomSpaceRef;
- if (bottomSpaceDiv != null) {
- bottomSpaceDiv.style.height = `${height}px`;
- }
- if (height === 0) {
- const alertHost = getAlertHost();
- if (alertHost != null) {
- alertHost.style.removeProperty('margin-bottom');
- }
- } else {
- const alertHost = getAlertHost();
- if (alertHost != null) {
- alertHost.style.marginBottom = `${height}px`;
- }
- }
- }, []);
-
- const resizeSubject = React.useMemo(() => new Subject(), []);
- const triggerResizeEvent = React.useCallback(() => {
- resizeSubject.next(null);
- }, [resizeSubject]);
-
- React.useEffect(() => {
- let scrollToBottom = true;
- const disableScrollToBottom = (): void => {
- scrollToBottom = false;
- };
-
- const subscriptions = [
- fromEvent(window, 'wheel').subscribe(disableScrollToBottom),
- fromEvent(window, 'pointerdown').subscribe(disableScrollToBottom),
- fromEvent(window, 'keydown').subscribe(disableScrollToBottom),
- resizeSubject.subscribe(() => {
- if (scrollToBottom) {
- window.scrollTo(0, document.body.scrollHeight);
- }
- }),
- ];
-
- return () => {
- subscriptions.forEach((s) => s.unsubscribe());
- };
- }, [resizeSubject, timeline, props.posts]);
-
- const [cardHeight, setCardHeight] = React.useState<number>(0);
-
- const onCardHeightChange = React.useCallback((height: number) => {
- setCardHeight(height);
- }, []);
-
- const genCardCollapseLocalStorageKey = (timelineName: string): string =>
- `timeline.${timelineName}.cardCollapse`;
-
- const cardCollapseLocalStorageKey =
- timeline != null ? genCardCollapseLocalStorageKey(timeline.name) : null;
-
- const [infoCardCollapse, setInfoCardCollapse] = React.useState<boolean>(true);
- React.useEffect(() => {
- if (cardCollapseLocalStorageKey != null) {
- const savedCollapse =
- window.localStorage.getItem(cardCollapseLocalStorageKey) === 'true';
- setInfoCardCollapse(savedCollapse);
- }
- }, [cardCollapseLocalStorageKey]);
-
- let body: React.ReactElement;
-
- if (props.error != null) {
- body = <p className="text-danger">{t(props.error)}</p>;
- } else {
- if (timeline != null) {
- let timelineBody: React.ReactElement;
- if (props.posts != null) {
- if (props.posts === 'forbid') {
- timelineBody = (
- <p className="text-danger">{t('timeline.messageCantSee')}</p>
- );
- } else {
- timelineBody = (
- <Timeline
- posts={props.posts}
- onDelete={props.onDelete}
- onResize={triggerResizeEvent}
- />
- );
- if (props.onPost != null) {
- timelineBody = (
- <>
- {timelineBody}
- <div ref={bottomSpaceRef} className="flex-fix-length" />
- <TimelinePostEdit
- onPost={props.onPost}
- onHeightChange={onPostEditHeightChange}
- timelineName={timeline.name}
- />
- </>
- );
- }
- }
- } else {
- timelineBody = (
- <div className="full-viewport-center-child">
- <Spinner color="primary" type="grow" />
- </div>
- );
- }
- const { CardComponent } = props;
-
- body = (
- <>
- <div
- className="fixed-top mt-appbar info-card-container"
- data-collapse={infoCardCollapse ? 'true' : 'false'}
- >
- <CollapseButton
- collapse={infoCardCollapse}
- onClick={() => {
- const newState = !infoCardCollapse;
- setInfoCardCollapse(newState);
- window.localStorage.setItem(
- genCardCollapseLocalStorageKey(timeline.name),
- newState.toString()
- );
- }}
- className="float-right m-1 info-card-collapse-button text-orange"
- />
- <CardComponent
- timeline={timeline}
- onManage={props.onManage}
- onMember={props.onMember}
- onHeight={onCardHeightChange}
- className="info-card-content"
- />
- </div>
- {timelineBody}
- </>
- );
- } else {
- body = (
- <div className="full-viewport-center-child">
- <Spinner color="primary" type="grow" />
- </div>
- );
- }
- }
-
- return (
- <>
- <AppBar />
- <div>
- <div
- style={{ height: 56 + cardHeight }}
- className="timeline-page-top-space flex-fix-length"
- />
- {body}
- </div>
- </>
- );
-}
+import React from 'react';
+import { Spinner } from 'reactstrap';
+import { useTranslation } from 'react-i18next';
+import { Subject, fromEvent } from 'rxjs';
+
+import { getAlertHost } from '../common/alert-service';
+
+import Timeline, {
+ TimelinePostInfoEx,
+ TimelineDeleteCallback,
+} from './Timeline';
+import AppBar from '../common/AppBar';
+import TimelinePostEdit, { TimelinePostSendCallback } from './TimelinePostEdit';
+import CollapseButton from '../common/CollapseButton';
+
+export interface TimelineCardComponentProps<TTimeline, TManageItems> {
+ timeline: TTimeline;
+ onManage?: (item: TManageItems | 'property') => void;
+ onMember: () => void;
+ className?: string;
+ onHeight?: (height: number) => void;
+}
+
+export interface TimelinePageTemplateUIProps<
+ TTimeline extends { name: string },
+ TManageItems
+> {
+ avatarKey?: string | number;
+ timeline?: TTimeline;
+ posts?: TimelinePostInfoEx[] | 'forbid';
+ CardComponent: React.ComponentType<
+ TimelineCardComponentProps<TTimeline, TManageItems>
+ >;
+ onMember: () => void;
+ onManage?: (item: TManageItems | 'property') => void;
+ onPost?: TimelinePostSendCallback;
+ onDelete: TimelineDeleteCallback;
+ error?: string;
+}
+
+export default function TimelinePageTemplateUI<
+ TTimeline extends { name: string },
+ TEditItems
+>(
+ props: TimelinePageTemplateUIProps<TTimeline, TEditItems>
+): React.ReactElement | null {
+ const { timeline } = props;
+
+ const { t } = useTranslation();
+
+ const bottomSpaceRef = React.useRef<HTMLDivElement | null>(null);
+
+ const onPostEditHeightChange = React.useCallback((height: number): void => {
+ const { current: bottomSpaceDiv } = bottomSpaceRef;
+ if (bottomSpaceDiv != null) {
+ bottomSpaceDiv.style.height = `${height}px`;
+ }
+ if (height === 0) {
+ const alertHost = getAlertHost();
+ if (alertHost != null) {
+ alertHost.style.removeProperty('margin-bottom');
+ }
+ } else {
+ const alertHost = getAlertHost();
+ if (alertHost != null) {
+ alertHost.style.marginBottom = `${height}px`;
+ }
+ }
+ }, []);
+
+ const resizeSubject = React.useMemo(() => new Subject(), []);
+ const triggerResizeEvent = React.useCallback(() => {
+ resizeSubject.next(null);
+ }, [resizeSubject]);
+
+ React.useEffect(() => {
+ let scrollToBottom = true;
+ const disableScrollToBottom = (): void => {
+ scrollToBottom = false;
+ };
+
+ const subscriptions = [
+ fromEvent(window, 'wheel').subscribe(disableScrollToBottom),
+ fromEvent(window, 'pointerdown').subscribe(disableScrollToBottom),
+ fromEvent(window, 'keydown').subscribe(disableScrollToBottom),
+ resizeSubject.subscribe(() => {
+ if (scrollToBottom) {
+ window.scrollTo(0, document.body.scrollHeight);
+ }
+ }),
+ ];
+
+ return () => {
+ subscriptions.forEach((s) => s.unsubscribe());
+ };
+ }, [resizeSubject, timeline, props.posts]);
+
+ const [cardHeight, setCardHeight] = React.useState<number>(0);
+
+ const onCardHeightChange = React.useCallback((height: number) => {
+ setCardHeight(height);
+ }, []);
+
+ const genCardCollapseLocalStorageKey = (timelineName: string): string =>
+ `timeline.${timelineName}.cardCollapse`;
+
+ const cardCollapseLocalStorageKey =
+ timeline != null ? genCardCollapseLocalStorageKey(timeline.name) : null;
+
+ const [infoCardCollapse, setInfoCardCollapse] = React.useState<boolean>(true);
+ React.useEffect(() => {
+ if (cardCollapseLocalStorageKey != null) {
+ const savedCollapse =
+ window.localStorage.getItem(cardCollapseLocalStorageKey) === 'true';
+ setInfoCardCollapse(savedCollapse);
+ }
+ }, [cardCollapseLocalStorageKey]);
+
+ let body: React.ReactElement;
+
+ if (props.error != null) {
+ body = <p className="text-danger">{t(props.error)}</p>;
+ } else {
+ if (timeline != null) {
+ let timelineBody: React.ReactElement;
+ if (props.posts != null) {
+ if (props.posts === 'forbid') {
+ timelineBody = (
+ <p className="text-danger">{t('timeline.messageCantSee')}</p>
+ );
+ } else {
+ timelineBody = (
+ <Timeline
+ posts={props.posts}
+ onDelete={props.onDelete}
+ onResize={triggerResizeEvent}
+ />
+ );
+ if (props.onPost != null) {
+ timelineBody = (
+ <>
+ {timelineBody}
+ <div ref={bottomSpaceRef} className="flex-fix-length" />
+ <TimelinePostEdit
+ onPost={props.onPost}
+ onHeightChange={onPostEditHeightChange}
+ timelineName={timeline.name}
+ />
+ </>
+ );
+ }
+ }
+ } else {
+ timelineBody = (
+ <div className="full-viewport-center-child">
+ <Spinner color="primary" type="grow" />
+ </div>
+ );
+ }
+ const { CardComponent } = props;
+
+ body = (
+ <>
+ <div
+ className="fixed-top mt-appbar info-card-container"
+ data-collapse={infoCardCollapse ? 'true' : 'false'}
+ >
+ <CollapseButton
+ collapse={infoCardCollapse}
+ onClick={() => {
+ const newState = !infoCardCollapse;
+ setInfoCardCollapse(newState);
+ window.localStorage.setItem(
+ genCardCollapseLocalStorageKey(timeline.name),
+ newState.toString()
+ );
+ }}
+ className="float-right m-1 info-card-collapse-button text-orange"
+ />
+ <CardComponent
+ timeline={timeline}
+ onManage={props.onManage}
+ onMember={props.onMember}
+ onHeight={onCardHeightChange}
+ className="info-card-content"
+ />
+ </div>
+ {timelineBody}
+ </>
+ );
+ } else {
+ body = (
+ <div className="full-viewport-center-child">
+ <Spinner color="primary" type="grow" />
+ </div>
+ );
+ }
+ }
+
+ return (
+ <>
+ <AppBar />
+ <div>
+ <div
+ style={{ height: 56 + cardHeight }}
+ className="timeline-page-top-space flex-fix-length"
+ />
+ {body}
+ </div>
+ </>
+ );
+}
diff --git a/Timeline/ClientApp/src/timeline/TimelinePageUI.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePageUI.tsx
index 01a230af..d7e9d81b 100644
--- a/Timeline/ClientApp/src/timeline/TimelinePageUI.tsx
+++ b/Timeline/ClientApp/src/app/timeline/TimelinePageUI.tsx
@@ -1,22 +1,22 @@
-import React from 'react';
-
-import { ExcludeKey } from '../type-utilities';
-import { TimelineInfo } from '../data/timeline';
-
-import TimelinePageTemplateUI, {
- TimelinePageTemplateUIProps
-} from './TimelinePageTemplateUI';
-import TimelineInfoCard, {
- OrdinaryTimelineManageItem
-} from './TimelineInfoCard';
-
-export type TimelinePageUIProps = ExcludeKey<
- TimelinePageTemplateUIProps<TimelineInfo, OrdinaryTimelineManageItem>,
- 'CardComponent'
->;
-
-const TimelinePageUI: React.FC<TimelinePageUIProps> = props => {
- return <TimelinePageTemplateUI {...props} CardComponent={TimelineInfoCard} />;
-};
-
-export default TimelinePageUI;
+import React from 'react';
+
+import { ExcludeKey } from '../type-utilities';
+import { TimelineInfo } from '../data/timeline';
+
+import TimelinePageTemplateUI, {
+ TimelinePageTemplateUIProps
+} from './TimelinePageTemplateUI';
+import TimelineInfoCard, {
+ OrdinaryTimelineManageItem
+} from './TimelineInfoCard';
+
+export type TimelinePageUIProps = ExcludeKey<
+ TimelinePageTemplateUIProps<TimelineInfo, OrdinaryTimelineManageItem>,
+ 'CardComponent'
+>;
+
+const TimelinePageUI: React.FC<TimelinePageUIProps> = props => {
+ return <TimelinePageTemplateUI {...props} CardComponent={TimelineInfoCard} />;
+};
+
+export default TimelinePageUI;
diff --git a/Timeline/ClientApp/src/timeline/TimelinePostEdit.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePostEdit.tsx
index d266bd8d..f12b6892 100644
--- a/Timeline/ClientApp/src/timeline/TimelinePostEdit.tsx
+++ b/Timeline/ClientApp/src/app/timeline/TimelinePostEdit.tsx
@@ -1,224 +1,224 @@
-import React from 'react';
-import clsx from 'clsx';
-import { Button, Spinner, Row, Col } from 'reactstrap';
-import { useTranslation } from 'react-i18next';
-
-import { pushAlert } from '../common/alert-service';
-import { CreatePostRequest } from '../data/timeline';
-
-import FileInput from '../common/FileInput';
-import { UiLogicError } from '../common';
-
-interface TimelinePostEditImageProps {
- onSelect: (blob: Blob | null) => void;
-}
-
-const TimelinePostEditImage: React.FC<TimelinePostEditImageProps> = (props) => {
- const { onSelect } = props;
- const { t } = useTranslation();
-
- const [file, setFile] = React.useState<File | null>(null);
- const [fileUrl, setFileUrl] = React.useState<string | null>(null);
- const [error, setError] = React.useState<string | null>(null);
-
- React.useEffect(() => {
- if (file != null) {
- const url = URL.createObjectURL(file);
- setFileUrl(url);
- return () => {
- URL.revokeObjectURL(url);
- };
- }
- }, [file]);
-
- const onInputChange: React.ChangeEventHandler<HTMLInputElement> = React.useCallback(
- (e) => {
- const files = e.target.files;
- if (files == null || files.length === 0) {
- setFile(null);
- setFileUrl(null);
- } else {
- setFile(files[0]);
- }
- onSelect(null);
- setError(null);
- },
- [onSelect]
- );
-
- const onImgLoad = React.useCallback(() => {
- onSelect(file);
- }, [onSelect, file]);
-
- const onImgError = React.useCallback(() => {
- setError('loadImageError');
- }, []);
-
- return (
- <>
- <FileInput
- labelText={t('chooseImage')}
- onChange={onInputChange}
- accept="image/*"
- className="mx-3 my-1"
- />
- {fileUrl && error == null && (
- <img
- src={fileUrl}
- className="timeline-post-edit-image"
- onLoad={onImgLoad}
- onError={onImgError}
- />
- )}
- {error != null && <div className="text-danger">{t(error)}</div>}
- </>
- );
-};
-
-export type TimelinePostSendCallback = (
- content: CreatePostRequest
-) => Promise<void>;
-
-export interface TimelinePostEditProps {
- className?: string;
- onPost: TimelinePostSendCallback;
- onHeightChange?: (height: number) => void;
- timelineName: string;
-}
-
-const TimelinePostEdit: React.FC<TimelinePostEditProps> = (props) => {
- const { onPost } = props;
-
- const { t } = useTranslation();
-
- const [state, setState] = React.useState<'input' | 'process'>('input');
- const [kind, setKind] = React.useState<'text' | 'image'>('text');
- const [text, setText] = React.useState<string>('');
- const [imageBlob, setImageBlob] = React.useState<Blob | null>(null);
-
- const draftLocalStorageKey = `timeline.${props.timelineName}.postDraft`;
-
- React.useEffect(() => {
- setText(window.localStorage.getItem(draftLocalStorageKey) ?? '');
- }, [draftLocalStorageKey]);
-
- const canSend = kind === 'text' || (kind === 'image' && imageBlob != null);
-
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- const containerRef = React.useRef<HTMLDivElement>(null!);
-
- React.useEffect(() => {
- if (props.onHeightChange) {
- props.onHeightChange(containerRef.current.clientHeight);
- }
- return () => {
- if (props.onHeightChange) {
- props.onHeightChange(0);
- }
- };
- });
-
- const toggleKind = React.useCallback(() => {
- setKind((oldKind) => (oldKind === 'text' ? 'image' : 'text'));
- setImageBlob(null);
- }, []);
-
- const onSend = React.useCallback(() => {
- setState('process');
-
- const req: CreatePostRequest = (() => {
- switch (kind) {
- case 'text':
- return {
- content: {
- type: 'text',
- text: text,
- },
- } as CreatePostRequest;
- case 'image':
- if (imageBlob == null) {
- throw new UiLogicError(
- 'Content type is image but image blob is null.'
- );
- }
- return {
- content: {
- type: 'image',
- data: imageBlob,
- },
- } as CreatePostRequest;
- default:
- throw new UiLogicError('Unknown content type.');
- }
- })();
-
- onPost(req).then(
- (_) => {
- if (kind === 'text') {
- setText('');
- window.localStorage.removeItem(draftLocalStorageKey);
- }
- setState('input');
- setKind('text');
- },
- (_) => {
- pushAlert({
- type: 'danger',
- message: t('timeline.sendPostFailed'),
- });
- setState('input');
- }
- );
- }, [onPost, kind, text, imageBlob, t, draftLocalStorageKey]);
-
- const onImageSelect = React.useCallback((blob: Blob | null) => {
- setImageBlob(blob);
- }, []);
-
- return (
- <div ref={containerRef} className="container-fluid fixed-bottom bg-light">
- <Row>
- <Col className="px-1 py-1">
- {kind === 'text' ? (
- <textarea
- className="w-100 h-100 timeline-post-edit"
- value={text}
- disabled={state === 'process'}
- onChange={(event: React.ChangeEvent<HTMLTextAreaElement>) => {
- const value = event.currentTarget.value;
- setText(value);
- window.localStorage.setItem(draftLocalStorageKey, value);
- }}
- />
- ) : (
- <TimelinePostEditImage onSelect={onImageSelect} />
- )}
- </Col>
- <Col sm="col-auto align-self-end m-1">
- {(() => {
- if (state === 'input') {
- return (
- <>
- <i
- className={clsx(
- 'fas d-block text-center large-icon mt-1 mb-2',
- kind === 'text' ? 'fa-image' : 'fa-font'
- )}
- onClick={toggleKind}
- />
- <Button color="primary" onClick={onSend} disabled={!canSend}>
- {t('timeline.send')}
- </Button>
- </>
- );
- } else {
- return <Spinner />;
- }
- })()}
- </Col>
- </Row>
- </div>
- );
-};
-
-export default TimelinePostEdit;
+import React from 'react';
+import clsx from 'clsx';
+import { Button, Spinner, Row, Col } from 'reactstrap';
+import { useTranslation } from 'react-i18next';
+
+import { pushAlert } from '../common/alert-service';
+import { CreatePostRequest } from '../data/timeline';
+
+import FileInput from '../common/FileInput';
+import { UiLogicError } from '../common';
+
+interface TimelinePostEditImageProps {
+ onSelect: (blob: Blob | null) => void;
+}
+
+const TimelinePostEditImage: React.FC<TimelinePostEditImageProps> = (props) => {
+ const { onSelect } = props;
+ const { t } = useTranslation();
+
+ const [file, setFile] = React.useState<File | null>(null);
+ const [fileUrl, setFileUrl] = React.useState<string | null>(null);
+ const [error, setError] = React.useState<string | null>(null);
+
+ React.useEffect(() => {
+ if (file != null) {
+ const url = URL.createObjectURL(file);
+ setFileUrl(url);
+ return () => {
+ URL.revokeObjectURL(url);
+ };
+ }
+ }, [file]);
+
+ const onInputChange: React.ChangeEventHandler<HTMLInputElement> = React.useCallback(
+ (e) => {
+ const files = e.target.files;
+ if (files == null || files.length === 0) {
+ setFile(null);
+ setFileUrl(null);
+ } else {
+ setFile(files[0]);
+ }
+ onSelect(null);
+ setError(null);
+ },
+ [onSelect]
+ );
+
+ const onImgLoad = React.useCallback(() => {
+ onSelect(file);
+ }, [onSelect, file]);
+
+ const onImgError = React.useCallback(() => {
+ setError('loadImageError');
+ }, []);
+
+ return (
+ <>
+ <FileInput
+ labelText={t('chooseImage')}
+ onChange={onInputChange}
+ accept="image/*"
+ className="mx-3 my-1"
+ />
+ {fileUrl && error == null && (
+ <img
+ src={fileUrl}
+ className="timeline-post-edit-image"
+ onLoad={onImgLoad}
+ onError={onImgError}
+ />
+ )}
+ {error != null && <div className="text-danger">{t(error)}</div>}
+ </>
+ );
+};
+
+export type TimelinePostSendCallback = (
+ content: CreatePostRequest
+) => Promise<void>;
+
+export interface TimelinePostEditProps {
+ className?: string;
+ onPost: TimelinePostSendCallback;
+ onHeightChange?: (height: number) => void;
+ timelineName: string;
+}
+
+const TimelinePostEdit: React.FC<TimelinePostEditProps> = (props) => {
+ const { onPost } = props;
+
+ const { t } = useTranslation();
+
+ const [state, setState] = React.useState<'input' | 'process'>('input');
+ const [kind, setKind] = React.useState<'text' | 'image'>('text');
+ const [text, setText] = React.useState<string>('');
+ const [imageBlob, setImageBlob] = React.useState<Blob | null>(null);
+
+ const draftLocalStorageKey = `timeline.${props.timelineName}.postDraft`;
+
+ React.useEffect(() => {
+ setText(window.localStorage.getItem(draftLocalStorageKey) ?? '');
+ }, [draftLocalStorageKey]);
+
+ const canSend = kind === 'text' || (kind === 'image' && imageBlob != null);
+
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ const containerRef = React.useRef<HTMLDivElement>(null!);
+
+ React.useEffect(() => {
+ if (props.onHeightChange) {
+ props.onHeightChange(containerRef.current.clientHeight);
+ }
+ return () => {
+ if (props.onHeightChange) {
+ props.onHeightChange(0);
+ }
+ };
+ });
+
+ const toggleKind = React.useCallback(() => {
+ setKind((oldKind) => (oldKind === 'text' ? 'image' : 'text'));
+ setImageBlob(null);
+ }, []);
+
+ const onSend = React.useCallback(() => {
+ setState('process');
+
+ const req: CreatePostRequest = (() => {
+ switch (kind) {
+ case 'text':
+ return {
+ content: {
+ type: 'text',
+ text: text,
+ },
+ } as CreatePostRequest;
+ case 'image':
+ if (imageBlob == null) {
+ throw new UiLogicError(
+ 'Content type is image but image blob is null.'
+ );
+ }
+ return {
+ content: {
+ type: 'image',
+ data: imageBlob,
+ },
+ } as CreatePostRequest;
+ default:
+ throw new UiLogicError('Unknown content type.');
+ }
+ })();
+
+ onPost(req).then(
+ (_) => {
+ if (kind === 'text') {
+ setText('');
+ window.localStorage.removeItem(draftLocalStorageKey);
+ }
+ setState('input');
+ setKind('text');
+ },
+ (_) => {
+ pushAlert({
+ type: 'danger',
+ message: t('timeline.sendPostFailed'),
+ });
+ setState('input');
+ }
+ );
+ }, [onPost, kind, text, imageBlob, t, draftLocalStorageKey]);
+
+ const onImageSelect = React.useCallback((blob: Blob | null) => {
+ setImageBlob(blob);
+ }, []);
+
+ return (
+ <div ref={containerRef} className="container-fluid fixed-bottom bg-light">
+ <Row>
+ <Col className="px-1 py-1">
+ {kind === 'text' ? (
+ <textarea
+ className="w-100 h-100 timeline-post-edit"
+ value={text}
+ disabled={state === 'process'}
+ onChange={(event: React.ChangeEvent<HTMLTextAreaElement>) => {
+ const value = event.currentTarget.value;
+ setText(value);
+ window.localStorage.setItem(draftLocalStorageKey, value);
+ }}
+ />
+ ) : (
+ <TimelinePostEditImage onSelect={onImageSelect} />
+ )}
+ </Col>
+ <Col sm="col-auto align-self-end m-1">
+ {(() => {
+ if (state === 'input') {
+ return (
+ <>
+ <i
+ className={clsx(
+ 'fas d-block text-center large-icon mt-1 mb-2',
+ kind === 'text' ? 'fa-image' : 'fa-font'
+ )}
+ onClick={toggleKind}
+ />
+ <Button color="primary" onClick={onSend} disabled={!canSend}>
+ {t('timeline.send')}
+ </Button>
+ </>
+ );
+ } else {
+ return <Spinner />;
+ }
+ })()}
+ </Col>
+ </Row>
+ </div>
+ );
+};
+
+export default TimelinePostEdit;
diff --git a/Timeline/ClientApp/src/timeline/TimelinePropertyChangeDialog.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePropertyChangeDialog.tsx
index 9ab725a6..ca1be31c 100644
--- a/Timeline/ClientApp/src/timeline/TimelinePropertyChangeDialog.tsx
+++ b/Timeline/ClientApp/src/app/timeline/TimelinePropertyChangeDialog.tsx
@@ -1,70 +1,70 @@
-import React from 'react';
-
-import {
- TimelineVisibility,
- kTimelineVisibilities,
- PersonalTimelineChangePropertyRequest
-} from '../data/timeline';
-
-import OperationDialog, {
- OperationSelectInputInfoOption
-} from '../common/OperationDialog';
-
-export interface TimelinePropertyInfo {
- visibility: TimelineVisibility;
- description: string;
-}
-
-export interface TimelinePropertyChangeDialogProps {
- open: boolean;
- close: () => void;
- oldInfo: TimelinePropertyInfo;
- onProcess: (request: PersonalTimelineChangePropertyRequest) => Promise<void>;
-}
-
-const labelMap: { [key in TimelineVisibility]: string } = {
- Private: 'timeline.visibility.private',
- Public: 'timeline.visibility.public',
- Register: 'timeline.visibility.register'
-};
-
-const TimelinePropertyChangeDialog: React.FC<TimelinePropertyChangeDialogProps> = props => {
- return (
- <OperationDialog
- title={'timeline.dialogChangeProperty.title'}
- titleColor="default"
- inputScheme={[
- {
- type: 'select',
- label: 'timeline.dialogChangeProperty.visibility',
- options: kTimelineVisibilities.map<OperationSelectInputInfoOption>(
- v => ({
- label: labelMap[v],
- value: v
- })
- ),
- initValue: props.oldInfo.visibility
- },
- {
- type: 'text',
- label: 'timeline.dialogChangeProperty.description',
- initValue: props.oldInfo.description
- }
- ]}
- open={props.open}
- close={props.close}
- onProcess={([newVisibility, newDescription]) => {
- const req: PersonalTimelineChangePropertyRequest = {};
- if (newVisibility !== props.oldInfo.visibility) {
- req.visibility = newVisibility as TimelineVisibility;
- }
- if (newDescription !== props.oldInfo.description) {
- req.description = newDescription as string;
- }
- return props.onProcess(req);
- }}
- />
- );
-};
-
-export default TimelinePropertyChangeDialog;
+import React from 'react';
+
+import {
+ TimelineVisibility,
+ kTimelineVisibilities,
+ PersonalTimelineChangePropertyRequest
+} from '../data/timeline';
+
+import OperationDialog, {
+ OperationSelectInputInfoOption
+} from '../common/OperationDialog';
+
+export interface TimelinePropertyInfo {
+ visibility: TimelineVisibility;
+ description: string;
+}
+
+export interface TimelinePropertyChangeDialogProps {
+ open: boolean;
+ close: () => void;
+ oldInfo: TimelinePropertyInfo;
+ onProcess: (request: PersonalTimelineChangePropertyRequest) => Promise<void>;
+}
+
+const labelMap: { [key in TimelineVisibility]: string } = {
+ Private: 'timeline.visibility.private',
+ Public: 'timeline.visibility.public',
+ Register: 'timeline.visibility.register'
+};
+
+const TimelinePropertyChangeDialog: React.FC<TimelinePropertyChangeDialogProps> = props => {
+ return (
+ <OperationDialog
+ title={'timeline.dialogChangeProperty.title'}
+ titleColor="default"
+ inputScheme={[
+ {
+ type: 'select',
+ label: 'timeline.dialogChangeProperty.visibility',
+ options: kTimelineVisibilities.map<OperationSelectInputInfoOption>(
+ v => ({
+ label: labelMap[v],
+ value: v
+ })
+ ),
+ initValue: props.oldInfo.visibility
+ },
+ {
+ type: 'text',
+ label: 'timeline.dialogChangeProperty.description',
+ initValue: props.oldInfo.description
+ }
+ ]}
+ open={props.open}
+ close={props.close}
+ onProcess={([newVisibility, newDescription]) => {
+ const req: PersonalTimelineChangePropertyRequest = {};
+ if (newVisibility !== props.oldInfo.visibility) {
+ req.visibility = newVisibility as TimelineVisibility;
+ }
+ if (newDescription !== props.oldInfo.description) {
+ req.description = newDescription as string;
+ }
+ return props.onProcess(req);
+ }}
+ />
+ );
+};
+
+export default TimelinePropertyChangeDialog;
diff --git a/Timeline/ClientApp/src/timeline/timeline-ui.sass b/Timeline/ClientApp/src/app/timeline/timeline-ui.sass
index b92327bd..c3616caf 100644
--- a/Timeline/ClientApp/src/timeline/timeline-ui.sass
+++ b/Timeline/ClientApp/src/app/timeline/timeline-ui.sass
@@ -1,18 +1,18 @@
-.info-card-container
- .info-card-collapse-button
- z-index: 1
- position: relative
-
- .info-card-content
- width: 100%
- position: absolute
- transform-origin: right top
- transition: transform 0.5s
-
- &[data-collapse='true']
- .info-card-content
- transform: scale(0)
-
-.timeline-page-top-space
- transition: height 0.5s
-
+.info-card-container
+ .info-card-collapse-button
+ z-index: 1
+ position: relative
+
+ .info-card-content
+ width: 100%
+ position: absolute
+ transform-origin: right top
+ transition: transform 0.5s
+
+ &[data-collapse='true']
+ .info-card-content
+ transform: scale(0)
+
+.timeline-page-top-space
+ transition: height 0.5s
+
diff --git a/Timeline/ClientApp/src/timeline/timeline.sass b/Timeline/ClientApp/src/app/timeline/timeline.sass
index 4f69295b..0b0e73b5 100644
--- a/Timeline/ClientApp/src/timeline/timeline.sass
+++ b/Timeline/ClientApp/src/app/timeline/timeline.sass
@@ -1,125 +1,125 @@
-@use 'sass:color'
-
-@keyframes timeline-enter-animation-mask-animation
- to
- height: 0
-
-.timeline-enter-animation-mask
- position: absolute
- left: 0
- top: 0
- height: calc(100% + 300px)
- width: 100%
- background: linear-gradient(to top, #ffffff00 0, 200px, white 300px, white)
- z-index: 100
- animation: timeline-enter-animation-mask-animation 5s 0.3s forwards // Give it 0.3s to load, which I think is reasonable
-
-$timeline-line-width: 7px
-$timeline-line-node-radius: 18px
-$timeline-line-color: $primary
-$timeline-line-color-current: #36c2e6
-
-@keyframes timeline-line-node-noncurrent
- from
- background: $timeline-line-color
-
- to
- background: color.adjust($timeline-line-color, $lightness: +10%)
- box-shadow: 0 0 20px 3px color.adjust($timeline-line-color, $lightness: +10%, $alpha: -0.1)
-
-
-@keyframes timeline-line-node-current
- from
- background: $timeline-line-color-current
-
- to
- background: color.adjust($timeline-line-color-current, $lightness: +10%)
- box-shadow: 0 0 20px 3px color.adjust($timeline-line-color-current, $lightness: +10%, $alpha: -0.1)
-
-.timeline-line
- &-area
- display: flex
- flex-direction: column
- align-items: center
- flex: 0 0 auto
- width: 60px
-
- &-segment
- width: $timeline-line-width
- background: $timeline-line-color
-
- &.start
- height: 20px
- flex: 0 0 auto
-
- &.end
- flex: 1 1 auto
-
- &.current-end
- height: 20px
- flex: 0 0 auto
- background: linear-gradient($timeline-line-color-current, transparent)
-
- &-node-container
- flex: 0 0 auto
- position: relative
- width: $timeline-line-node-radius
- height: $timeline-line-node-radius
-
- &-node
- width: $timeline-line-node-radius + 2
- height: $timeline-line-node-radius + 2
- position: absolute
- left: -1px
- top: -1px
- border-radius: 50%
- box-sizing: border-box
- z-index: 1
- animation: 1s infinite alternate
- animation-name: timeline-line-node-noncurrent
-
-
-.current
- .timeline-line
- &-segment
-
- &.start
- background: linear-gradient($timeline-line-color, $timeline-line-color-current)
-
- &.end
- background: $timeline-line-color-current
-
- &-node
- animation-name: timeline-line-node-current
-
-.timeline-pt-start
- padding-top: 18px
-
-.timeline-item-delete-button
- position: absolute
- right: 0
- bottom: 0
-
-.timeline-content
- white-space: pre-line
-
-.timeline-content-image
- max-width: 60%
- max-height: 200px
-
-
-.timeline-post-edit-image
- max-width: 100px
- max-height: 100px
-
-.mask
- background: change-color($color: white, $alpha: 0.8)
- z-index: 100
-
-textarea.timeline-post-edit
- @extend .border-primary
- @extend .rounded
-
- &:focus
- outline: none
- box-shadow: 0 0 5px 0 $primary
+@use 'sass:color'
+
+@keyframes timeline-enter-animation-mask-animation
+ to
+ height: 0
+
+.timeline-enter-animation-mask
+ position: absolute
+ left: 0
+ top: 0
+ height: calc(100% + 300px)
+ width: 100%
+ background: linear-gradient(to top, #ffffff00 0, 200px, white 300px, white)
+ z-index: 100
+ animation: timeline-enter-animation-mask-animation 5s 0.3s forwards // Give it 0.3s to load, which I think is reasonable
+
+$timeline-line-width: 7px
+$timeline-line-node-radius: 18px
+$timeline-line-color: $primary
+$timeline-line-color-current: #36c2e6
+
+@keyframes timeline-line-node-noncurrent
+ from
+ background: $timeline-line-color
+
+ to
+ background: color.adjust($timeline-line-color, $lightness: +10%)
+ box-shadow: 0 0 20px 3px color.adjust($timeline-line-color, $lightness: +10%, $alpha: -0.1)
+
+
+@keyframes timeline-line-node-current
+ from
+ background: $timeline-line-color-current
+
+ to
+ background: color.adjust($timeline-line-color-current, $lightness: +10%)
+ box-shadow: 0 0 20px 3px color.adjust($timeline-line-color-current, $lightness: +10%, $alpha: -0.1)
+
+.timeline-line
+ &-area
+ display: flex
+ flex-direction: column
+ align-items: center
+ flex: 0 0 auto
+ width: 60px
+
+ &-segment
+ width: $timeline-line-width
+ background: $timeline-line-color
+
+ &.start
+ height: 20px
+ flex: 0 0 auto
+
+ &.end
+ flex: 1 1 auto
+
+ &.current-end
+ height: 20px
+ flex: 0 0 auto
+ background: linear-gradient($timeline-line-color-current, transparent)
+
+ &-node-container
+ flex: 0 0 auto
+ position: relative
+ width: $timeline-line-node-radius
+ height: $timeline-line-node-radius
+
+ &-node
+ width: $timeline-line-node-radius + 2
+ height: $timeline-line-node-radius + 2
+ position: absolute
+ left: -1px
+ top: -1px
+ border-radius: 50%
+ box-sizing: border-box
+ z-index: 1
+ animation: 1s infinite alternate
+ animation-name: timeline-line-node-noncurrent
+
+
+.current
+ .timeline-line
+ &-segment
+
+ &.start
+ background: linear-gradient($timeline-line-color, $timeline-line-color-current)
+
+ &.end
+ background: $timeline-line-color-current
+
+ &-node
+ animation-name: timeline-line-node-current
+
+.timeline-pt-start
+ padding-top: 18px
+
+.timeline-item-delete-button
+ position: absolute
+ right: 0
+ bottom: 0
+
+.timeline-content
+ white-space: pre-line
+
+.timeline-content-image
+ max-width: 60%
+ max-height: 200px
+
+
+.timeline-post-edit-image
+ max-width: 100px
+ max-height: 100px
+
+.mask
+ background: change-color($color: white, $alpha: 0.8)
+ z-index: 100
+
+textarea.timeline-post-edit
+ @extend .border-primary
+ @extend .rounded
+
+ &:focus
+ outline: none
+ box-shadow: 0 0 5px 0 $primary
diff --git a/Timeline/ClientApp/src/app/tsconfig.json b/Timeline/ClientApp/src/app/tsconfig.json
new file mode 100644
index 00000000..14e6327f
--- /dev/null
+++ b/Timeline/ClientApp/src/app/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "extends": "../tsconfig.json",
+ "compilerOptions": {
+ "lib": [
+ "dom",
+ "dom.iterable",
+ "esnext"
+ ]
+ },
+ "include": [
+ "."
+ ]
+}
diff --git a/Timeline/ClientApp/src/type-utilities.ts b/Timeline/ClientApp/src/app/type-utilities.ts
index 307e5825..8df9bf0f 100644
--- a/Timeline/ClientApp/src/type-utilities.ts
+++ b/Timeline/ClientApp/src/app/type-utilities.ts
@@ -1 +1 @@
-export type ExcludeKey<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
+export type ExcludeKey<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
diff --git a/Timeline/ClientApp/src/typings.d.ts b/Timeline/ClientApp/src/app/typings.d.ts
index 521d38e4..426fb214 100644
--- a/Timeline/ClientApp/src/typings.d.ts
+++ b/Timeline/ClientApp/src/app/typings.d.ts
@@ -1,24 +1,24 @@
-declare module '*.png' {
- const content: string;
- export default content;
-}
-
-declare module '*.jpeg' {
- const content: string;
- export default content;
-}
-
-declare module '*.jpg' {
- const content: string;
- export default content;
-}
-
-declare module '*.gif' {
- const content: string;
- export default content;
-}
-
-declare module '*.svg' {
- const content: string;
- export default content;
-}
+declare module '*.png' {
+ const content: string;
+ export default content;
+}
+
+declare module '*.jpeg' {
+ const content: string;
+ export default content;
+}
+
+declare module '*.jpg' {
+ const content: string;
+ export default content;
+}
+
+declare module '*.gif' {
+ const content: string;
+ export default content;
+}
+
+declare module '*.svg' {
+ const content: string;
+ export default content;
+}
diff --git a/Timeline/ClientApp/src/user/ChangeAvatarDialog.tsx b/Timeline/ClientApp/src/app/user/ChangeAvatarDialog.tsx
index f7b25252..a0e56621 100644
--- a/Timeline/ClientApp/src/user/ChangeAvatarDialog.tsx
+++ b/Timeline/ClientApp/src/app/user/ChangeAvatarDialog.tsx
@@ -1,306 +1,306 @@
-import React, { useState, useEffect } from 'react';
-import { useTranslation } from 'react-i18next';
-import {
- Modal,
- ModalHeader,
- Row,
- Button,
- ModalBody,
- ModalFooter,
-} from 'reactstrap';
-import { AxiosError } from 'axios';
-
-import ImageCropper, { Clip, applyClipToImage } from '../common/ImageCropper';
-import { UiLogicError } from '../common';
-
-export interface ChangeAvatarDialogProps {
- open: boolean;
- close: () => void;
- process: (blob: Blob) => Promise<void>;
-}
-
-const ChangeAvatarDialog: React.FC<ChangeAvatarDialogProps> = (props) => {
- const { t } = useTranslation();
-
- const [file, setFile] = React.useState<File | null>(null);
- const [fileUrl, setFileUrl] = React.useState<string | null>(null);
- const [clip, setClip] = React.useState<Clip | null>(null);
- const [
- cropImgElement,
- setCropImgElement,
- ] = React.useState<HTMLImageElement | null>(null);
- const [resultBlob, setResultBlob] = React.useState<Blob | null>(null);
- const [resultUrl, setResultUrl] = React.useState<string | null>(null);
-
- const [state, setState] = React.useState<
- | 'select'
- | 'crop'
- | 'processcrop'
- | 'preview'
- | 'uploading'
- | 'success'
- | 'error'
- >('select');
-
- const [message, setMessage] = useState<
- string | { type: 'custom'; text: string } | null
- >('userPage.dialogChangeAvatar.prompt.select');
-
- const trueMessage =
- message == null
- ? null
- : typeof message === 'string'
- ? t(message)
- : message.text;
-
- const closeDialog = props.close;
-
- const toggle = React.useCallback((): void => {
- if (!(state === 'uploading')) {
- closeDialog();
- }
- }, [state, closeDialog]);
-
- useEffect(() => {
- if (file != null) {
- const url = URL.createObjectURL(file);
- setClip(null);
- setFileUrl(url);
- setState('crop');
- return () => {
- URL.revokeObjectURL(url);
- };
- } else {
- setFileUrl(null);
- setState('select');
- }
- }, [file]);
-
- React.useEffect(() => {
- if (resultBlob != null) {
- const url = URL.createObjectURL(resultBlob);
- setResultUrl(url);
- setState('preview');
- return () => {
- URL.revokeObjectURL(url);
- };
- } else {
- setResultUrl(null);
- }
- }, [resultBlob]);
-
- const onSelectFile = React.useCallback(
- (e: React.ChangeEvent<HTMLInputElement>): void => {
- const files = e.target.files;
- if (files == null || files.length === 0) {
- setFile(null);
- } else {
- setFile(files[0]);
- }
- },
- []
- );
-
- const onCropNext = React.useCallback(() => {
- if (
- cropImgElement == null ||
- clip == null ||
- clip.width === 0 ||
- file == null
- ) {
- throw new UiLogicError();
- }
-
- setState('processcrop');
- void applyClipToImage(cropImgElement, clip, file.type).then((b) => {
- setResultBlob(b);
- });
- }, [cropImgElement, clip, file]);
-
- const onCropPrevious = React.useCallback(() => {
- setFile(null);
- setState('select');
- }, []);
-
- const onPreviewPrevious = React.useCallback(() => {
- setResultBlob(null);
- setState('crop');
- }, []);
-
- const process = props.process;
-
- const upload = React.useCallback(() => {
- if (resultBlob == null) {
- throw new UiLogicError();
- }
-
- setState('uploading');
- process(resultBlob).then(
- () => {
- setState('success');
- },
- (e: unknown) => {
- setState('error');
- setMessage({ type: 'custom', text: (e as AxiosError).message });
- }
- );
- }, [resultBlob, process]);
-
- const createPreviewRow = (): React.ReactElement => {
- if (resultUrl == null) {
- throw new UiLogicError();
- }
- return (
- <Row className="justify-content-center">
- <img
- className="change-avatar-img"
- src={resultUrl}
- alt={t('userPage.dialogChangeAvatar.previewImgAlt')}
- />
- </Row>
- );
- };
-
- return (
- <Modal isOpen={props.open} toggle={toggle}>
- <ModalHeader> {t('userPage.dialogChangeAvatar.title')}</ModalHeader>
- {(() => {
- if (state === 'select') {
- return (
- <>
- <ModalBody className="container">
- <Row>{t('userPage.dialogChangeAvatar.prompt.select')}</Row>
- <Row>
- <input type="file" accept="image/*" onChange={onSelectFile} />
- </Row>
- </ModalBody>
- <ModalFooter>
- <Button color="secondary" onClick={toggle}>
- {t('operationDialog.cancel')}
- </Button>
- </ModalFooter>
- </>
- );
- } else if (state === 'crop') {
- if (fileUrl == null) {
- throw new UiLogicError();
- }
- return (
- <>
- <ModalBody className="container">
- <Row className="justify-content-center">
- <ImageCropper
- clip={clip}
- onChange={setClip}
- imageUrl={fileUrl}
- imageElementCallback={setCropImgElement}
- />
- </Row>
- <Row>{t('userPage.dialogChangeAvatar.prompt.crop')}</Row>
- </ModalBody>
- <ModalFooter>
- <Button color="secondary" onClick={toggle}>
- {t('operationDialog.cancel')}
- </Button>
- <Button color="secondary" onClick={onCropPrevious}>
- {t('operationDialog.previousStep')}
- </Button>
- <Button
- color="primary"
- onClick={onCropNext}
- disabled={
- cropImgElement == null || clip == null || clip.width === 0
- }
- >
- {t('operationDialog.nextStep')}
- </Button>
- </ModalFooter>
- </>
- );
- } else if (state === 'processcrop') {
- return (
- <>
- <ModalBody className="container">
- <Row>
- {t('userPage.dialogChangeAvatar.prompt.processingCrop')}
- </Row>
- </ModalBody>
- <ModalFooter>
- <Button color="secondary" onClick={toggle}>
- {t('operationDialog.cancel')}
- </Button>
- <Button color="secondary" onClick={onPreviewPrevious}>
- {t('operationDialog.previousStep')}
- </Button>
- </ModalFooter>
- </>
- );
- } else if (state === 'preview') {
- return (
- <>
- <ModalBody className="container">
- {createPreviewRow()}
- <Row>{t('userPage.dialogChangeAvatar.prompt.preview')}</Row>
- </ModalBody>
- <ModalFooter>
- <Button color="secondary" onClick={toggle}>
- {t('operationDialog.cancel')}
- </Button>
- <Button color="secondary" onClick={onPreviewPrevious}>
- {t('operationDialog.previousStep')}
- </Button>
- <Button color="primary" onClick={upload}>
- {t('userPage.dialogChangeAvatar.upload')}
- </Button>
- </ModalFooter>
- </>
- );
- } else if (state === 'uploading') {
- return (
- <>
- <ModalBody className="container">
- {createPreviewRow()}
- <Row>{t('userPage.dialogChangeAvatar.prompt.uploading')}</Row>
- </ModalBody>
- <ModalFooter></ModalFooter>
- </>
- );
- } else if (state === 'success') {
- return (
- <>
- <ModalBody className="container">
- <Row className="p-4 text-success">
- {t('operationDialog.success')}
- </Row>
- </ModalBody>
- <ModalFooter>
- <Button color="success" onClick={toggle}>
- {t('operationDialog.ok')}
- </Button>
- </ModalFooter>
- </>
- );
- } else {
- return (
- <>
- <ModalBody className="container">
- {createPreviewRow()}
- <Row className="text-danger">{trueMessage}</Row>
- </ModalBody>
- <ModalFooter>
- <Button color="secondary" onClick={toggle}>
- {t('operationDialog.cancel')}
- </Button>
- <Button color="primary" onClick={upload}>
- {t('operationDialog.retry')}
- </Button>
- </ModalFooter>
- </>
- );
- }
- })()}
- </Modal>
- );
-};
-
-export default ChangeAvatarDialog;
+import React, { useState, useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
+import {
+ Modal,
+ ModalHeader,
+ Row,
+ Button,
+ ModalBody,
+ ModalFooter,
+} from 'reactstrap';
+import { AxiosError } from 'axios';
+
+import ImageCropper, { Clip, applyClipToImage } from '../common/ImageCropper';
+import { UiLogicError } from '../common';
+
+export interface ChangeAvatarDialogProps {
+ open: boolean;
+ close: () => void;
+ process: (blob: Blob) => Promise<void>;
+}
+
+const ChangeAvatarDialog: React.FC<ChangeAvatarDialogProps> = (props) => {
+ const { t } = useTranslation();
+
+ const [file, setFile] = React.useState<File | null>(null);
+ const [fileUrl, setFileUrl] = React.useState<string | null>(null);
+ const [clip, setClip] = React.useState<Clip | null>(null);
+ const [
+ cropImgElement,
+ setCropImgElement,
+ ] = React.useState<HTMLImageElement | null>(null);
+ const [resultBlob, setResultBlob] = React.useState<Blob | null>(null);
+ const [resultUrl, setResultUrl] = React.useState<string | null>(null);
+
+ const [state, setState] = React.useState<
+ | 'select'
+ | 'crop'
+ | 'processcrop'
+ | 'preview'
+ | 'uploading'
+ | 'success'
+ | 'error'
+ >('select');
+
+ const [message, setMessage] = useState<
+ string | { type: 'custom'; text: string } | null
+ >('userPage.dialogChangeAvatar.prompt.select');
+
+ const trueMessage =
+ message == null
+ ? null
+ : typeof message === 'string'
+ ? t(message)
+ : message.text;
+
+ const closeDialog = props.close;
+
+ const toggle = React.useCallback((): void => {
+ if (!(state === 'uploading')) {
+ closeDialog();
+ }
+ }, [state, closeDialog]);
+
+ useEffect(() => {
+ if (file != null) {
+ const url = URL.createObjectURL(file);
+ setClip(null);
+ setFileUrl(url);
+ setState('crop');
+ return () => {
+ URL.revokeObjectURL(url);
+ };
+ } else {
+ setFileUrl(null);
+ setState('select');
+ }
+ }, [file]);
+
+ React.useEffect(() => {
+ if (resultBlob != null) {
+ const url = URL.createObjectURL(resultBlob);
+ setResultUrl(url);
+ setState('preview');
+ return () => {
+ URL.revokeObjectURL(url);
+ };
+ } else {
+ setResultUrl(null);
+ }
+ }, [resultBlob]);
+
+ const onSelectFile = React.useCallback(
+ (e: React.ChangeEvent<HTMLInputElement>): void => {
+ const files = e.target.files;
+ if (files == null || files.length === 0) {
+ setFile(null);
+ } else {
+ setFile(files[0]);
+ }
+ },
+ []
+ );
+
+ const onCropNext = React.useCallback(() => {
+ if (
+ cropImgElement == null ||
+ clip == null ||
+ clip.width === 0 ||
+ file == null
+ ) {
+ throw new UiLogicError();
+ }
+
+ setState('processcrop');
+ void applyClipToImage(cropImgElement, clip, file.type).then((b) => {
+ setResultBlob(b);
+ });
+ }, [cropImgElement, clip, file]);
+
+ const onCropPrevious = React.useCallback(() => {
+ setFile(null);
+ setState('select');
+ }, []);
+
+ const onPreviewPrevious = React.useCallback(() => {
+ setResultBlob(null);
+ setState('crop');
+ }, []);
+
+ const process = props.process;
+
+ const upload = React.useCallback(() => {
+ if (resultBlob == null) {
+ throw new UiLogicError();
+ }
+
+ setState('uploading');
+ process(resultBlob).then(
+ () => {
+ setState('success');
+ },
+ (e: unknown) => {
+ setState('error');
+ setMessage({ type: 'custom', text: (e as AxiosError).message });
+ }
+ );
+ }, [resultBlob, process]);
+
+ const createPreviewRow = (): React.ReactElement => {
+ if (resultUrl == null) {
+ throw new UiLogicError();
+ }
+ return (
+ <Row className="justify-content-center">
+ <img
+ className="change-avatar-img"
+ src={resultUrl}
+ alt={t('userPage.dialogChangeAvatar.previewImgAlt')}
+ />
+ </Row>
+ );
+ };
+
+ return (
+ <Modal isOpen={props.open} toggle={toggle}>
+ <ModalHeader> {t('userPage.dialogChangeAvatar.title')}</ModalHeader>
+ {(() => {
+ if (state === 'select') {
+ return (
+ <>
+ <ModalBody className="container">
+ <Row>{t('userPage.dialogChangeAvatar.prompt.select')}</Row>
+ <Row>
+ <input type="file" accept="image/*" onChange={onSelectFile} />
+ </Row>
+ </ModalBody>
+ <ModalFooter>
+ <Button color="secondary" onClick={toggle}>
+ {t('operationDialog.cancel')}
+ </Button>
+ </ModalFooter>
+ </>
+ );
+ } else if (state === 'crop') {
+ if (fileUrl == null) {
+ throw new UiLogicError();
+ }
+ return (
+ <>
+ <ModalBody className="container">
+ <Row className="justify-content-center">
+ <ImageCropper
+ clip={clip}
+ onChange={setClip}
+ imageUrl={fileUrl}
+ imageElementCallback={setCropImgElement}
+ />
+ </Row>
+ <Row>{t('userPage.dialogChangeAvatar.prompt.crop')}</Row>
+ </ModalBody>
+ <ModalFooter>
+ <Button color="secondary" onClick={toggle}>
+ {t('operationDialog.cancel')}
+ </Button>
+ <Button color="secondary" onClick={onCropPrevious}>
+ {t('operationDialog.previousStep')}
+ </Button>
+ <Button
+ color="primary"
+ onClick={onCropNext}
+ disabled={
+ cropImgElement == null || clip == null || clip.width === 0
+ }
+ >
+ {t('operationDialog.nextStep')}
+ </Button>
+ </ModalFooter>
+ </>
+ );
+ } else if (state === 'processcrop') {
+ return (
+ <>
+ <ModalBody className="container">
+ <Row>
+ {t('userPage.dialogChangeAvatar.prompt.processingCrop')}
+ </Row>
+ </ModalBody>
+ <ModalFooter>
+ <Button color="secondary" onClick={toggle}>
+ {t('operationDialog.cancel')}
+ </Button>
+ <Button color="secondary" onClick={onPreviewPrevious}>
+ {t('operationDialog.previousStep')}
+ </Button>
+ </ModalFooter>
+ </>
+ );
+ } else if (state === 'preview') {
+ return (
+ <>
+ <ModalBody className="container">
+ {createPreviewRow()}
+ <Row>{t('userPage.dialogChangeAvatar.prompt.preview')}</Row>
+ </ModalBody>
+ <ModalFooter>
+ <Button color="secondary" onClick={toggle}>
+ {t('operationDialog.cancel')}
+ </Button>
+ <Button color="secondary" onClick={onPreviewPrevious}>
+ {t('operationDialog.previousStep')}
+ </Button>
+ <Button color="primary" onClick={upload}>
+ {t('userPage.dialogChangeAvatar.upload')}
+ </Button>
+ </ModalFooter>
+ </>
+ );
+ } else if (state === 'uploading') {
+ return (
+ <>
+ <ModalBody className="container">
+ {createPreviewRow()}
+ <Row>{t('userPage.dialogChangeAvatar.prompt.uploading')}</Row>
+ </ModalBody>
+ <ModalFooter></ModalFooter>
+ </>
+ );
+ } else if (state === 'success') {
+ return (
+ <>
+ <ModalBody className="container">
+ <Row className="p-4 text-success">
+ {t('operationDialog.success')}
+ </Row>
+ </ModalBody>
+ <ModalFooter>
+ <Button color="success" onClick={toggle}>
+ {t('operationDialog.ok')}
+ </Button>
+ </ModalFooter>
+ </>
+ );
+ } else {
+ return (
+ <>
+ <ModalBody className="container">
+ {createPreviewRow()}
+ <Row className="text-danger">{trueMessage}</Row>
+ </ModalBody>
+ <ModalFooter>
+ <Button color="secondary" onClick={toggle}>
+ {t('operationDialog.cancel')}
+ </Button>
+ <Button color="primary" onClick={upload}>
+ {t('operationDialog.retry')}
+ </Button>
+ </ModalFooter>
+ </>
+ );
+ }
+ })()}
+ </Modal>
+ );
+};
+
+export default ChangeAvatarDialog;
diff --git a/Timeline/ClientApp/src/user/ChangeNicknameDialog.tsx b/Timeline/ClientApp/src/app/user/ChangeNicknameDialog.tsx
index 393d88f6..dbc216e1 100644
--- a/Timeline/ClientApp/src/user/ChangeNicknameDialog.tsx
+++ b/Timeline/ClientApp/src/app/user/ChangeNicknameDialog.tsx
@@ -1,28 +1,28 @@
-import React from 'react';
-
-import OperationDialog from '../common/OperationDialog';
-
-export interface ChangeNicknameDialogProps {
- open: boolean;
- close: () => void;
- onProcess: (newNickname: string) => Promise<void>;
-}
-
-const ChangeNicknameDialog: React.FC<ChangeNicknameDialogProps> = props => {
- return (
- <OperationDialog
- open={props.open}
- title="userPage.dialogChangeNickname.title"
- titleColor="default"
- inputScheme={[
- { type: 'text', label: 'userPage.dialogChangeNickname.inputLabel' }
- ]}
- onProcess={([newNickname]) => {
- return props.onProcess(newNickname as string);
- }}
- close={props.close}
- />
- );
-};
-
-export default ChangeNicknameDialog;
+import React from 'react';
+
+import OperationDialog from '../common/OperationDialog';
+
+export interface ChangeNicknameDialogProps {
+ open: boolean;
+ close: () => void;
+ onProcess: (newNickname: string) => Promise<void>;
+}
+
+const ChangeNicknameDialog: React.FC<ChangeNicknameDialogProps> = props => {
+ return (
+ <OperationDialog
+ open={props.open}
+ title="userPage.dialogChangeNickname.title"
+ titleColor="default"
+ inputScheme={[
+ { type: 'text', label: 'userPage.dialogChangeNickname.inputLabel' }
+ ]}
+ onProcess={([newNickname]) => {
+ return props.onProcess(newNickname as string);
+ }}
+ close={props.close}
+ />
+ );
+};
+
+export default ChangeNicknameDialog;
diff --git a/Timeline/ClientApp/src/user/Login.tsx b/Timeline/ClientApp/src/app/user/Login.tsx
index 24cd58d0..f8b3f0e7 100644
--- a/Timeline/ClientApp/src/user/Login.tsx
+++ b/Timeline/ClientApp/src/app/user/Login.tsx
@@ -1,147 +1,147 @@
-import React, { Fragment, useState, useEffect } from 'react';
-import { useHistory } from 'react-router';
-import { useTranslation } from 'react-i18next';
-import { AxiosError } from 'axios';
-
-import AppBar from '../common/AppBar';
-
-import { userLogin, useUser } from '../data/user';
-import {
- Label,
- FormGroup,
- Input,
- Form,
- FormFeedback,
- Spinner,
- Button,
-} from 'reactstrap';
-
-const Login: React.FC = (_) => {
- const { t } = useTranslation();
- const history = useHistory();
- const [username, setUsername] = useState<string>('');
- const [usernameDirty, setUsernameDirty] = useState<boolean>(false);
- const [password, setPassword] = useState<string>('');
- const [passwordDirty, setPasswordDirty] = useState<boolean>(false);
- const [rememberMe, setRememberMe] = useState<boolean>(true);
- const [process, setProcess] = useState<boolean>(false);
- const [error, setError] = useState<string | null>(null);
-
- const user = useUser();
-
- useEffect(() => {
- if (user != null) {
- const id = setTimeout(() => history.push('/'), 3000);
- return () => {
- clearTimeout(id);
- };
- }
- }, [history, user]);
-
- if (user != null) {
- return (
- <>
- <AppBar />
- <p className="mt-appbar">{t('login.alreadyLogin')}</p>
- </>
- );
- }
-
- function onSubmit(event: React.SyntheticEvent): void {
- if (username === '' || password === '') {
- setUsernameDirty(true);
- setPasswordDirty(true);
- return;
- }
-
- setProcess(true);
- userLogin(
- {
- username: username,
- password: password,
- },
- rememberMe
- ).then(
- (_) => {
- if (history.length === 0) {
- history.push('/');
- } else {
- history.goBack();
- }
- },
- (e: AxiosError | Error) => {
- setProcess(false);
- setError(e.message);
- }
- );
- event.preventDefault();
- }
-
- return (
- <Fragment>
- <AppBar />
- <div className="container login-container mt-appbar">
- <h1>{t('welcome')}</h1>
- <Form>
- <FormGroup>
- <Label for="username">{t('user.username')}</Label>
- <Input
- id="username"
- disabled={process}
- onChange={(e) => {
- setUsername(e.target.value);
- setUsernameDirty(true);
- }}
- value={username}
- invalid={usernameDirty && username === ''}
- />
- {usernameDirty && username === '' && (
- <FormFeedback>{t('login.emptyUsername')}</FormFeedback>
- )}
- </FormGroup>
- <FormGroup>
- <Label for="password">{t('user.password')}</Label>
- <Input
- id="password"
- type="password"
- disabled={process}
- onChange={(e) => {
- setPassword(e.target.value);
- setPasswordDirty(true);
- }}
- value={password}
- invalid={passwordDirty && password === ''}
- />
- {passwordDirty && password === '' && (
- <FormFeedback>{t('login.emptyPassword')}</FormFeedback>
- )}
- </FormGroup>
- <FormGroup check>
- <Input
- id="remember-me"
- type="checkbox"
- checked={rememberMe}
- onChange={(e) => {
- const v = (e.target as HTMLInputElement).checked;
- setRememberMe(v);
- }}
- />
- <Label for="remember-me">{t('user.rememberMe')}</Label>
- </FormGroup>
- {error ? <p className="text-error">{t(error)}</p> : null}
- <div>
- {process ? (
- <Spinner />
- ) : (
- <Button color="primary" onClick={onSubmit}>
- {t('user.login')}
- </Button>
- )}
- </div>
- </Form>
- </div>
- </Fragment>
- );
-};
-
-export default Login;
+import React, { Fragment, useState, useEffect } from 'react';
+import { useHistory } from 'react-router';
+import { useTranslation } from 'react-i18next';
+import { AxiosError } from 'axios';
+
+import AppBar from '../common/AppBar';
+
+import { userLogin, useUser } from '../data/user';
+import {
+ Label,
+ FormGroup,
+ Input,
+ Form,
+ FormFeedback,
+ Spinner,
+ Button,
+} from 'reactstrap';
+
+const Login: React.FC = (_) => {
+ const { t } = useTranslation();
+ const history = useHistory();
+ const [username, setUsername] = useState<string>('');
+ const [usernameDirty, setUsernameDirty] = useState<boolean>(false);
+ const [password, setPassword] = useState<string>('');
+ const [passwordDirty, setPasswordDirty] = useState<boolean>(false);
+ const [rememberMe, setRememberMe] = useState<boolean>(true);
+ const [process, setProcess] = useState<boolean>(false);
+ const [error, setError] = useState<string | null>(null);
+
+ const user = useUser();
+
+ useEffect(() => {
+ if (user != null) {
+ const id = setTimeout(() => history.push('/'), 3000);
+ return () => {
+ clearTimeout(id);
+ };
+ }
+ }, [history, user]);
+
+ if (user != null) {
+ return (
+ <>
+ <AppBar />
+ <p className="mt-appbar">{t('login.alreadyLogin')}</p>
+ </>
+ );
+ }
+
+ function onSubmit(event: React.SyntheticEvent): void {
+ if (username === '' || password === '') {
+ setUsernameDirty(true);
+ setPasswordDirty(true);
+ return;
+ }
+
+ setProcess(true);
+ userLogin(
+ {
+ username: username,
+ password: password,
+ },
+ rememberMe
+ ).then(
+ (_) => {
+ if (history.length === 0) {
+ history.push('/');
+ } else {
+ history.goBack();
+ }
+ },
+ (e: AxiosError | Error) => {
+ setProcess(false);
+ setError(e.message);
+ }
+ );
+ event.preventDefault();
+ }
+
+ return (
+ <Fragment>
+ <AppBar />
+ <div className="container login-container mt-appbar">
+ <h1>{t('welcome')}</h1>
+ <Form>
+ <FormGroup>
+ <Label for="username">{t('user.username')}</Label>
+ <Input
+ id="username"
+ disabled={process}
+ onChange={(e) => {
+ setUsername(e.target.value);
+ setUsernameDirty(true);
+ }}
+ value={username}
+ invalid={usernameDirty && username === ''}
+ />
+ {usernameDirty && username === '' && (
+ <FormFeedback>{t('login.emptyUsername')}</FormFeedback>
+ )}
+ </FormGroup>
+ <FormGroup>
+ <Label for="password">{t('user.password')}</Label>
+ <Input
+ id="password"
+ type="password"
+ disabled={process}
+ onChange={(e) => {
+ setPassword(e.target.value);
+ setPasswordDirty(true);
+ }}
+ value={password}
+ invalid={passwordDirty && password === ''}
+ />
+ {passwordDirty && password === '' && (
+ <FormFeedback>{t('login.emptyPassword')}</FormFeedback>
+ )}
+ </FormGroup>
+ <FormGroup check>
+ <Input
+ id="remember-me"
+ type="checkbox"
+ checked={rememberMe}
+ onChange={(e) => {
+ const v = (e.target as HTMLInputElement).checked;
+ setRememberMe(v);
+ }}
+ />
+ <Label for="remember-me">{t('user.rememberMe')}</Label>
+ </FormGroup>
+ {error ? <p className="text-error">{t(error)}</p> : null}
+ <div>
+ {process ? (
+ <Spinner />
+ ) : (
+ <Button color="primary" onClick={onSubmit}>
+ {t('user.login')}
+ </Button>
+ )}
+ </div>
+ </Form>
+ </div>
+ </Fragment>
+ );
+};
+
+export default Login;
diff --git a/Timeline/ClientApp/src/user/User.tsx b/Timeline/ClientApp/src/app/user/User.tsx
index a281be42..0e1977b1 100644
--- a/Timeline/ClientApp/src/user/User.tsx
+++ b/Timeline/ClientApp/src/app/user/User.tsx
@@ -1,79 +1,79 @@
-import React, { useState } from 'react';
-import { useParams } from 'react-router';
-
-import { useUser } from '../data/user';
-import { changeNickname, changeAvatar } from './api';
-import { personalTimelineService } from '../data/timeline';
-
-import UserPage from './UserPage';
-import ChangeNicknameDialog from './ChangeNicknameDialog';
-import ChangeAvatarDialog from './ChangeAvatarDialog';
-import TimelinePageTemplate from '../timeline/TimelinePageTemplate';
-import { PersonalTimelineManageItem } from './UserInfoCard';
-import { UiLogicError } from '../common';
-
-const User: React.FC = (_) => {
- const { username } = useParams<{ username: string }>();
-
- const user = useUser();
-
- const [dialog, setDialog] = useState<null | PersonalTimelineManageItem>(null);
- const [dataKey, setDataKey] = useState<number>(0);
-
- let dialogElement: React.ReactElement | undefined;
-
- const closeDialogHandler = (): void => {
- setDialog(null);
- };
-
- if (dialog === 'nickname') {
- if (user == null) {
- throw new UiLogicError('Change nickname without login.');
- }
-
- dialogElement = (
- <ChangeNicknameDialog
- open
- close={closeDialogHandler}
- onProcess={(newNickname) => {
- const p = changeNickname(user.token, username, newNickname);
- return p.then((_) => {
- setDataKey(dataKey + 1);
- });
- }}
- />
- );
- } else if (dialog === 'avatar') {
- if (user == null) {
- throw new UiLogicError('Change avatar without login.');
- }
-
- dialogElement = (
- <ChangeAvatarDialog
- open
- close={closeDialogHandler}
- process={(file) => changeAvatar(user.token, username, file, file.type)}
- />
- );
- }
-
- const onManage = React.useCallback((item: PersonalTimelineManageItem) => {
- setDialog(item);
- }, []);
-
- return (
- <>
- <TimelinePageTemplate
- dataVersion={dataKey}
- name={username}
- UiComponent={UserPage}
- onManage={onManage}
- service={personalTimelineService}
- notFoundI18nKey="timeline.userNotExist"
- />
- {dialogElement}
- </>
- );
-};
-
-export default User;
+import React, { useState } from 'react';
+import { useParams } from 'react-router';
+
+import { useUser } from '../data/user';
+import { changeNickname, changeAvatar } from './api';
+import { personalTimelineService } from '../data/timeline';
+
+import UserPage from './UserPage';
+import ChangeNicknameDialog from './ChangeNicknameDialog';
+import ChangeAvatarDialog from './ChangeAvatarDialog';
+import TimelinePageTemplate from '../timeline/TimelinePageTemplate';
+import { PersonalTimelineManageItem } from './UserInfoCard';
+import { UiLogicError } from '../common';
+
+const User: React.FC = (_) => {
+ const { username } = useParams<{ username: string }>();
+
+ const user = useUser();
+
+ const [dialog, setDialog] = useState<null | PersonalTimelineManageItem>(null);
+ const [dataKey, setDataKey] = useState<number>(0);
+
+ let dialogElement: React.ReactElement | undefined;
+
+ const closeDialogHandler = (): void => {
+ setDialog(null);
+ };
+
+ if (dialog === 'nickname') {
+ if (user == null) {
+ throw new UiLogicError('Change nickname without login.');
+ }
+
+ dialogElement = (
+ <ChangeNicknameDialog
+ open
+ close={closeDialogHandler}
+ onProcess={(newNickname) => {
+ const p = changeNickname(user.token, username, newNickname);
+ return p.then((_) => {
+ setDataKey(dataKey + 1);
+ });
+ }}
+ />
+ );
+ } else if (dialog === 'avatar') {
+ if (user == null) {
+ throw new UiLogicError('Change avatar without login.');
+ }
+
+ dialogElement = (
+ <ChangeAvatarDialog
+ open
+ close={closeDialogHandler}
+ process={(file) => changeAvatar(user.token, username, file, file.type)}
+ />
+ );
+ }
+
+ const onManage = React.useCallback((item: PersonalTimelineManageItem) => {
+ setDialog(item);
+ }, []);
+
+ return (
+ <>
+ <TimelinePageTemplate
+ dataVersion={dataKey}
+ name={username}
+ UiComponent={UserPage}
+ onManage={onManage}
+ service={personalTimelineService}
+ notFoundI18nKey="timeline.userNotExist"
+ />
+ {dialogElement}
+ </>
+ );
+};
+
+export default User;
diff --git a/Timeline/ClientApp/src/user/UserInfoCard.tsx b/Timeline/ClientApp/src/app/user/UserInfoCard.tsx
index 3f812a8b..b4924a5d 100644
--- a/Timeline/ClientApp/src/user/UserInfoCard.tsx
+++ b/Timeline/ClientApp/src/app/user/UserInfoCard.tsx
@@ -1,116 +1,116 @@
-import React from 'react';
-import clsx from 'clsx';
-import {
- Dropdown,
- DropdownToggle,
- DropdownMenu,
- DropdownItem,
- Button,
-} from 'reactstrap';
-import { useTranslation } from 'react-i18next';
-import { fromEvent } from 'rxjs';
-
-import {
- TimelineInfo,
- timelineVisibilityTooltipTranslationMap,
-} from '../data/timeline';
-import { useAvatarVersion, useAvatarUrlWithGivenVersion } from './api';
-import { useUser } from '../data/user';
-
-import { TimelineCardComponentProps } from '../timeline/TimelinePageTemplateUI';
-
-export type PersonalTimelineManageItem = 'avatar' | 'nickname';
-
-export type UserInfoCardProps = TimelineCardComponentProps<
- TimelineInfo,
- PersonalTimelineManageItem
->;
-
-const UserInfoCard: React.FC<UserInfoCardProps> = (props) => {
- const { onHeight, onManage } = props;
- const { t } = useTranslation();
- const user = useUser();
-
- const avatarVersion = useAvatarVersion();
- const avatarUrl = useAvatarUrlWithGivenVersion(
- user != null && user.username === props.timeline.owner.username
- ? avatarVersion
- : undefined,
- props.timeline.owner._links.avatar
- );
-
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- const containerRef = React.useRef<HTMLDivElement>(null!);
-
- const notifyHeight = React.useCallback((): void => {
- if (onHeight) {
- onHeight(containerRef.current.getBoundingClientRect().height);
- }
- }, [onHeight]);
-
- React.useEffect(() => {
- const subscription = fromEvent(window, 'resize').subscribe(notifyHeight);
- return () => subscription.unsubscribe();
- });
-
- const [manageDropdownOpen, setManageDropdownOpen] = React.useState<boolean>(
- false
- );
- const toggleManageDropdown = React.useCallback(
- (): void => setManageDropdownOpen((old) => !old),
- []
- );
-
- return (
- <div
- ref={containerRef}
- className={clsx('rounded border bg-light p-2', props.className)}
- onTransitionEnd={notifyHeight}
- >
- <img
- src={avatarUrl}
- onLoad={notifyHeight}
- className="avatar large mr-2 mb-2 rounded-circle float-left"
- />
- <div>
- {props.timeline.owner.nickname}
- <small className="ml-3 text-secondary">
- @{props.timeline.owner.username}
- </small>
- </div>
- <p className="mb-0">{props.timeline.description}</p>
- <small className="mt-1 d-block">
- {t(timelineVisibilityTooltipTranslationMap[props.timeline.visibility])}
- </small>
- <div className="text-right mt-2">
- {onManage != null ? (
- <Dropdown isOpen={manageDropdownOpen} toggle={toggleManageDropdown}>
- <DropdownToggle outline color="primary">
- {t('timeline.manage')}
- </DropdownToggle>
- <DropdownMenu>
- <DropdownItem onClick={() => onManage('nickname')}>
- {t('timeline.manageItem.nickname')}
- </DropdownItem>
- <DropdownItem onClick={() => onManage('avatar')}>
- {t('timeline.manageItem.avatar')}
- </DropdownItem>
- <DropdownItem onClick={() => onManage('property')}>
- {t('timeline.manageItem.property')}
- </DropdownItem>
- <DropdownItem onClick={props.onMember}>
- {t('timeline.manageItem.member')}
- </DropdownItem>
- </DropdownMenu>
- </Dropdown>
- ) : (
- <Button color="primary" outline onClick={props.onMember}>
- {t('timeline.memberButton')}
- </Button>
- )}
- </div>
- </div>
- );
-};
-
-export default UserInfoCard;
+import React from 'react';
+import clsx from 'clsx';
+import {
+ Dropdown,
+ DropdownToggle,
+ DropdownMenu,
+ DropdownItem,
+ Button,
+} from 'reactstrap';
+import { useTranslation } from 'react-i18next';
+import { fromEvent } from 'rxjs';
+
+import {
+ TimelineInfo,
+ timelineVisibilityTooltipTranslationMap,
+} from '../data/timeline';
+import { useAvatarVersion, useAvatarUrlWithGivenVersion } from './api';
+import { useUser } from '../data/user';
+
+import { TimelineCardComponentProps } from '../timeline/TimelinePageTemplateUI';
+
+export type PersonalTimelineManageItem = 'avatar' | 'nickname';
+
+export type UserInfoCardProps = TimelineCardComponentProps<
+ TimelineInfo,
+ PersonalTimelineManageItem
+>;
+
+const UserInfoCard: React.FC<UserInfoCardProps> = (props) => {
+ const { onHeight, onManage } = props;
+ const { t } = useTranslation();
+ const user = useUser();
+
+ const avatarVersion = useAvatarVersion();
+ const avatarUrl = useAvatarUrlWithGivenVersion(
+ user != null && user.username === props.timeline.owner.username
+ ? avatarVersion
+ : undefined,
+ props.timeline.owner._links.avatar
+ );
+
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ const containerRef = React.useRef<HTMLDivElement>(null!);
+
+ const notifyHeight = React.useCallback((): void => {
+ if (onHeight) {
+ onHeight(containerRef.current.getBoundingClientRect().height);
+ }
+ }, [onHeight]);
+
+ React.useEffect(() => {
+ const subscription = fromEvent(window, 'resize').subscribe(notifyHeight);
+ return () => subscription.unsubscribe();
+ });
+
+ const [manageDropdownOpen, setManageDropdownOpen] = React.useState<boolean>(
+ false
+ );
+ const toggleManageDropdown = React.useCallback(
+ (): void => setManageDropdownOpen((old) => !old),
+ []
+ );
+
+ return (
+ <div
+ ref={containerRef}
+ className={clsx('rounded border bg-light p-2', props.className)}
+ onTransitionEnd={notifyHeight}
+ >
+ <img
+ src={avatarUrl}
+ onLoad={notifyHeight}
+ className="avatar large mr-2 mb-2 rounded-circle float-left"
+ />
+ <div>
+ {props.timeline.owner.nickname}
+ <small className="ml-3 text-secondary">
+ @{props.timeline.owner.username}
+ </small>
+ </div>
+ <p className="mb-0">{props.timeline.description}</p>
+ <small className="mt-1 d-block">
+ {t(timelineVisibilityTooltipTranslationMap[props.timeline.visibility])}
+ </small>
+ <div className="text-right mt-2">
+ {onManage != null ? (
+ <Dropdown isOpen={manageDropdownOpen} toggle={toggleManageDropdown}>
+ <DropdownToggle outline color="primary">
+ {t('timeline.manage')}
+ </DropdownToggle>
+ <DropdownMenu>
+ <DropdownItem onClick={() => onManage('nickname')}>
+ {t('timeline.manageItem.nickname')}
+ </DropdownItem>
+ <DropdownItem onClick={() => onManage('avatar')}>
+ {t('timeline.manageItem.avatar')}
+ </DropdownItem>
+ <DropdownItem onClick={() => onManage('property')}>
+ {t('timeline.manageItem.property')}
+ </DropdownItem>
+ <DropdownItem onClick={props.onMember}>
+ {t('timeline.manageItem.member')}
+ </DropdownItem>
+ </DropdownMenu>
+ </Dropdown>
+ ) : (
+ <Button color="primary" outline onClick={props.onMember}>
+ {t('timeline.memberButton')}
+ </Button>
+ )}
+ </div>
+ </div>
+ );
+};
+
+export default UserInfoCard;
diff --git a/Timeline/ClientApp/src/user/UserPage.tsx b/Timeline/ClientApp/src/app/user/UserPage.tsx
index 723415c7..98fd06f8 100644
--- a/Timeline/ClientApp/src/user/UserPage.tsx
+++ b/Timeline/ClientApp/src/app/user/UserPage.tsx
@@ -1,20 +1,20 @@
-import React from 'react';
-
-import { ExcludeKey } from '../type-utilities';
-import { TimelineInfo } from '../data/timeline';
-
-import TimelinePageTemplateUI, {
- TimelinePageTemplateUIProps
-} from '../timeline/TimelinePageTemplateUI';
-import UserInfoCard, { PersonalTimelineManageItem } from './UserInfoCard';
-
-export type UserPageProps = ExcludeKey<
- TimelinePageTemplateUIProps<TimelineInfo, PersonalTimelineManageItem>,
- 'CardComponent'
->;
-
-const UserPage: React.FC<UserPageProps> = props => {
- return <TimelinePageTemplateUI {...props} CardComponent={UserInfoCard} />;
-};
-
-export default UserPage;
+import React from 'react';
+
+import { ExcludeKey } from '../type-utilities';
+import { TimelineInfo } from '../data/timeline';
+
+import TimelinePageTemplateUI, {
+ TimelinePageTemplateUIProps
+} from '../timeline/TimelinePageTemplateUI';
+import UserInfoCard, { PersonalTimelineManageItem } from './UserInfoCard';
+
+export type UserPageProps = ExcludeKey<
+ TimelinePageTemplateUIProps<TimelineInfo, PersonalTimelineManageItem>,
+ 'CardComponent'
+>;
+
+const UserPage: React.FC<UserPageProps> = props => {
+ return <TimelinePageTemplateUI {...props} CardComponent={UserInfoCard} />;
+};
+
+export default UserPage;
diff --git a/Timeline/ClientApp/src/user/api.ts b/Timeline/ClientApp/src/app/user/api.ts
index 583370e2..a9855eb0 100644
--- a/Timeline/ClientApp/src/user/api.ts
+++ b/Timeline/ClientApp/src/app/user/api.ts
@@ -1,79 +1,79 @@
-import React from 'react';
-import axios from 'axios';
-import { BehaviorSubject, Observable } from 'rxjs';
-
-import { apiBaseUrl } from '../config';
-import { User } from '../data/user';
-import { updateQueryString } from '../helper';
-
-export function changeNickname(
- token: string,
- username: string,
- newNickname: string
-): Promise<User> {
- return axios
- .patch<User>(`${apiBaseUrl}/users/${username}?token=${token}`, {
- nickname: newNickname,
- })
- .then((res) => res.data);
-}
-
-const avatarVersionSubject = new BehaviorSubject<number | undefined>(undefined);
-
-export function changeAvatar(
- token: string,
- username: string,
- data: Blob,
- type: string
-): Promise<void> {
- return axios
- .put(`${apiBaseUrl}/users/${username}/avatar?token=${token}`, data, {
- headers: {
- 'Content-Type': type,
- },
- })
- .then(() => {
- avatarVersionSubject.next((avatarVersionSubject.value ?? 0) + 1);
- });
-}
-
-export const avatarVersion$: Observable<
- number | undefined
-> = avatarVersionSubject;
-
-export function useAvatarVersion(): number | undefined {
- const [version, setVersion] = React.useState<number | undefined>();
- React.useEffect(() => {
- const subscription = avatarVersion$.subscribe((v) => setVersion(v));
- return () => subscription.unsubscribe();
- }, []);
- return version;
-}
-
-export function useOptionalVersionedAvatarUrl(
- url: string | undefined
-): string | undefined {
- const avatarVersion = useAvatarVersion();
- return React.useMemo(
- () =>
- url == null
- ? undefined
- : updateQueryString(
- 'v',
- avatarVersion == null ? null : avatarVersion.toString(),
- url
- ),
- [avatarVersion, url]
- );
-}
-
-export function useAvatarUrlWithGivenVersion(
- version: number | null | undefined,
- url: string
-): string {
- return React.useMemo(
- () =>
- updateQueryString('v', version == null ? null : version.toString(), url),
- [version, url]
- );
-}
+import React from 'react';
+import axios from 'axios';
+import { BehaviorSubject, Observable } from 'rxjs';
+
+import { apiBaseUrl } from '../config';
+import { User } from '../data/user';
+import { updateQueryString } from '../helper';
+
+export function changeNickname(
+ token: string,
+ username: string,
+ newNickname: string
+): Promise<User> {
+ return axios
+ .patch<User>(`${apiBaseUrl}/users/${username}?token=${token}`, {
+ nickname: newNickname,
+ })
+ .then((res) => res.data);
+}
+
+const avatarVersionSubject = new BehaviorSubject<number | undefined>(undefined);
+
+export function changeAvatar(
+ token: string,
+ username: string,
+ data: Blob,
+ type: string
+): Promise<void> {
+ return axios
+ .put(`${apiBaseUrl}/users/${username}/avatar?token=${token}`, data, {
+ headers: {
+ 'Content-Type': type,
+ },
+ })
+ .then(() => {
+ avatarVersionSubject.next((avatarVersionSubject.value ?? 0) + 1);
+ });
+}
+
+export const avatarVersion$: Observable<
+ number | undefined
+> = avatarVersionSubject;
+
+export function useAvatarVersion(): number | undefined {
+ const [version, setVersion] = React.useState<number | undefined>();
+ React.useEffect(() => {
+ const subscription = avatarVersion$.subscribe((v) => setVersion(v));
+ return () => subscription.unsubscribe();
+ }, []);
+ return version;
+}
+
+export function useOptionalVersionedAvatarUrl(
+ url: string | undefined
+): string | undefined {
+ const avatarVersion = useAvatarVersion();
+ return React.useMemo(
+ () =>
+ url == null
+ ? undefined
+ : updateQueryString(
+ 'v',
+ avatarVersion == null ? null : avatarVersion.toString(),
+ url
+ ),
+ [avatarVersion, url]
+ );
+}
+
+export function useAvatarUrlWithGivenVersion(
+ version: number | null | undefined,
+ url: string
+): string {
+ return React.useMemo(
+ () =>
+ updateQueryString('v', version == null ? null : version.toString(), url),
+ [version, url]
+ );
+}
diff --git a/Timeline/ClientApp/src/user/user-page.sass b/Timeline/ClientApp/src/app/user/user-page.sass
index 1e2e802b..fe3ab70b 100644
--- a/Timeline/ClientApp/src/user/user-page.sass
+++ b/Timeline/ClientApp/src/app/user/user-page.sass
@@ -1,11 +1,11 @@
-.login-container
- max-width: 600px
-
-.change-avatar-cropper-row
- max-height: 400px
-
-.change-avatar-img
- min-width: 50%
- max-width: 100%
- max-height: 400px
-
+.login-container
+ max-width: 600px
+
+.change-avatar-cropper-row
+ max-height: 400px
+
+.change-avatar-img
+ min-width: 50%
+ max-width: 100%
+ max-height: 400px
+
diff --git a/Timeline/ClientApp/src/sw/sw.ts b/Timeline/ClientApp/src/sw/sw.ts
new file mode 100644
index 00000000..67f5dfd4
--- /dev/null
+++ b/Timeline/ClientApp/src/sw/sw.ts
@@ -0,0 +1,22 @@
+import { precacheAndRoute, matchPrecache } from 'workbox-precaching';
+import { setDefaultHandler } from 'workbox-routing';
+import { NetworkOnly } from 'workbox-strategies';
+
+declare let self: ServiceWorkerGlobalScope;
+
+precacheAndRoute(self.__WB_MANIFEST);
+
+const networkOnly = new NetworkOnly();
+
+setDefaultHandler((options) => {
+ const { request, url } = options;
+ if (url && url.pathname.startsWith('/api/')) {
+ return networkOnly.handle(options);
+ }
+
+ if (request instanceof Request && request.destination === 'document')
+ return matchPrecache('/index.html').then((r) =>
+ r == null ? Response.error() : r
+ );
+ else return networkOnly.handle(options);
+});
diff --git a/Timeline/ClientApp/src/sw/tsconfig.json b/Timeline/ClientApp/src/sw/tsconfig.json
new file mode 100644
index 00000000..aac99e59
--- /dev/null
+++ b/Timeline/ClientApp/src/sw/tsconfig.json
@@ -0,0 +1,12 @@
+{
+ "extends": "../tsconfig.json",
+ "compilerOptions": {
+ "lib": [
+ "esnext",
+ "webworker"
+ ]
+ },
+ "include": [
+ "."
+ ]
+}
diff --git a/Timeline/ClientApp/tsconfig.json b/Timeline/ClientApp/src/tsconfig.json
index 9a057241..ec0a3fad 100644
--- a/Timeline/ClientApp/tsconfig.json
+++ b/Timeline/ClientApp/src/tsconfig.json
@@ -1,25 +1,17 @@
-{
- "compilerOptions": {
- "target": "es5",
- "lib": [
- "dom",
- "dom.iterable",
- "esnext"
- ],
- "allowJs": true,
- "skipLibCheck": true,
- "esModuleInterop": true,
- "allowSyntheticDefaultImports": true,
- "strict": true,
- "forceConsistentCasingInFileNames": true,
- "module": "esnext",
- "moduleResolution": "node",
- "resolveJsonModule": true,
- "isolatedModules": true,
- "noEmit": true,
- "jsx": "react"
- },
- "include": [
- "src"
- ]
-}
+{
+ "compilerOptions": {
+ "target": "es5",
+ "allowJs": true,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "module": "esnext",
+ "moduleResolution": "node",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react"
+ }
+}
diff --git a/Timeline/ClientApp/webpack.config.dev.js b/Timeline/ClientApp/webpack.config.dev.js
index 12b25253..7a38f2b6 100644
--- a/Timeline/ClientApp/webpack.config.dev.js
+++ b/Timeline/ClientApp/webpack.config.dev.js
@@ -2,12 +2,14 @@ const path = require('path');
const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
+const CopyPlugin = require('copy-webpack-plugin');
const PnpWebpackPlugin = require('pnp-webpack-plugin');
+const WorkboxPlugin = require('workbox-webpack-plugin');
const { commonRules, htmlCommonConfig } = require('./webpack.common');
const config = {
- entry: ['react-hot-loader/patch', './src/index.tsx'],
+ entry: ['react-hot-loader/patch', './src/app/index.tsx'],
mode: 'development',
devtool: 'eval-source-map',
module: {
@@ -53,7 +55,7 @@ const config = {
publicPath: '/',
},
devServer: {
- contentBase: path.join(__dirname, 'public/'),
+ contentBase: false,
host: '0.0.0.0',
port: 3000,
publicPath: 'http://localhost:3000/',
@@ -65,8 +67,25 @@ const config = {
...htmlCommonConfig,
devServer: 'http://localhost:3000',
}),
- new ForkTsCheckerWebpackPlugin(),
+ new CopyPlugin({
+ patterns: [
+ {
+ from: path.resolve(__dirname, 'public/'),
+ to: path.resolve(__dirname, 'dist/'),
+ },
+ ],
+ }),
+ new ForkTsCheckerWebpackPlugin({
+ tsconfig: './src/app/tsconfig.json',
+ }),
+ new ForkTsCheckerWebpackPlugin({
+ tsconfig: './src/sw/tsconfig.json',
+ }),
new webpack.HotModuleReplacementPlugin(),
+ new WorkboxPlugin.InjectManifest({
+ swSrc: './src/sw/sw.ts',
+ maximumFileSizeToCacheInBytes: 15000000,
+ }),
],
};
diff --git a/Timeline/ClientApp/webpack.config.prod.js b/Timeline/ClientApp/webpack.config.prod.js
index 2115824d..3b74faf6 100644
--- a/Timeline/ClientApp/webpack.config.prod.js
+++ b/Timeline/ClientApp/webpack.config.prod.js
@@ -4,11 +4,12 @@ const HtmlWebpackPlugin = require('html-webpack-plugin');
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
const CopyPlugin = require('copy-webpack-plugin');
const PnpWebpackPlugin = require('pnp-webpack-plugin');
+const WorkboxPlugin = require('workbox-webpack-plugin');
const { commonRules, htmlCommonConfig } = require('./webpack.common');
const config = {
- entry: ['./src/index.tsx'],
+ entry: ['./src/app/index.tsx'],
mode: 'production',
devtool: 'source-map',
module: {
@@ -50,7 +51,12 @@ const config = {
plugins: [
new CleanWebpackPlugin(),
new HtmlWebpackPlugin(htmlCommonConfig),
- new ForkTsCheckerWebpackPlugin(),
+ new ForkTsCheckerWebpackPlugin({
+ tsconfig: './src/app/tsconfig.json',
+ }),
+ new ForkTsCheckerWebpackPlugin({
+ tsconfig: './src/sw/tsconfig.json',
+ }),
new CopyPlugin({
patterns: [
{
@@ -59,6 +65,10 @@ const config = {
},
],
}),
+ new WorkboxPlugin.InjectManifest({
+ swSrc: './src/sw/sw.ts',
+ maximumFileSizeToCacheInBytes: 15000000,
+ }),
],
};
diff --git a/Timeline/ClientApp/yarn.lock b/Timeline/ClientApp/yarn.lock
index 3790914f..53c2c923 100644
--- a/Timeline/ClientApp/yarn.lock
+++ b/Timeline/ClientApp/yarn.lock
@@ -24,7 +24,7 @@ __metadata:
languageName: node
linkType: hard
-"@babel/core@npm:^7.10.2":
+"@babel/core@npm:^7.10.2, @babel/core@npm:^7.8.4":
version: 7.10.2
resolution: "@babel/core@npm:7.10.2"
dependencies:
@@ -237,7 +237,7 @@ __metadata:
languageName: node
linkType: hard
-"@babel/helper-module-imports@npm:^7.10.1":
+"@babel/helper-module-imports@npm:^7.0.0, @babel/helper-module-imports@npm:^7.10.1":
version: 7.10.1
resolution: "@babel/helper-module-imports@npm:7.10.1"
dependencies:
@@ -1154,7 +1154,7 @@ __metadata:
languageName: node
linkType: hard
-"@babel/preset-env@npm:^7.10.2":
+"@babel/preset-env@npm:^7.10.2, @babel/preset-env@npm:^7.8.4":
version: 7.10.2
resolution: "@babel/preset-env@npm:7.10.2"
dependencies:
@@ -1348,6 +1348,48 @@ __metadata:
languageName: node
linkType: hard
+"@hapi/address@npm:2.x.x":
+ version: 2.1.4
+ resolution: "@hapi/address@npm:2.1.4"
+ checksum: 3/5dc5d0d3d6aad953bef59c5f24af704ae349dce626460eb2df93bd1e4b560136e354f92ce1c573292dfc7edce84189859794d28381711b50f738e67042081278
+ languageName: node
+ linkType: hard
+
+"@hapi/bourne@npm:1.x.x":
+ version: 1.3.2
+ resolution: "@hapi/bourne@npm:1.3.2"
+ checksum: 3/bc23796d94afbca6bf691161d181bf005e86eac3f16fa4a11c38ca1acc9ffabf4e83791a98e9234bd09539ac013675bb53ea2de119373f9e9349f3b94312b76d
+ languageName: node
+ linkType: hard
+
+"@hapi/hoek@npm:8.x.x, @hapi/hoek@npm:^8.3.0":
+ version: 8.5.1
+ resolution: "@hapi/hoek@npm:8.5.1"
+ checksum: 3/17bf9a0b6f2f9ecb248824dab838c66c50b16b00b1d3785233fafd5abacb06cc6cdcbd6f4c7be87babb227fc02fff46ad1c23de3f5b6f48ffe36b6aac829d82c
+ languageName: node
+ linkType: hard
+
+"@hapi/joi@npm:^15.1.0":
+ version: 15.1.1
+ resolution: "@hapi/joi@npm:15.1.1"
+ dependencies:
+ "@hapi/address": 2.x.x
+ "@hapi/bourne": 1.x.x
+ "@hapi/hoek": 8.x.x
+ "@hapi/topo": 3.x.x
+ checksum: 3/7edbb0d5a5c1ff376b66243427a3b98a559e9ea89f7d40ee55916e0519bc1be56a9ac69f1e446a2c39c153fe835c57e4ee71297d4266b0ca82c49f7a2e90f681
+ languageName: node
+ linkType: hard
+
+"@hapi/topo@npm:3.x.x":
+ version: 3.1.6
+ resolution: "@hapi/topo@npm:3.1.6"
+ dependencies:
+ "@hapi/hoek": ^8.3.0
+ checksum: 3/4550d3d7498a203ce5c0e53753eb9f510aa2b74c08bfaf7d7c4676a0943b27d72f22297ff006e8396eb74e6b73154ebf98feab19c199b0768a084a777d024a50
+ languageName: node
+ linkType: hard
+
"@hot-loader/react-dom@npm:^16.13.0":
version: 16.13.0
resolution: "@hot-loader/react-dom@npm:16.13.0"
@@ -1398,6 +1440,56 @@ __metadata:
languageName: node
linkType: hard
+"@rollup/plugin-node-resolve@npm:^7.1.1":
+ version: 7.1.3
+ resolution: "@rollup/plugin-node-resolve@npm:7.1.3"
+ dependencies:
+ "@rollup/pluginutils": ^3.0.8
+ "@types/resolve": 0.0.8
+ builtin-modules: ^3.1.0
+ is-module: ^1.0.0
+ resolve: ^1.14.2
+ peerDependencies:
+ rollup: ^1.20.0||^2.0.0
+ checksum: 3/4d751a407f752a320dae3fed9cc8be293bf15c3b2a7f387945195275580612f540572f69ffe8bbeb1d89732e63dfb19694901172603a68f58968e3ff65ce7684
+ languageName: node
+ linkType: hard
+
+"@rollup/plugin-replace@npm:^2.3.1":
+ version: 2.3.3
+ resolution: "@rollup/plugin-replace@npm:2.3.3"
+ dependencies:
+ "@rollup/pluginutils": ^3.0.8
+ magic-string: ^0.25.5
+ peerDependencies:
+ rollup: ^1.20.0 || ^2.0.0
+ checksum: 3/d08a514b8f446a3fe3db7e9de7d1a57c4aaeaaa3d6f70c2e18925d893ed81480dbfd29fe939f61c1ef31194b036ab7b1df22a744ffd33196335f9fa1925d3c22
+ languageName: node
+ linkType: hard
+
+"@rollup/pluginutils@npm:^3.0.8":
+ version: 3.1.0
+ resolution: "@rollup/pluginutils@npm:3.1.0"
+ dependencies:
+ "@types/estree": 0.0.39
+ estree-walker: ^1.0.1
+ picomatch: ^2.2.2
+ peerDependencies:
+ rollup: ^1.20.0||^2.0.0
+ checksum: 3/45da6411e045d1b034242a8144f4a5e8c02ff1b68a2e0857807f5bb4b091c416f2015e075057f0f0dec200e7b35efe6ed4e301b43e365cedea09192f01a6839b
+ languageName: node
+ linkType: hard
+
+"@surma/rollup-plugin-off-main-thread@npm:^1.1.1":
+ version: 1.4.1
+ resolution: "@surma/rollup-plugin-off-main-thread@npm:1.4.1"
+ dependencies:
+ ejs: ^2.6.1
+ magic-string: ^0.25.0
+ checksum: 3/4f91dc8b394f216bfa49864b4c1ef3eac91e129adb054e27f61bae2ee6af73d3eb227d5b29dbec72f0044da182c75331bcbd78cf328f2e1ce0ebe7e9b2410b60
+ languageName: node
+ linkType: hard
+
"@types/anymatch@npm:*":
version: 1.3.1
resolution: "@types/anymatch@npm:1.3.1"
@@ -1426,6 +1518,20 @@ __metadata:
languageName: node
linkType: hard
+"@types/estree@npm:*":
+ version: 0.0.44
+ resolution: "@types/estree@npm:0.0.44"
+ checksum: 3/b8b905f9dabd23ff3f566397a60e24cff5e56f076b89ef34acf4281e6819c75a534c827f473d777cf1bed06d054b3e717fad3816308febc3bc55018db9b9a1e9
+ languageName: node
+ linkType: hard
+
+"@types/estree@npm:0.0.39":
+ version: 0.0.39
+ resolution: "@types/estree@npm:0.0.39"
+ checksum: 3/43e5361de39969def145f32f4599391ab13055ec94841f1633a7cfe10f0e8a940ebf0e9a4b2770454a6bddd034b57e7e0d51a4d565cb2714ee2accf10a7718be
+ languageName: node
+ linkType: hard
+
"@types/events@npm:*":
version: 3.0.0
resolution: "@types/events@npm:3.0.0"
@@ -1562,6 +1668,15 @@ __metadata:
languageName: node
linkType: hard
+"@types/resolve@npm:0.0.8":
+ version: 0.0.8
+ resolution: "@types/resolve@npm:0.0.8"
+ dependencies:
+ "@types/node": "*"
+ checksum: 3/f54f13e4b6ac46a6c7bde9e609cd730f4369b434aa59c5230478b9262bb75e7349c3247fd2cdb917e98d053a57f5609dd552379c612a720b59a8714914d324ed
+ languageName: node
+ linkType: hard
+
"@types/source-list-map@npm:*":
version: 0.1.2
resolution: "@types/source-list-map@npm:0.1.2"
@@ -1926,7 +2041,7 @@ __metadata:
languageName: node
linkType: hard
-"acorn@npm:^7.2.0":
+"acorn@npm:^7.1.0, acorn@npm:^7.2.0":
version: 7.2.0
resolution: "acorn@npm:7.2.0"
bin:
@@ -2322,6 +2437,15 @@ __metadata:
languageName: node
linkType: hard
+"babel-extract-comments@npm:^1.0.0":
+ version: 1.0.0
+ resolution: "babel-extract-comments@npm:1.0.0"
+ dependencies:
+ babylon: ^6.18.0
+ checksum: 3/2a291f1a3afb95052b98346e6fc41d36add460d557dc7f01bacaae92efd1dd98521a632d211801a7045ef563c1eebd8d6d88d1a86548e57ffb7c68b4aaab9d0a
+ languageName: node
+ linkType: hard
+
"babel-loader@npm:^8.1.0":
version: 8.1.0
resolution: "babel-loader@npm:8.1.0"
@@ -2356,6 +2480,13 @@ __metadata:
languageName: node
linkType: hard
+"babel-plugin-syntax-object-rest-spread@npm:^6.8.0":
+ version: 6.13.0
+ resolution: "babel-plugin-syntax-object-rest-spread@npm:6.13.0"
+ checksum: 3/459844d1a89dfe580876daa6c8be3f120931db2705cfc32ffacaa93442ca8036e38ad3f687fc889e9cd6e96f51d83cb4b520c063d8f12223baf6f8a34a07e4cc
+ languageName: node
+ linkType: hard
+
"babel-plugin-transform-builtin-extend@npm:^1.1.2":
version: 1.1.2
resolution: "babel-plugin-transform-builtin-extend@npm:1.1.2"
@@ -2366,6 +2497,16 @@ __metadata:
languageName: node
linkType: hard
+"babel-plugin-transform-object-rest-spread@npm:^6.26.0":
+ version: 6.26.0
+ resolution: "babel-plugin-transform-object-rest-spread@npm:6.26.0"
+ dependencies:
+ babel-plugin-syntax-object-rest-spread: ^6.8.0
+ babel-runtime: ^6.26.0
+ checksum: 3/1d8ff820576afd78850081dc71e36f77be08484b502a8fe87b959bad4463581bd0731c605b09307cd3ffabeb372c70524c0f8a303dc99c4d15085f84c06f26e3
+ languageName: node
+ linkType: hard
+
"babel-runtime@npm:^6.2.0, babel-runtime@npm:^6.22.0, babel-runtime@npm:^6.26.0":
version: 6.26.0
resolution: "babel-runtime@npm:6.26.0"
@@ -2740,6 +2881,13 @@ __metadata:
languageName: node
linkType: hard
+"builtin-modules@npm:^3.1.0":
+ version: 3.1.0
+ resolution: "builtin-modules@npm:3.1.0"
+ checksum: 3/54f062393cd48ff6c7293d885a106faa0988cbffb8b5f7790bfd0148e56cedf21b13bd4c1e544911ec7b4b022c52fd4c606e8697469b342e8b3e529ce48d8831
+ languageName: node
+ linkType: hard
+
"builtin-status-codes@npm:^3.0.0":
version: 3.0.0
resolution: "builtin-status-codes@npm:3.0.0"
@@ -3183,6 +3331,13 @@ __metadata:
languageName: node
linkType: hard
+"common-tags@npm:^1.8.0":
+ version: 1.8.0
+ resolution: "common-tags@npm:1.8.0"
+ checksum: 3/f37a868d868929cf345fe49c4122efde693f9b06bf5764df36c3bdf5d3c271a24bb3fb6fbfaeec1f29768e60ad648cc11a4092c91bac05a8bde90ddbf5aae1a8
+ languageName: node
+ linkType: hard
+
"commondir@npm:^1.0.1":
version: 1.0.1
resolution: "commondir@npm:1.0.1"
@@ -3499,6 +3654,13 @@ __metadata:
languageName: node
linkType: hard
+"crypto-random-string@npm:^1.0.0":
+ version: 1.0.0
+ resolution: "crypto-random-string@npm:1.0.0"
+ checksum: 3/0876b316ccf6f501d0deda42507adcfff276790eba38ab87ba4a7f77e1f11404f406fa36a8c273e781c9eabbbe5da4eab4d7577ca1e84dcac556743ffc816af5
+ languageName: node
+ linkType: hard
+
"css-loader@npm:^3.5.3":
version: 3.5.3
resolution: "css-loader@npm:3.5.3"
@@ -3973,6 +4135,13 @@ __metadata:
languageName: node
linkType: hard
+"ejs@npm:^2.6.1":
+ version: 2.7.4
+ resolution: "ejs@npm:2.7.4"
+ checksum: 3/f066d9a932fb921bdb6e87133d747d5e3408a1c1303f9a15e5a7a3973afdf444a672c98c2f6d97b9a1a76363bd8ae6d05286f26c6b6b7b9674dfc5802fc8546d
+ languageName: node
+ linkType: hard
+
"electron-to-chromium@npm:^1.3.413":
version: 1.3.441
resolution: "electron-to-chromium@npm:1.3.441"
@@ -4353,6 +4522,20 @@ __metadata:
languageName: node
linkType: hard
+"estree-walker@npm:^0.6.1":
+ version: 0.6.1
+ resolution: "estree-walker@npm:0.6.1"
+ checksum: 3/a63b94b841fcda2d79ff8e313b4ea8439b3364094ba66547289814bd7da4399a54cd33849b23504d239266430f219a142627071201a9fb6eb488bd025575f8fc
+ languageName: node
+ linkType: hard
+
+"estree-walker@npm:^1.0.1":
+ version: 1.0.1
+ resolution: "estree-walker@npm:1.0.1"
+ checksum: 3/85e7cee763e9125a7d8a947b3a06a8b9282873936df220dd0d791d9b3315e45e40ab096b43ba71bdc99140c11a6d23fdcf686642dc119a7b2d6181004fdb24d2
+ languageName: node
+ linkType: hard
+
"esutils@npm:^2.0.2":
version: 2.0.3
resolution: "esutils@npm:2.0.3"
@@ -4566,7 +4749,7 @@ __metadata:
languageName: node
linkType: hard
-"fast-json-stable-stringify@npm:^2.0.0":
+"fast-json-stable-stringify@npm:^2.0.0, fast-json-stable-stringify@npm:^2.1.0":
version: 2.1.0
resolution: "fast-json-stable-stringify@npm:2.1.0"
checksum: 3/7df3fabfe445d65953b2d9d9d3958bd895438b215a40fb87dae8b2165c5169a897785eb5d51e6cf0eb03523af756e3d82ea01083f6ac6341fe16db532fee3016
@@ -4868,6 +5051,17 @@ __metadata:
languageName: node
linkType: hard
+"fs-extra@npm:^8.1.0":
+ version: 8.1.0
+ resolution: "fs-extra@npm:8.1.0"
+ dependencies:
+ graceful-fs: ^4.2.0
+ jsonfile: ^4.0.0
+ universalify: ^0.1.0
+ checksum: 3/056a96d4f55ab8728b021e251175a4a6440d1edb5845e6c2e8e010019bde3e63de188a0eb99386c21c71804ca1a571cd6e08f507971f10a2bc4f4f7667720fa4
+ languageName: node
+ linkType: hard
+
"fs-minipass@npm:^1.2.5":
version: 1.2.7
resolution: "fs-minipass@npm:1.2.7"
@@ -4991,6 +5185,13 @@ fsevents@~2.1.2:
languageName: node
linkType: hard
+"get-own-enumerable-property-symbols@npm:^3.0.0":
+ version: 3.0.2
+ resolution: "get-own-enumerable-property-symbols@npm:3.0.2"
+ checksum: 3/23f13946c768d9803a8e072ba13a4250528ced6bd5af4b4b31306eb197281f01a6426936b24b16725ff0e55f9097475296e4bcdb6d33455989683c3d385079ce
+ languageName: node
+ linkType: hard
+
"get-stdin@npm:^6.0.0":
version: 6.0.0
resolution: "get-stdin@npm:6.0.0"
@@ -5169,7 +5370,7 @@ fsevents@~2.1.2:
languageName: node
linkType: hard
-"graceful-fs@npm:^4.1.11, graceful-fs@npm:^4.1.15, graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.2.2":
+"graceful-fs@npm:^4.1.11, graceful-fs@npm:^4.1.15, graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.2":
version: 4.2.3
resolution: "graceful-fs@npm:4.2.3"
checksum: 3/67b7e3f6a687c91287f17a2adfcce462406e2aa16ea4440618e1daaecd579ae6362c0b13303f86c77c165ed8074fa8b0868bb0a73173fa3407c2b747e89353f9
@@ -6034,6 +6235,13 @@ fsevents@~2.1.2:
languageName: node
linkType: hard
+"is-module@npm:^1.0.0":
+ version: 1.0.0
+ resolution: "is-module@npm:1.0.0"
+ checksum: 3/2cbd41e2760874130b76aee84cc53120c4feef0d0f196fa665326857b444c8549909cc840f3f3a59652a7e8df46146a77f6c0f3f70a578704e03670975843e74
+ languageName: node
+ linkType: hard
+
"is-number@npm:^3.0.0":
version: 3.0.0
resolution: "is-number@npm:3.0.0"
@@ -6050,6 +6258,13 @@ fsevents@~2.1.2:
languageName: node
linkType: hard
+"is-obj@npm:^1.0.1":
+ version: 1.0.1
+ resolution: "is-obj@npm:1.0.1"
+ checksum: 3/0913a3bb6424d6bfb37e2daa5ef4a5d31a388b0f5a53f36bbe1fd95f1264efe92c6fd87a5c3f41e25b3db42fe60924fe6ae1f0efb274375b090fd093a5301ccf
+ languageName: node
+ linkType: hard
+
"is-path-cwd@npm:^2.0.0":
version: 2.2.0
resolution: "is-path-cwd@npm:2.2.0"
@@ -6100,6 +6315,13 @@ fsevents@~2.1.2:
languageName: node
linkType: hard
+"is-regexp@npm:^1.0.0":
+ version: 1.0.0
+ resolution: "is-regexp@npm:1.0.0"
+ checksum: 3/b6c3ea4f405d31e20c9612f0480b5deb86d71477f3e08c78a889a8b7b4c9f9e9944b2621b997bede7b94b6f8607dc8333b521b6b69a2f8ad97c80d9eb47d04a9
+ languageName: node
+ linkType: hard
+
"is-stream@npm:^1.1.0":
version: 1.1.0
resolution: "is-stream@npm:1.1.0"
@@ -6188,6 +6410,16 @@ fsevents@~2.1.2:
languageName: node
linkType: hard
+"jest-worker@npm:^24.9.0":
+ version: 24.9.0
+ resolution: "jest-worker@npm:24.9.0"
+ dependencies:
+ merge-stream: ^2.0.0
+ supports-color: ^6.1.0
+ checksum: 3/9740355081d8f98b15e035405a76a9eafc4ee2b943e00bbc74c34fa632a23e2c2d9d9efb4eb86165435ff76f8bc95dcd74ec63b5acbeb2f0755c83e77d0e71f4
+ languageName: node
+ linkType: hard
+
"js-tokens@npm:^3.0.0 || ^4.0.0, js-tokens@npm:^4.0.0":
version: 4.0.0
resolution: "js-tokens@npm:4.0.0"
@@ -6312,6 +6544,18 @@ fsevents@~2.1.2:
languageName: node
linkType: hard
+"jsonfile@npm:^4.0.0":
+ version: 4.0.0
+ resolution: "jsonfile@npm:4.0.0"
+ dependencies:
+ graceful-fs: ^4.1.6
+ dependenciesMeta:
+ graceful-fs:
+ optional: true
+ checksum: 3/a40b7b64da41c84b0dc7ad753737ba240bb0dc50a94be20ec0b73459707dede69a6f89eb44b4d29e6994ed93ddf8c9b6e57f6b1f09dd707567959880ad6cee7f
+ languageName: node
+ linkType: hard
+
"jsprim@npm:^1.2.2":
version: 1.4.1
resolution: "jsprim@npm:1.4.1"
@@ -6489,6 +6733,32 @@ fsevents@~2.1.2:
languageName: node
linkType: hard
+"lodash._reinterpolate@npm:^3.0.0":
+ version: 3.0.0
+ resolution: "lodash._reinterpolate@npm:3.0.0"
+ checksum: 3/27513557d6fe526296324f1de9e1b8e8ac88ef2a2544a655e825f3ab0f52c5a675f1a73a0c9ff3c64fda031c56dfb4deb9dac7c7d21f9a04bc63dd7db5a5a73d
+ languageName: node
+ linkType: hard
+
+"lodash.template@npm:^4.5.0":
+ version: 4.5.0
+ resolution: "lodash.template@npm:4.5.0"
+ dependencies:
+ lodash._reinterpolate: ^3.0.0
+ lodash.templatesettings: ^4.0.0
+ checksum: 3/e27068e20b7a374938c20ab76a093dd49e9626bfbe1882d9d05d81efefe3210cfcd6ad24f1cb0d956ce57d75855fec17bd386a4aa54762a144bd7c0891ee7ee1
+ languageName: node
+ linkType: hard
+
+"lodash.templatesettings@npm:^4.0.0":
+ version: 4.2.0
+ resolution: "lodash.templatesettings@npm:4.2.0"
+ dependencies:
+ lodash._reinterpolate: ^3.0.0
+ checksum: 3/45546a5b76376b138ef4f01aa2722813127c639428eb9baef3fbac176b509ee2dab5cb9d1ee8267dbeeef8d49371f9a748af3df83649bf8b75fa54993f65b7aa
+ languageName: node
+ linkType: hard
+
"lodash@npm:^4.17.11, lodash@npm:^4.17.13, lodash@npm:^4.17.14, lodash@npm:^4.17.15, lodash@npm:^4.17.3, lodash@npm:^4.17.4":
version: 4.17.15
resolution: "lodash@npm:4.17.15"
@@ -6530,6 +6800,15 @@ fsevents@~2.1.2:
languageName: node
linkType: hard
+"magic-string@npm:^0.25.0, magic-string@npm:^0.25.5":
+ version: 0.25.7
+ resolution: "magic-string@npm:0.25.7"
+ dependencies:
+ sourcemap-codec: ^1.4.4
+ checksum: 3/4b70c13eb21c6f1c54bf7fb029748dc44d6bfcd3c59e5deeda060eecc38df6144b91d10fb7a3cf6156fadab1a68f83d69a189df20ca5f6bd088bf0196ea8f039
+ languageName: node
+ linkType: hard
+
"make-dir@npm:^2.0.0":
version: 2.1.0
resolution: "make-dir@npm:2.1.0"
@@ -6639,6 +6918,13 @@ fsevents@~2.1.2:
languageName: node
linkType: hard
+"merge-stream@npm:^2.0.0":
+ version: 2.0.0
+ resolution: "merge-stream@npm:2.0.0"
+ checksum: 3/cde834809a0e65485e474de3162af9853ab2a07977fd36d328947b7b3e6207df719ffb115b11085ecc570501e15a2aa8bacd772ac53f77873f53b0626e52a39a
+ languageName: node
+ linkType: hard
+
"merge2@npm:^1.3.0":
version: 1.3.0
resolution: "merge2@npm:1.3.0"
@@ -7801,7 +8087,7 @@ fsevents@~2.1.2:
languageName: node
linkType: hard
-"picomatch@npm:^2.0.5, picomatch@npm:^2.2.1":
+"picomatch@npm:^2.0.5, picomatch@npm:^2.2.1, picomatch@npm:^2.2.2":
version: 2.2.2
resolution: "picomatch@npm:2.2.2"
checksum: 3/20fa75e0a58b39d83425b3db68744d5f6f361fd4fd66ec7745d884036d502abba0d553a637703af79939b844164b13e60eea339ccb043d7fbd74c3da2592b864
@@ -8016,6 +8302,13 @@ fsevents@~2.1.2:
languageName: node
linkType: hard
+"pretty-bytes@npm:^5.3.0":
+ version: 5.3.0
+ resolution: "pretty-bytes@npm:5.3.0"
+ checksum: 3/ecc6b1670f7ebcf6c78b91edad97ffdc0be58283ff5fa6c95c99c6bda48d2aa1858367fae8eccce35bc36eb90ec3cbcc24b9d7e29fd6ad98cc52d53d2e307789
+ languageName: node
+ linkType: hard
+
"pretty-error@npm:^2.0.2":
version: 2.1.1
resolution: "pretty-error@npm:2.1.1"
@@ -8754,7 +9047,7 @@ fsevents@~2.1.2:
languageName: node
linkType: hard
-"resolve@^1.15.1, resolve@^1.3.2":
+"resolve@^1.14.2, resolve@^1.15.1, resolve@^1.3.2":
version: 1.15.1
resolution: "resolve@npm:1.15.1"
dependencies:
@@ -8763,7 +9056,7 @@ fsevents@~2.1.2:
languageName: node
linkType: hard
-"resolve@patch:resolve@^1.15.1#builtin<compat/resolve>, resolve@patch:resolve@^1.3.2#builtin<compat/resolve>":
+"resolve@patch:resolve@^1.14.2#builtin<compat/resolve>, resolve@patch:resolve@^1.15.1#builtin<compat/resolve>, resolve@patch:resolve@^1.3.2#builtin<compat/resolve>":
version: 1.15.1
resolution: "resolve@patch:resolve@npm%3A1.15.1#builtin<compat/resolve>::version=1.15.1&hash=e7677c"
dependencies:
@@ -8846,6 +9139,56 @@ fsevents@~2.1.2:
languageName: node
linkType: hard
+"rollup-plugin-babel@npm:^4.3.3":
+ version: 4.4.0
+ resolution: "rollup-plugin-babel@npm:4.4.0"
+ dependencies:
+ "@babel/helper-module-imports": ^7.0.0
+ rollup-pluginutils: ^2.8.1
+ peerDependencies:
+ "@babel/core": 7 || ^7.0.0-rc.2
+ rollup: ">=0.60.0 <3"
+ checksum: 3/9f82719d1595e4f6770d6d5521f8bd982eace92d97ac258d37c0e2410c3e355f7bb5423ca00a178fe78e6c07facfd20cfa06c25f468d419b3152802e256d6f86
+ languageName: node
+ linkType: hard
+
+"rollup-plugin-terser@npm:^5.2.0":
+ version: 5.3.0
+ resolution: "rollup-plugin-terser@npm:5.3.0"
+ dependencies:
+ "@babel/code-frame": ^7.5.5
+ jest-worker: ^24.9.0
+ rollup-pluginutils: ^2.8.2
+ serialize-javascript: ^2.1.2
+ terser: ^4.6.2
+ peerDependencies:
+ rollup: ">=0.66.0 <3"
+ checksum: 3/51d113b7288d74dfc71750e90d2247b6d0d561a8b03b13f5f3478b6297a0dfaa5cb9045d5369c8d2c4045a66191d33a354a2147750a02a6986b63599c3544240
+ languageName: node
+ linkType: hard
+
+"rollup-pluginutils@npm:^2.8.1, rollup-pluginutils@npm:^2.8.2":
+ version: 2.8.2
+ resolution: "rollup-pluginutils@npm:2.8.2"
+ dependencies:
+ estree-walker: ^0.6.1
+ checksum: 3/6922c1a26df033cc3da4650106244fb2211b5ddf72a93be5010cbe51a0817c9abcab08f61cbc3f5fc906b2701df123d8c9b0dae0a34e69dd07218e34e5d357b8
+ languageName: node
+ linkType: hard
+
+"rollup@npm:^1.31.1":
+ version: 1.32.1
+ resolution: "rollup@npm:1.32.1"
+ dependencies:
+ "@types/estree": "*"
+ "@types/node": "*"
+ acorn: ^7.1.0
+ bin:
+ rollup: dist/bin/rollup
+ checksum: 3/fc59b8af482e0729fd720a6be1221f477ae1848fd88b0474d10f805567aa4ad5f16afa9a976c8fd30fd196fcb689b252826cdd138e7a4ad88462547adb5dbf40
+ languageName: node
+ linkType: hard
+
"run-async@npm:^2.2.0":
version: 2.4.0
resolution: "run-async@npm:2.4.0"
@@ -9379,6 +9722,13 @@ fsevents@~2.1.2:
languageName: node
linkType: hard
+"sourcemap-codec@npm:^1.4.4":
+ version: 1.4.8
+ resolution: "sourcemap-codec@npm:1.4.8"
+ checksum: 3/4d56d1232a45af813606d1755f11e7ae6b3542c615a7e3f904382f0134a9412ba8d090e83749254d78449eafdfcc62d5158b8f35e6241480b51b74b5c46b99f9
+ languageName: node
+ linkType: hard
+
"spdy-transport@npm:^3.0.0":
version: 3.0.0
resolution: "spdy-transport@npm:3.0.0"
@@ -9603,6 +9953,17 @@ fsevents@~2.1.2:
languageName: node
linkType: hard
+"stringify-object@npm:^3.3.0":
+ version: 3.3.0
+ resolution: "stringify-object@npm:3.3.0"
+ dependencies:
+ get-own-enumerable-property-symbols: ^3.0.0
+ is-obj: ^1.0.1
+ is-regexp: ^1.0.0
+ checksum: 3/4b0a6802f0294a3a340f31822a0802a4945f12b0823e640c9a3dd64b487abf0a0e7099b43d6133a9aa28a9b99ffe187ee5e066f0798ea60019c87e156bcaf6d3
+ languageName: node
+ linkType: hard
+
"strip-ansi@npm:^3.0.0, strip-ansi@npm:^3.0.1":
version: 3.0.1
resolution: "strip-ansi@npm:3.0.1"
@@ -9630,6 +9991,16 @@ fsevents@~2.1.2:
languageName: node
linkType: hard
+"strip-comments@npm:^1.0.2":
+ version: 1.0.2
+ resolution: "strip-comments@npm:1.0.2"
+ dependencies:
+ babel-extract-comments: ^1.0.0
+ babel-plugin-transform-object-rest-spread: ^6.26.0
+ checksum: 3/21d667d3ba6dc0e0cd377c64856e51a8399ea2e4b3e43df6f356c0e0a7bc7b6cf962d7069a1e9d0f2d72a67d2fe4b3b85e0e3dea23d71aa518b318744159326a
+ languageName: node
+ linkType: hard
+
"strip-eof@npm:^1.0.0":
version: 1.0.0
resolution: "strip-eof@npm:1.0.0"
@@ -9745,6 +10116,24 @@ fsevents@~2.1.2:
languageName: node
linkType: hard
+"temp-dir@npm:^1.0.0":
+ version: 1.0.0
+ resolution: "temp-dir@npm:1.0.0"
+ checksum: 3/4cc703b6ac3a3989c9da69c1b861babddff5e14a7913c26b4933049983a2d8392d3c6bbfa4bbd2ec4b9762a2460e8e7599f827dbc7c8ef1662e6e905d0f92b0b
+ languageName: node
+ linkType: hard
+
+"tempy@npm:^0.3.0":
+ version: 0.3.0
+ resolution: "tempy@npm:0.3.0"
+ dependencies:
+ temp-dir: ^1.0.0
+ type-fest: ^0.3.1
+ unique-string: ^1.0.0
+ checksum: 3/487b16624f9179b7ccd36684d330d0986861cd3e6bbc3875cae5728067b470c6e6fbe87dc2189874d8f4f5142c2782cd168f82bf2a22368b66f68312352d0ff1
+ languageName: node
+ linkType: hard
+
"terser-webpack-plugin@npm:^1.4.3":
version: 1.4.3
resolution: "terser-webpack-plugin@npm:1.4.3"
@@ -9764,7 +10153,7 @@ fsevents@~2.1.2:
languageName: node
linkType: hard
-"terser@npm:^4.1.2":
+"terser@npm:^4.1.2, terser@npm:^4.6.2":
version: 4.6.4
resolution: "terser@npm:4.6.4"
dependencies:
@@ -9882,6 +10271,10 @@ fsevents@~2.1.2:
webpack: ^4.43.0
webpack-cli: ^3.3.11
webpack-dev-server: ^3.11.0
+ workbox-precaching: ^5.1.3
+ workbox-routing: ^5.1.3
+ workbox-strategies: ^5.1.3
+ workbox-webpack-plugin: ^5.1.3
xregexp: ^4.3.0
languageName: unknown
linkType: soft
@@ -10065,6 +10458,13 @@ fsevents@~2.1.2:
languageName: node
linkType: hard
+"type-fest@npm:^0.3.1":
+ version: 0.3.1
+ resolution: "type-fest@npm:0.3.1"
+ checksum: 3/508923061144ff7ebc69d4f49bc812c7b8a81c633d10e89191092efb5746531ee6c4dd912db1447e954a766186ed48eee0dcfa53047c55a7646076a76640ff43
+ languageName: node
+ linkType: hard
+
"type-fest@npm:^0.8.1":
version: 0.8.1
resolution: "type-fest@npm:0.8.1"
@@ -10212,6 +10612,22 @@ typescript@^3.9.5:
languageName: node
linkType: hard
+"unique-string@npm:^1.0.0":
+ version: 1.0.0
+ resolution: "unique-string@npm:1.0.0"
+ dependencies:
+ crypto-random-string: ^1.0.0
+ checksum: 3/860f1ab835e8699e19ebaf0d651b967cf926ca80e22ae9f098996f8fec12ca007ac4914bf47e37556aca0ae10bd80ca4322982abfd30abcffec44d5e7612ae5f
+ languageName: node
+ linkType: hard
+
+"universalify@npm:^0.1.0":
+ version: 0.1.2
+ resolution: "universalify@npm:0.1.2"
+ checksum: 3/420fc6547357782c700d53e9a92506a8e95345b13e97684c8f9ab75237912ec2ebb6af8ac10d4f7406b7b6bd21c58f6c5c0811414fb0b4091b78b4743fa6806e
+ languageName: node
+ linkType: hard
+
"unpipe@npm:1.0.0, unpipe@npm:~1.0.0":
version: 1.0.0
resolution: "unpipe@npm:1.0.0"
@@ -10229,7 +10645,7 @@ typescript@^3.9.5:
languageName: node
linkType: hard
-"upath@npm:^1.1.1":
+"upath@npm:^1.1.1, upath@npm:^1.1.2, upath@npm:^1.2.0":
version: 1.2.0
resolution: "upath@npm:1.2.0"
checksum: 3/ecb08ff3e7e3b152e03bceb7089e6f0077bf3494764397a301eb99a7a5cd4c593ea4d0b13a7714195ad8a3ddca9d7a5964037a1c0bc712e1ba7b67a79165a0be
@@ -10547,7 +10963,7 @@ typescript@^3.9.5:
languageName: node
linkType: hard
-"webpack-sources@npm:^1.4.0, webpack-sources@npm:^1.4.1, webpack-sources@npm:^1.4.3":
+"webpack-sources@npm:^1.3.0, webpack-sources@npm:^1.4.0, webpack-sources@npm:^1.4.1, webpack-sources@npm:^1.4.3":
version: 1.4.3
resolution: "webpack-sources@npm:1.4.3"
dependencies:
@@ -10662,6 +11078,193 @@ typescript@^3.9.5:
languageName: node
linkType: hard
+"workbox-background-sync@npm:^5.1.3":
+ version: 5.1.3
+ resolution: "workbox-background-sync@npm:5.1.3"
+ dependencies:
+ workbox-core: ^5.1.3
+ checksum: 3/c7ac08296ae17cc3e9e1bd9eceb4c0e3d89136661bf91ca4f7db11871f43a21b88baf86d3622447b77ef1db793660bbfeca7443f13684281bfc6b4ee5f3d4bd7
+ languageName: node
+ linkType: hard
+
+"workbox-broadcast-update@npm:^5.1.3":
+ version: 5.1.3
+ resolution: "workbox-broadcast-update@npm:5.1.3"
+ dependencies:
+ workbox-core: ^5.1.3
+ checksum: 3/14025d78437318f2457dfd326a3e7c8300e11912d7ab04b01a9718970844be9028660724744fec853ff4a053113931f0b40af0a2b18e89c9e9eaa8264e65d326
+ languageName: node
+ linkType: hard
+
+"workbox-build@npm:^5.1.3":
+ version: 5.1.3
+ resolution: "workbox-build@npm:5.1.3"
+ dependencies:
+ "@babel/core": ^7.8.4
+ "@babel/preset-env": ^7.8.4
+ "@babel/runtime": ^7.8.4
+ "@hapi/joi": ^15.1.0
+ "@rollup/plugin-node-resolve": ^7.1.1
+ "@rollup/plugin-replace": ^2.3.1
+ "@surma/rollup-plugin-off-main-thread": ^1.1.1
+ common-tags: ^1.8.0
+ fast-json-stable-stringify: ^2.1.0
+ fs-extra: ^8.1.0
+ glob: ^7.1.6
+ lodash.template: ^4.5.0
+ pretty-bytes: ^5.3.0
+ rollup: ^1.31.1
+ rollup-plugin-babel: ^4.3.3
+ rollup-plugin-terser: ^5.2.0
+ source-map: ^0.7.3
+ source-map-url: ^0.4.0
+ stringify-object: ^3.3.0
+ strip-comments: ^1.0.2
+ tempy: ^0.3.0
+ upath: ^1.2.0
+ workbox-background-sync: ^5.1.3
+ workbox-broadcast-update: ^5.1.3
+ workbox-cacheable-response: ^5.1.3
+ workbox-core: ^5.1.3
+ workbox-expiration: ^5.1.3
+ workbox-google-analytics: ^5.1.3
+ workbox-navigation-preload: ^5.1.3
+ workbox-precaching: ^5.1.3
+ workbox-range-requests: ^5.1.3
+ workbox-routing: ^5.1.3
+ workbox-strategies: ^5.1.3
+ workbox-streams: ^5.1.3
+ workbox-sw: ^5.1.3
+ workbox-window: ^5.1.3
+ checksum: 3/00aca86aba88a40ec1b39885ce86637c5b4949073b7f05d156a3eef97521a0b03ea38769303e2e6846f11182887ed898d7e4e336d5999b257e0f18ba22384996
+ languageName: node
+ linkType: hard
+
+"workbox-cacheable-response@npm:^5.1.3":
+ version: 5.1.3
+ resolution: "workbox-cacheable-response@npm:5.1.3"
+ dependencies:
+ workbox-core: ^5.1.3
+ checksum: 3/230c095ace1ecd378375d92bc743a91ac8f8b418774ec0001baa7037811c7476904bc9ee634a0d1f4ef14c038d1dc47b871131dae14d120947a503a8944b2b28
+ languageName: node
+ linkType: hard
+
+"workbox-core@npm:^5.1.3":
+ version: 5.1.3
+ resolution: "workbox-core@npm:5.1.3"
+ checksum: 3/c41419973e36833e59bab0a15ca10f96850a0f5c913b310b0c5df6424ad4c0571a53694b2899a4aa32c1338bf433debc3bee616662c37ad76f4bbe3913bd3485
+ languageName: node
+ linkType: hard
+
+"workbox-expiration@npm:^5.1.3":
+ version: 5.1.3
+ resolution: "workbox-expiration@npm:5.1.3"
+ dependencies:
+ workbox-core: ^5.1.3
+ checksum: 3/17919bfa88025859fe7a854e9b3b537879a83875170a16ef57897cfa07e96f73ea3b590537bb5f5e043ccc5741a30df9827a8092aed29be1375a5a9be146e7eb
+ languageName: node
+ linkType: hard
+
+"workbox-google-analytics@npm:^5.1.3":
+ version: 5.1.3
+ resolution: "workbox-google-analytics@npm:5.1.3"
+ dependencies:
+ workbox-background-sync: ^5.1.3
+ workbox-core: ^5.1.3
+ workbox-routing: ^5.1.3
+ workbox-strategies: ^5.1.3
+ checksum: 3/e652c439bb0e401e2e8b50b865d4bcf8b2b7685317b3412a2f9a8c85179b6e857b57fc356dde6d57d5a5636ab3bf22f91a5d3ac921f24420ca44cc6c248b7709
+ languageName: node
+ linkType: hard
+
+"workbox-navigation-preload@npm:^5.1.3":
+ version: 5.1.3
+ resolution: "workbox-navigation-preload@npm:5.1.3"
+ dependencies:
+ workbox-core: ^5.1.3
+ checksum: 3/9767bc4131e6847ced355386b7099984ffe416c8f06a96fb4a10bc22b1df5d1d0688e5a7c4821c7830efac5c7b62d5e0d15f772fa093b5d279226985ab24dcd8
+ languageName: node
+ linkType: hard
+
+"workbox-precaching@npm:^5.1.3":
+ version: 5.1.3
+ resolution: "workbox-precaching@npm:5.1.3"
+ dependencies:
+ workbox-core: ^5.1.3
+ checksum: 3/e50f6688f1097ef3791c6ad044ff3f766404b1d3e8a6f01c753df077976f9586ac5f1a7ea749edf23b7765ddf81ea0b06e1c624b10ae4c2c0288e7922eb7f46d
+ languageName: node
+ linkType: hard
+
+"workbox-range-requests@npm:^5.1.3":
+ version: 5.1.3
+ resolution: "workbox-range-requests@npm:5.1.3"
+ dependencies:
+ workbox-core: ^5.1.3
+ checksum: 3/78f01f01e70723ef438e8d66a99f3a62db588bd4d9d87abf764c59534acebe47e5c5e9342bd2d088e3f9d95ddddb1ee5c9bc416533aab32eaa071a410f88db9a
+ languageName: node
+ linkType: hard
+
+"workbox-routing@npm:^5.1.3":
+ version: 5.1.3
+ resolution: "workbox-routing@npm:5.1.3"
+ dependencies:
+ workbox-core: ^5.1.3
+ checksum: 3/e913922f8343377fe9a75f3db7bd062c4d39bd963d6731c21b20c88119ab888ee54876701cf65ae76c3fa68d73000466ece2edfad05b35c1f0fa7ef9c94105c4
+ languageName: node
+ linkType: hard
+
+"workbox-strategies@npm:^5.1.3":
+ version: 5.1.3
+ resolution: "workbox-strategies@npm:5.1.3"
+ dependencies:
+ workbox-core: ^5.1.3
+ workbox-routing: ^5.1.3
+ checksum: 3/d12589ac0122b49c43a2f24f8b0d25266d2d41b3b1a06b7c7f1e9ace69dc230c2b5053de2d2fb6eb9ab89c1e9d9c69b60a9f051213b0177219d5da75321d5d85
+ languageName: node
+ linkType: hard
+
+"workbox-streams@npm:^5.1.3":
+ version: 5.1.3
+ resolution: "workbox-streams@npm:5.1.3"
+ dependencies:
+ workbox-core: ^5.1.3
+ workbox-routing: ^5.1.3
+ checksum: 3/00b8e75830ab16078b938c2e41c9c38e34b4b46e64cfc74fb7f89a2e3863be1f7104e02cd132172d1f21f4809290e9c6bcab9c46768ba3e01ba3b7990b768c38
+ languageName: node
+ linkType: hard
+
+"workbox-sw@npm:^5.1.3":
+ version: 5.1.3
+ resolution: "workbox-sw@npm:5.1.3"
+ checksum: 3/00cf72e87920cb959309fe0f96e4ce5a70afea5fa79788a9f3712f1e0c0c8b89de428d04f913bd73746f99aceb2ae4bffb815d2740e192c70737aecc404cbe1e
+ languageName: node
+ linkType: hard
+
+"workbox-webpack-plugin@npm:^5.1.3":
+ version: 5.1.3
+ resolution: "workbox-webpack-plugin@npm:5.1.3"
+ dependencies:
+ "@babel/runtime": ^7.5.5
+ fast-json-stable-stringify: ^2.0.0
+ source-map-url: ^0.4.0
+ upath: ^1.1.2
+ webpack-sources: ^1.3.0
+ workbox-build: ^5.1.3
+ peerDependencies:
+ webpack: ^4.0.0
+ checksum: 3/7cc6eeec53efbeab3398c631dd4601d7f0b7b9ffa1cf0472228fb7685345793ef46b783bdd5432dc80eb789e3fae3ad9b456f86d05edfb3b63bd7ed9a926319e
+ languageName: node
+ linkType: hard
+
+"workbox-window@npm:^5.1.3":
+ version: 5.1.3
+ resolution: "workbox-window@npm:5.1.3"
+ dependencies:
+ workbox-core: ^5.1.3
+ checksum: 3/8bca74541cf1240bf26239f1895b811a33bb67369112830936c6ac7400f30ab334836b2f53cc565ec6a59a5fcef00943b1f2b341597e4139ec8481170a1fec05
+ languageName: node
+ linkType: hard
+
"worker-farm@npm:^1.7.0":
version: 1.7.0
resolution: "worker-farm@npm:1.7.0"