Add 'webapp/' from commit '3bb5ed17be8cd990fad40b4c244cbc8076838392'
git-subtree-dir: webapp git-subtree-mainline:master7ebd80d792
git-subtree-split:3bb5ed17be
commit
5ad5c166ea
@ -0,0 +1 @@
|
|||||||
|
EXTEND_ESLINT=true
|
@ -0,0 +1,113 @@
|
|||||||
|
module.exports = {
|
||||||
|
"env": {
|
||||||
|
"shared-node-browser": true,
|
||||||
|
"es6": true,
|
||||||
|
},
|
||||||
|
"parser": "@typescript-eslint/parser",
|
||||||
|
"parserOptions": {
|
||||||
|
"sourceType": "module",
|
||||||
|
"ecmaFeatures": {
|
||||||
|
"jsx": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"react": {
|
||||||
|
"version": "detect",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
"@typescript-eslint",
|
||||||
|
],
|
||||||
|
"extends": [
|
||||||
|
"eslint:recommended",
|
||||||
|
"plugin:react/recommended",
|
||||||
|
"plugin:@typescript-eslint/eslint-recommended",
|
||||||
|
"plugin:@typescript-eslint/recommended"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"@typescript-eslint/explicit-function-return-type": "off",
|
||||||
|
"@typescript-eslint/no-use-before-define": "off",
|
||||||
|
"@typescript-eslint/no-non-null-assertion": "off",
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
"@typescript-eslint/member-delimiter-style": ["error", {
|
||||||
|
"multiline": {
|
||||||
|
"delimiter": "semi",
|
||||||
|
"requireLast": true
|
||||||
|
},
|
||||||
|
"singleline": {
|
||||||
|
"delimiter": "comma",
|
||||||
|
"requireLast": false
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
"@typescript-eslint/no-unused-vars": ["warn", {
|
||||||
|
"vars": "all",
|
||||||
|
"args": "all",
|
||||||
|
"ignoreRestSiblings": true,
|
||||||
|
"argsIgnorePattern": "^_",
|
||||||
|
}],
|
||||||
|
|
||||||
|
"react/display-name": "off",
|
||||||
|
"react/no-unescaped-entities": "off",
|
||||||
|
"react/jsx-tag-spacing": ["error", {
|
||||||
|
"closingSlash": "never",
|
||||||
|
"beforeSelfClosing": "always",
|
||||||
|
"afterOpening": "never",
|
||||||
|
"beforeClosing": "never"
|
||||||
|
}],
|
||||||
|
"react/jsx-boolean-value": ["error", "never"],
|
||||||
|
"react/jsx-curly-spacing": ["error", { "when": "never", "children": true }],
|
||||||
|
"react/jsx-equals-spacing": ["error", "never"],
|
||||||
|
"react/jsx-indent-props": ["error", 2],
|
||||||
|
"react/jsx-curly-brace-presence": ["error", "never"],
|
||||||
|
"react/jsx-key": ["error", { "checkFragmentShorthand": true }],
|
||||||
|
"react/jsx-indent": ["error", 2, { checkAttributes: true, indentLogicalExpressions: true }],
|
||||||
|
"react/void-dom-elements-no-children": ["error"],
|
||||||
|
"react/no-unknown-property": ["error"],
|
||||||
|
|
||||||
|
"quotes": "off",
|
||||||
|
"@typescript-eslint/quotes": ["error", "double", { "allowTemplateLiterals": true, "avoidEscape": true }],
|
||||||
|
"semi": "off",
|
||||||
|
"@typescript-eslint/semi": ["error", "always", { "omitLastInOneLineBlock": true }],
|
||||||
|
"comma-dangle": ["error", {
|
||||||
|
"arrays": "always-multiline",
|
||||||
|
"objects": "always-multiline",
|
||||||
|
"imports": "always-multiline",
|
||||||
|
"exports": "always-multiline",
|
||||||
|
"functions": "never"
|
||||||
|
}],
|
||||||
|
"comma-spacing": ["error"],
|
||||||
|
"eqeqeq": ["error", "smart"],
|
||||||
|
"indent": "off",
|
||||||
|
"@typescript-eslint/indent": ["error", 2, {
|
||||||
|
"SwitchCase": 1,
|
||||||
|
}],
|
||||||
|
"no-multi-spaces": "error",
|
||||||
|
"object-curly-spacing": ["error", "always"],
|
||||||
|
"arrow-parens": "error",
|
||||||
|
"arrow-spacing": "error",
|
||||||
|
"key-spacing": "error",
|
||||||
|
"keyword-spacing": "error",
|
||||||
|
"func-call-spacing": "off",
|
||||||
|
"@typescript-eslint/func-call-spacing": ["error"],
|
||||||
|
"space-before-function-paren": ["error", {
|
||||||
|
"anonymous": "always",
|
||||||
|
"named": "never",
|
||||||
|
"asyncArrow": "always"
|
||||||
|
}],
|
||||||
|
"space-in-parens": ["error", "never"],
|
||||||
|
"space-before-blocks": "error",
|
||||||
|
"curly": ["error", "all"],
|
||||||
|
"space-infix-ops": "error",
|
||||||
|
"consistent-return": "error",
|
||||||
|
"jsx-quotes": ["error"],
|
||||||
|
"array-bracket-spacing": "error",
|
||||||
|
"brace-style": "off",
|
||||||
|
"@typescript-eslint/brace-style": [
|
||||||
|
"error",
|
||||||
|
"1tbs",
|
||||||
|
{ allowSingleLine: true },
|
||||||
|
],
|
||||||
|
"no-useless-constructor": "off",
|
||||||
|
"@typescript-eslint/no-useless-constructor": "warn",
|
||||||
|
}
|
||||||
|
};
|
@ -0,0 +1,2 @@
|
|||||||
|
github: etesync
|
||||||
|
custom: https://www.etesync.com/contribute/#donate
|
@ -0,0 +1,23 @@
|
|||||||
|
# See https://help.github.com/ignore-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.*.swp
|
||||||
|
Session.vim
|
||||||
|
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
@ -0,0 +1,6 @@
|
|||||||
|
Tom Hacohen <tom@stosb.com>
|
||||||
|
Tal Leibman <leibman2@gmail.com>
|
||||||
|
rugk <rugk@posteo.de>
|
||||||
|
Andrew P Maney <amaney@usc.edu>
|
||||||
|
Bryce McNab <betsythefc@users.noreply.github.com>
|
||||||
|
Claus Niesen <cniesen@users.noreply.github.com>
|
@ -0,0 +1,661 @@
|
|||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
Developers that use our General Public Licenses protect your rights
|
||||||
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
|
you this License which gives you legal permission to copy, distribute
|
||||||
|
and/or modify the software.
|
||||||
|
|
||||||
|
A secondary benefit of defending all users' freedom is that
|
||||||
|
improvements made in alternate versions of the program, if they
|
||||||
|
receive widespread use, become available for other developers to
|
||||||
|
incorporate. Many developers of free software are heartened and
|
||||||
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
|
The GNU Affero General Public License is designed specifically to
|
||||||
|
ensure that, in such cases, the modified source code becomes available
|
||||||
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
|
An older license, called the Affero General Public License and
|
||||||
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
|
this license.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the work with which it is combined will remain governed by version
|
||||||
|
3 of the GNU General Public License.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If your software can interact with users remotely through a computer
|
||||||
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
|
interface could display a "Source" link that leads users to an archive
|
||||||
|
of the code. There are many ways you could offer source, and different
|
||||||
|
solutions will be better for different programs; see section 13 for the
|
||||||
|
specific requirements.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
@ -0,0 +1,55 @@
|
|||||||
|
<p align="center">
|
||||||
|
<img width="120" src="src/images/logo.svg" />
|
||||||
|
<h1 align="center">EteSync - Encrypt Everything</h1>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
The EteSync Web App - Use EteSync from the browser!
|
||||||
|
|
||||||
|
|
||||||
|
![GitHub tag](https://img.shields.io/github/tag/etesync/etesync-web.svg)
|
||||||
|
[![Chat on freenode](https://img.shields.io/badge/irc.freenode.net-%23EteSync-blue.svg)](https://webchat.freenode.net/?channels=#etesync)
|
||||||
|
|
||||||
|
For notes, please refer to [the EteSync Notes](https://github.com/etesync/etesync-notes/) repository.
|
||||||
|
|
||||||
|
# Usage
|
||||||
|
|
||||||
|
A live instance is available on: https://pim.etesync.com
|
||||||
|
|
||||||
|
Please be advised that while it's probably safe enough to use the hosted client
|
||||||
|
in many cases, it's generally not preferable. It's recommended that you use signed
|
||||||
|
releases which's signature you manually verify and are run locally!
|
||||||
|
|
||||||
|
More info is available on the [FAQ](https://www.etesync.com/faq/#web-client).
|
||||||
|
|
||||||
|
## Running your own
|
||||||
|
|
||||||
|
You can either self-host your own client to be served from your own server, or
|
||||||
|
better yet, just run an instance locally.
|
||||||
|
|
||||||
|
You can get the latest version of the web client from https://pim.etesync.com/etesync-web.tgz. This
|
||||||
|
file is automatically generated on each deploy and is exactly the same as the delpoyed version.
|
||||||
|
After fetching this file you need to extract it by e.g. running `tar -xzf etesync-web.tgz`, and then
|
||||||
|
you can serve the files using your favourite web server. Please keep in mind that opening the HTML files
|
||||||
|
directly in the browser is not supported.
|
||||||
|
|
||||||
|
If you are just serving the app locally, you could, for example, use the python built-in web server by
|
||||||
|
running `python3 -m http.server` from inside the extracted `etesync-web` directory. If you plan on
|
||||||
|
serving it from a server, please use a proper web server such as nginx.
|
||||||
|
|
||||||
|
## Building it yourself
|
||||||
|
|
||||||
|
Before you can build the web app from source, you need to make sure you have `yarn` install.
|
||||||
|
|
||||||
|
Then clone this repository `yarn`, run `yarn` and wait until all of the deps are installed.
|
||||||
|
|
||||||
|
Then it's recommended you run `yarn build` to build a production ready client you should serve
|
||||||
|
(even if run locally!) and then just serve the `build` directory from a web server.
|
||||||
|
|
||||||
|
The URL of the EteSync API the web app connects to defaults to `api.etebase.com`, but can be changed on
|
||||||
|
the login page. You can change this default by setting the environment variable `REACT_APP_DEFAULT_API_PATH`
|
||||||
|
during the build. This can be useful for self-hosting. You can set the default URL to the address
|
||||||
|
of your self-hosted EteSync server so you don't have to change the address for every login.
|
||||||
|
|
||||||
|
### Serving from a subdirectory
|
||||||
|
|
||||||
|
In order to run your own version and serve it from a subdirectory rather than the top level of the domain, add `"homepage": "/subdir-name"` to the `package.json` file.
|
@ -0,0 +1,18 @@
|
|||||||
|
set -e
|
||||||
|
|
||||||
|
SSH_HOST=client.etesync.com
|
||||||
|
SSH_PORT=22
|
||||||
|
SSH_USER=etesync
|
||||||
|
SSH_TARGET_DIR=sites/pim.etesync.com
|
||||||
|
|
||||||
|
OUTPUTDIR=./build
|
||||||
|
|
||||||
|
export INLINE_RUNTIME_CHUNK=false
|
||||||
|
|
||||||
|
yarn build
|
||||||
|
|
||||||
|
sed -i "s#\(<script type=\"text/javascript\"\)#\1 integrity=\"sha384-$(shasum -b -a 384 build/static/js/main.*.js | xxd -r -p | base64 -w0)\" crossorigin=\"anonymous\"#" build/index.html
|
||||||
|
./page-signer.js build/index.html build/index.html
|
||||||
|
# Create a source tarball
|
||||||
|
bsdtar -czf build/etesync-web.tgz --exclude build/etesync-web.tgz -s /build/etesync-web/ build/*
|
||||||
|
rsync -e "ssh -p ${SSH_PORT}" -P --delete -rvc -zz ${OUTPUTDIR}/ ${SSH_USER}@${SSH_HOST}:${SSH_TARGET_DIR}
|
@ -0,0 +1,71 @@
|
|||||||
|
{
|
||||||
|
"name": "etesync-web",
|
||||||
|
"version": "0.6.0",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@date-io/moment": "^1.x",
|
||||||
|
"@material-ui/core": "^4.11.0",
|
||||||
|
"@material-ui/icons": "^4.9.1",
|
||||||
|
"@material-ui/lab": "^4.0.0-alpha.56",
|
||||||
|
"@material-ui/pickers": "^3.2.10",
|
||||||
|
"@material-ui/styles": "^4.10.0",
|
||||||
|
"etebase": "^0.41.0",
|
||||||
|
"fuse.js": "^5.0.9-beta",
|
||||||
|
"ical.js": "^1.4.0",
|
||||||
|
"immutable": "^4.0.0-rc.12",
|
||||||
|
"localforage": "^1.9.0",
|
||||||
|
"memoizee": "^0.4.14",
|
||||||
|
"moment": "^2.27.0",
|
||||||
|
"react": "^16.13.1",
|
||||||
|
"react-big-calendar": "^0.26.0",
|
||||||
|
"react-dom": "^16.13.1",
|
||||||
|
"react-dropzone": "^10.0.4",
|
||||||
|
"react-redux": "^7.2.1",
|
||||||
|
"react-router": "^5.2.0",
|
||||||
|
"react-router-dom": "^5.2.0",
|
||||||
|
"react-scripts": "^3.4.1",
|
||||||
|
"react-transition-group": "^4.3.0",
|
||||||
|
"react-virtualized": "^9.21.2",
|
||||||
|
"redux": "^4.0.5",
|
||||||
|
"redux-actions": "^2.6.5",
|
||||||
|
"redux-logger": "^3.0.6",
|
||||||
|
"redux-persist": "^6.0.0",
|
||||||
|
"redux-thunk": "^2.3.0",
|
||||||
|
"reselect": "^4.0.0",
|
||||||
|
"uuid": "^3.1.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "react-scripts start",
|
||||||
|
"build": "react-scripts build",
|
||||||
|
"test": "react-scripts test --env=jsdom",
|
||||||
|
"lint": "eslint --ext .js,.jsx,.ts,.tsx src",
|
||||||
|
"eject": "react-scripts eject"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@testing-library/jest-dom": "^4.2.4",
|
||||||
|
"@testing-library/react": "^9.3.2",
|
||||||
|
"@testing-library/user-event": "^7.1.2",
|
||||||
|
"@types/color": "^3.0.1",
|
||||||
|
"@types/jest": "^24.0.4",
|
||||||
|
"@types/memoizee": "^0.4.4",
|
||||||
|
"@types/node": "^11.9.3",
|
||||||
|
"@types/react": "^16.9.0",
|
||||||
|
"@types/react-big-calendar": "^0.22.3",
|
||||||
|
"@types/react-dom": "^16.9.0",
|
||||||
|
"@types/react-redux": "^7.1.9",
|
||||||
|
"@types/react-router": "^5.1.8",
|
||||||
|
"@types/react-router-dom": "^5.1.5",
|
||||||
|
"@types/react-virtualized": "^9.21.8",
|
||||||
|
"@types/redux-actions": "^2.6.1",
|
||||||
|
"@types/redux-logger": "^3.0.8",
|
||||||
|
"@types/urijs": "^1.15.38",
|
||||||
|
"@types/uuid": "^3.4.3",
|
||||||
|
"typescript": "~3.9.7"
|
||||||
|
},
|
||||||
|
"browserslist": [
|
||||||
|
">0.2%",
|
||||||
|
"not dead",
|
||||||
|
"not ie <= 11",
|
||||||
|
"not op_mini all"
|
||||||
|
]
|
||||||
|
}
|
Binary file not shown.
After Width: | Height: | Size: 16 KiB |
@ -0,0 +1,50 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<!--!
|
||||||
|
%%%SIGNED_PAGES_PGP_SIGNATURE%%%
|
||||||
|
-->
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
|
<meta name="theme-color" content="#ffc107">
|
||||||
|
|
||||||
|
<meta name="google" content="notranslate">
|
||||||
|
<meta name="referrer" content="no-referrer">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="EteSync">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<!--
|
||||||
|
manifest.json provides metadata used when your web app is added to the
|
||||||
|
homescreen on Android. See https://developers.google.com/web/fundamentals/engage-and-retain/web-app-manifest/
|
||||||
|
-->
|
||||||
|
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
|
||||||
|
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
|
||||||
|
<link rel="apple-touch-icon" size="192x192" href="%PUBLIC_URL%/favicon.ico">
|
||||||
|
<!--
|
||||||
|
Notice the use of %PUBLIC_URL% in the tags above.
|
||||||
|
It will be replaced with the URL of the `public` folder during the build.
|
||||||
|
Only files inside the `public` folder can be referenced from the HTML.
|
||||||
|
|
||||||
|
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||||
|
work correctly both with client-side routing and a non-root public URL.
|
||||||
|
Learn how to configure a non-root public URL by running `npm run build`.
|
||||||
|
-->
|
||||||
|
<title>EteSync - Secure Data Sync</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>
|
||||||
|
You need to enable JavaScript to run this app.
|
||||||
|
</noscript>
|
||||||
|
<div id="root"></div>
|
||||||
|
<!--
|
||||||
|
This HTML file is a template.
|
||||||
|
If you open it directly in the browser, you will see an empty page.
|
||||||
|
|
||||||
|
You can add webfonts, meta tags, or analytics to this file.
|
||||||
|
The build step will place the bundled scripts into the <body> tag.
|
||||||
|
|
||||||
|
To begin the development, run `npm start` or `yarn start`.
|
||||||
|
To create a production bundle, use `npm run build` or `yarn build`.
|
||||||
|
-->
|
||||||
|
</body>
|
||||||
|
</html>
|
@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"short_name": "EteSync",
|
||||||
|
"name": "EteSync - Secure Data Sync",
|
||||||
|
"description": "Secure, end-to-end encrypted, and privacy respecting sync for your contacts, calendars, tasks, and notes.",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "favicon.ico",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"start_url": "https://pim.etesync.com",
|
||||||
|
"display": "standalone",
|
||||||
|
"theme_color": "#ffc107",
|
||||||
|
"background_color": "#03a9f4",
|
||||||
|
"categories": [
|
||||||
|
"productivity",
|
||||||
|
"utilities"
|
||||||
|
],
|
||||||
|
"related_applications": [
|
||||||
|
{
|
||||||
|
"platform": "play",
|
||||||
|
"url": "https://play.google.com/store/apps/details?id=com.etesync.syncadapter",
|
||||||
|
"id": "com.etesync.syncadapter"
|
||||||
|
}, {
|
||||||
|
"platform": "itunes",
|
||||||
|
"url": "https://apps.apple.com/us/app/apple-store/id1489574285"
|
||||||
|
}, {
|
||||||
|
"platform": "f-droid",
|
||||||
|
"url": "https://f-droid.org/packages/com.etesync.syncadapter/",
|
||||||
|
"id": "com.etesync.syncadapter"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shortcuts": [
|
||||||
|
{
|
||||||
|
"name": "Calendar",
|
||||||
|
"url": "/pim/events"
|
||||||
|
}, {
|
||||||
|
"name": "Tasks",
|
||||||
|
"url": "/pim/tasks"
|
||||||
|
}, {
|
||||||
|
"name": "Address Book",
|
||||||
|
"url": "/pim/contacts"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
@ -0,0 +1 @@
|
|||||||
|
export PATH="${HOME}/.npm-packages/bin:${PATH}"
|
@ -0,0 +1,29 @@
|
|||||||
|
.App {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.App-logo {
|
||||||
|
animation: App-logo-spin infinite 20s linear;
|
||||||
|
height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.App-header {
|
||||||
|
background-color: #222;
|
||||||
|
height: 150px;
|
||||||
|
padding: 20px;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.App-intro {
|
||||||
|
font-size: large;
|
||||||
|
}
|
||||||
|
|
||||||
|
.App-drawer-header {
|
||||||
|
background-color: #555;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.App-drawer-logo {
|
||||||
|
width: 60px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
@ -0,0 +1,18 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as ReactDOM from "react-dom";
|
||||||
|
import { Provider } from "react-redux";
|
||||||
|
import App from "./App";
|
||||||
|
|
||||||
|
import { store } from "./store";
|
||||||
|
|
||||||
|
it("renders without crashing", () => {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
ReactDOM.render(
|
||||||
|
<Provider store={store}>
|
||||||
|
<App />
|
||||||
|
</Provider>
|
||||||
|
, div);
|
||||||
|
});
|
@ -0,0 +1,265 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import { MuiThemeProvider as ThemeProvider, createMuiTheme } from "@material-ui/core/styles"; // v1.x
|
||||||
|
import amber from "@material-ui/core/colors/amber";
|
||||||
|
import lightBlue from "@material-ui/core/colors/lightBlue";
|
||||||
|
import AppBar from "@material-ui/core/AppBar";
|
||||||
|
import Toolbar from "@material-ui/core/Toolbar";
|
||||||
|
import Drawer from "@material-ui/core/Drawer";
|
||||||
|
import IconButton from "@material-ui/core/IconButton";
|
||||||
|
import Badge from "@material-ui/core/Badge";
|
||||||
|
|
||||||
|
import NavigationMenu from "@material-ui/icons/Menu";
|
||||||
|
import NavigationRefresh from "@material-ui/icons/Refresh";
|
||||||
|
import ErrorsIcon from "@material-ui/icons/Error";
|
||||||
|
|
||||||
|
import "react-virtualized/styles.css"; // only needs to be imported once
|
||||||
|
|
||||||
|
import "./App.css";
|
||||||
|
|
||||||
|
import ConfirmationDialog from "./widgets/ConfirmationDialog";
|
||||||
|
import { List, ListItem } from "./widgets/List";
|
||||||
|
import withSpin from "./widgets/withSpin";
|
||||||
|
import ErrorBoundary from "./components/ErrorBoundary";
|
||||||
|
import SideMenu from "./SideMenu";
|
||||||
|
import { RouteResolver } from "./routes";
|
||||||
|
|
||||||
|
import * as store from "./store";
|
||||||
|
import * as actions from "./store/actions";
|
||||||
|
|
||||||
|
import { useCredentials } from "./credentials";
|
||||||
|
import { SyncManager } from "./sync/SyncManager";
|
||||||
|
import MainRouter from "./MainRouter";
|
||||||
|
|
||||||
|
export const routeResolver = new RouteResolver({
|
||||||
|
home: "",
|
||||||
|
pim: {
|
||||||
|
contacts: {
|
||||||
|
_id: {
|
||||||
|
_base: ":itemUid",
|
||||||
|
edit: {
|
||||||
|
contact: "contact",
|
||||||
|
group: "group",
|
||||||
|
},
|
||||||
|
log: "log",
|
||||||
|
},
|
||||||
|
new: {
|
||||||
|
contact: "contact",
|
||||||
|
group: "group",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
_id: {
|
||||||
|
_base: ":itemUid",
|
||||||
|
edit: "edit",
|
||||||
|
duplicate: "duplicate",
|
||||||
|
log: "log",
|
||||||
|
},
|
||||||
|
new: "new",
|
||||||
|
},
|
||||||
|
tasks: {
|
||||||
|
_id: {
|
||||||
|
_base: ":itemUid",
|
||||||
|
edit: "edit",
|
||||||
|
log: "log",
|
||||||
|
},
|
||||||
|
new: "new",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
collections: {
|
||||||
|
_id: {
|
||||||
|
_base: ":colUid",
|
||||||
|
edit: "edit",
|
||||||
|
items: {
|
||||||
|
_id: {
|
||||||
|
_base: ":itemUid",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
entries: {
|
||||||
|
_id: {
|
||||||
|
_base: ":entryUid",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
members: {
|
||||||
|
},
|
||||||
|
},
|
||||||
|
invitations: {
|
||||||
|
incoming: {
|
||||||
|
},
|
||||||
|
outgoing: {
|
||||||
|
},
|
||||||
|
},
|
||||||
|
new: "new",
|
||||||
|
import: "import",
|
||||||
|
},
|
||||||
|
login: {
|
||||||
|
},
|
||||||
|
signup: {
|
||||||
|
},
|
||||||
|
wizard: {
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
},
|
||||||
|
debug: {
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
interface AppBarPropsType {
|
||||||
|
toggleDrawerIcon: any;
|
||||||
|
iconElementRight: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppBarWitHistory(props: AppBarPropsType) {
|
||||||
|
const {
|
||||||
|
toggleDrawerIcon,
|
||||||
|
iconElementRight,
|
||||||
|
...rest
|
||||||
|
} = props;
|
||||||
|
return (
|
||||||
|
<AppBar
|
||||||
|
position="static"
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
<Toolbar>
|
||||||
|
<div style={{ marginLeft: -12, marginRight: 20 }}>
|
||||||
|
{toggleDrawerIcon}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flexGrow: 1, fontSize: "1.25em" }} id="appbar-title" />
|
||||||
|
|
||||||
|
<div style={{ marginRight: -12 }} id="appbar-buttons">
|
||||||
|
{iconElementRight}
|
||||||
|
</div>
|
||||||
|
</Toolbar>
|
||||||
|
</AppBar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const IconRefreshWithSpin = withSpin(NavigationRefresh);
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [drawerOpen, setDrawerOpen] = React.useState(false);
|
||||||
|
const [errorsDialog, setErrorsDialog] = React.useState(false);
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const etebase = useCredentials();
|
||||||
|
const darkMode = useSelector((state: store.StoreState) => state.settings.darkMode);
|
||||||
|
const fetchCount = useSelector((state: store.StoreState) => state.fetchCount);
|
||||||
|
const errors = useSelector((state: store.StoreState) => state.errors);
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
const syncManager = SyncManager.getManager(etebase!);
|
||||||
|
const sync = syncManager.sync();
|
||||||
|
dispatch(actions.performSync(sync));
|
||||||
|
await sync;
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoRefresh() {
|
||||||
|
if (navigator.onLine && etebase) {
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const interval = 5 * 60 * 1000;
|
||||||
|
const id = setInterval(autoRefresh, interval);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function toggleDrawer() {
|
||||||
|
setDrawerOpen(!drawerOpen);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDrawer() {
|
||||||
|
setDrawerOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentials = etebase ?? null;
|
||||||
|
|
||||||
|
const fetching = fetchCount > 0;
|
||||||
|
|
||||||
|
const muiTheme = createMuiTheme({
|
||||||
|
palette: {
|
||||||
|
type: darkMode ? "dark" : undefined,
|
||||||
|
primary: amber,
|
||||||
|
secondary: {
|
||||||
|
light: lightBlue.A200,
|
||||||
|
main: lightBlue.A400,
|
||||||
|
dark: lightBlue.A700,
|
||||||
|
contrastText: "#fff",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const styles: {[key: string]: React.CSSProperties} = {
|
||||||
|
main: {
|
||||||
|
backgroundColor: muiTheme.palette.background.default,
|
||||||
|
color: muiTheme.palette.text.primary,
|
||||||
|
flexGrow: 1,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeProvider theme={muiTheme}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<div style={styles.main} className={darkMode ? "theme-dark" : "theme-light"}>
|
||||||
|
<AppBarWitHistory
|
||||||
|
toggleDrawerIcon={<IconButton onClick={toggleDrawer}><NavigationMenu /></IconButton>}
|
||||||
|
iconElementRight={
|
||||||
|
<>
|
||||||
|
{(errors.size > 0) && (
|
||||||
|
<IconButton onClick={() => setErrorsDialog(true)} title="Errors">
|
||||||
|
<Badge badgeContent={errors.size} color="error">
|
||||||
|
<ErrorsIcon />
|
||||||
|
</Badge>
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
<IconButton disabled={!credentials || fetching} onClick={refresh} title="Refresh">
|
||||||
|
<IconRefreshWithSpin spin={fetching} />
|
||||||
|
</IconButton>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ConfirmationDialog
|
||||||
|
title="Sync Errors"
|
||||||
|
open={errorsDialog}
|
||||||
|
labelOk="OK"
|
||||||
|
onCancel={() => setErrorsDialog(false)}
|
||||||
|
onOk={() => setErrorsDialog(false)}
|
||||||
|
>
|
||||||
|
<h4>
|
||||||
|
Please contact developers if any of the errors below persist.
|
||||||
|
</h4>
|
||||||
|
<List>
|
||||||
|
{errors.map((error, index) => (
|
||||||
|
<ListItem
|
||||||
|
key={index}
|
||||||
|
style={{ height: "unset" }}
|
||||||
|
onClick={() => (window as any).navigator.clipboard.writeText(`${error.message}\n\n${error.stack}`)}
|
||||||
|
>
|
||||||
|
{error.message}
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</ConfirmationDialog>
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
open={drawerOpen}
|
||||||
|
onClose={toggleDrawer}
|
||||||
|
>
|
||||||
|
<SideMenu onCloseDrawerRequest={closeDrawer} />
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
<ErrorBoundary>
|
||||||
|
<MainRouter />
|
||||||
|
</ErrorBoundary>
|
||||||
|
</div>
|
||||||
|
</BrowserRouter>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,9 @@
|
|||||||
|
/* Hack the layout on mobile. */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.rbc-toolbar {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.rbc-toolbar-label {
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,118 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Calendar as BigCalendar, momentLocalizer, View } from "react-big-calendar";
|
||||||
|
import "react-big-calendar/lib/css/react-big-calendar.css";
|
||||||
|
import moment from "moment";
|
||||||
|
import * as ICAL from "ical.js";
|
||||||
|
import { store } from "../store";
|
||||||
|
import { appendError } from "../store/actions";
|
||||||
|
|
||||||
|
import { EventType } from "../pim-types";
|
||||||
|
|
||||||
|
import "./Calendar.css";
|
||||||
|
|
||||||
|
const calendarLocalizer = momentLocalizer(moment);
|
||||||
|
|
||||||
|
const MAX_RECURRENCE_DATE = ICAL.Time.now();
|
||||||
|
MAX_RECURRENCE_DATE.adjust(800, 0, 0, 0);
|
||||||
|
|
||||||
|
function eventPropGetter(event: EventType) {
|
||||||
|
return {
|
||||||
|
style: {
|
||||||
|
backgroundColor: event.color,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function agendaHeaderFormat(date: {start: Date, end: Date}, _culture: string, localizer: any) {
|
||||||
|
const format = "ll";
|
||||||
|
return localizer.format(date.start, format) + " - " + localizer.format(date.end, format);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PropsType {
|
||||||
|
entries: EventType[];
|
||||||
|
onItemClick: (contact: EventType) => void;
|
||||||
|
onSlotClick?: (start: Date, end: Date) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Calendar extends React.PureComponent<PropsType> {
|
||||||
|
public state: {
|
||||||
|
currentDate?: Date;
|
||||||
|
view?: View;
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(props: any) {
|
||||||
|
super(props);
|
||||||
|
this.state = {};
|
||||||
|
|
||||||
|
this.onNavigate = this.onNavigate.bind(this);
|
||||||
|
this.onView = this.onView.bind(this);
|
||||||
|
this.slotClicked = this.slotClicked.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public render() {
|
||||||
|
const entries = [] as EventType[];
|
||||||
|
this.props.entries.forEach((event) => {
|
||||||
|
entries.push(event);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (event.isRecurring()) {
|
||||||
|
const recur = event.iterator();
|
||||||
|
|
||||||
|
let next = recur.next(); // Skip the first one
|
||||||
|
while ((next = recur.next())) {
|
||||||
|
if (next.compare(MAX_RECURRENCE_DATE) > 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shift = next.subtractDateTz(event.startDate);
|
||||||
|
const ev = event.clone();
|
||||||
|
ev.startDate.addDuration(shift);
|
||||||
|
ev.endDate.addDuration(shift);
|
||||||
|
entries.push(ev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
store.dispatch(appendError(e));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ width: "100%", height: "calc(100vh - 230px)", minHeight: 500 }}>
|
||||||
|
<BigCalendar
|
||||||
|
defaultDate={new Date()}
|
||||||
|
scrollToTime={new Date(1970, 1, 1, 8)}
|
||||||
|
localizer={calendarLocalizer}
|
||||||
|
events={entries}
|
||||||
|
selectable
|
||||||
|
onSelectEvent={this.props.onItemClick as any}
|
||||||
|
onSelectSlot={this.slotClicked as any}
|
||||||
|
formats={{ agendaHeaderFormat: agendaHeaderFormat as any }}
|
||||||
|
eventPropGetter={eventPropGetter}
|
||||||
|
date={this.state.currentDate}
|
||||||
|
onNavigate={this.onNavigate}
|
||||||
|
view={this.state.view}
|
||||||
|
onView={this.onView}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onNavigate(currentDate: Date) {
|
||||||
|
this.setState({ currentDate });
|
||||||
|
}
|
||||||
|
|
||||||
|
private onView(view: string) {
|
||||||
|
this.setState({ view });
|
||||||
|
}
|
||||||
|
|
||||||
|
private slotClicked(slotInfo: {start: Date, end: Date}) {
|
||||||
|
if (this.props.onSlotClick) {
|
||||||
|
this.props.onSlotClick(slotInfo.start, slotInfo.end);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Calendar;
|
@ -0,0 +1,47 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import PimItemHeader from "../components/PimItemHeader";
|
||||||
|
|
||||||
|
import { formatDateRange, formatOurTimezoneOffset } from "../helpers";
|
||||||
|
|
||||||
|
import { EventType } from "../pim-types";
|
||||||
|
|
||||||
|
class Event extends React.PureComponent {
|
||||||
|
public props: {
|
||||||
|
item?: EventType;
|
||||||
|
};
|
||||||
|
|
||||||
|
public render() {
|
||||||
|
if (this.props.item === undefined) {
|
||||||
|
throw Error("Event should be defined!");
|
||||||
|
}
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
content: {
|
||||||
|
padding: 15,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const timezone = this.props.item.timezone;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
<PimItemHeader text={this.props.item.summary} backgroundColor={this.props.item.color}>
|
||||||
|
<div>{formatDateRange(this.props.item.startDate, this.props.item.endDate)} {timezone && <small>({formatOurTimezoneOffset()})</small>}</div>
|
||||||
|
<br />
|
||||||
|
<div><u>{this.props.item.location}</u></div>
|
||||||
|
</PimItemHeader>
|
||||||
|
<div style={style.content}>
|
||||||
|
<p style={{ wordWrap: "break-word" }}>{this.props.item.description}</p>
|
||||||
|
{(this.props.item.attendees.length > 0) && (
|
||||||
|
<div>Attendees: {this.props.item.attendees.map((x) => (x.getFirstValue())).join(", ")}</div>)}
|
||||||
|
</div>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Event;
|
@ -0,0 +1,436 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import FormGroup from "@material-ui/core/FormGroup";
|
||||||
|
import FormControlLabel from "@material-ui/core/FormControlLabel";
|
||||||
|
import Switch from "@material-ui/core/Switch";
|
||||||
|
|
||||||
|
import Button from "@material-ui/core/Button";
|
||||||
|
import TextField from "@material-ui/core/TextField";
|
||||||
|
import Select from "@material-ui/core/Select";
|
||||||
|
import MenuItem from "@material-ui/core/MenuItem";
|
||||||
|
import FormControl from "@material-ui/core/FormControl";
|
||||||
|
import FormHelperText from "@material-ui/core/FormHelperText";
|
||||||
|
import InputLabel from "@material-ui/core/InputLabel";
|
||||||
|
import * as colors from "@material-ui/core/colors";
|
||||||
|
|
||||||
|
import IconDelete from "@material-ui/icons/Delete";
|
||||||
|
import IconCancel from "@material-ui/icons/Clear";
|
||||||
|
import IconSave from "@material-ui/icons/Save";
|
||||||
|
|
||||||
|
import DateTimePicker from "../widgets/DateTimePicker";
|
||||||
|
|
||||||
|
import ConfirmationDialog from "../widgets/ConfirmationDialog";
|
||||||
|
import TimezonePicker from "../widgets/TimezonePicker";
|
||||||
|
import Toast from "../widgets/Toast";
|
||||||
|
|
||||||
|
import * as uuid from "uuid";
|
||||||
|
import * as ICAL from "ical.js";
|
||||||
|
|
||||||
|
import { getCurrentTimezone } from "../helpers";
|
||||||
|
|
||||||
|
import { EventType, timezoneLoadFromName } from "../pim-types";
|
||||||
|
import RRule, { RRuleOptions } from "../widgets/RRule";
|
||||||
|
|
||||||
|
import { History } from "history";
|
||||||
|
import { CachedCollection } from "../Pim/helpers";
|
||||||
|
|
||||||
|
interface PropsType {
|
||||||
|
collections: CachedCollection[];
|
||||||
|
initialCollection?: string;
|
||||||
|
item?: EventType;
|
||||||
|
onSave: (event: EventType, collectionUid: string, originalEvent?: EventType) => Promise<void>;
|
||||||
|
onDelete: (event: EventType, collectionUid: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
history: History;
|
||||||
|
duplicate?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class EventEdit extends React.PureComponent<PropsType> {
|
||||||
|
public state: {
|
||||||
|
uid: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
allDay: boolean;
|
||||||
|
start?: Date;
|
||||||
|
end?: Date;
|
||||||
|
timezone: string | null;
|
||||||
|
rrule?: RRuleOptions;
|
||||||
|
location: string;
|
||||||
|
collectionUid: string;
|
||||||
|
|
||||||
|
error?: string;
|
||||||
|
showDeleteDialog: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(props: PropsType) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
uid: "",
|
||||||
|
title: "",
|
||||||
|
allDay: false,
|
||||||
|
location: "",
|
||||||
|
description: "",
|
||||||
|
timezone: null,
|
||||||
|
|
||||||
|
collectionUid: "",
|
||||||
|
showDeleteDialog: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const locState = this.props.history.location.state as EventType;
|
||||||
|
if (locState) {
|
||||||
|
// FIXME: Hack to determine if all day. Should be passed as a proper state.
|
||||||
|
this.state.allDay = (locState.start &&
|
||||||
|
(locState.start.getHours() === 0) &&
|
||||||
|
(locState.start.getMinutes() === 0) &&
|
||||||
|
(locState.start.getHours() === locState.end.getHours()) &&
|
||||||
|
(locState.start.getMinutes() === locState.end.getMinutes()));
|
||||||
|
this.state.start = (locState.start) ? locState.start : undefined;
|
||||||
|
this.state.end = (locState.end) ? locState.end : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.props.item !== undefined) {
|
||||||
|
const event = this.props.item;
|
||||||
|
|
||||||
|
const allDay = event.startDate.isDate;
|
||||||
|
const endDate = event.endDate.clone();
|
||||||
|
|
||||||
|
if (allDay) {
|
||||||
|
endDate.adjust(-1, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.props.duplicate) {
|
||||||
|
this.state.title = event.title ? `Copy of ${event.title}` : "";
|
||||||
|
} else {
|
||||||
|
this.state.uid = event.uid;
|
||||||
|
this.state.title = event.title ? event.title : "";
|
||||||
|
}
|
||||||
|
this.state.allDay = allDay;
|
||||||
|
this.state.start = event.startDate.convertToZone(ICAL.Timezone.localTimezone).toJSDate();
|
||||||
|
this.state.end = endDate.convertToZone(ICAL.Timezone.localTimezone).toJSDate();
|
||||||
|
this.state.location = event.location ? event.location : "";
|
||||||
|
this.state.description = event.description ? event.description : "";
|
||||||
|
this.state.timezone = event.timezone;
|
||||||
|
const rruleProp = this.props.item?.component.getFirstPropertyValue<ICAL.Recur>("rrule");
|
||||||
|
if (rruleProp) {
|
||||||
|
this.state.rrule = rruleProp.toJSON() as any;
|
||||||
|
if (this.state.rrule && rruleProp.until) {
|
||||||
|
this.state.rrule.until = rruleProp.until;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.props.duplicate || this.props.item === undefined) {
|
||||||
|
this.state.uid = uuid.v4();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state.timezone = this.state.timezone || getCurrentTimezone();
|
||||||
|
|
||||||
|
if (props.initialCollection) {
|
||||||
|
this.state.collectionUid = props.initialCollection;
|
||||||
|
} else if (props.collections[0]) {
|
||||||
|
this.state.collectionUid = props.collections[0].collection.uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.onSubmit = this.onSubmit.bind(this);
|
||||||
|
this.handleChange = this.handleChange.bind(this);
|
||||||
|
this.handleInputChange = this.handleInputChange.bind(this);
|
||||||
|
this.toggleAllDay = this.toggleAllDay.bind(this);
|
||||||
|
this.onDeleteRequest = this.onDeleteRequest.bind(this);
|
||||||
|
this.toggleRecurring = this.toggleRecurring.bind(this);
|
||||||
|
this.handleRRuleChange = this.handleRRuleChange.bind(this);
|
||||||
|
this.handleCloseToast = this.handleCloseToast.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public handleChange(name: string, value: string) {
|
||||||
|
this.setState({
|
||||||
|
[name]: value,
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public handleInputChange(event: React.ChangeEvent<any>) {
|
||||||
|
const name = event.target.name;
|
||||||
|
const value = event.target.value;
|
||||||
|
this.handleChange(name, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public toggleAllDay() {
|
||||||
|
this.setState({ allDay: !this.state.allDay });
|
||||||
|
}
|
||||||
|
public toggleRecurring() {
|
||||||
|
const value = this.state.rrule ? undefined : { freq: "WEEKLY", interval: 1 };
|
||||||
|
this.setState({ rrule: value });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public handleRRuleChange(rrule: RRuleOptions): void {
|
||||||
|
this.setState({ rrule: rrule });
|
||||||
|
}
|
||||||
|
|
||||||
|
public handleCloseToast(_event?: React.SyntheticEvent, reason?: string) {
|
||||||
|
if (reason === "clickaway") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState({ error: "" });
|
||||||
|
}
|
||||||
|
|
||||||
|
public onSubmit(e: React.FormEvent<any>) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if ((!this.state.start) || (!this.state.end)) {
|
||||||
|
this.setState({ error: "Both start and end time must be set!" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromDate(date: Date, allDay: boolean) {
|
||||||
|
const ret = ICAL.Time.fromJSDate(date, false);
|
||||||
|
if (!allDay) {
|
||||||
|
return ret;
|
||||||
|
} else {
|
||||||
|
const data = ret.toJSON();
|
||||||
|
data.isDate = allDay;
|
||||||
|
return ICAL.Time.fromData(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDate = fromDate(this.state.start, this.state.allDay);
|
||||||
|
const endDate = fromDate(this.state.end, this.state.allDay);
|
||||||
|
|
||||||
|
if (this.state.allDay) {
|
||||||
|
endDate.adjust(1, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startDate.compare(endDate) >= 0) {
|
||||||
|
this.setState({ error: "End time must be later than start time!" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = (this.props.item && !this.props.duplicate) ?
|
||||||
|
this.props.item.clone()
|
||||||
|
:
|
||||||
|
new EventType()
|
||||||
|
;
|
||||||
|
|
||||||
|
event.uid = this.state.uid;
|
||||||
|
event.summary = this.state.title;
|
||||||
|
event.startDate = startDate;
|
||||||
|
event.endDate = endDate;
|
||||||
|
event.location = this.state.location;
|
||||||
|
event.description = this.state.description;
|
||||||
|
if (this.state.timezone) {
|
||||||
|
const timezone = timezoneLoadFromName(this.state.timezone);
|
||||||
|
if (timezone) {
|
||||||
|
event.startDate = event.startDate?.convertToZone(timezone);
|
||||||
|
event.endDate = event.endDate?.convertToZone(timezone);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.state.rrule) {
|
||||||
|
event.component.updatePropertyWithValue("rrule", new ICAL.Recur(this.state.rrule!));
|
||||||
|
}
|
||||||
|
|
||||||
|
event.component.updatePropertyWithValue("last-modified", ICAL.Time.now());
|
||||||
|
|
||||||
|
this.props.onSave(event, this.state.collectionUid, this.props.item)
|
||||||
|
.then(() => {
|
||||||
|
this.props.history.goBack();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public onDeleteRequest() {
|
||||||
|
this.setState({
|
||||||
|
showDeleteDialog: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public render() {
|
||||||
|
const styles = {
|
||||||
|
form: {
|
||||||
|
},
|
||||||
|
fullWidth: {
|
||||||
|
width: "100%",
|
||||||
|
boxSizing: "border-box" as any,
|
||||||
|
marginTop: 16,
|
||||||
|
},
|
||||||
|
submit: {
|
||||||
|
marginTop: 40,
|
||||||
|
marginBottom: 20,
|
||||||
|
textAlign: "right" as any,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const recurring = this.props.item && this.props.item.isRecurring();
|
||||||
|
const differentTimezone = this.state.timezone && (this.state.timezone !== getCurrentTimezone()) && timezoneLoadFromName(this.state.timezone);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2>
|
||||||
|
{(this.props.item && !this.props.duplicate) ? "Edit Event" : "New Event"}
|
||||||
|
</h2>
|
||||||
|
{recurring && (
|
||||||
|
<div>
|
||||||
|
<span style={{ color: "red" }}>IMPORTANT: </span>
|
||||||
|
This is a recurring event, for now, only editing the whole series
|
||||||
|
(by editing the first instance) is supported.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Toast open={!!this.state.error} onClose={this.handleCloseToast}>
|
||||||
|
ERROR! {this.state.error}
|
||||||
|
</Toast>
|
||||||
|
<form style={styles.form} onSubmit={this.onSubmit}>
|
||||||
|
<TextField
|
||||||
|
name="title"
|
||||||
|
placeholder="Enter title"
|
||||||
|
style={styles.fullWidth}
|
||||||
|
value={this.state.title}
|
||||||
|
onChange={this.handleInputChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormControl disabled={this.props.item !== undefined} style={styles.fullWidth}>
|
||||||
|
<InputLabel>
|
||||||
|
Saving to
|
||||||
|
</InputLabel>
|
||||||
|
<Select
|
||||||
|
name="collectionUid"
|
||||||
|
value={this.state.collectionUid}
|
||||||
|
disabled={this.props.item !== undefined && !this.props.duplicate}
|
||||||
|
onChange={this.handleInputChange}
|
||||||
|
>
|
||||||
|
{this.props.collections.map((x) => (
|
||||||
|
<MenuItem key={x.collection.uid} value={x.collection.uid}>{x.metadata.name}</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<FormHelperText>FROM</FormHelperText>
|
||||||
|
<DateTimePicker
|
||||||
|
dateOnly={this.state.allDay}
|
||||||
|
placeholder="Start"
|
||||||
|
value={this.state.start}
|
||||||
|
onChange={(date?: Date) => {
|
||||||
|
// If end is unset, set it to start + 30 minutes
|
||||||
|
const end = this.state.end ?? (
|
||||||
|
new Date(date!.getTime() + 30 * 60 * 1000)
|
||||||
|
);
|
||||||
|
this.setState({ start: date, end });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{differentTimezone && this.state.start && (
|
||||||
|
<FormHelperText>{ICAL.Time.fromJSDate(this.state.start, false).convertToZone(differentTimezone!).toJSDate().toString()}</FormHelperText>
|
||||||
|
)}
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<FormHelperText>TO</FormHelperText>
|
||||||
|
<DateTimePicker
|
||||||
|
dateOnly={this.state.allDay}
|
||||||
|
placeholder="End"
|
||||||
|
value={this.state.end}
|
||||||
|
onChange={(date?: Date) => this.setState({ end: date })}
|
||||||
|
/>
|
||||||
|
{differentTimezone && this.state.end && (
|
||||||
|
<FormHelperText>{ICAL.Time.fromJSDate(this.state.end, false).convertToZone(differentTimezone!).toJSDate().toString()}</FormHelperText>
|
||||||
|
)}
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormGroup>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
name="allDay"
|
||||||
|
checked={this.state.allDay}
|
||||||
|
onChange={this.toggleAllDay}
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="All Day"
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
{(!this.state.allDay) && (
|
||||||
|
<TimezonePicker value={this.state.timezone} onChange={(zone) => this.setState({ timezone: zone })} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
name="location"
|
||||||
|
placeholder="Add location"
|
||||||
|
style={styles.fullWidth}
|
||||||
|
value={this.state.location}
|
||||||
|
onChange={this.handleInputChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
name="description"
|
||||||
|
placeholder="Add description"
|
||||||
|
multiline
|
||||||
|
style={styles.fullWidth}
|
||||||
|
value={this.state.description}
|
||||||
|
onChange={this.handleInputChange}
|
||||||
|
/>
|
||||||
|
<FormGroup>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
name="recurring"
|
||||||
|
checked={!!this.state.rrule}
|
||||||
|
onChange={this.toggleRecurring}
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Recurring"
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
{this.state.rrule &&
|
||||||
|
<RRule
|
||||||
|
onChange={this.handleRRuleChange}
|
||||||
|
rrule={this.state.rrule ? this.state.rrule : { freq: "DAILY", interval: 1 }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
<div style={styles.submit}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={this.props.onCancel}
|
||||||
|
>
|
||||||
|
<IconCancel style={{ marginRight: 8 }} />
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{this.props.item &&
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
style={{ marginLeft: 15, backgroundColor: colors.red[500], color: "white" }}
|
||||||
|
onClick={this.onDeleteRequest}
|
||||||
|
>
|
||||||
|
<IconDelete style={{ marginRight: 8 }} />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
color="secondary"
|
||||||
|
style={{ marginLeft: 15 }}
|
||||||
|
>
|
||||||
|
<IconSave style={{ marginRight: 8 }} />
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<ConfirmationDialog
|
||||||
|
title="Delete Confirmation"
|
||||||
|
labelOk="Delete"
|
||||||
|
open={this.state.showDeleteDialog}
|
||||||
|
onOk={() => this.props.onDelete(this.props.item!, this.props.initialCollection!)}
|
||||||
|
onCancel={() => this.setState({ showDeleteDialog: false })}
|
||||||
|
>
|
||||||
|
Are you sure you would like to delete this event?
|
||||||
|
</ConfirmationDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,251 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2020 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Switch, Route, useHistory } from "react-router";
|
||||||
|
|
||||||
|
import * as Etebase from "etebase";
|
||||||
|
|
||||||
|
import { Button, useTheme } from "@material-ui/core";
|
||||||
|
import IconEdit from "@material-ui/icons/Edit";
|
||||||
|
import IconDuplicate from "@material-ui/icons/FileCopy";
|
||||||
|
import IconChangeHistory from "@material-ui/icons/ChangeHistory";
|
||||||
|
|
||||||
|
import { EventType, PimType } from "../pim-types";
|
||||||
|
import { useCredentials } from "../credentials";
|
||||||
|
import { useItems, useCollections } from "../etebase-helpers";
|
||||||
|
import { routeResolver } from "../App";
|
||||||
|
import Calendar from "./Calendar";
|
||||||
|
import Event from "./Event";
|
||||||
|
import LoadingIndicator from "../widgets/LoadingIndicator";
|
||||||
|
import EventEdit from "./EventEdit";
|
||||||
|
import PageNotFound, { PageNotFoundRoute } from "../PageNotFound";
|
||||||
|
|
||||||
|
import { CachedCollection, getItemNavigationUid, getDecryptCollectionsFunction, getDecryptItemsFunction, PimFab, itemDelete, itemSave, defaultColor } from "../Pim/helpers";
|
||||||
|
import { historyPersistor } from "../persist-state-history";
|
||||||
|
import ItemChangeHistory from "../Pim/ItemChangeHistory";
|
||||||
|
|
||||||
|
const PersistCalendar = historyPersistor("Calendar")(Calendar);
|
||||||
|
|
||||||
|
const colType = "etebase.vevent";
|
||||||
|
|
||||||
|
const decryptCollections = getDecryptCollectionsFunction(colType);
|
||||||
|
const decryptItems = getDecryptItemsFunction(colType, EventType.parse);
|
||||||
|
|
||||||
|
export default function CalendarsMain() {
|
||||||
|
const [entries, setEntries] = React.useState<Map<string, Map<string, EventType>>>();
|
||||||
|
const [cachedCollections, setCachedCollections] = React.useState<CachedCollection[]>();
|
||||||
|
const theme = useTheme();
|
||||||
|
const history = useHistory();
|
||||||
|
const etebase = useCredentials()!;
|
||||||
|
const collections = useCollections(etebase, colType);
|
||||||
|
const items = useItems(etebase, colType);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!collections || !items) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
(async () => {
|
||||||
|
const colEntries = await decryptCollections(collections);
|
||||||
|
|
||||||
|
const entries = await decryptItems(items);
|
||||||
|
|
||||||
|
for (const collection of colEntries) {
|
||||||
|
const items = entries.get(collection.collection.uid)!;
|
||||||
|
for (const item of items.values()) {
|
||||||
|
item.color = collection.metadata.color || defaultColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setCachedCollections(colEntries);
|
||||||
|
setEntries(entries);
|
||||||
|
})();
|
||||||
|
}, [items, collections]);
|
||||||
|
|
||||||
|
if (!entries || !cachedCollections) {
|
||||||
|
return (
|
||||||
|
<LoadingIndicator />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onItemSave(item: PimType, collectionUid: string, originalItem?: PimType): Promise<void> {
|
||||||
|
const collection = collections!.find((x) => x.uid === collectionUid)!;
|
||||||
|
await itemSave(etebase, collection, items!, item, collectionUid, originalItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onItemDelete(item: PimType, collectionUid: string) {
|
||||||
|
const collection = collections!.find((x) => x.uid === collectionUid)!;
|
||||||
|
await itemDelete(etebase, collection, items!, item, collectionUid);
|
||||||
|
|
||||||
|
history.push(routeResolver.getRoute("pim.events"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCancel() {
|
||||||
|
history.goBack();
|
||||||
|
}
|
||||||
|
|
||||||
|
const flatEntries = [];
|
||||||
|
for (const col of entries.values()) {
|
||||||
|
for (const item of col.values()) {
|
||||||
|
flatEntries.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
button: {
|
||||||
|
marginLeft: theme.spacing(1),
|
||||||
|
},
|
||||||
|
leftIcon: {
|
||||||
|
marginRight: theme.spacing(1),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Switch>
|
||||||
|
<Route
|
||||||
|
path={routeResolver.getRoute("pim.events")}
|
||||||
|
exact
|
||||||
|
>
|
||||||
|
<PersistCalendar
|
||||||
|
entries={flatEntries}
|
||||||
|
onItemClick={(item: EventType) => history.push(
|
||||||
|
routeResolver.getRoute("pim.events._id", { itemUid: getItemNavigationUid(item) })
|
||||||
|
)}
|
||||||
|
onSlotClick={(start?: Date, end?: Date) => history.push(
|
||||||
|
routeResolver.getRoute("pim.events.new"),
|
||||||
|
{ start, end }
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<PimFab
|
||||||
|
onClick={() => history.push(
|
||||||
|
routeResolver.getRoute("pim.events.new")
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
<Route
|
||||||
|
path={routeResolver.getRoute("pim.events.new")}
|
||||||
|
exact
|
||||||
|
>
|
||||||
|
<EventEdit
|
||||||
|
collections={cachedCollections}
|
||||||
|
onSave={onItemSave}
|
||||||
|
onDelete={onItemDelete}
|
||||||
|
onCancel={onCancel}
|
||||||
|
history={history}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
<Route
|
||||||
|
path={routeResolver.getRoute("pim.events._id.log")}
|
||||||
|
render={({ match }) => {
|
||||||
|
// We have this path outside because we don't want the item existing check
|
||||||
|
const [colUid, itemUid] = match.params.itemUid.split("|");
|
||||||
|
const cachedCollection = cachedCollections!.find((x) => x.collection.uid === colUid)!;
|
||||||
|
if (!cachedCollection) {
|
||||||
|
return (<PageNotFound />);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ItemChangeHistory collection={cachedCollection} itemUid={itemUid} />
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={routeResolver.getRoute("pim.events._id")}
|
||||||
|
render={({ match }) => {
|
||||||
|
const [colUid, itemUid] = match.params.itemUid.split("|");
|
||||||
|
const item = entries.get(colUid)?.get(itemUid);
|
||||||
|
if (!item) {
|
||||||
|
return (<PageNotFound />);
|
||||||
|
}
|
||||||
|
|
||||||
|
const collection = collections!.find((x) => x.uid === colUid)!;
|
||||||
|
const readOnly = collection.accessLevel === Etebase.CollectionAccessLevel.ReadOnly;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Switch>
|
||||||
|
<Route
|
||||||
|
path={routeResolver.getRoute("pim.events._id.edit")}
|
||||||
|
exact
|
||||||
|
>
|
||||||
|
<EventEdit
|
||||||
|
key={itemUid}
|
||||||
|
initialCollection={item.collectionUid}
|
||||||
|
item={item}
|
||||||
|
collections={cachedCollections}
|
||||||
|
onSave={onItemSave}
|
||||||
|
onDelete={onItemDelete}
|
||||||
|
onCancel={onCancel}
|
||||||
|
history={history}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
<Route
|
||||||
|
path={routeResolver.getRoute("pim.events._id.duplicate")}
|
||||||
|
exact
|
||||||
|
>
|
||||||
|
<EventEdit
|
||||||
|
key={itemUid}
|
||||||
|
initialCollection={item.collectionUid}
|
||||||
|
item={item}
|
||||||
|
collections={cachedCollections}
|
||||||
|
onSave={onItemSave}
|
||||||
|
onDelete={onItemDelete}
|
||||||
|
onCancel={onCancel}
|
||||||
|
history={history}
|
||||||
|
duplicate
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
<Route
|
||||||
|
path={routeResolver.getRoute("pim.events._id")}
|
||||||
|
exact
|
||||||
|
>
|
||||||
|
<div style={{ textAlign: "right", marginBottom: 15 }}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
style={styles.button}
|
||||||
|
onClick={() =>
|
||||||
|
history.push(routeResolver.getRoute("pim.events._id.log", { itemUid: getItemNavigationUid(item) }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<IconChangeHistory style={styles.leftIcon} />
|
||||||
|
Change History
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
color="secondary"
|
||||||
|
variant="contained"
|
||||||
|
disabled={readOnly}
|
||||||
|
style={{ ...styles.button, marginLeft: 15 }}
|
||||||
|
onClick={() =>
|
||||||
|
history.push(routeResolver.getRoute("pim.events._id.edit", { itemUid: getItemNavigationUid(item) }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<IconEdit style={styles.leftIcon} />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
color="secondary"
|
||||||
|
variant="contained"
|
||||||
|
disabled={readOnly}
|
||||||
|
style={{ ...styles.button, marginLeft: 15 }}
|
||||||
|
onClick={() =>
|
||||||
|
history.push(routeResolver.getRoute("pim.events._id.duplicate", { itemUid: getItemNavigationUid(item) }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<IconDuplicate style={styles.leftIcon} />
|
||||||
|
Duplicate
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<Event item={item} />
|
||||||
|
</Route>
|
||||||
|
<PageNotFoundRoute />
|
||||||
|
</Switch>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<PageNotFoundRoute />
|
||||||
|
</Switch>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,96 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import IconButton from "@material-ui/core/IconButton";
|
||||||
|
import IconEdit from "@material-ui/icons/Edit";
|
||||||
|
import IconMembers from "@material-ui/icons/People";
|
||||||
|
import IconImport from "@material-ui/icons/ImportExport";
|
||||||
|
|
||||||
|
import * as Etebase from "etebase";
|
||||||
|
|
||||||
|
|
||||||
|
import AppBarOverride from "../widgets/AppBarOverride";
|
||||||
|
import Container from "../widgets/Container";
|
||||||
|
|
||||||
|
import CollectionChangeEntries from "./CollectionChangeEntries";
|
||||||
|
import ImportDialog from "./ImportDialog";
|
||||||
|
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
import { routeResolver } from "../App";
|
||||||
|
|
||||||
|
import { CachedCollection } from "../Pim/helpers";
|
||||||
|
|
||||||
|
interface PropsType {
|
||||||
|
collection: CachedCollection;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Collection extends React.Component<PropsType> {
|
||||||
|
public state: {
|
||||||
|
tab: number;
|
||||||
|
importDialogOpen: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(props: PropsType) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
this.importDialogToggle = this.importDialogToggle.bind(this);
|
||||||
|
this.state = {
|
||||||
|
tab: 0,
|
||||||
|
importDialogOpen: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public render() {
|
||||||
|
const { collection, metadata } = this.props.collection;
|
||||||
|
const isAdmin = collection.accessLevel === Etebase.CollectionAccessLevel.Admin;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
<AppBarOverride title={metadata.name!!}>
|
||||||
|
{isAdmin &&
|
||||||
|
<>
|
||||||
|
<IconButton
|
||||||
|
component={Link}
|
||||||
|
title="Edit"
|
||||||
|
{...{ to: routeResolver.getRoute("collections._id.edit", { colUid: collection.uid }) }}
|
||||||
|
>
|
||||||
|
<IconEdit />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
component={Link}
|
||||||
|
title="Members"
|
||||||
|
{...{ to: routeResolver.getRoute("collections._id.members", { colUid: collection.uid }) }}
|
||||||
|
>
|
||||||
|
<IconMembers />
|
||||||
|
</IconButton>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
<IconButton
|
||||||
|
title="Import"
|
||||||
|
onClick={this.importDialogToggle}
|
||||||
|
>
|
||||||
|
<IconImport />
|
||||||
|
</IconButton>
|
||||||
|
</AppBarOverride>
|
||||||
|
<Container>
|
||||||
|
<CollectionChangeEntries collection={this.props.collection} />
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
<ImportDialog
|
||||||
|
key={this.state.importDialogOpen.toString()}
|
||||||
|
collection={this.props.collection}
|
||||||
|
open={this.state.importDialogOpen}
|
||||||
|
onClose={this.importDialogToggle}
|
||||||
|
/>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private importDialogToggle() {
|
||||||
|
this.setState((state: any) => ({ importDialogOpen: !state.importDialogOpen }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Collection;
|
@ -0,0 +1,98 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import * as Etebase from "etebase";
|
||||||
|
|
||||||
|
import { useCredentials } from "../credentials";
|
||||||
|
import { useItems } from "../etebase-helpers";
|
||||||
|
import { CachedCollection, getRawItemNavigationUid } from "../Pim/helpers";
|
||||||
|
import LoadingIndicator from "../widgets/LoadingIndicator";
|
||||||
|
import GenericChangeHistory from "../components/GenericChangeHistory";
|
||||||
|
import { useHistory } from "react-router";
|
||||||
|
import { routeResolver } from "../App";
|
||||||
|
|
||||||
|
export interface CachedItem {
|
||||||
|
item: Etebase.Item;
|
||||||
|
metadata: Etebase.ItemMetadata;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIXME: use the ones used by e.g. Contacts/Main so ew share the cache.
|
||||||
|
// Only problem though is that we want the deleted items here and not there.
|
||||||
|
async function decryptItems(items: Map<string, Map<string, Etebase.Item>>) {
|
||||||
|
const entries: Map<string, Map<string, CachedItem>> = new Map();
|
||||||
|
for (const [colUid, col] of items.entries()) {
|
||||||
|
const cur = new Map();
|
||||||
|
entries.set(colUid, cur);
|
||||||
|
for (const item of col.values()) {
|
||||||
|
cur.set(item.uid, {
|
||||||
|
item,
|
||||||
|
metadata: item.getMeta(),
|
||||||
|
content: await item.getContent(Etebase.OutputFormat.String),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PropsType {
|
||||||
|
collection: CachedCollection;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CollectionChangeEntries(props: PropsType) {
|
||||||
|
const [entries, setEntries] = React.useState<Map<string, CachedItem>>();
|
||||||
|
const history = useHistory();
|
||||||
|
const etebase = useCredentials()!;
|
||||||
|
|
||||||
|
const { collection, collectionType } = props.collection;
|
||||||
|
const items = useItems(etebase, collectionType);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (items) {
|
||||||
|
decryptItems(items)
|
||||||
|
.then((entries) => setEntries(entries.get(collection.uid)));
|
||||||
|
}
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
|
if (!entries) {
|
||||||
|
return (
|
||||||
|
<LoadingIndicator />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entriesList = Array.from(entries.values()).sort((a_, b_) => {
|
||||||
|
const a = a_.metadata.mtime ?? 0;
|
||||||
|
const b = b_.metadata.mtime ?? 0;
|
||||||
|
return a - b;
|
||||||
|
});
|
||||||
|
|
||||||
|
let changelogRoute = "";
|
||||||
|
switch (collectionType) {
|
||||||
|
case "etebase.vevent": {
|
||||||
|
changelogRoute = "pim.events._id.log";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "etebase.vtodo": {
|
||||||
|
changelogRoute = "pim.tasks._id.log";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "etebase.vcard": {
|
||||||
|
changelogRoute = "pim.contacts._id.log";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ height: "calc(100vh - 300px)" }}>
|
||||||
|
<GenericChangeHistory
|
||||||
|
items={entriesList}
|
||||||
|
onItemClick={(item) =>
|
||||||
|
history.push(routeResolver.getRoute(changelogRoute, { itemUid: getRawItemNavigationUid(collection.uid, item.item.uid) }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,233 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import Button from "@material-ui/core/Button";
|
||||||
|
import TextField from "@material-ui/core/TextField";
|
||||||
|
import Select from "@material-ui/core/Select";
|
||||||
|
import MenuItem from "@material-ui/core/MenuItem";
|
||||||
|
import FormControl from "@material-ui/core/FormControl";
|
||||||
|
import InputLabel from "@material-ui/core/InputLabel";
|
||||||
|
import IconDelete from "@material-ui/icons/Delete";
|
||||||
|
import IconCancel from "@material-ui/icons/Clear";
|
||||||
|
import IconSave from "@material-ui/icons/Save";
|
||||||
|
import * as colors from "@material-ui/core/colors";
|
||||||
|
|
||||||
|
import AppBarOverride from "../widgets/AppBarOverride";
|
||||||
|
import Container from "../widgets/Container";
|
||||||
|
import ConfirmationDialog from "../widgets/ConfirmationDialog";
|
||||||
|
|
||||||
|
import * as Etebase from "etebase";
|
||||||
|
|
||||||
|
import ColorPicker from "../widgets/ColorPicker";
|
||||||
|
import { defaultColor } from "../Pim/helpers";
|
||||||
|
import { CachedCollection } from "../Pim/helpers";
|
||||||
|
import { useCredentials } from "../credentials";
|
||||||
|
import { getCollectionManager } from "../etebase-helpers";
|
||||||
|
|
||||||
|
interface PropsType {
|
||||||
|
collection?: CachedCollection;
|
||||||
|
onSave: (collection: Etebase.Collection) => void;
|
||||||
|
onDelete: (collection: Etebase.Collection) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormErrors {
|
||||||
|
name?: string;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CollectionEdit(props: PropsType) {
|
||||||
|
const [errors, setErrors] = React.useState<FormErrors>({});
|
||||||
|
const [showDeleteDialog, setShowDeleteDialog] = React.useState(false);
|
||||||
|
const [colType, setColType] = React.useState("");
|
||||||
|
const [info, setInfo] = React.useState<Etebase.ItemMetadata>();
|
||||||
|
const [selectedColor, setSelectedColor] = React.useState("");
|
||||||
|
const etebase = useCredentials()!;
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (props.collection !== undefined) {
|
||||||
|
setColType(props.collection.collectionType);
|
||||||
|
setInfo(props.collection.metadata);
|
||||||
|
if (props.collection.metadata.color) {
|
||||||
|
setSelectedColor(props.collection.metadata.color);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setColType("etebase.vcard");
|
||||||
|
setInfo({
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [props.collection]);
|
||||||
|
|
||||||
|
if (info === undefined) {
|
||||||
|
return <React.Fragment />;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit(e: React.FormEvent<any>) {
|
||||||
|
e.preventDefault();
|
||||||
|
const saveErrors: FormErrors = {};
|
||||||
|
const fieldRequired = "This field is required!";
|
||||||
|
|
||||||
|
const { onSave } = props;
|
||||||
|
|
||||||
|
if (!info) {
|
||||||
|
throw new Error("Got undefined info. Should never happen.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = info.name;
|
||||||
|
const color = selectedColor;
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
saveErrors.name = fieldRequired;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (color && !/^#[0-9a-f]{6}([0-9a-f]{2})?$/i.test(color)) {
|
||||||
|
saveErrors.color = "Must be of the form #RRGGBB or #RRGGBBAA or empty";
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(saveErrors);
|
||||||
|
if (Object.keys(saveErrors).length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const colMgr = getCollectionManager(etebase);
|
||||||
|
const mtime = (new Date()).getTime();
|
||||||
|
const meta = { ...info, color, mtime };
|
||||||
|
let collection;
|
||||||
|
if (props.collection) {
|
||||||
|
collection = props.collection.collection;
|
||||||
|
collection.setMeta(meta);
|
||||||
|
} else {
|
||||||
|
collection = await colMgr.create(colType, meta, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
onSave(collection);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDeleteRequest() {
|
||||||
|
setShowDeleteDialog(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { collection, onDelete, onCancel } = props;
|
||||||
|
const item = collection?.metadata;
|
||||||
|
|
||||||
|
const pageTitle = (item !== undefined) ? item.name! : "New Collection";
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
fullWidth: {
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
submit: {
|
||||||
|
marginTop: 40,
|
||||||
|
marginBottom: 20,
|
||||||
|
textAlign: "right" as any,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const colTypes = {
|
||||||
|
"etebase.vcard": "Address Book",
|
||||||
|
"etebase.vevent": "Calendar",
|
||||||
|
"etebase.vtodo": "Task List",
|
||||||
|
};
|
||||||
|
let collectionColorBox: React.ReactNode;
|
||||||
|
switch (colType) {
|
||||||
|
case "etebase.vevent":
|
||||||
|
case "etebase.vtodo":
|
||||||
|
collectionColorBox = (
|
||||||
|
<ColorPicker
|
||||||
|
defaultColor={defaultColor}
|
||||||
|
color={selectedColor}
|
||||||
|
onChange={(color: string) => setSelectedColor(color)}
|
||||||
|
error={errors.color}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AppBarOverride title={pageTitle} />
|
||||||
|
<Container style={{ maxWidth: "30rem" }}>
|
||||||
|
<form onSubmit={onSubmit}>
|
||||||
|
<FormControl disabled={props.collection !== undefined} style={styles.fullWidth}>
|
||||||
|
<InputLabel>
|
||||||
|
Collection type
|
||||||
|
</InputLabel>
|
||||||
|
<Select
|
||||||
|
name="type"
|
||||||
|
required
|
||||||
|
value={colType}
|
||||||
|
onChange={(event: React.ChangeEvent<{ value: string }>) => setColType(event.target.value)}
|
||||||
|
>
|
||||||
|
{Object.keys(colTypes).map((x) => (
|
||||||
|
<MenuItem key={x} value={x}>{colTypes[x]}</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<TextField
|
||||||
|
name="name"
|
||||||
|
required
|
||||||
|
label="Name of this collection"
|
||||||
|
value={info.name}
|
||||||
|
onChange={(event: React.ChangeEvent<{ value: string }>) => setInfo({ ...info, name: event.target.value })}
|
||||||
|
style={styles.fullWidth}
|
||||||
|
margin="normal"
|
||||||
|
error={!!errors.name}
|
||||||
|
helperText={errors.name}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
name="description"
|
||||||
|
label="Description (optional)"
|
||||||
|
value={info.description}
|
||||||
|
onChange={(event: React.ChangeEvent<{ value: string }>) => setInfo({ ...info, description: event.target.value })}
|
||||||
|
style={styles.fullWidth}
|
||||||
|
margin="normal"
|
||||||
|
/>
|
||||||
|
{collectionColorBox}
|
||||||
|
|
||||||
|
<div style={styles.submit}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
<IconCancel style={{ marginRight: 8 }} />
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{props.collection &&
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
style={{ marginLeft: 15, backgroundColor: colors.red[500], color: "white" }}
|
||||||
|
onClick={onDeleteRequest}
|
||||||
|
>
|
||||||
|
<IconDelete style={{ marginRight: 8 }} />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
color="secondary"
|
||||||
|
style={{ marginLeft: 15 }}
|
||||||
|
>
|
||||||
|
<IconSave style={{ marginRight: 8 }} />
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Container>
|
||||||
|
<ConfirmationDialog
|
||||||
|
title="Delete Confirmation"
|
||||||
|
labelOk="Delete"
|
||||||
|
open={showDeleteDialog}
|
||||||
|
onOk={() => onDelete(props.collection?.collection!)}
|
||||||
|
onCancel={() => setShowDeleteDialog(false)}
|
||||||
|
>
|
||||||
|
Are you sure you would like to delete this collection?
|
||||||
|
</ConfirmationDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,86 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import ContactsIcon from "@material-ui/icons/Contacts";
|
||||||
|
import CalendarTodayIcon from "@material-ui/icons/CalendarToday";
|
||||||
|
import FormatListBulletedIcon from "@material-ui/icons/FormatListBulleted";
|
||||||
|
|
||||||
|
import { List, ListItem } from "../widgets/List";
|
||||||
|
|
||||||
|
import AppBarOverride from "../widgets/AppBarOverride";
|
||||||
|
import Container from "../widgets/Container";
|
||||||
|
|
||||||
|
import { CachedCollection, defaultColor } from "../Pim/helpers";
|
||||||
|
import ColorBox from "../widgets/ColorBox";
|
||||||
|
import ImportDialog from "./ImportDialog";
|
||||||
|
|
||||||
|
interface PropsType {
|
||||||
|
collections: CachedCollection[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CollectionImport(props: PropsType) {
|
||||||
|
const [selectedCollection, setSelectedCollection] = React.useState<CachedCollection>();
|
||||||
|
|
||||||
|
const collectionMap = {
|
||||||
|
"etebase.vcard": [],
|
||||||
|
"etebase.vevent": [],
|
||||||
|
"etebase.vtodo": [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function colClicked(colUid: string) {
|
||||||
|
const collection = props.collections.find((x) => x.collection.uid === colUid);
|
||||||
|
setSelectedCollection(collection);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const col of props.collections) {
|
||||||
|
const colType = col.collectionType;
|
||||||
|
if (collectionMap[colType]) {
|
||||||
|
const supportsColor = (["etebase.vevent", "etebase.vtodo"].includes(colType));
|
||||||
|
const colorBox = (supportsColor) ? (
|
||||||
|
<ColorBox size={24} color={col.metadata.color || defaultColor} />
|
||||||
|
) : undefined;
|
||||||
|
|
||||||
|
collectionMap[colType].push((
|
||||||
|
<ListItem key={col.collection.uid} rightIcon={colorBox} insetChildren
|
||||||
|
onClick={() => colClicked(col.collection.uid)}>
|
||||||
|
{col.metadata.name}
|
||||||
|
</ListItem>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<AppBarOverride title="Import" />
|
||||||
|
<List>
|
||||||
|
<ListItem
|
||||||
|
primaryText="Address Books"
|
||||||
|
leftIcon={<ContactsIcon />}
|
||||||
|
nestedItems={collectionMap["etebase.vcard"]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ListItem
|
||||||
|
primaryText="Calendars"
|
||||||
|
leftIcon={<CalendarTodayIcon />}
|
||||||
|
nestedItems={collectionMap["etebase.vevent"]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ListItem
|
||||||
|
primaryText="Tasks"
|
||||||
|
leftIcon={<FormatListBulletedIcon />}
|
||||||
|
nestedItems={collectionMap["etebase.vtodo"]}
|
||||||
|
/>
|
||||||
|
</List>
|
||||||
|
{selectedCollection && (
|
||||||
|
<ImportDialog
|
||||||
|
key={(!selectedCollection).toString()}
|
||||||
|
collection={selectedCollection}
|
||||||
|
open
|
||||||
|
onClose={() => setSelectedCollection(undefined)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,89 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { Link, useHistory } from "react-router-dom";
|
||||||
|
|
||||||
|
import IconButton from "@material-ui/core/IconButton";
|
||||||
|
import IconAdd from "@material-ui/icons/Add";
|
||||||
|
import ContactsIcon from "@material-ui/icons/Contacts";
|
||||||
|
import CalendarTodayIcon from "@material-ui/icons/CalendarToday";
|
||||||
|
import FormatListBulletedIcon from "@material-ui/icons/FormatListBulleted";
|
||||||
|
|
||||||
|
import { List, ListItem } from "../widgets/List";
|
||||||
|
|
||||||
|
import AppBarOverride from "../widgets/AppBarOverride";
|
||||||
|
import Container from "../widgets/Container";
|
||||||
|
|
||||||
|
import { routeResolver } from "../App";
|
||||||
|
import { CachedCollection, defaultColor } from "../Pim/helpers";
|
||||||
|
import ColorBox from "../widgets/ColorBox";
|
||||||
|
|
||||||
|
interface PropsType {
|
||||||
|
collections: CachedCollection[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CollectionList(props: PropsType) {
|
||||||
|
const history = useHistory();
|
||||||
|
|
||||||
|
const collectionMap = {
|
||||||
|
"etebase.vcard": [],
|
||||||
|
"etebase.vevent": [],
|
||||||
|
"etebase.vtodo": [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function colClicked(colUid: string) {
|
||||||
|
history.push(routeResolver.getRoute("collections._id", { colUid }));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const col of props.collections) {
|
||||||
|
const colType = col.collectionType;
|
||||||
|
if (collectionMap[colType]) {
|
||||||
|
const supportsColor = (["etebase.vevent", "etebase.vtodo"].includes(colType));
|
||||||
|
const colorBox = (supportsColor) ? (
|
||||||
|
<ColorBox size={24} color={col.metadata.color || defaultColor} />
|
||||||
|
) : undefined;
|
||||||
|
|
||||||
|
collectionMap[colType].push((
|
||||||
|
<ListItem key={col.collection.uid} rightIcon={colorBox} insetChildren
|
||||||
|
onClick={() => colClicked(col.collection.uid)}>
|
||||||
|
{col.metadata.name}
|
||||||
|
</ListItem>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<AppBarOverride title="Collections">
|
||||||
|
<IconButton
|
||||||
|
component={Link}
|
||||||
|
title="New"
|
||||||
|
{...{ to: routeResolver.getRoute("collections.new") }}
|
||||||
|
>
|
||||||
|
<IconAdd />
|
||||||
|
</IconButton>
|
||||||
|
</AppBarOverride>
|
||||||
|
<List>
|
||||||
|
<ListItem
|
||||||
|
primaryText="Address Books"
|
||||||
|
leftIcon={<ContactsIcon />}
|
||||||
|
nestedItems={collectionMap["etebase.vcard"]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ListItem
|
||||||
|
primaryText="Calendars"
|
||||||
|
leftIcon={<CalendarTodayIcon />}
|
||||||
|
nestedItems={collectionMap["etebase.vevent"]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ListItem
|
||||||
|
primaryText="Tasks"
|
||||||
|
leftIcon={<FormatListBulletedIcon />}
|
||||||
|
nestedItems={collectionMap["etebase.vtodo"]}
|
||||||
|
/>
|
||||||
|
</List>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,120 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import * as Etebase from "etebase";
|
||||||
|
|
||||||
|
import TextField from "@material-ui/core/TextField";
|
||||||
|
import Checkbox from "@material-ui/core/Checkbox";
|
||||||
|
import FormControlLabel from "@material-ui/core/FormControlLabel";
|
||||||
|
|
||||||
|
import LoadingIndicator from "../widgets/LoadingIndicator";
|
||||||
|
import ConfirmationDialog from "../widgets/ConfirmationDialog";
|
||||||
|
import PrettyFingerprint from "../widgets/PrettyFingerprint";
|
||||||
|
import { CachedCollection } from "../Pim/helpers";
|
||||||
|
import { useCredentials } from "../credentials";
|
||||||
|
|
||||||
|
interface PropsType {
|
||||||
|
collection: CachedCollection;
|
||||||
|
onOk: (username: string, publicKey: Uint8Array, accessLevel: Etebase.CollectionAccessLevel) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CollectionMemberAddDialog(props: PropsType) {
|
||||||
|
const etebase = useCredentials()!;
|
||||||
|
const [addUser, setAddUser] = React.useState("");
|
||||||
|
const [publicKey, setPublicKey] = React.useState<Uint8Array>();
|
||||||
|
const [readOnly, setReadOnly] = React.useState(false);
|
||||||
|
const [userChosen, setUserChosen] = React.useState(false);
|
||||||
|
const [error, setError] = React.useState<Error>();
|
||||||
|
|
||||||
|
async function onAddRequest(_user: string) {
|
||||||
|
setUserChosen(true);
|
||||||
|
const inviteMgr = etebase.getInvitationManager();
|
||||||
|
try {
|
||||||
|
const userProfile = await inviteMgr.fetchUserProfile(addUser);
|
||||||
|
setPublicKey(userProfile.pubkey);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onOk() {
|
||||||
|
props.onOk(addUser, publicKey!, readOnly ? Etebase.CollectionAccessLevel.ReadOnly : Etebase.CollectionAccessLevel.ReadWrite);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { onClose } = props;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ConfirmationDialog
|
||||||
|
title="Error adding member"
|
||||||
|
labelOk="OK"
|
||||||
|
open
|
||||||
|
onOk={onClose}
|
||||||
|
onCancel={onClose}
|
||||||
|
>
|
||||||
|
User ({addUser}) not found.
|
||||||
|
</ConfirmationDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (publicKey) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ConfirmationDialog
|
||||||
|
title="Verify security fingerprint"
|
||||||
|
labelOk="OK"
|
||||||
|
open
|
||||||
|
onOk={onOk}
|
||||||
|
onCancel={onClose}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Verify {addUser}'s security fingerprint to ensure the encryption is secure.
|
||||||
|
</p>
|
||||||
|
<div style={{ textAlign: "center" }}>
|
||||||
|
<PrettyFingerprint publicKey={publicKey} />
|
||||||
|
</div>
|
||||||
|
</ConfirmationDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ConfirmationDialog
|
||||||
|
title="Invite user"
|
||||||
|
labelOk="OK"
|
||||||
|
open={!userChosen}
|
||||||
|
onOk={onAddRequest}
|
||||||
|
onCancel={onClose}
|
||||||
|
>
|
||||||
|
{userChosen ?
|
||||||
|
<LoadingIndicator />
|
||||||
|
:
|
||||||
|
<>
|
||||||
|
<TextField
|
||||||
|
name="addUser"
|
||||||
|
placeholder="Username"
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
value={addUser}
|
||||||
|
onChange={(ev) => setAddUser(ev.target.value)}
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={readOnly}
|
||||||
|
onChange={(event) => setReadOnly(event.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Read only?"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
</ConfirmationDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,157 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import * as Etebase from "etebase";
|
||||||
|
|
||||||
|
import { List, ListItem } from "../widgets/List";
|
||||||
|
|
||||||
|
import IconMemberAdd from "@material-ui/icons/PersonAdd";
|
||||||
|
import VisibilityIcon from "@material-ui/icons/Visibility";
|
||||||
|
import AdminIcon from "@material-ui/icons/SupervisedUserCircle";
|
||||||
|
|
||||||
|
import AppBarOverride from "../widgets/AppBarOverride";
|
||||||
|
import Container from "../widgets/Container";
|
||||||
|
import LoadingIndicator from "../widgets/LoadingIndicator";
|
||||||
|
import ConfirmationDialog from "../widgets/ConfirmationDialog";
|
||||||
|
|
||||||
|
import { useCredentials } from "../credentials";
|
||||||
|
import { getCollectionManager } from "../etebase-helpers";
|
||||||
|
import { CachedCollection } from "../Pim/helpers";
|
||||||
|
|
||||||
|
import CollectionMemberAddDialog from "./CollectionMemberAddDialog";
|
||||||
|
import Alert from "@material-ui/lab/Alert";
|
||||||
|
import { pushMessage } from "../store/actions";
|
||||||
|
import { useDispatch } from "react-redux";
|
||||||
|
|
||||||
|
interface PropsType {
|
||||||
|
collection: CachedCollection;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CollectionMembers(props: PropsType) {
|
||||||
|
const etebase = useCredentials()!;
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const [members, setMembers] = React.useState<Etebase.CollectionMember[]>();
|
||||||
|
const [revokeUser, setRevokeUser] = React.useState<Etebase.CollectionMember | null>(null);
|
||||||
|
const [addMemberOpen, setAddMemberOpen] = React.useState(false);
|
||||||
|
const [error, setError] = React.useState<Error>();
|
||||||
|
|
||||||
|
const { collection, metadata } = props.collection;
|
||||||
|
|
||||||
|
const revokeUserIsAdmin = revokeUser?.accessLevel === Etebase.CollectionAccessLevel.Admin;
|
||||||
|
|
||||||
|
async function fetchMembers() {
|
||||||
|
const colMgr = getCollectionManager(etebase);
|
||||||
|
const memberManager = colMgr.getMemberManager(collection);
|
||||||
|
try {
|
||||||
|
const ret: Etebase.CollectionMember[] = [];
|
||||||
|
let iterator: string | null = null;
|
||||||
|
let done = false;
|
||||||
|
while (!done) {
|
||||||
|
const members = await memberManager.list({ iterator, limit: 30 });
|
||||||
|
iterator = members.iterator as string;
|
||||||
|
done = members.done;
|
||||||
|
|
||||||
|
for (const member of members.data) {
|
||||||
|
ret.push(member);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setMembers(ret);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
fetchMembers();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function onRevokeDo() {
|
||||||
|
const colMgr = getCollectionManager(etebase);
|
||||||
|
const memberManager = colMgr.getMemberManager(collection);
|
||||||
|
await memberManager.remove(revokeUser!.username);
|
||||||
|
await fetchMembers();
|
||||||
|
setRevokeUser(null);
|
||||||
|
dispatch(pushMessage({ message: "Removed member", severity: "success" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onMemberAdd(username: string, pubkey: Uint8Array, accessLevel: Etebase.CollectionAccessLevel) {
|
||||||
|
const inviteMgr = etebase.getInvitationManager();
|
||||||
|
await inviteMgr.invite(collection, username, pubkey, accessLevel);
|
||||||
|
await fetchMembers();
|
||||||
|
setAddMemberOpen(false);
|
||||||
|
dispatch(pushMessage({ message: "Invitation sent", severity: "success" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AppBarOverride title={`${metadata.name} - Members`} />
|
||||||
|
<Container style={{ maxWidth: "30rem" }}>
|
||||||
|
{error && (
|
||||||
|
<Alert color="error">
|
||||||
|
{error.toString()}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{members ?
|
||||||
|
<List>
|
||||||
|
<ListItem rightIcon={<IconMemberAdd />} onClick={() => setAddMemberOpen(true)}>
|
||||||
|
Invite user
|
||||||
|
</ListItem>
|
||||||
|
{(members.length > 0 ?
|
||||||
|
members.map((member) => {
|
||||||
|
let rightIcon: React.ReactElement | undefined = undefined;
|
||||||
|
if (member.accessLevel === Etebase.CollectionAccessLevel.ReadOnly) {
|
||||||
|
rightIcon = (<div title="Read Only"><VisibilityIcon /></div>);
|
||||||
|
} else if (member.accessLevel === Etebase.CollectionAccessLevel.Admin) {
|
||||||
|
rightIcon = (<div title="Admin"><AdminIcon /></div>);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ListItem
|
||||||
|
key={member.username}
|
||||||
|
onClick={() => setRevokeUser(member)}
|
||||||
|
rightIcon={rightIcon}
|
||||||
|
>
|
||||||
|
{member.username}
|
||||||
|
</ListItem>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
:
|
||||||
|
<ListItem>
|
||||||
|
No members
|
||||||
|
</ListItem>
|
||||||
|
)}
|
||||||
|
</List>
|
||||||
|
:
|
||||||
|
<LoadingIndicator />
|
||||||
|
}
|
||||||
|
</Container>
|
||||||
|
<ConfirmationDialog
|
||||||
|
title="Remove member"
|
||||||
|
labelOk="OK"
|
||||||
|
open={revokeUser !== null}
|
||||||
|
onOk={(revokeUserIsAdmin) ? () => setRevokeUser(null) : onRevokeDo}
|
||||||
|
onCancel={() => setRevokeUser(null)}
|
||||||
|
>
|
||||||
|
{(revokeUserIsAdmin) ? (
|
||||||
|
<p>
|
||||||
|
Revoking admin access is not allowed.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p>
|
||||||
|
Would you like to revoke {revokeUser?.username}'s access?<br />
|
||||||
|
Please be advised that a malicious user would potentially be able to retain access to encryption keys. Please refer to the FAQ for more information.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</ConfirmationDialog>
|
||||||
|
|
||||||
|
{addMemberOpen &&
|
||||||
|
<CollectionMemberAddDialog
|
||||||
|
collection={props.collection}
|
||||||
|
onOk={onMemberAdd}
|
||||||
|
onClose={() => setAddMemberOpen(false)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,215 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import Button from "@material-ui/core/Button";
|
||||||
|
import Dialog from "@material-ui/core/Dialog";
|
||||||
|
import DialogActions from "@material-ui/core/DialogActions";
|
||||||
|
import DialogContent from "@material-ui/core/DialogContent";
|
||||||
|
import DialogContentText from "@material-ui/core/DialogContentText";
|
||||||
|
import DialogTitle from "@material-ui/core/DialogTitle";
|
||||||
|
|
||||||
|
import Dropzone from "react-dropzone";
|
||||||
|
|
||||||
|
import LoadingIndicator from "../widgets/LoadingIndicator";
|
||||||
|
|
||||||
|
import { arrayToChunkIterator } from "../helpers";
|
||||||
|
|
||||||
|
import * as uuid from "uuid";
|
||||||
|
import * as ICAL from "ical.js";
|
||||||
|
import { ContactType, EventType, TaskType, PimType } from "../pim-types";
|
||||||
|
import { useCredentials } from "../credentials";
|
||||||
|
import { CachedCollection } from "../Pim/helpers";
|
||||||
|
import { getCollectionManager } from "../etebase-helpers";
|
||||||
|
import { useAsyncDispatch } from "../store";
|
||||||
|
import { itemBatch } from "../store/actions";
|
||||||
|
|
||||||
|
const CHUNK_SIZE = 40;
|
||||||
|
|
||||||
|
interface PropsType {
|
||||||
|
collection: CachedCollection;
|
||||||
|
open: boolean;
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ImportDialog(props: PropsType) {
|
||||||
|
const etebase = useCredentials()!;
|
||||||
|
const dispatch = useAsyncDispatch();
|
||||||
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
const [itemsProcessed, setItemsProccessed] = React.useState<number>();
|
||||||
|
|
||||||
|
function onFileDropCommon(itemsCreator: (fileText: string) => PimType[], acceptedFiles: File[], rejectedFiles: File[]) {
|
||||||
|
// XXX: implement handling of rejectedFiles
|
||||||
|
const reader = new FileReader();
|
||||||
|
|
||||||
|
reader.onabort = () => {
|
||||||
|
setLoading(false);
|
||||||
|
console.error("Import Aborted");
|
||||||
|
alert("file reading was aborted");
|
||||||
|
};
|
||||||
|
reader.onerror = (e) => {
|
||||||
|
setLoading(false);
|
||||||
|
console.error(e);
|
||||||
|
alert("file reading has failed");
|
||||||
|
};
|
||||||
|
reader.onload = async () => {
|
||||||
|
try {
|
||||||
|
const fileText = reader.result as string;
|
||||||
|
const items = itemsCreator(fileText);
|
||||||
|
|
||||||
|
const { collection } = props.collection;
|
||||||
|
const colMgr = getCollectionManager(etebase);
|
||||||
|
const itemMgr = colMgr.getItemManager(collection);
|
||||||
|
|
||||||
|
const eteItems = [];
|
||||||
|
for (const item of items) {
|
||||||
|
const mtime = (new Date()).getTime();
|
||||||
|
const meta = {
|
||||||
|
mtime,
|
||||||
|
name: item.uid,
|
||||||
|
};
|
||||||
|
const content = item.toIcal();
|
||||||
|
|
||||||
|
const eteItem = await itemMgr.create(meta, content);
|
||||||
|
eteItems.push(eteItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks = arrayToChunkIterator(eteItems, CHUNK_SIZE);
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
await dispatch(itemBatch(collection, itemMgr, chunk));
|
||||||
|
}
|
||||||
|
|
||||||
|
setItemsProccessed(items.length);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert("An error has occurred, please contact developers.");
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
if (props.onClose) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (acceptedFiles.length > 0) {
|
||||||
|
setLoading(true);
|
||||||
|
acceptedFiles.forEach((file) => {
|
||||||
|
reader.readAsText(file);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
alert("Failed importing file. Is the file type supported?");
|
||||||
|
console.log("Failed importing files. Rejected:", rejectedFiles);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFileDropContact(acceptedFiles: File[], rejectedFiles: File[]) {
|
||||||
|
const itemsCreator = (fileText: string) => {
|
||||||
|
const mainComp = ICAL.parse(fileText);
|
||||||
|
return mainComp.map((comp) => {
|
||||||
|
const ret = new ContactType(new ICAL.Component(comp));
|
||||||
|
if (!ret.uid) {
|
||||||
|
ret.uid = uuid.v4();
|
||||||
|
}
|
||||||
|
return ret;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
onFileDropCommon(itemsCreator, acceptedFiles, rejectedFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFileDropEvent(acceptedFiles: File[], rejectedFiles: File[]) {
|
||||||
|
const itemsCreator = (fileText: string) => {
|
||||||
|
const calendarComp = new ICAL.Component(ICAL.parse(fileText));
|
||||||
|
return calendarComp.getAllSubcomponents("vevent").map((comp) => {
|
||||||
|
const ret = new EventType(comp);
|
||||||
|
if (!ret.uid) {
|
||||||
|
ret.uid = uuid.v4();
|
||||||
|
}
|
||||||
|
return ret;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
onFileDropCommon(itemsCreator, acceptedFiles, rejectedFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFileDropTask(acceptedFiles: File[], rejectedFiles: File[]) {
|
||||||
|
const itemsCreator = (fileText: string) => {
|
||||||
|
const calendarComp = new ICAL.Component(ICAL.parse(fileText));
|
||||||
|
return calendarComp.getAllSubcomponents("vtodo").map((comp) => {
|
||||||
|
const ret = new TaskType(comp);
|
||||||
|
if (!ret.uid) {
|
||||||
|
ret.uid = uuid.v4();
|
||||||
|
}
|
||||||
|
return ret;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
onFileDropCommon(itemsCreator, acceptedFiles, rejectedFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClose() {
|
||||||
|
if (loading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.onClose) {
|
||||||
|
props.onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { collectionType } = props.collection;
|
||||||
|
let acceptTypes;
|
||||||
|
let dropFunction;
|
||||||
|
|
||||||
|
if (collectionType === "etebase.vcard") {
|
||||||
|
acceptTypes = ["text/vcard", "text/directory", "text/x-vcard", ".vcf"];
|
||||||
|
dropFunction = onFileDropContact;
|
||||||
|
} else if (collectionType === "etebase.vevent") {
|
||||||
|
acceptTypes = ["text/calendar", ".ics", ".ical"];
|
||||||
|
dropFunction = onFileDropEvent;
|
||||||
|
} else if (collectionType === "etebase.vtodo") {
|
||||||
|
acceptTypes = ["text/calendar", ".ics", ".ical"];
|
||||||
|
dropFunction = onFileDropTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
<Dialog
|
||||||
|
open={props.open}
|
||||||
|
onClose={onClose}
|
||||||
|
>
|
||||||
|
<DialogTitle>Import entries from file?</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
{(itemsProcessed !== undefined) ? (
|
||||||
|
<p>Imported {itemsProcessed} items.</p>
|
||||||
|
) : (loading ?
|
||||||
|
<LoadingIndicator style={{ display: "block", margin: "auto" }} />
|
||||||
|
:
|
||||||
|
<Dropzone
|
||||||
|
onDrop={dropFunction}
|
||||||
|
multiple={false}
|
||||||
|
accept={acceptTypes}
|
||||||
|
>
|
||||||
|
{({ getRootProps, getInputProps }) => (
|
||||||
|
<section>
|
||||||
|
<div {...getRootProps()}>
|
||||||
|
<input {...getInputProps()} />
|
||||||
|
<DialogContentText id="alert-dialog-description">
|
||||||
|
To import entries from a file, drag 'n' drop it here, or click to open the file selector.
|
||||||
|
</DialogContentText>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</Dropzone>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button disabled={loading} onClick={onClose} color="primary">
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,135 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2020 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Switch, Route, Redirect } from "react-router";
|
||||||
|
|
||||||
|
import * as Etebase from "etebase";
|
||||||
|
|
||||||
|
import { useCredentials } from "../credentials";
|
||||||
|
import { routeResolver } from "../App";
|
||||||
|
import LoadingIndicator from "../widgets/LoadingIndicator";
|
||||||
|
|
||||||
|
import AppBarOverride from "../widgets/AppBarOverride";
|
||||||
|
import { List, ListItem } from "../widgets/List";
|
||||||
|
import Container from "../widgets/Container";
|
||||||
|
import { IconButton } from "@material-ui/core";
|
||||||
|
import IconAccept from "@material-ui/icons/Done";
|
||||||
|
import IconReject from "@material-ui/icons/Close";
|
||||||
|
import ConfirmationDialog from "../widgets/ConfirmationDialog";
|
||||||
|
import PrettyFingerprint from "../widgets/PrettyFingerprint";
|
||||||
|
|
||||||
|
async function loadInvitations(etebase: Etebase.Account) {
|
||||||
|
const ret: Etebase.SignedInvitation[] = [];
|
||||||
|
const invitationManager = etebase.getInvitationManager();
|
||||||
|
|
||||||
|
let iterator: string | null = null;
|
||||||
|
let done = false;
|
||||||
|
while (!done) {
|
||||||
|
const invitations = await invitationManager.listIncoming({ iterator, limit: 30 });
|
||||||
|
iterator = invitations.iterator as string;
|
||||||
|
done = invitations.done;
|
||||||
|
|
||||||
|
ret.push(...invitations.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Invitations() {
|
||||||
|
return (
|
||||||
|
<Switch>
|
||||||
|
<Route
|
||||||
|
path={routeResolver.getRoute("collections.invitations")}
|
||||||
|
exact
|
||||||
|
>
|
||||||
|
<Redirect to={routeResolver.getRoute("collections.invitations.incoming")} />
|
||||||
|
</Route>
|
||||||
|
<Route
|
||||||
|
path={routeResolver.getRoute("collections.invitations.incoming")}
|
||||||
|
exact
|
||||||
|
>
|
||||||
|
<InvitationsIncoming
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
</Switch>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InvitationsIncoming() {
|
||||||
|
const [invitations, setInvitations] = React.useState<Etebase.SignedInvitation[]>();
|
||||||
|
const [chosenInvitation, setChosenInvitation] = React.useState<Etebase.SignedInvitation>();
|
||||||
|
const etebase = useCredentials()!;
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
loadInvitations(etebase).then(setInvitations);
|
||||||
|
}, [etebase]);
|
||||||
|
|
||||||
|
function removeInvitation(invite: Etebase.SignedInvitation) {
|
||||||
|
setInvitations(invitations?.filter((x) => x.uid !== invite.uid));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reject(invite: Etebase.SignedInvitation) {
|
||||||
|
const invitationManager = etebase.getInvitationManager();
|
||||||
|
await invitationManager.reject(invite);
|
||||||
|
removeInvitation(invite);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function accept(invite: Etebase.SignedInvitation) {
|
||||||
|
const invitationManager = etebase.getInvitationManager();
|
||||||
|
await invitationManager.accept(invite);
|
||||||
|
setChosenInvitation(undefined);
|
||||||
|
removeInvitation(invite);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AppBarOverride title="Incoming Invitations" />
|
||||||
|
<Container style={{ maxWidth: "30rem" }}>
|
||||||
|
{invitations ?
|
||||||
|
<List>
|
||||||
|
{(invitations.length > 0 ?
|
||||||
|
invitations.map((invite) => (
|
||||||
|
<ListItem
|
||||||
|
key={invite.uid}
|
||||||
|
rightIcon={(
|
||||||
|
<>
|
||||||
|
<IconButton title="Reject" onClick={() => reject(invite)}>
|
||||||
|
<IconReject color="error" />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton title="Accept" onClick={() => setChosenInvitation(invite)}>
|
||||||
|
<IconAccept color="secondary" />
|
||||||
|
</IconButton>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Invitation from {invite.fromUsername}
|
||||||
|
</ListItem>
|
||||||
|
))
|
||||||
|
:
|
||||||
|
<ListItem>
|
||||||
|
No invitations
|
||||||
|
</ListItem>
|
||||||
|
)}
|
||||||
|
</List>
|
||||||
|
:
|
||||||
|
<LoadingIndicator />
|
||||||
|
}
|
||||||
|
</Container>
|
||||||
|
{chosenInvitation && (
|
||||||
|
<ConfirmationDialog
|
||||||
|
title="Accept invitation"
|
||||||
|
labelOk="OK"
|
||||||
|
open={!!chosenInvitation}
|
||||||
|
onOk={() => accept(chosenInvitation)}
|
||||||
|
onCancel={() => setChosenInvitation(undefined)}
|
||||||
|
>
|
||||||
|
Please verify the inviter's security fingerprint to ensure the invitation is secure:
|
||||||
|
<div style={{ textAlign: "center" }}>
|
||||||
|
<PrettyFingerprint publicKey={chosenInvitation.fromPubkey} />
|
||||||
|
</div>
|
||||||
|
</ConfirmationDialog>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,151 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2020 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Switch, Route, useHistory } from "react-router";
|
||||||
|
|
||||||
|
import * as Etebase from "etebase";
|
||||||
|
|
||||||
|
import { useCredentials } from "../credentials";
|
||||||
|
import { useCollections, getCollectionManager } from "../etebase-helpers";
|
||||||
|
import { routeResolver } from "../App";
|
||||||
|
import LoadingIndicator from "../widgets/LoadingIndicator";
|
||||||
|
|
||||||
|
import { CachedCollection, getDecryptCollectionsFunction, PimFab } from "../Pim/helpers";
|
||||||
|
import CollectionList from "./CollectionList";
|
||||||
|
import CollectionImport from "./CollectionImport";
|
||||||
|
import PageNotFound from "../PageNotFound";
|
||||||
|
import CollectionEdit from "./CollectionEdit";
|
||||||
|
import CollectionMembers from "./CollectionMembers";
|
||||||
|
import Collection from "./Collection";
|
||||||
|
import { useAsyncDispatch } from "../store";
|
||||||
|
import { collectionUpload, pushMessage } from "../store/actions";
|
||||||
|
import Invitations from "./Invitations";
|
||||||
|
|
||||||
|
const decryptCollections = getDecryptCollectionsFunction();
|
||||||
|
|
||||||
|
export default function CollectionsMain() {
|
||||||
|
const [cachedCollections, setCachedCollections] = React.useState<CachedCollection[]>();
|
||||||
|
const history = useHistory();
|
||||||
|
const etebase = useCredentials()!;
|
||||||
|
const collections = useCollections(etebase);
|
||||||
|
const dispatch = useAsyncDispatch();
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (collections) {
|
||||||
|
decryptCollections(collections)
|
||||||
|
.then((entries) => setCachedCollections(entries));
|
||||||
|
// FIXME: handle failure to decrypt collections
|
||||||
|
}
|
||||||
|
}, [collections]);
|
||||||
|
|
||||||
|
if (!cachedCollections) {
|
||||||
|
return (
|
||||||
|
<LoadingIndicator />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSave(collection: Etebase.Collection): Promise<void> {
|
||||||
|
const colMgr = getCollectionManager(etebase);
|
||||||
|
await dispatch(collectionUpload(colMgr, collection));
|
||||||
|
dispatch(pushMessage({ message: "Collection saved", severity: "success" }));
|
||||||
|
|
||||||
|
history.push(routeResolver.getRoute("collections"));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete(collection: Etebase.Collection) {
|
||||||
|
const colMgr = getCollectionManager(etebase);
|
||||||
|
const mtime = (new Date()).getTime();
|
||||||
|
const meta = collection.getMeta();
|
||||||
|
collection.setMeta({ ...meta, mtime });
|
||||||
|
collection.delete(true);
|
||||||
|
await dispatch(collectionUpload(colMgr, collection));
|
||||||
|
dispatch(pushMessage({ message: "Collection deleted", severity: "success" }));
|
||||||
|
|
||||||
|
history.push(routeResolver.getRoute("collections"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCancel() {
|
||||||
|
history.goBack();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Switch>
|
||||||
|
<Route
|
||||||
|
path={routeResolver.getRoute("collections")}
|
||||||
|
exact
|
||||||
|
>
|
||||||
|
<CollectionList
|
||||||
|
collections={cachedCollections}
|
||||||
|
/>
|
||||||
|
<PimFab
|
||||||
|
onClick={() => history.push(
|
||||||
|
routeResolver.getRoute("collections.new")
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
<Route
|
||||||
|
path={routeResolver.getRoute("collections.import")}
|
||||||
|
exact
|
||||||
|
>
|
||||||
|
<CollectionImport
|
||||||
|
collections={cachedCollections}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
<Route
|
||||||
|
path={routeResolver.getRoute("collections.new")}
|
||||||
|
exact
|
||||||
|
>
|
||||||
|
<CollectionEdit
|
||||||
|
onSave={onSave}
|
||||||
|
onDelete={onDelete}
|
||||||
|
onCancel={onCancel}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
<Route
|
||||||
|
path={routeResolver.getRoute("collections.invitations")}
|
||||||
|
>
|
||||||
|
<Invitations />
|
||||||
|
</Route>
|
||||||
|
<Route
|
||||||
|
path={routeResolver.getRoute("collections._id")}
|
||||||
|
render={({ match }) => {
|
||||||
|
const colUid = match.params.colUid;
|
||||||
|
const collection = cachedCollections.find((x) => x.collection.uid === colUid);
|
||||||
|
if (!collection) {
|
||||||
|
return (<PageNotFound />);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Switch>
|
||||||
|
<Route
|
||||||
|
path={routeResolver.getRoute("collections._id.edit")}
|
||||||
|
exact
|
||||||
|
>
|
||||||
|
<CollectionEdit
|
||||||
|
collection={collection}
|
||||||
|
onSave={onSave}
|
||||||
|
onDelete={onDelete}
|
||||||
|
onCancel={onCancel}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
<Route
|
||||||
|
path={routeResolver.getRoute("collections._id.members")}
|
||||||
|
exact
|
||||||
|
>
|
||||||
|
<CollectionMembers collection={collection} />
|
||||||
|
</Route>
|
||||||
|
<Route
|
||||||
|
path={routeResolver.getRoute("collections._id")}
|
||||||
|
exact
|
||||||
|
>
|
||||||
|
<Collection collection={collection} />
|
||||||
|
</Route>
|
||||||
|
</Switch>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Switch>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,125 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { createSelector } from "reselect";
|
||||||
|
|
||||||
|
import * as colors from "@material-ui/core/colors";
|
||||||
|
import { AutoSizer, List as VirtualizedList } from "react-virtualized";
|
||||||
|
|
||||||
|
import { Avatar } from "../widgets/Avatar";
|
||||||
|
import { List, ListItem } from "../widgets/List";
|
||||||
|
|
||||||
|
import { ContactType } from "../pim-types";
|
||||||
|
|
||||||
|
function getContactColor(contact: ContactType) {
|
||||||
|
const colorOptions = [
|
||||||
|
colors.red[500],
|
||||||
|
colors.pink[500],
|
||||||
|
colors.purple[500],
|
||||||
|
colors.deepPurple[500],
|
||||||
|
colors.indigo[500],
|
||||||
|
colors.blue[500],
|
||||||
|
colors.lightBlue[500],
|
||||||
|
colors.cyan[500],
|
||||||
|
colors.teal[500],
|
||||||
|
colors.green[500],
|
||||||
|
colors.lightGreen[500],
|
||||||
|
colors.lime[500],
|
||||||
|
colors.yellow[500],
|
||||||
|
colors.amber[500],
|
||||||
|
colors.orange[500],
|
||||||
|
colors.deepOrange[500],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!contact.uid) {
|
||||||
|
console.error(`Contact uid is null for contact ${contact.fn}`);
|
||||||
|
console.error(contact.toIcal());
|
||||||
|
return colorOptions[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
let sum = 0;
|
||||||
|
const uid = contact.uid;
|
||||||
|
for (let i = 0 ; i < uid.length ; i++) {
|
||||||
|
sum += uid.charCodeAt(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
return colorOptions[sum % colorOptions.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
const AddressBookItem = React.memo((_props: any) => {
|
||||||
|
const {
|
||||||
|
style,
|
||||||
|
entry,
|
||||||
|
onClick,
|
||||||
|
} = _props;
|
||||||
|
const name = entry.fn;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListItem
|
||||||
|
style={style}
|
||||||
|
leftIcon={
|
||||||
|
<Avatar style={{ backgroundColor: getContactColor(entry) }}>
|
||||||
|
{name && name[0] && name[0].toUpperCase()}
|
||||||
|
</Avatar>}
|
||||||
|
primaryText={name}
|
||||||
|
onClick={() => onClick(entry)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortSelector = createSelector(
|
||||||
|
(entries: ContactType[]) => entries,
|
||||||
|
(entries) => {
|
||||||
|
return entries.sort((_a, _b) => {
|
||||||
|
const a = _a.fn ?? "";
|
||||||
|
const b = _b.fn ?? "";
|
||||||
|
|
||||||
|
return a.localeCompare(b, undefined, { sensitivity: "base" });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
interface PropsType {
|
||||||
|
entries: ContactType[];
|
||||||
|
onItemClick: (contact: ContactType) => void;
|
||||||
|
filter?: (a: ContactType) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AddressBook extends React.PureComponent<PropsType> {
|
||||||
|
public render() {
|
||||||
|
const sortedEntries = sortSelector(this.props.entries);
|
||||||
|
|
||||||
|
const entries = (this.props.filter) ?
|
||||||
|
sortedEntries.filter(this.props.filter)
|
||||||
|
: sortedEntries;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<List style={{ height: "calc(100vh - 350px)" }}>
|
||||||
|
<AutoSizer>
|
||||||
|
{({ height, width }) => (
|
||||||
|
<VirtualizedList
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
rowCount={entries.length}
|
||||||
|
rowHeight={56}
|
||||||
|
rowRenderer={({ index, key, style }) => {
|
||||||
|
return (
|
||||||
|
<AddressBookItem
|
||||||
|
key={key}
|
||||||
|
style={style}
|
||||||
|
entry={entries[index]}
|
||||||
|
onClick={this.props.onItemClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AutoSizer>
|
||||||
|
</List>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AddressBook;
|
@ -0,0 +1,203 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import moment from "moment";
|
||||||
|
|
||||||
|
import { List, ListItem, ListDivider as Divider } from "../widgets/List";
|
||||||
|
import IconHome from "@material-ui/icons/Home";
|
||||||
|
import IconDate from "@material-ui/icons/DateRange";
|
||||||
|
import CommunicationCall from "@material-ui/icons/Call";
|
||||||
|
import CommunicationChatBubble from "@material-ui/icons/ChatBubble";
|
||||||
|
import CommunicationEmail from "@material-ui/icons/Email";
|
||||||
|
import CopyIcon from "../icons/Copy";
|
||||||
|
|
||||||
|
import PimItemHeader from "../components/PimItemHeader";
|
||||||
|
|
||||||
|
import { ContactType } from "../pim-types";
|
||||||
|
import { IconButton, Avatar } from "@material-ui/core";
|
||||||
|
|
||||||
|
class Contact extends React.PureComponent {
|
||||||
|
public props: {
|
||||||
|
item?: ContactType;
|
||||||
|
};
|
||||||
|
|
||||||
|
public render() {
|
||||||
|
if (this.props.item === undefined) {
|
||||||
|
throw Error("Contact should be defined!");
|
||||||
|
}
|
||||||
|
|
||||||
|
const contact = this.props.item;
|
||||||
|
const name = contact.fn;
|
||||||
|
|
||||||
|
const revProp = contact.comp.getFirstProperty("rev");
|
||||||
|
const lastModified = (revProp) ?
|
||||||
|
"Modified: " + moment(revProp.getFirstValue().toJSDate()).format("LLLL") : undefined;
|
||||||
|
|
||||||
|
const lists = [];
|
||||||
|
|
||||||
|
function getAllType(
|
||||||
|
propName: string,
|
||||||
|
props_: any,
|
||||||
|
valueToHref?: (value: string, type: string) => string,
|
||||||
|
primaryTransform?: (value: string, type: string) => string,
|
||||||
|
secondaryTransform?: (value: string, type: string) => string) {
|
||||||
|
|
||||||
|
return contact.comp.getAllProperties(propName).map((prop, idx) => {
|
||||||
|
const type = prop.toJSON()[1].type;
|
||||||
|
const values = prop.getValues().map((val) => {
|
||||||
|
const primaryText = primaryTransform ? primaryTransform(val, type) : val;
|
||||||
|
const clipboardButton = (
|
||||||
|
<IconButton
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
(window as any).navigator.clipboard.writeText(primaryText);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CopyIcon />
|
||||||
|
</IconButton>
|
||||||
|
);
|
||||||
|
|
||||||
|
const { ref, ...props } = props_;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListItem
|
||||||
|
key={idx}
|
||||||
|
href={valueToHref ? valueToHref(val, type) : undefined}
|
||||||
|
primaryText={primaryText}
|
||||||
|
rightIcon={clipboardButton}
|
||||||
|
secondaryText={secondaryTransform ? secondaryTransform(val, type) : type}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return values;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
lists.push(getAllType(
|
||||||
|
"tel",
|
||||||
|
{
|
||||||
|
leftIcon: <CommunicationCall />,
|
||||||
|
},
|
||||||
|
(x) => ("tel:" + x)
|
||||||
|
));
|
||||||
|
|
||||||
|
lists.push(getAllType(
|
||||||
|
"email",
|
||||||
|
{
|
||||||
|
leftIcon: <CommunicationEmail />,
|
||||||
|
},
|
||||||
|
(x) => ("mailto:" + x)
|
||||||
|
));
|
||||||
|
|
||||||
|
lists.push(getAllType(
|
||||||
|
"impp",
|
||||||
|
{
|
||||||
|
leftIcon: <CommunicationChatBubble />,
|
||||||
|
},
|
||||||
|
(x) => x,
|
||||||
|
(x) => (x.substring(x.indexOf(":") + 1)),
|
||||||
|
(x) => (x.substring(0, x.indexOf(":")))
|
||||||
|
));
|
||||||
|
|
||||||
|
lists.push(getAllType(
|
||||||
|
"adr",
|
||||||
|
{
|
||||||
|
leftIcon: <IconHome />,
|
||||||
|
}
|
||||||
|
));
|
||||||
|
|
||||||
|
lists.push(getAllType(
|
||||||
|
"bday",
|
||||||
|
{
|
||||||
|
leftIcon: <IconDate />,
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
((x: any) => moment(x.toJSDate()).format("dddd, LL")),
|
||||||
|
() => "Birthday"
|
||||||
|
));
|
||||||
|
|
||||||
|
lists.push(getAllType(
|
||||||
|
"anniversary",
|
||||||
|
{
|
||||||
|
leftIcon: <IconDate />,
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
((x: any) => moment(x.toJSDate()).format("dddd, LL")),
|
||||||
|
() => "Anniversary"
|
||||||
|
));
|
||||||
|
|
||||||
|
const skips = ["tel", "email", "impp", "adr", "bday", "anniversary", "rev",
|
||||||
|
"prodid", "uid", "fn", "n", "version", "photo", "note"];
|
||||||
|
const theRest = contact.comp.getAllProperties().filter((prop) => (
|
||||||
|
skips.indexOf(prop.name) === -1
|
||||||
|
)).map((prop, idx) => {
|
||||||
|
const values = prop.getValues().map((_val) => {
|
||||||
|
const val = (_val instanceof String) ? _val : _val.toString();
|
||||||
|
return (
|
||||||
|
<ListItem
|
||||||
|
key={idx}
|
||||||
|
insetChildren
|
||||||
|
primaryText={val}
|
||||||
|
secondaryText={prop.name}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return values;
|
||||||
|
});
|
||||||
|
|
||||||
|
{
|
||||||
|
const note = contact.comp.getFirstPropertyValue("note");
|
||||||
|
const item = (
|
||||||
|
<ListItem
|
||||||
|
key="note"
|
||||||
|
insetChildren
|
||||||
|
secondaryText="note"
|
||||||
|
>
|
||||||
|
<pre style={{ wordWrap: "break-word", whiteSpace: "pre-wrap", overflowX: "auto" }}>{note}</pre>
|
||||||
|
</ListItem>
|
||||||
|
);
|
||||||
|
theRest.push([item]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function listIfNotEmpty(items: JSX.Element[][]) {
|
||||||
|
if (items.length > 0) {
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
<List>
|
||||||
|
{items}
|
||||||
|
</List>
|
||||||
|
<List>
|
||||||
|
<Divider inset />
|
||||||
|
</List>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const contactImageSrc = contact.comp.getFirstProperty("photo")?.getFirstValue();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PimItemHeader text={name} rightItem={contactImageSrc && (<Avatar style={{ width: "3em", height: "3em" }} src={contactImageSrc} />)}>
|
||||||
|
{lastModified && (
|
||||||
|
<span style={{ fontSize: "90%" }}>{lastModified}</span>
|
||||||
|
)}
|
||||||
|
</PimItemHeader>
|
||||||
|
{lists.map((list, idx) => (
|
||||||
|
<React.Fragment key={idx}>
|
||||||
|
{listIfNotEmpty(list)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
<List>
|
||||||
|
{theRest}
|
||||||
|
</List>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Contact;
|
@ -0,0 +1,710 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import IconButton from "@material-ui/core/IconButton";
|
||||||
|
import Button from "@material-ui/core/Button";
|
||||||
|
import TextField from "@material-ui/core/TextField";
|
||||||
|
import Select from "@material-ui/core/Select";
|
||||||
|
import MenuItem from "@material-ui/core/MenuItem";
|
||||||
|
import FormControl from "@material-ui/core/FormControl";
|
||||||
|
import InputLabel from "@material-ui/core/InputLabel";
|
||||||
|
import * as colors from "@material-ui/core/colors";
|
||||||
|
|
||||||
|
import IconDelete from "@material-ui/icons/Delete";
|
||||||
|
import IconAdd from "@material-ui/icons/Add";
|
||||||
|
import IconClear from "@material-ui/icons/Clear";
|
||||||
|
import IconCancel from "@material-ui/icons/Clear";
|
||||||
|
import IconSave from "@material-ui/icons/Save";
|
||||||
|
|
||||||
|
import ConfirmationDialog from "../widgets/ConfirmationDialog";
|
||||||
|
|
||||||
|
import { CachedCollection } from "../Pim/helpers";
|
||||||
|
|
||||||
|
import * as uuid from "uuid";
|
||||||
|
import * as ICAL from "ical.js";
|
||||||
|
|
||||||
|
import { ContactType } from "../pim-types";
|
||||||
|
|
||||||
|
import { History } from "history";
|
||||||
|
import Autocomplete from "@material-ui/lab/Autocomplete";
|
||||||
|
|
||||||
|
const telTypes = [
|
||||||
|
{ type: "Home" },
|
||||||
|
{ type: "Work" },
|
||||||
|
{ type: "Cell" },
|
||||||
|
{ type: "Other" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const emailTypes = telTypes;
|
||||||
|
|
||||||
|
const addressTypes = [
|
||||||
|
{ type: "Home" },
|
||||||
|
{ type: "Work" },
|
||||||
|
{ type: "Other" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const imppTypes = [
|
||||||
|
{ type: "Jabber" },
|
||||||
|
{ type: "Hangouts" },
|
||||||
|
{ type: "Other" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TypeSelector = (props: any) => {
|
||||||
|
const types = props.types as Array<{ type: string }>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
style={props.style}
|
||||||
|
value={props.value}
|
||||||
|
onChange={props.onChange}
|
||||||
|
>
|
||||||
|
{types.map((x) => (
|
||||||
|
<MenuItem key={x.type} value={x.type.toLowerCase()}>{x.type}</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
class ValueType {
|
||||||
|
public type: string;
|
||||||
|
public value: string;
|
||||||
|
|
||||||
|
constructor(type?: string, value?: string) {
|
||||||
|
this.type = type ? type : "home";
|
||||||
|
this.value = value ? value : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ValueTypeComponentProps {
|
||||||
|
type?: string;
|
||||||
|
style?: object;
|
||||||
|
multiline?: boolean;
|
||||||
|
|
||||||
|
types: Array<{ type: string }>;
|
||||||
|
name: string;
|
||||||
|
placeholder: string;
|
||||||
|
value: ValueType;
|
||||||
|
onClearRequest: (name: string) => void;
|
||||||
|
onChange: (name: string, type: string, value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ValueTypeComponent = (props: ValueTypeComponentProps) => {
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
<TextField
|
||||||
|
type={props.type}
|
||||||
|
placeholder={props.placeholder}
|
||||||
|
multiline={props.multiline}
|
||||||
|
style={props.style}
|
||||||
|
value={props.value.value}
|
||||||
|
onChange={(event: React.ChangeEvent<any>) => props.onChange(props.name, props.value.type, event.target.value)}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
onClick={() => props.onClearRequest(props.name)}
|
||||||
|
title="Remove"
|
||||||
|
>
|
||||||
|
<IconClear />
|
||||||
|
</IconButton>
|
||||||
|
<TypeSelector
|
||||||
|
value={props.value.type}
|
||||||
|
types={props.types}
|
||||||
|
onChange={(event: React.ChangeEvent<HTMLSelectElement>) => (
|
||||||
|
props.onChange(props.name, event.target.value, props.value.value)
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PropsType {
|
||||||
|
collections: CachedCollection[];
|
||||||
|
initialCollection?: string;
|
||||||
|
item?: ContactType;
|
||||||
|
onSave: (contact: ContactType, collectionUid: string, originalContact?: ContactType) => Promise<void>;
|
||||||
|
onDelete: (contact: ContactType, collectionUid: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
history: History<any>;
|
||||||
|
allGroups: ContactType[];
|
||||||
|
}
|
||||||
|
|
||||||
|
class ContactEdit extends React.PureComponent<PropsType> {
|
||||||
|
public state: {
|
||||||
|
uid: string;
|
||||||
|
fn: string;
|
||||||
|
lastName: string;
|
||||||
|
firstName: string;
|
||||||
|
middleName: string;
|
||||||
|
namePrefix: string;
|
||||||
|
nameSuffix: string;
|
||||||
|
phone: ValueType[];
|
||||||
|
email: ValueType[];
|
||||||
|
address: ValueType[];
|
||||||
|
impp: ValueType[];
|
||||||
|
org: string;
|
||||||
|
note: string;
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
collectionUid: string;
|
||||||
|
showDeleteDialog: boolean;
|
||||||
|
collectionGroups: {};
|
||||||
|
newGroups: string[];
|
||||||
|
originalGroups: {};
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(props: PropsType) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
uid: "",
|
||||||
|
fn: "",
|
||||||
|
lastName: "",
|
||||||
|
firstName: "",
|
||||||
|
middleName: "",
|
||||||
|
namePrefix: "",
|
||||||
|
nameSuffix: "",
|
||||||
|
phone: [new ValueType()],
|
||||||
|
email: [new ValueType()],
|
||||||
|
address: [new ValueType()],
|
||||||
|
impp: [new ValueType("jabber")],
|
||||||
|
org: "",
|
||||||
|
note: "",
|
||||||
|
title: "",
|
||||||
|
|
||||||
|
collectionUid: "",
|
||||||
|
showDeleteDialog: false,
|
||||||
|
collectionGroups: {},
|
||||||
|
newGroups: [],
|
||||||
|
originalGroups: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.props.item !== undefined) {
|
||||||
|
const contact = this.props.item;
|
||||||
|
|
||||||
|
this.state.uid = contact.uid;
|
||||||
|
this.state.fn = contact.fn ? contact.fn : "";
|
||||||
|
if (contact.n) {
|
||||||
|
this.state.lastName = contact.n[0];
|
||||||
|
this.state.firstName = contact.n[1];
|
||||||
|
this.state.middleName = contact.n[2];
|
||||||
|
this.state.namePrefix = contact.n[3];
|
||||||
|
this.state.nameSuffix = contact.n[4];
|
||||||
|
} else {
|
||||||
|
let name = this.state.fn.trim().split(",");
|
||||||
|
if (name.length > 2 && name[0] !== "" && name[name.length - 1] !== "") {
|
||||||
|
this.state.nameSuffix = name.pop() || "";
|
||||||
|
}
|
||||||
|
name = name.join(",").split(" ");
|
||||||
|
if (name.length === 1) {
|
||||||
|
this.state.firstName = name[0];
|
||||||
|
} else if (name.length === 2) {
|
||||||
|
this.state.firstName = name[0];
|
||||||
|
this.state.lastName = name[1];
|
||||||
|
} else if (name.length > 2) {
|
||||||
|
this.state.firstName = name.slice(0, name.length - 2).join(" ");
|
||||||
|
this.state.middleName = name[name.length - 2];
|
||||||
|
this.state.lastName = name[name.length - 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIXME: Am I really getting all the values this way?
|
||||||
|
const propToValueType = (comp: ICAL.Component, propName: string) => (
|
||||||
|
comp.getAllProperties(propName).map((prop) => (
|
||||||
|
new ValueType(
|
||||||
|
prop.toJSON()[1].type,
|
||||||
|
prop.getFirstValue()
|
||||||
|
)
|
||||||
|
))
|
||||||
|
);
|
||||||
|
|
||||||
|
this.state.phone = propToValueType(contact.comp, "tel");
|
||||||
|
this.state.email = propToValueType(contact.comp, "email");
|
||||||
|
this.state.address = propToValueType(contact.comp, "adr");
|
||||||
|
this.state.impp = propToValueType(contact.comp, "impp");
|
||||||
|
|
||||||
|
const propToStringType = (comp: ICAL.Component, propName: string) => {
|
||||||
|
const val = comp.getFirstPropertyValue(propName);
|
||||||
|
return val ? val : "";
|
||||||
|
};
|
||||||
|
|
||||||
|
this.state.org = propToStringType(contact.comp, "org");
|
||||||
|
this.state.title = propToStringType(contact.comp, "title");
|
||||||
|
this.state.note = propToStringType(contact.comp, "note");
|
||||||
|
|
||||||
|
} else {
|
||||||
|
this.state.uid = uuid.v4();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.initialCollection) {
|
||||||
|
this.state.collectionUid = props.initialCollection;
|
||||||
|
} else if (props.collections[0]) {
|
||||||
|
this.state.collectionUid = props.collections[0].collection.uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state.collectionGroups = this.getCollectionGroups(this.state.collectionUid);
|
||||||
|
Object.values(this.state.collectionGroups).forEach((group: ContactType) => {
|
||||||
|
if (group.members.includes(this.state.uid)) {
|
||||||
|
this.state.newGroups.push(group.fn);
|
||||||
|
this.state.originalGroups[group.fn] = undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.onSubmit = this.onSubmit.bind(this);
|
||||||
|
this.addMetadata = this.addMetadata.bind(this);
|
||||||
|
this.getCollectionGroups = this.getCollectionGroups.bind(this);
|
||||||
|
this.handleChange = this.handleChange.bind(this);
|
||||||
|
this.handleInputChange = this.handleInputChange.bind(this);
|
||||||
|
this.handleCollectionChange = this.handleCollectionChange.bind(this);
|
||||||
|
this.reloadGroupSuggestions = this.reloadGroupSuggestions.bind(this);
|
||||||
|
this.handleValueTypeChange = this.handleValueTypeChange.bind(this);
|
||||||
|
this.addValueType = this.addValueType.bind(this);
|
||||||
|
this.removeValueType = this.removeValueType.bind(this);
|
||||||
|
this.onDeleteRequest = this.onDeleteRequest.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public addValueType(name: string, _type?: string) {
|
||||||
|
const type = _type ? _type : "home";
|
||||||
|
this.setState((prevState) => {
|
||||||
|
const newArray = prevState[name].slice(0);
|
||||||
|
newArray.push(new ValueType(type));
|
||||||
|
return {
|
||||||
|
...prevState,
|
||||||
|
[name]: newArray,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public removeValueType(name: string, idx: number) {
|
||||||
|
this.setState((prevState) => {
|
||||||
|
const newArray = prevState[name].slice(0);
|
||||||
|
newArray.splice(idx, 1);
|
||||||
|
return {
|
||||||
|
...prevState,
|
||||||
|
[name]: newArray,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public handleValueTypeChange(name: string, idx: number, value: ValueType) {
|
||||||
|
this.setState((prevState) => {
|
||||||
|
const newArray = prevState[name].slice(0);
|
||||||
|
newArray[idx] = value;
|
||||||
|
return {
|
||||||
|
...prevState,
|
||||||
|
[name]: newArray,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public handleChange(name: string, value: string | string[]) {
|
||||||
|
this.setState({
|
||||||
|
[name]: value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public getCollectionGroups(collectionUid: string) {
|
||||||
|
const groups = {};
|
||||||
|
this.props.allGroups.forEach((group) => {
|
||||||
|
if (collectionUid === group.collectionUid) {
|
||||||
|
groups[group.fn] = group;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
public reloadGroupSuggestions(collectionUid: string) {
|
||||||
|
this.setState({
|
||||||
|
collectionGroups: this.getCollectionGroups(collectionUid),
|
||||||
|
newGroups: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public handleCollectionChange(contact: any) {
|
||||||
|
const name = contact.target.name;
|
||||||
|
const value = contact.target.value;
|
||||||
|
this.reloadGroupSuggestions(value);
|
||||||
|
this.handleChange(name, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public handleInputChange(contact: any) {
|
||||||
|
const name = contact.target.name;
|
||||||
|
const value = contact.target.value;
|
||||||
|
this.handleChange(name, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public addMetadata(item: ContactType, uid: string, isGroup: boolean) {
|
||||||
|
const comp = item.comp;
|
||||||
|
comp.updatePropertyWithValue("prodid", "-//iCal.js EteSync Web");
|
||||||
|
comp.updatePropertyWithValue("version", "4.0");
|
||||||
|
comp.updatePropertyWithValue("uid", uid);
|
||||||
|
comp.updatePropertyWithValue("rev", ICAL.Time.now());
|
||||||
|
if (isGroup) {
|
||||||
|
comp.updatePropertyWithValue("kind", "group");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public onSubmit(e: React.FormEvent<any>) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const contact = (this.props.item) ?
|
||||||
|
this.props.item.clone()
|
||||||
|
:
|
||||||
|
new ContactType(new ICAL.Component(["vcard", [], []]))
|
||||||
|
;
|
||||||
|
|
||||||
|
const comp = contact.comp;
|
||||||
|
this.addMetadata(contact, this.state.uid, false);
|
||||||
|
|
||||||
|
// Add new groups
|
||||||
|
this.state.newGroups.forEach((group) => {
|
||||||
|
if (!this.state.collectionGroups[group]) {
|
||||||
|
const newGroup = new ContactType(new ICAL.Component(["vcard", [], []]));
|
||||||
|
this.addMetadata(newGroup, uuid.v4(), true);
|
||||||
|
newGroup.comp.updatePropertyWithValue("fn", group.trim());
|
||||||
|
newGroup.comp.updatePropertyWithValue("member", `urn:uuid:${this.state.uid}`);
|
||||||
|
this.props.onSave(newGroup, this.state.collectionUid, undefined);
|
||||||
|
} else if (!(group in this.state.originalGroups)) {
|
||||||
|
const oldGroup = this.state.collectionGroups[group];
|
||||||
|
const updatedGroup = oldGroup.clone();
|
||||||
|
updatedGroup.comp.addPropertyWithValue("member", `urn:uuid:${this.state.uid}`);
|
||||||
|
this.props.onSave(updatedGroup, this.state.collectionUid, oldGroup);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove deleted groups
|
||||||
|
Object.keys(this.state.originalGroups).filter((x) => !this.state.newGroups.includes(x)).forEach((removed) => {
|
||||||
|
const deletedGroup = this.state.collectionGroups[removed];
|
||||||
|
const updatedGroup = deletedGroup.clone();
|
||||||
|
const members = updatedGroup.members.filter((uid: string) => uid !== this.state.uid);
|
||||||
|
updatedGroup.comp.removeAllProperties("member");
|
||||||
|
members.forEach((m: string) => updatedGroup.comp.addPropertyWithValue("member", `urn:uuid:${m}`));
|
||||||
|
this.props.onSave(updatedGroup, this.state.collectionUid, deletedGroup);
|
||||||
|
});
|
||||||
|
|
||||||
|
const lastName = this.state.lastName.trim();
|
||||||
|
const firstName = this.state.firstName.trim();
|
||||||
|
const middleName = this.state.middleName.trim();
|
||||||
|
const namePrefix = this.state.namePrefix.trim();
|
||||||
|
const nameSuffix = this.state.nameSuffix.trim();
|
||||||
|
|
||||||
|
let fn = `${namePrefix} ${firstName} ${middleName} ${lastName}`.trim();
|
||||||
|
|
||||||
|
if (fn === "") {
|
||||||
|
fn = nameSuffix;
|
||||||
|
} else if (nameSuffix !== "") {
|
||||||
|
fn = `${fn}, ${nameSuffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
comp.updatePropertyWithValue("fn", fn);
|
||||||
|
|
||||||
|
const name = [lastName,
|
||||||
|
firstName,
|
||||||
|
middleName,
|
||||||
|
namePrefix,
|
||||||
|
nameSuffix,
|
||||||
|
];
|
||||||
|
|
||||||
|
comp.updatePropertyWithValue("n", name);
|
||||||
|
|
||||||
|
function setProperties(name: string, source: ValueType[]) {
|
||||||
|
comp.removeAllProperties(name);
|
||||||
|
source.forEach((x) => {
|
||||||
|
if (x.value === "") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prop = new ICAL.Property(name, comp);
|
||||||
|
prop.setParameter("type", x.type);
|
||||||
|
prop.setValue(x.value);
|
||||||
|
comp.addProperty(prop);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setProperties("tel", this.state.phone);
|
||||||
|
setProperties("email", this.state.email);
|
||||||
|
setProperties("adr", this.state.address);
|
||||||
|
setProperties("impp", this.state.impp.map((x) => (
|
||||||
|
{ type: x.type, value: x.type + ":" + x.value }
|
||||||
|
)));
|
||||||
|
|
||||||
|
function setProperty(name: string, value: string) {
|
||||||
|
comp.removeAllProperties(name);
|
||||||
|
if (value !== "") {
|
||||||
|
comp.updatePropertyWithValue(name, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setProperty("org", this.state.org);
|
||||||
|
setProperty("title", this.state.title);
|
||||||
|
setProperty("note", this.state.note);
|
||||||
|
|
||||||
|
this.props.onSave(contact, this.state.collectionUid, this.props.item)
|
||||||
|
.then(() => {
|
||||||
|
this.props.history.goBack();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public onDeleteRequest() {
|
||||||
|
this.setState({
|
||||||
|
showDeleteDialog: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public render() {
|
||||||
|
const styles = {
|
||||||
|
form: {
|
||||||
|
},
|
||||||
|
fullWidth: {
|
||||||
|
width: "100%",
|
||||||
|
boxSizing: "border-box" as any,
|
||||||
|
},
|
||||||
|
submit: {
|
||||||
|
marginTop: 40,
|
||||||
|
marginBottom: 20,
|
||||||
|
textAlign: "right" as any,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
<h2>
|
||||||
|
{this.props.item ? "Edit Contact" : "New Contact"}
|
||||||
|
</h2>
|
||||||
|
<form style={styles.form} onSubmit={this.onSubmit}>
|
||||||
|
<FormControl disabled={this.props.item !== undefined} style={styles.fullWidth}>
|
||||||
|
<InputLabel>
|
||||||
|
Saving to
|
||||||
|
</InputLabel>
|
||||||
|
<Select
|
||||||
|
name="collectionUid"
|
||||||
|
value={this.state.collectionUid}
|
||||||
|
onChange={this.handleCollectionChange}
|
||||||
|
>
|
||||||
|
{this.props.collections.map((x) => (
|
||||||
|
<MenuItem key={x.collection.uid} value={x.collection.uid}>{x.metadata.name}</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
name="namePrefix"
|
||||||
|
placeholder="Prefix"
|
||||||
|
style={{ marginTop: "2rem", ...styles.fullWidth }}
|
||||||
|
value={this.state.namePrefix}
|
||||||
|
onChange={this.handleInputChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
name="firstName"
|
||||||
|
placeholder="First name"
|
||||||
|
style={{ marginTop: "2rem", ...styles.fullWidth }}
|
||||||
|
value={this.state.firstName}
|
||||||
|
onChange={this.handleInputChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
name="middleName"
|
||||||
|
placeholder="Middle name"
|
||||||
|
style={{ marginTop: "2rem", ...styles.fullWidth }}
|
||||||
|
value={this.state.middleName}
|
||||||
|
onChange={this.handleInputChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
name="lastName"
|
||||||
|
placeholder="Last name"
|
||||||
|
style={{ marginTop: "2rem", ...styles.fullWidth }}
|
||||||
|
value={this.state.lastName}
|
||||||
|
onChange={this.handleInputChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
name="nameSuffix"
|
||||||
|
placeholder="Suffix"
|
||||||
|
style={{ marginTop: "2rem", ...styles.fullWidth }}
|
||||||
|
value={this.state.nameSuffix}
|
||||||
|
onChange={this.handleInputChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
Phone numbers
|
||||||
|
<IconButton
|
||||||
|
onClick={() => this.addValueType("phone")}
|
||||||
|
title="Add phone number"
|
||||||
|
>
|
||||||
|
<IconAdd />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
{this.state.phone.map((x, idx) => (
|
||||||
|
<ValueTypeComponent
|
||||||
|
key={idx}
|
||||||
|
name="phone"
|
||||||
|
placeholder="Phone"
|
||||||
|
types={telTypes}
|
||||||
|
value={x}
|
||||||
|
onClearRequest={(name: string) => this.removeValueType(name, idx)}
|
||||||
|
onChange={(name: string, type: string, value: string) => (
|
||||||
|
this.handleValueTypeChange(name, idx, { type, value })
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
Emails
|
||||||
|
<IconButton
|
||||||
|
onClick={() => this.addValueType("email")}
|
||||||
|
title="Add email address"
|
||||||
|
>
|
||||||
|
<IconAdd />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
{this.state.email.map((x, idx) => (
|
||||||
|
<ValueTypeComponent
|
||||||
|
key={idx}
|
||||||
|
name="email"
|
||||||
|
placeholder="Email"
|
||||||
|
types={emailTypes}
|
||||||
|
value={x}
|
||||||
|
onClearRequest={(name: string) => this.removeValueType(name, idx)}
|
||||||
|
onChange={(name: string, type: string, value: string) => (
|
||||||
|
this.handleValueTypeChange(name, idx, { type, value })
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
IMPP
|
||||||
|
<IconButton
|
||||||
|
onClick={() => this.addValueType("impp", "jabber")}
|
||||||
|
title="Add impp address"
|
||||||
|
>
|
||||||
|
<IconAdd />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
{this.state.impp.map((x, idx) => (
|
||||||
|
<ValueTypeComponent
|
||||||
|
key={idx}
|
||||||
|
name="impp"
|
||||||
|
placeholder="IMPP"
|
||||||
|
types={imppTypes}
|
||||||
|
value={x}
|
||||||
|
onClearRequest={(name: string) => this.removeValueType(name, idx)}
|
||||||
|
onChange={(name: string, type: string, value: string) => (
|
||||||
|
this.handleValueTypeChange(name, idx, { type, value })
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
Addresses
|
||||||
|
<IconButton
|
||||||
|
onClick={() => this.addValueType("address")}
|
||||||
|
title="Add address"
|
||||||
|
>
|
||||||
|
<IconAdd />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
{this.state.address.map((x, idx) => (
|
||||||
|
<ValueTypeComponent
|
||||||
|
key={idx}
|
||||||
|
name="address"
|
||||||
|
placeholder="Address"
|
||||||
|
types={addressTypes}
|
||||||
|
multiline
|
||||||
|
value={x}
|
||||||
|
onClearRequest={(name: string) => this.removeValueType(name, idx)}
|
||||||
|
onChange={(name: string, type: string, value: string) => (
|
||||||
|
this.handleValueTypeChange(name, idx, { type, value })
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
name="org"
|
||||||
|
placeholder="Organization"
|
||||||
|
style={styles.fullWidth}
|
||||||
|
value={this.state.org}
|
||||||
|
onChange={this.handleInputChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
name="title"
|
||||||
|
placeholder="Title"
|
||||||
|
style={styles.fullWidth}
|
||||||
|
value={this.state.title}
|
||||||
|
onChange={this.handleInputChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
name="note"
|
||||||
|
multiline
|
||||||
|
placeholder="Note"
|
||||||
|
style={styles.fullWidth}
|
||||||
|
value={this.state.note}
|
||||||
|
onChange={this.handleInputChange}
|
||||||
|
/>
|
||||||
|
<Autocomplete
|
||||||
|
style={styles.fullWidth}
|
||||||
|
freeSolo
|
||||||
|
multiple
|
||||||
|
clearOnBlur
|
||||||
|
selectOnFocus
|
||||||
|
options={Object.keys(this.state.collectionGroups)}
|
||||||
|
value={this.state.newGroups}
|
||||||
|
onChange={(_e, value) => this.handleChange("newGroups", value)}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
variant="standard"
|
||||||
|
label="Groups"
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={styles.submit}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={this.props.onCancel}
|
||||||
|
>
|
||||||
|
<IconCancel style={{ marginRight: 8 }} />
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{this.props.item &&
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
style={{ marginLeft: 15, backgroundColor: colors.red[500], color: "white" }}
|
||||||
|
onClick={this.onDeleteRequest}
|
||||||
|
>
|
||||||
|
<IconDelete style={{ marginRight: 8 }} />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
color="secondary"
|
||||||
|
style={{ marginLeft: 15 }}
|
||||||
|
>
|
||||||
|
<IconSave style={{ marginRight: 8 }} />
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<ConfirmationDialog
|
||||||
|
title="Delete Confirmation"
|
||||||
|
labelOk="Delete"
|
||||||
|
open={this.state.showDeleteDialog}
|
||||||
|
onOk={() => this.props.onDelete(this.props.item!, this.props.initialCollection!)}
|
||||||
|
onCancel={() => this.setState({ showDeleteDialog: false })}
|
||||||
|
>
|
||||||
|
Are you sure you would like to delete this contact?
|
||||||
|
</ConfirmationDialog>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ContactEdit;
|
@ -0,0 +1,288 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import Button from "@material-ui/core/Button";
|
||||||
|
import TextField from "@material-ui/core/TextField";
|
||||||
|
import Select from "@material-ui/core/Select";
|
||||||
|
import MenuItem from "@material-ui/core/MenuItem";
|
||||||
|
import FormControl from "@material-ui/core/FormControl";
|
||||||
|
import InputLabel from "@material-ui/core/InputLabel";
|
||||||
|
import * as colors from "@material-ui/core/colors";
|
||||||
|
|
||||||
|
import IconDelete from "@material-ui/icons/Delete";
|
||||||
|
import IconCancel from "@material-ui/icons/Clear";
|
||||||
|
import IconSave from "@material-ui/icons/Save";
|
||||||
|
|
||||||
|
import ConfirmationDialog from "../widgets/ConfirmationDialog";
|
||||||
|
|
||||||
|
import { CachedCollection } from "../Pim/helpers";
|
||||||
|
|
||||||
|
import * as uuid from "uuid";
|
||||||
|
import * as ICAL from "ical.js";
|
||||||
|
|
||||||
|
import { ContactType } from "../pim-types";
|
||||||
|
|
||||||
|
import { History } from "history";
|
||||||
|
|
||||||
|
class ValueType {
|
||||||
|
public type: string;
|
||||||
|
public value: string;
|
||||||
|
|
||||||
|
constructor(type?: string, value?: string) {
|
||||||
|
this.type = type ? type : "home";
|
||||||
|
this.value = value ? value : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PropsType {
|
||||||
|
collections: CachedCollection[];
|
||||||
|
initialCollection?: string;
|
||||||
|
item?: ContactType;
|
||||||
|
onSave: (contact: ContactType, collectionUid: string, originalContact?: ContactType) => Promise<void>;
|
||||||
|
onDelete: (contact: ContactType, collectionUid: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
history: History<any>;
|
||||||
|
allGroups: ContactType[];
|
||||||
|
}
|
||||||
|
|
||||||
|
class GroupEdit extends React.PureComponent<PropsType> {
|
||||||
|
public state: {
|
||||||
|
uid: string;
|
||||||
|
fn: string;
|
||||||
|
collectionUid: string;
|
||||||
|
showDeleteDialog: boolean;
|
||||||
|
collectionGroups: {};
|
||||||
|
showError: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(props: PropsType) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
uid: "",
|
||||||
|
fn: "",
|
||||||
|
collectionUid: "",
|
||||||
|
showDeleteDialog: false,
|
||||||
|
collectionGroups: {},
|
||||||
|
showError: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.props.item !== undefined) {
|
||||||
|
const group = this.props.item;
|
||||||
|
this.state.uid = group.uid;
|
||||||
|
this.state.fn = group.fn;
|
||||||
|
} else {
|
||||||
|
this.state.uid = uuid.v4();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.initialCollection) {
|
||||||
|
this.state.collectionUid = props.initialCollection;
|
||||||
|
} else if (props.collections[0]) {
|
||||||
|
this.state.collectionUid = props.collections[0].collection.uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state.collectionGroups = this.getCollectionGroups(this.state.collectionUid);
|
||||||
|
|
||||||
|
this.onSubmit = this.onSubmit.bind(this);
|
||||||
|
this.getCollectionGroups = this.getCollectionGroups.bind(this);
|
||||||
|
this.handleChange = this.handleChange.bind(this);
|
||||||
|
this.handleCollectionChange = this.handleCollectionChange.bind(this);
|
||||||
|
this.handleInputChange = this.handleInputChange.bind(this);
|
||||||
|
this.handleValueTypeChange = this.handleValueTypeChange.bind(this);
|
||||||
|
this.addValueType = this.addValueType.bind(this);
|
||||||
|
this.removeValueType = this.removeValueType.bind(this);
|
||||||
|
this.onDeleteRequest = this.onDeleteRequest.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public addValueType(name: string, _type?: string) {
|
||||||
|
const type = _type ? _type : "home";
|
||||||
|
this.setState((prevState) => {
|
||||||
|
const newArray = prevState[name].slice(0);
|
||||||
|
newArray.push(new ValueType(type));
|
||||||
|
return {
|
||||||
|
...prevState,
|
||||||
|
[name]: newArray,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public removeValueType(name: string, idx: number) {
|
||||||
|
this.setState((prevState) => {
|
||||||
|
const newArray = prevState[name].slice(0);
|
||||||
|
newArray.splice(idx, 1);
|
||||||
|
return {
|
||||||
|
...prevState,
|
||||||
|
[name]: newArray,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public handleValueTypeChange(name: string, idx: number, value: ValueType) {
|
||||||
|
this.setState((prevState) => {
|
||||||
|
const newArray = prevState[name].slice(0);
|
||||||
|
newArray[idx] = value;
|
||||||
|
return {
|
||||||
|
...prevState,
|
||||||
|
[name]: newArray,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public handleChange(name: string, value: string) {
|
||||||
|
this.setState({
|
||||||
|
[name]: value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public getCollectionGroups(collectionUid: string) {
|
||||||
|
const groups = {};
|
||||||
|
this.props.allGroups.forEach((group) => {
|
||||||
|
if (collectionUid === group.collectionUid) {
|
||||||
|
groups[group.fn] = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
public handleCollectionChange(contact: any) {
|
||||||
|
const name = contact.target.name;
|
||||||
|
const collectionUid: string = contact.target.value;
|
||||||
|
this.handleChange(name, collectionUid);
|
||||||
|
this.setState({ "collectionGroups": this.getCollectionGroups(collectionUid) });
|
||||||
|
}
|
||||||
|
|
||||||
|
public handleInputChange(contact: any) {
|
||||||
|
const name = contact.target.name;
|
||||||
|
const value = contact.target.value;
|
||||||
|
this.handleChange(name, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public onSubmit(e: React.FormEvent<any>) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const nameUsed = this.state.fn in this.state.collectionGroups;
|
||||||
|
if ((this.props.item && this.state.fn !== this.props.item.fn && nameUsed) || (!this.props.item && nameUsed)) {
|
||||||
|
this.setState({ showError: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = (this.props.item) ?
|
||||||
|
this.props.item.clone()
|
||||||
|
:
|
||||||
|
new ContactType(new ICAL.Component(["vcard", [], []]))
|
||||||
|
;
|
||||||
|
|
||||||
|
const comp = group.comp;
|
||||||
|
comp.updatePropertyWithValue("prodid", "-//iCal.js EteSync Web");
|
||||||
|
comp.updatePropertyWithValue("version", "4.0");
|
||||||
|
comp.updatePropertyWithValue("uid", this.state.uid);
|
||||||
|
comp.updatePropertyWithValue("rev", ICAL.Time.now());
|
||||||
|
comp.updatePropertyWithValue("kind", "group");
|
||||||
|
comp.updatePropertyWithValue("fn", this.state.fn.trim());
|
||||||
|
|
||||||
|
this.props.onSave(group, this.state.collectionUid, this.props.item)
|
||||||
|
.then(() => {
|
||||||
|
this.props.history.goBack();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public onDeleteRequest() {
|
||||||
|
this.setState({
|
||||||
|
showDeleteDialog: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public render() {
|
||||||
|
const styles = {
|
||||||
|
form: {
|
||||||
|
},
|
||||||
|
fullWidth: {
|
||||||
|
width: "100%",
|
||||||
|
boxSizing: "border-box" as any,
|
||||||
|
},
|
||||||
|
submit: {
|
||||||
|
marginTop: 40,
|
||||||
|
marginBottom: 20,
|
||||||
|
textAlign: "right" as any,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
<h2>
|
||||||
|
{this.props.item ? "Edit Group" : "New Group"}
|
||||||
|
</h2>
|
||||||
|
<form style={styles.form} onSubmit={this.onSubmit}>
|
||||||
|
<FormControl disabled={this.props.item !== undefined} style={styles.fullWidth}>
|
||||||
|
<InputLabel>
|
||||||
|
Saving to
|
||||||
|
</InputLabel>
|
||||||
|
<Select
|
||||||
|
name="collectionUid"
|
||||||
|
value={this.state.collectionUid}
|
||||||
|
onChange={this.handleCollectionChange}
|
||||||
|
>
|
||||||
|
{this.props.collections.map((x) => (
|
||||||
|
<MenuItem key={x.collection.uid} value={x.collection.uid}>{x.metadata.name}</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
name="fn"
|
||||||
|
placeholder="Name"
|
||||||
|
error={this.state.showError}
|
||||||
|
helperText="Group names must be unique"
|
||||||
|
style={{ marginTop: "2rem", ...styles.fullWidth }}
|
||||||
|
value={this.state.fn}
|
||||||
|
onChange={this.handleInputChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={styles.submit}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={this.props.onCancel}
|
||||||
|
>
|
||||||
|
<IconCancel style={{ marginRight: 8 }} />
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{this.props.item &&
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
style={{ marginLeft: 15, backgroundColor: colors.red[500], color: "white" }}
|
||||||
|
onClick={this.onDeleteRequest}
|
||||||
|
>
|
||||||
|
<IconDelete style={{ marginRight: 8 }} />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
color="secondary"
|
||||||
|
style={{ marginLeft: 15 }}
|
||||||
|
disabled={this.state.fn.length === 0}
|
||||||
|
>
|
||||||
|
<IconSave style={{ marginRight: 8 }} />
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<ConfirmationDialog
|
||||||
|
title="Delete Confirmation"
|
||||||
|
labelOk="Delete"
|
||||||
|
open={this.state.showDeleteDialog}
|
||||||
|
onOk={() => this.props.onDelete(this.props.item!, this.props.initialCollection!)}
|
||||||
|
onCancel={() => this.setState({ showDeleteDialog: false })}
|
||||||
|
>
|
||||||
|
Are you sure you would like to delete this group?
|
||||||
|
</ConfirmationDialog>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GroupEdit;
|
@ -0,0 +1,242 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2020 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Switch, Route, useHistory } from "react-router";
|
||||||
|
|
||||||
|
import * as Etebase from "etebase";
|
||||||
|
|
||||||
|
import { Button, useTheme } from "@material-ui/core";
|
||||||
|
import IconEdit from "@material-ui/icons/Edit";
|
||||||
|
import IconChangeHistory from "@material-ui/icons/ChangeHistory";
|
||||||
|
|
||||||
|
import { ContactType, PimType } from "../pim-types";
|
||||||
|
import { useCredentials } from "../credentials";
|
||||||
|
import { useItems, useCollections } from "../etebase-helpers";
|
||||||
|
import { routeResolver } from "../App";
|
||||||
|
import SearchableAddressBook from "./SearchableAddressBook";
|
||||||
|
import Contact from "./Contact";
|
||||||
|
import LoadingIndicator from "../widgets/LoadingIndicator";
|
||||||
|
import ContactEdit from "./ContactEdit";
|
||||||
|
import GroupEdit from "./GroupEdit";
|
||||||
|
import PageNotFound, { PageNotFoundRoute } from "../PageNotFound";
|
||||||
|
|
||||||
|
import { CachedCollection, getItemNavigationUid, getDecryptCollectionsFunction, getDecryptItemsFunction, PimFab, itemSave, itemDelete } from "../Pim/helpers";
|
||||||
|
import ItemChangeHistory from "../Pim/ItemChangeHistory";
|
||||||
|
|
||||||
|
const colType = "etebase.vcard";
|
||||||
|
|
||||||
|
const decryptCollections = getDecryptCollectionsFunction(colType);
|
||||||
|
const decryptItems = getDecryptItemsFunction(colType, ContactType.parse);
|
||||||
|
|
||||||
|
export default function ContactsMain() {
|
||||||
|
const [entries, setEntries] = React.useState<Map<string, Map<string, ContactType>>>();
|
||||||
|
const [cachedCollections, setCachedCollections] = React.useState<CachedCollection[]>();
|
||||||
|
const theme = useTheme();
|
||||||
|
const history = useHistory();
|
||||||
|
const etebase = useCredentials()!;
|
||||||
|
const collections = useCollections(etebase, colType);
|
||||||
|
const items = useItems(etebase, colType);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!collections || !items) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
(async () => {
|
||||||
|
const colEntries = await decryptCollections(collections);
|
||||||
|
const entries = await decryptItems(items);
|
||||||
|
|
||||||
|
setCachedCollections(colEntries);
|
||||||
|
setEntries(entries);
|
||||||
|
})();
|
||||||
|
}, [items, collections]);
|
||||||
|
|
||||||
|
if (!entries || !cachedCollections) {
|
||||||
|
return (
|
||||||
|
<LoadingIndicator />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onItemSave(item: PimType, collectionUid: string, originalItem?: PimType): Promise<void> {
|
||||||
|
const collection = collections!.find((x) => x.uid === collectionUid)!;
|
||||||
|
await itemSave(etebase, collection, items!, item, collectionUid, originalItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onItemDelete(item: PimType, collectionUid: string) {
|
||||||
|
const collection = collections!.find((x) => x.uid === collectionUid)!;
|
||||||
|
await itemDelete(etebase, collection, items!, item, collectionUid);
|
||||||
|
|
||||||
|
history.push(routeResolver.getRoute("pim.contacts"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCancel() {
|
||||||
|
history.goBack();
|
||||||
|
}
|
||||||
|
|
||||||
|
const flatEntries = [];
|
||||||
|
for (const col of entries.values()) {
|
||||||
|
for (const item of col.values()) {
|
||||||
|
flatEntries.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allGroups = flatEntries.filter((x) => x.group);
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
button: {
|
||||||
|
marginLeft: theme.spacing(1),
|
||||||
|
},
|
||||||
|
leftIcon: {
|
||||||
|
marginRight: theme.spacing(1),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Switch>
|
||||||
|
<Route
|
||||||
|
path={routeResolver.getRoute("pim.contacts")}
|
||||||
|
exact
|
||||||
|
>
|
||||||
|
<SearchableAddressBook
|
||||||
|
entries={flatEntries}
|
||||||
|
onItemClick={(item) => history.push(
|
||||||
|
routeResolver.getRoute("pim.contacts._id", { itemUid: getItemNavigationUid(item) })
|
||||||
|
)}
|
||||||
|
onNewGroupClick={() => history.push(
|
||||||
|
routeResolver.getRoute("pim.contacts.new.group")
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<PimFab
|
||||||
|
onClick={() => history.push(
|
||||||
|
routeResolver.getRoute("pim.contacts.new.contact")
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
<Route
|
||||||
|
path={routeResolver.getRoute("pim.contacts.new.group")}
|
||||||
|
exact
|
||||||
|
>
|
||||||
|
<GroupEdit
|
||||||
|
collections={cachedCollections}
|
||||||
|
onSave={onItemSave}
|
||||||
|
onDelete={onItemDelete}
|
||||||
|
onCancel={onCancel}
|
||||||
|
history={history}
|
||||||
|
allGroups={allGroups}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
<Route
|
||||||
|
path={routeResolver.getRoute("pim.contacts.new.contact")}
|
||||||
|
exact
|
||||||
|
>
|
||||||
|
<ContactEdit
|
||||||
|
collections={cachedCollections}
|
||||||
|
onSave={onItemSave}
|
||||||
|
onDelete={onItemDelete}
|
||||||
|
onCancel={onCancel}
|
||||||
|
history={history}
|
||||||
|
allGroups={allGroups}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
<Route
|
||||||
|
path={routeResolver.getRoute("pim.contacts._id.log")}
|
||||||
|
render={({ match }) => {
|
||||||
|
// We have this path outside because we don't want the item existing check
|
||||||
|
const [colUid, itemUid] = match.params.itemUid.split("|");
|
||||||
|
const cachedCollection = cachedCollections!.find((x) => x.collection.uid === colUid)!;
|
||||||
|
if (!cachedCollection) {
|
||||||
|
return (<PageNotFound />);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ItemChangeHistory collection={cachedCollection} itemUid={itemUid} />
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={routeResolver.getRoute("pim.contacts._id")}
|
||||||
|
render={({ match }) => {
|
||||||
|
const [colUid, itemUid] = match.params.itemUid.split("|");
|
||||||
|
const item = entries.get(colUid)?.get(itemUid);
|
||||||
|
if (!item) {
|
||||||
|
return (<PageNotFound />);
|
||||||
|
}
|
||||||
|
|
||||||
|
const collection = collections!.find((x) => x.uid === colUid)!;
|
||||||
|
const readOnly = collection.accessLevel === Etebase.CollectionAccessLevel.ReadOnly;
|
||||||
|
const path = `pim.contacts._id.edit.${item.group ? "group" : "contact"}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Switch>
|
||||||
|
<Route
|
||||||
|
path={routeResolver.getRoute(path)}
|
||||||
|
exact
|
||||||
|
>
|
||||||
|
{item.group ?
|
||||||
|
<GroupEdit
|
||||||
|
key={itemUid}
|
||||||
|
initialCollection={item.collectionUid}
|
||||||
|
item={item}
|
||||||
|
collections={cachedCollections}
|
||||||
|
onSave={onItemSave}
|
||||||
|
onDelete={onItemDelete}
|
||||||
|
onCancel={onCancel}
|
||||||
|
history={history}
|
||||||
|
allGroups={allGroups}
|
||||||
|
/>
|
||||||
|
:
|
||||||
|
<ContactEdit
|
||||||
|
key={itemUid}
|
||||||
|
initialCollection={item.collectionUid}
|
||||||
|
item={item}
|
||||||
|
collections={cachedCollections}
|
||||||
|
onSave={onItemSave}
|
||||||
|
onDelete={onItemDelete}
|
||||||
|
onCancel={onCancel}
|
||||||
|
history={history}
|
||||||
|
allGroups={allGroups}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</Route>
|
||||||
|
<Route
|
||||||
|
path={routeResolver.getRoute("pim.contacts._id")}
|
||||||
|
exact
|
||||||
|
>
|
||||||
|
<div style={{ textAlign: "right", marginBottom: 15 }}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
style={styles.button}
|
||||||
|
onClick={() =>
|
||||||
|
history.push(routeResolver.getRoute("pim.contacts._id.log", { itemUid: getItemNavigationUid(item) }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<IconChangeHistory style={styles.leftIcon} />
|
||||||
|
Change History
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
color="secondary"
|
||||||
|
variant="contained"
|
||||||
|
disabled={readOnly}
|
||||||
|
style={{ ...styles.button, marginLeft: 15 }}
|
||||||
|
onClick={() =>
|
||||||
|
history.push(routeResolver.getRoute(path, { itemUid: getItemNavigationUid(item) }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<IconEdit style={styles.leftIcon} />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<Contact item={item} />
|
||||||
|
</Route>
|
||||||
|
<PageNotFoundRoute />
|
||||||
|
</Switch>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<PageNotFoundRoute />
|
||||||
|
</Switch>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,82 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { makeStyles, useTheme } from "@material-ui/core/styles";
|
||||||
|
import Divider from "@material-ui/core/Divider";
|
||||||
|
import Grid from "@material-ui/core/Grid";
|
||||||
|
|
||||||
|
import { ContactType } from "../pim-types";
|
||||||
|
|
||||||
|
import Sidebar from "./Sidebar";
|
||||||
|
import Toolbar from "./Toolbar";
|
||||||
|
import AddressBook from "./AddressBook";
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme) => ({
|
||||||
|
topBar: {
|
||||||
|
backgroundColor: theme.palette.primary[500],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface PropsType {
|
||||||
|
entries: ContactType[];
|
||||||
|
onItemClick: (contact: ContactType) => void;
|
||||||
|
onNewGroupClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SearchableAddressBook(props: PropsType) {
|
||||||
|
const [searchQuery, setSearchQuery] = React.useState("");
|
||||||
|
const [filterByGroup, setFilterByGroup] = React.useState<string>();
|
||||||
|
const theme = useTheme();
|
||||||
|
const classes = useStyles();
|
||||||
|
|
||||||
|
const groups = React.useMemo(
|
||||||
|
(() => props.entries.filter((x) => x.group)),
|
||||||
|
[props.entries]
|
||||||
|
);
|
||||||
|
const group = React.useMemo(
|
||||||
|
(() => groups.find((x) => x.uid === filterByGroup)),
|
||||||
|
[groups, filterByGroup]
|
||||||
|
);
|
||||||
|
|
||||||
|
function filterFunc(ent: ContactType) {
|
||||||
|
return (
|
||||||
|
(!group || (group.members.includes(ent.uid))) &&
|
||||||
|
ent.fn?.match(reg)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reg = new RegExp(searchQuery, "i");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid container spacing={4}>
|
||||||
|
<Grid item xs={3} className={classes.topBar}>
|
||||||
|
{/* spacer */}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={9} className={classes.topBar}>
|
||||||
|
<Toolbar
|
||||||
|
searchTerm={searchQuery}
|
||||||
|
setSearchTerm={setSearchQuery}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={3} style={{ borderRight: `1px solid ${theme.palette.divider}` }}>
|
||||||
|
<Sidebar
|
||||||
|
groups={groups}
|
||||||
|
filterByGroup={filterByGroup}
|
||||||
|
setFilterByGroup={setFilterByGroup}
|
||||||
|
newGroup={props.onNewGroupClick}
|
||||||
|
editGroup={props.onItemClick}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs>
|
||||||
|
<Divider style={{ marginTop: "1em" }} />
|
||||||
|
|
||||||
|
<AddressBook filter={filterFunc} {...props} />
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,93 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import InboxIcon from "@material-ui/icons/Inbox";
|
||||||
|
import LabelIcon from "@material-ui/icons/LabelOutlined";
|
||||||
|
import AddIcon from "@material-ui/icons/Add";
|
||||||
|
import EditIcon from "@material-ui/icons/EditOutlined";
|
||||||
|
|
||||||
|
import IconButton from "@material-ui/core/IconButton";
|
||||||
|
|
||||||
|
import { List, ListItem, ListSubheader } from "../widgets/List";
|
||||||
|
import { ContactType } from "../pim-types";
|
||||||
|
|
||||||
|
interface ListItemPropsType {
|
||||||
|
name: string | undefined;
|
||||||
|
icon?: React.ReactElement;
|
||||||
|
primaryText: string;
|
||||||
|
filterByGroup: string | undefined;
|
||||||
|
setFilterByGroup: (group: string | undefined) => void;
|
||||||
|
editGroup: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarListItem(props: ListItemPropsType) {
|
||||||
|
const { name, icon, primaryText, filterByGroup, editGroup } = props;
|
||||||
|
|
||||||
|
const handleClick = () => props.setFilterByGroup(name);
|
||||||
|
|
||||||
|
const selected = name === filterByGroup;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListItem
|
||||||
|
onClick={handleClick}
|
||||||
|
selected={selected}
|
||||||
|
leftIcon={icon}
|
||||||
|
primaryText={primaryText}
|
||||||
|
secondaryAction={name && selected &&
|
||||||
|
<IconButton onClick={editGroup}>
|
||||||
|
<EditIcon />
|
||||||
|
</IconButton>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PropsType {
|
||||||
|
groups: ContactType[];
|
||||||
|
filterByGroup: string | undefined;
|
||||||
|
setFilterByGroup: (group: string | undefined) => void;
|
||||||
|
newGroup: () => void;
|
||||||
|
editGroup: (group: ContactType) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.memo(function Sidebar(props: PropsType) {
|
||||||
|
const { groups, filterByGroup, setFilterByGroup, newGroup, editGroup } = props;
|
||||||
|
|
||||||
|
const groupList = [...groups].sort((a, b) => a.fn.localeCompare(b.fn)).map((group) => (
|
||||||
|
<SidebarListItem
|
||||||
|
key={group.uid}
|
||||||
|
name={group.uid}
|
||||||
|
primaryText={group.fn}
|
||||||
|
icon={<LabelIcon />}
|
||||||
|
filterByGroup={filterByGroup}
|
||||||
|
setFilterByGroup={setFilterByGroup}
|
||||||
|
editGroup={() => editGroup(group)}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<List dense>
|
||||||
|
<SidebarListItem
|
||||||
|
name={undefined}
|
||||||
|
primaryText="All"
|
||||||
|
icon={<InboxIcon />}
|
||||||
|
filterByGroup={filterByGroup}
|
||||||
|
setFilterByGroup={setFilterByGroup}
|
||||||
|
editGroup={newGroup}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
||||||
|
<ListSubheader>
|
||||||
|
Groups
|
||||||
|
</ListSubheader>
|
||||||
|
<IconButton
|
||||||
|
edge="end"
|
||||||
|
onClick={newGroup}
|
||||||
|
>
|
||||||
|
<AddIcon />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{groupList}
|
||||||
|
</List>
|
||||||
|
);
|
||||||
|
});
|
@ -0,0 +1,64 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import SearchIcon from "@material-ui/icons/Search";
|
||||||
|
import TextField from "@material-ui/core/TextField";
|
||||||
|
import { makeStyles } from "@material-ui/core/styles";
|
||||||
|
import { Transition } from "react-transition-group";
|
||||||
|
import InputAdornment from "@material-ui/core/InputAdornment";
|
||||||
|
|
||||||
|
const transitionTimeout = 300;
|
||||||
|
|
||||||
|
const transitionStyles = {
|
||||||
|
entering: { visibility: "visible", width: "100%", overflow: "hidden" },
|
||||||
|
entered: { visibility: "visible", width: "100%" },
|
||||||
|
exiting: { visibility: "visible", width: "0%", overflow: "hidden" },
|
||||||
|
exited: { visibility: "hidden", width: "0%" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme) => ({
|
||||||
|
button: {
|
||||||
|
marginRight: theme.spacing(1),
|
||||||
|
},
|
||||||
|
textField: {
|
||||||
|
transition: `width ${transitionTimeout}ms`,
|
||||||
|
marginRight: theme.spacing(1),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface PropsType {
|
||||||
|
searchTerm: string;
|
||||||
|
setSearchTerm: (term: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Toolbar(props: PropsType) {
|
||||||
|
const { searchTerm, setSearchTerm } = props;
|
||||||
|
|
||||||
|
const showSearchField = true;
|
||||||
|
const classes = useStyles();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", justifyContent: "flex-end", alignItems: "center" }}>
|
||||||
|
<Transition in={showSearchField} timeout={transitionTimeout}>
|
||||||
|
{(state) => (
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
placeholder="Search"
|
||||||
|
value={searchTerm}
|
||||||
|
color="secondary"
|
||||||
|
variant="standard"
|
||||||
|
className={classes.textField}
|
||||||
|
style={transitionStyles[state]}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<SearchIcon />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,92 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as Etebase from "etebase";
|
||||||
|
import Button from "@material-ui/core/Button";
|
||||||
|
import TextField from "@material-ui/core/TextField";
|
||||||
|
|
||||||
|
import Container from "./widgets/Container";
|
||||||
|
import { useSelector } from "react-redux";
|
||||||
|
import { StoreState } from "./store";
|
||||||
|
import { useCredentials } from "./credentials";
|
||||||
|
import { getCollectionManager } from "./etebase-helpers";
|
||||||
|
|
||||||
|
export default function Debug() {
|
||||||
|
const etebase = useCredentials()!;
|
||||||
|
const [stateCollectionUid, setCollectionUid] = React.useState("");
|
||||||
|
const [itemsUids, setEntriesUids] = React.useState("");
|
||||||
|
const [result, setResult] = React.useState("");
|
||||||
|
const cacheCollections = useSelector((state: StoreState) => state.cache.collections);
|
||||||
|
const cacheItems = useSelector((state: StoreState) => state.cache.items);
|
||||||
|
|
||||||
|
function handleInputChange(func: (value: string) => void) {
|
||||||
|
return (event: React.ChangeEvent<any>) => {
|
||||||
|
func(event.target.value);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<div>
|
||||||
|
<TextField
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
type="text"
|
||||||
|
label="Collection UID"
|
||||||
|
value={stateCollectionUid}
|
||||||
|
onChange={handleInputChange(setCollectionUid)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<TextField
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
type="text"
|
||||||
|
multiline
|
||||||
|
label="Item UIDs"
|
||||||
|
value={itemsUids}
|
||||||
|
onChange={handleInputChange(setEntriesUids)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="secondary"
|
||||||
|
onClick={async () => {
|
||||||
|
const colUid = stateCollectionUid.trim();
|
||||||
|
const cachedCollection = cacheCollections.get(colUid);
|
||||||
|
const colItems = cacheItems.get(colUid);
|
||||||
|
if (!colItems || !cachedCollection) {
|
||||||
|
setResult("Error: collection uid not found.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const colMgr = getCollectionManager(etebase);
|
||||||
|
const col = colMgr.cacheLoad(cachedCollection);
|
||||||
|
const itemMgr = colMgr.getItemManager(col);
|
||||||
|
|
||||||
|
const wantedEntries = {};
|
||||||
|
const wantAll = (itemsUids.trim() === "all");
|
||||||
|
itemsUids.split("\n").forEach((ent) => wantedEntries[ent.trim()] = true);
|
||||||
|
|
||||||
|
const retEntries = [];
|
||||||
|
console.log(wantAll, colItems.size);
|
||||||
|
for (const cached of colItems.values()) {
|
||||||
|
const item = itemMgr.cacheLoad(cached);
|
||||||
|
const meta = item.getMeta();
|
||||||
|
const content = await item.getContent(Etebase.OutputFormat.String);
|
||||||
|
if (wantAll || wantedEntries[item.uid]) {
|
||||||
|
retEntries.push(`${JSON.stringify(meta)}\n${content}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setResult(retEntries.join("\n\n"));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Decrypt
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<p>Result:</p>
|
||||||
|
<pre>{result}</pre>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,100 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { useDispatch } from "react-redux";
|
||||||
|
|
||||||
|
import Container from "./widgets/Container";
|
||||||
|
import ExternalLink from "./widgets/ExternalLink";
|
||||||
|
import LoginForm from "./components/LoginForm";
|
||||||
|
|
||||||
|
import { login } from "./store/actions";
|
||||||
|
|
||||||
|
import * as Etebase from "etebase";
|
||||||
|
|
||||||
|
import * as C from "./constants";
|
||||||
|
|
||||||
|
import SignedPagesBadge from "./images/signed-pages-badge.svg";
|
||||||
|
import { useCredentials } from "./credentials";
|
||||||
|
import LoadingIndicator from "./widgets/LoadingIndicator";
|
||||||
|
import { startTask } from "./helpers";
|
||||||
|
import { Redirect, useLocation } from "react-router";
|
||||||
|
import { routeResolver } from "./App";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const credentials = useCredentials();
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const location = useLocation();
|
||||||
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
const [fetchError, setFetchError] = React.useState<Error>();
|
||||||
|
|
||||||
|
if (credentials) {
|
||||||
|
return (
|
||||||
|
<Redirect to={{ pathname: routeResolver.getRoute("wizard"), state: location.state }} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onFormSubmit(username: string, password: string, serviceApiUrl?: string) {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setFetchError(undefined);
|
||||||
|
const etebase = await startTask((async () => {
|
||||||
|
return await Etebase.Account.login(username, password, serviceApiUrl ?? C.defaultServerUrl);
|
||||||
|
}));
|
||||||
|
dispatch(login(etebase));
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e);
|
||||||
|
if ((e instanceof Etebase.HttpError) && (e.status === 404)) {
|
||||||
|
setFetchError(new Error("Etebase server not found: are you sure the server URL is correct?"));
|
||||||
|
} else {
|
||||||
|
setFetchError(e);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (credentials === undefined) {
|
||||||
|
return (
|
||||||
|
<LoadingIndicator />
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const style = {
|
||||||
|
isSafe: {
|
||||||
|
textDecoration: "none",
|
||||||
|
display: "block",
|
||||||
|
},
|
||||||
|
divider: {
|
||||||
|
margin: "30px 0",
|
||||||
|
color: "#00000025",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container style={{ maxWidth: "30rem" }}>
|
||||||
|
<h2 style={{ marginBottom: "0.1em" }}>Log In</h2>
|
||||||
|
<div style={{ fontSize: "90%" }}>or <Link to={routeResolver.getRoute("signup")}>create an account</Link></div>
|
||||||
|
<LoginForm
|
||||||
|
onSubmit={onFormSubmit}
|
||||||
|
loading={loading}
|
||||||
|
error={fetchError}
|
||||||
|
/>
|
||||||
|
<hr style={style.divider} />
|
||||||
|
<ExternalLink style={style.isSafe} href="https://www.etesync.com/faq/#signed-pages">
|
||||||
|
<img alt="SignedPgaes badge" src={SignedPagesBadge} />
|
||||||
|
</ExternalLink>
|
||||||
|
<ul>
|
||||||
|
<li><ExternalLink style={style.isSafe} href={C.homePage}>
|
||||||
|
The EteSync Website
|
||||||
|
</ExternalLink></li>
|
||||||
|
<li><ExternalLink style={style.isSafe} href={C.faq + "#web-client"}>
|
||||||
|
Is the web client safe to use?
|
||||||
|
</ExternalLink></li>
|
||||||
|
<li><ExternalLink style={style.isSafe} href={C.sourceCode}>Source code</ExternalLink></li>
|
||||||
|
</ul>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,101 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Route, Switch, Redirect, RouteProps } from "react-router";
|
||||||
|
import { useCredentials } from "./credentials";
|
||||||
|
import LoadingIndicator from "./widgets/LoadingIndicator";
|
||||||
|
import SyncGate from "./SyncGate";
|
||||||
|
import { routeResolver } from "./App";
|
||||||
|
import SignupPage from "./SignupPage";
|
||||||
|
import LoginPage from "./LoginPage";
|
||||||
|
import WizardPage from "./WizardPage";
|
||||||
|
import { Snackbar } from "@material-ui/core";
|
||||||
|
import Alert from "@material-ui/lab/Alert";
|
||||||
|
import { useSelector, useDispatch } from "react-redux";
|
||||||
|
import { StoreState } from "./store";
|
||||||
|
import { popMessage } from "./store/actions";
|
||||||
|
|
||||||
|
|
||||||
|
export default function MainRouter() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Switch>
|
||||||
|
<Route
|
||||||
|
path={routeResolver.getRoute("signup")}
|
||||||
|
exact
|
||||||
|
>
|
||||||
|
<SignupPage />
|
||||||
|
</Route>
|
||||||
|
<Route
|
||||||
|
path={routeResolver.getRoute("login")}
|
||||||
|
exact
|
||||||
|
>
|
||||||
|
<LoginPage />
|
||||||
|
</Route>
|
||||||
|
<PrivateRoute
|
||||||
|
path={routeResolver.getRoute("wizard")}
|
||||||
|
exact
|
||||||
|
>
|
||||||
|
<WizardPage />
|
||||||
|
</PrivateRoute>
|
||||||
|
<PrivateRoute
|
||||||
|
path="*"
|
||||||
|
>
|
||||||
|
<SyncGate />
|
||||||
|
</PrivateRoute>
|
||||||
|
</Switch>
|
||||||
|
<GlobalMessages />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GlobalMessages() {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const message = useSelector((state: StoreState) => state.messages.first(undefined));
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
dispatch(popMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Snackbar
|
||||||
|
key={message?.message}
|
||||||
|
open={!!message}
|
||||||
|
autoHideDuration={5000}
|
||||||
|
onClose={handleClose}
|
||||||
|
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
|
||||||
|
>
|
||||||
|
<Alert onClose={handleClose} severity={message?.severity}>
|
||||||
|
{message?.message}
|
||||||
|
</Alert>
|
||||||
|
</Snackbar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PrivateRoute(props: Omit<RouteProps, "render">) {
|
||||||
|
const credentials = useCredentials();
|
||||||
|
const { children, ...rest } = props;
|
||||||
|
|
||||||
|
if (credentials === undefined) {
|
||||||
|
return (<LoadingIndicator style={{ display: "block", margin: "40px auto" }} />);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Route
|
||||||
|
{...rest}
|
||||||
|
render={({ location }) => (
|
||||||
|
(credentials) ? (
|
||||||
|
children
|
||||||
|
) : (
|
||||||
|
<Redirect
|
||||||
|
to={{
|
||||||
|
pathname: "/login",
|
||||||
|
state: { from: location },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,27 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2020 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Route } from "react-router";
|
||||||
|
|
||||||
|
import Container from "./widgets/Container";
|
||||||
|
|
||||||
|
export function PageNotFoundRoute(props: { container?: boolean }) {
|
||||||
|
return (
|
||||||
|
<Route path="*">
|
||||||
|
{props.container ? (
|
||||||
|
<Container>
|
||||||
|
<PageNotFound />
|
||||||
|
</Container>
|
||||||
|
) : (
|
||||||
|
<PageNotFound />
|
||||||
|
)}
|
||||||
|
</Route>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PageNotFound() {
|
||||||
|
return (
|
||||||
|
<h1>404 Page Not Found</h1>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,113 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import * as Etebase from "etebase";
|
||||||
|
|
||||||
|
import Dialog from "@material-ui/core/Dialog";
|
||||||
|
import DialogTitle from "@material-ui/core/DialogTitle";
|
||||||
|
import DialogContent from "@material-ui/core/DialogContent";
|
||||||
|
import DialogActions from "@material-ui/core/DialogActions";
|
||||||
|
import Button from "@material-ui/core/Button";
|
||||||
|
|
||||||
|
import { useCredentials } from "../credentials";
|
||||||
|
import LoadingIndicator from "../widgets/LoadingIndicator";
|
||||||
|
import GenericChangeHistory from "../components/GenericChangeHistory";
|
||||||
|
import { useItems } from "../etebase-helpers";
|
||||||
|
import { CachedCollection } from "./helpers";
|
||||||
|
import PageNotFound from "../PageNotFound";
|
||||||
|
|
||||||
|
export interface CachedItem {
|
||||||
|
item: Etebase.Item;
|
||||||
|
metadata: Etebase.ItemMetadata;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRevisions(etebase: Etebase.Account, col: Etebase.Collection, item: Etebase.Item) {
|
||||||
|
const ret: CachedItem[] = [];
|
||||||
|
const colMgr = etebase.getCollectionManager();
|
||||||
|
const itemManager = colMgr.getItemManager(col);
|
||||||
|
|
||||||
|
let iterator: string | null = null;
|
||||||
|
let done = false;
|
||||||
|
while (!done) {
|
||||||
|
const revisions = await itemManager.itemRevisions(item, { iterator, limit: 30 });
|
||||||
|
iterator = revisions.iterator as string;
|
||||||
|
done = revisions.done;
|
||||||
|
|
||||||
|
for (const item of revisions.data) {
|
||||||
|
ret.push({
|
||||||
|
item,
|
||||||
|
metadata: item.getMeta(),
|
||||||
|
content: await item.getContent(Etebase.OutputFormat.String),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PropsType {
|
||||||
|
collection: CachedCollection;
|
||||||
|
itemUid: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ItemChangeHistory(props: PropsType) {
|
||||||
|
const [entries, setEntries] = React.useState<CachedItem[]>();
|
||||||
|
const [dialog, setDialog] = React.useState<CachedItem>();
|
||||||
|
const etebase = useCredentials()!;
|
||||||
|
const { collection, collectionType } = props.collection;
|
||||||
|
const items = useItems(etebase, collectionType);
|
||||||
|
|
||||||
|
const item = items?.get(collection.uid)?.get(props.itemUid);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (item) {
|
||||||
|
loadRevisions(etebase, collection, item)
|
||||||
|
.then((entries) => setEntries(entries));
|
||||||
|
}
|
||||||
|
}, [etebase, collection, item]);
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
return (<PageNotFound />);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!entries) {
|
||||||
|
return (
|
||||||
|
<LoadingIndicator />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ height: "calc(100vh - 300px)" }}>
|
||||||
|
<Dialog
|
||||||
|
open={dialog !== undefined}
|
||||||
|
onClose={() => setDialog(undefined)}
|
||||||
|
>
|
||||||
|
<DialogTitle>
|
||||||
|
Raw Content
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<div>Revision UID: <pre className="d-inline-block">{dialog?.item.etag}</pre></div>
|
||||||
|
<div>Content:
|
||||||
|
<pre>{dialog?.content}</pre>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
onClick={() => setDialog(undefined)}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
<GenericChangeHistory
|
||||||
|
items={entries}
|
||||||
|
onItemClick={setDialog}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,58 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2020 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Tabs, Tab, useTheme } from "@material-ui/core";
|
||||||
|
import { useHistory } from "react-router";
|
||||||
|
import { routeResolver } from "../App";
|
||||||
|
|
||||||
|
export type TabValue = "contacts" | "events" | "tasks";
|
||||||
|
|
||||||
|
interface PropsType {
|
||||||
|
value: TabValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PimNavigationTabs(props: PropsType) {
|
||||||
|
const theme = useTheme();
|
||||||
|
const history = useHistory();
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ title: "Address Book", linkValue: "contacts" },
|
||||||
|
{ title: "Calendar", linkValue: "events" },
|
||||||
|
{ title: "Tasks", linkValue: "tasks" },
|
||||||
|
];
|
||||||
|
|
||||||
|
let selected;
|
||||||
|
switch (props.value) {
|
||||||
|
case "contacts": {
|
||||||
|
selected = 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "events": {
|
||||||
|
selected = 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "tasks": {
|
||||||
|
selected = 2;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs
|
||||||
|
variant="fullWidth"
|
||||||
|
style={{ backgroundColor: theme.palette.primary.main }}
|
||||||
|
value={selected}
|
||||||
|
onChange={(_event, value) => history.push(
|
||||||
|
routeResolver.getRoute(`pim.${tabs[value].linkValue}`)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tabs.map((x) => (
|
||||||
|
<Tab
|
||||||
|
key={x.linkValue}
|
||||||
|
label={x.title}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,156 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2020 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Fab } from "@material-ui/core";
|
||||||
|
import ContentAdd from "@material-ui/icons/Add";
|
||||||
|
|
||||||
|
import memoize from "memoizee";
|
||||||
|
|
||||||
|
import * as Etebase from "etebase";
|
||||||
|
|
||||||
|
import { PimType } from "../pim-types";
|
||||||
|
import { getCollectionManager } from "../etebase-helpers";
|
||||||
|
import { asyncDispatch, store } from "../store";
|
||||||
|
import { itemBatch, appendError } from "../store/actions";
|
||||||
|
|
||||||
|
export const defaultColor = "#8BC34A";
|
||||||
|
|
||||||
|
export interface CachedCollection {
|
||||||
|
collection: Etebase.Collection;
|
||||||
|
metadata: Etebase.ItemMetadata;
|
||||||
|
collectionType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRawItemNavigationUid(collectionUid: string, itemUid: string) {
|
||||||
|
// Both collectionUid and itemUid are url safe
|
||||||
|
return `${collectionUid}|${itemUid}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getItemNavigationUid(item: PimType) {
|
||||||
|
return getRawItemNavigationUid(item.collectionUid!, item.itemUid!);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDecryptCollectionsFunction(_colType?: string) {
|
||||||
|
return memoize(
|
||||||
|
async function (collections: Etebase.Collection[]) {
|
||||||
|
const entries: CachedCollection[] = [];
|
||||||
|
if (collections) {
|
||||||
|
for (const collection of collections) {
|
||||||
|
try {
|
||||||
|
entries.push({
|
||||||
|
collection,
|
||||||
|
metadata: collection.getMeta(),
|
||||||
|
collectionType: collection.getCollectionType(),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
store.dispatch(appendError(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
},
|
||||||
|
{ max: 1 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDecryptItemsFunction<T extends PimType>(_colType: string, parseFunc: (str: string) => T) {
|
||||||
|
return memoize(
|
||||||
|
async function (items: Map<string, Map<string, Etebase.Item>>) {
|
||||||
|
const entries: Map<string, Map<string, T>> = new Map();
|
||||||
|
if (items) {
|
||||||
|
for (const [colUid, col] of items.entries()) {
|
||||||
|
const cur = new Map();
|
||||||
|
entries.set(colUid, cur);
|
||||||
|
for (const item of col.values()) {
|
||||||
|
if (item.isDeleted) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const contact = parseFunc(await item.getContent(Etebase.OutputFormat.String));
|
||||||
|
contact.collectionUid = colUid;
|
||||||
|
contact.itemUid = item.uid;
|
||||||
|
cur.set(item.uid, contact);
|
||||||
|
} catch (e) {
|
||||||
|
store.dispatch(appendError(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
},
|
||||||
|
{ max: 1 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function itemSave(etebase: Etebase.Account, collection: Etebase.Collection, items: Map<string, Map<string, Etebase.Item>>, item: PimType, collectionUid: string, originalItem?: PimType): Promise<void> {
|
||||||
|
const itemUid = originalItem?.itemUid;
|
||||||
|
const colMgr = getCollectionManager(etebase);
|
||||||
|
const itemMgr = colMgr.getItemManager(collection);
|
||||||
|
|
||||||
|
const mtime = (new Date()).getTime();
|
||||||
|
const content = item.toIcal();
|
||||||
|
|
||||||
|
let eteItem;
|
||||||
|
if (itemUid) {
|
||||||
|
// Existing item
|
||||||
|
eteItem = items!.get(collectionUid)?.get(itemUid)!;
|
||||||
|
await eteItem.setContent(content);
|
||||||
|
const meta = eteItem.getMeta();
|
||||||
|
meta.mtime = mtime;
|
||||||
|
eteItem.setMeta(meta);
|
||||||
|
} else {
|
||||||
|
// New
|
||||||
|
const meta: Etebase.ItemMetadata = {
|
||||||
|
mtime,
|
||||||
|
name: item.uid,
|
||||||
|
};
|
||||||
|
eteItem = await itemMgr.create(meta, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
await asyncDispatch(itemBatch(collection, itemMgr, [eteItem]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function itemDelete(etebase: Etebase.Account, collection: Etebase.Collection, items: Map<string, Map<string, Etebase.Item>>, item: PimType, collectionUid: string) {
|
||||||
|
const itemUid = item.itemUid!;
|
||||||
|
const colMgr = getCollectionManager(etebase);
|
||||||
|
const itemMgr = colMgr.getItemManager(collection);
|
||||||
|
|
||||||
|
const eteItem = items!.get(collectionUid)?.get(itemUid)!;
|
||||||
|
const mtime = (new Date()).getTime();
|
||||||
|
const meta = eteItem.getMeta();
|
||||||
|
meta.mtime = mtime;
|
||||||
|
eteItem.setMeta(meta);
|
||||||
|
eteItem.delete(true);
|
||||||
|
|
||||||
|
await asyncDispatch(itemBatch(collection, itemMgr, [eteItem]));
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PimFabPropsType {
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PimFab(props: PimFabPropsType) {
|
||||||
|
const style = {
|
||||||
|
floatingButton: {
|
||||||
|
margin: 0,
|
||||||
|
top: "auto",
|
||||||
|
right: 20,
|
||||||
|
bottom: 20,
|
||||||
|
left: "auto",
|
||||||
|
position: "fixed",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fab
|
||||||
|
color="primary"
|
||||||
|
style={style.floatingButton as any}
|
||||||
|
onClick={props.onClick}
|
||||||
|
>
|
||||||
|
<ContentAdd />
|
||||||
|
</Fab>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,246 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as Etebase from "etebase";
|
||||||
|
import { useSelector, useDispatch } from "react-redux";
|
||||||
|
|
||||||
|
import Select from "@material-ui/core/Select";
|
||||||
|
import MenuItem from "@material-ui/core/MenuItem";
|
||||||
|
import FormControl from "@material-ui/core/FormControl";
|
||||||
|
import FormControlLabel from "@material-ui/core/FormControlLabel";
|
||||||
|
import FormGroup from "@material-ui/core/FormGroup";
|
||||||
|
import Switch from "@material-ui/core/Switch";
|
||||||
|
import InputLabel from "@material-ui/core/InputLabel";
|
||||||
|
|
||||||
|
import { StoreState } from "../store";
|
||||||
|
import { setSettings, login, pushMessage } from "../store/actions";
|
||||||
|
|
||||||
|
import Container from "../widgets/Container";
|
||||||
|
import AppBarOverride from "../widgets/AppBarOverride";
|
||||||
|
import PrettyFingerprint from "../widgets/PrettyFingerprint";
|
||||||
|
import { useCredentials } from "../credentials";
|
||||||
|
import { Button } from "@material-ui/core";
|
||||||
|
import ConfirmationDialog from "../widgets/ConfirmationDialog";
|
||||||
|
import PasswordField from "../widgets/PasswordField";
|
||||||
|
import Alert from "@material-ui/lab/Alert";
|
||||||
|
import { PASSWORD_MIN_LENGTH, startTask, enforcePasswordRules } from "../helpers";
|
||||||
|
|
||||||
|
function SecurityFingerprint() {
|
||||||
|
const etebase = useCredentials()!;
|
||||||
|
const inviteMgr = etebase.getInvitationManager();
|
||||||
|
const publicKey = inviteMgr.pubkey;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
Your security fingerprint is:
|
||||||
|
</p>
|
||||||
|
<PrettyFingerprint publicKey={publicKey} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChangePasswordFormErrors {
|
||||||
|
oldPassword?: string;
|
||||||
|
newPassword?: string;
|
||||||
|
|
||||||
|
general?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChangePassword() {
|
||||||
|
const etebase = useCredentials()!;
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const [showDialog, setShowDialog] = React.useState(false);
|
||||||
|
const [oldPassword, setOldPassword] = React.useState("");
|
||||||
|
const [newPassword, setNewPassword] = React.useState("");
|
||||||
|
const [errors, setErrors] = React.useState<ChangePasswordFormErrors>({});
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
infoAlert: {
|
||||||
|
marginTop: 20,
|
||||||
|
},
|
||||||
|
textField: {
|
||||||
|
marginTop: 20,
|
||||||
|
width: "18em",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleInputChange(func: (value: string) => void) {
|
||||||
|
return (event: React.ChangeEvent<any>) => {
|
||||||
|
func(event.target.value);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onChangePassword() {
|
||||||
|
try {
|
||||||
|
const fieldNotEmpty = "Password can't be empty.";
|
||||||
|
const errors: ChangePasswordFormErrors = {};
|
||||||
|
if (!oldPassword) {
|
||||||
|
errors.oldPassword = fieldNotEmpty;
|
||||||
|
}
|
||||||
|
if (!newPassword) {
|
||||||
|
errors.newPassword = fieldNotEmpty;
|
||||||
|
} else {
|
||||||
|
const passwordRulesError = enforcePasswordRules(newPassword);
|
||||||
|
if (passwordRulesError) {
|
||||||
|
errors.newPassword = passwordRulesError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(errors);
|
||||||
|
if (Object.keys(errors).length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await startTask(async () => {
|
||||||
|
const serverUrl = etebase.serverUrl;
|
||||||
|
const username = etebase.user.username;
|
||||||
|
try {
|
||||||
|
const etebase = await Etebase.Account.login(username, oldPassword, serverUrl);
|
||||||
|
await etebase.logout();
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Etebase.UnauthorizedError) {
|
||||||
|
setErrors({ oldPassword: "Error: wrong encryption password." });
|
||||||
|
} else {
|
||||||
|
setErrors({ oldPassword: e.toString() });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await etebase.changePassword(newPassword);
|
||||||
|
dispatch(login(etebase));
|
||||||
|
dispatch(pushMessage({ message: "Password successfully changed.", severity: "success" }));
|
||||||
|
setShowDialog(false);
|
||||||
|
} catch (e) {
|
||||||
|
setErrors({ newPassword: e.toString() });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
// Cleanup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
Change your password by clicking here;
|
||||||
|
</p>
|
||||||
|
<Button color="secondary" variant="contained" onClick={() => setShowDialog(true)}>
|
||||||
|
Change Password
|
||||||
|
</Button>
|
||||||
|
<ConfirmationDialog
|
||||||
|
title="Change Password"
|
||||||
|
key={showDialog}
|
||||||
|
open={showDialog}
|
||||||
|
onOk={onChangePassword}
|
||||||
|
onCancel={() => setShowDialog(false)}
|
||||||
|
>
|
||||||
|
<PasswordField
|
||||||
|
style={styles.textField}
|
||||||
|
error={!!errors.oldPassword}
|
||||||
|
helperText={errors.oldPassword}
|
||||||
|
label="Current Password"
|
||||||
|
value={oldPassword}
|
||||||
|
onChange={handleInputChange(setOldPassword)}
|
||||||
|
/>
|
||||||
|
<PasswordField
|
||||||
|
style={styles.textField}
|
||||||
|
error={!!errors.newPassword}
|
||||||
|
helperText={errors.newPassword}
|
||||||
|
label="New Password"
|
||||||
|
inputProps={{
|
||||||
|
minLength: PASSWORD_MIN_LENGTH,
|
||||||
|
}}
|
||||||
|
value={newPassword}
|
||||||
|
onChange={handleInputChange(setNewPassword)}
|
||||||
|
/>
|
||||||
|
{errors.general && (
|
||||||
|
<Alert severity="error" style={styles.infoAlert}>{errors.general}</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Alert severity="warning" style={styles.infoAlert}>
|
||||||
|
Please make sure you remember your password, as it <em>can't</em> be recovered if lost!
|
||||||
|
</Alert>
|
||||||
|
</ConfirmationDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.memo(function Settings() {
|
||||||
|
const etebase = useCredentials();
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const settings = useSelector((state: StoreState) => state.settings);
|
||||||
|
|
||||||
|
const darkMode = !!settings.darkMode;
|
||||||
|
|
||||||
|
function handleChange(event: React.ChangeEvent<any>) {
|
||||||
|
const name = event.target.name;
|
||||||
|
const value = event.target.value;
|
||||||
|
|
||||||
|
dispatch(setSettings({ ...settings, [name]: value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AppBarOverride title="Settings" />
|
||||||
|
<Container>
|
||||||
|
{(etebase) && (
|
||||||
|
<>
|
||||||
|
<h1>Account</h1>
|
||||||
|
<h2>Security Fingerprint</h2>
|
||||||
|
<SecurityFingerprint />
|
||||||
|
|
||||||
|
<h2>Account Dashboard</h2>
|
||||||
|
<p>
|
||||||
|
Change your payment info, plan and other account settings
|
||||||
|
</p>
|
||||||
|
<Button color="secondary" variant="contained" onClick={async () => {
|
||||||
|
try {
|
||||||
|
const url = await etebase!.getDashboardUrl();
|
||||||
|
window.open(url, "_blank", "noopener,noreferrer");
|
||||||
|
} catch (e) {
|
||||||
|
dispatch(pushMessage({ message: e.message, severity: "error" }));
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
Open Dashboard
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<h2>Password</h2>
|
||||||
|
<ChangePassword />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h1>Look & Feel</h1>
|
||||||
|
<h2>Date & Time</h2>
|
||||||
|
<FormControl style={{ width: "15em" }}>
|
||||||
|
<InputLabel>
|
||||||
|
Locale
|
||||||
|
</InputLabel>
|
||||||
|
<Select
|
||||||
|
name="locale"
|
||||||
|
value={settings.locale}
|
||||||
|
onChange={handleChange}
|
||||||
|
>
|
||||||
|
<MenuItem value="en-gb">English (United Kingdom)</MenuItem>
|
||||||
|
<MenuItem value="en-us">English (United States)</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<h2>Dark mode</h2>
|
||||||
|
<FormGroup>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
color="primary"
|
||||||
|
checked={darkMode}
|
||||||
|
onChange={() => dispatch(setSettings({ ...settings, darkMode: !darkMode }))}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Dark mode"
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
</Container>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
@ -0,0 +1,111 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { List, ListItem, ListSubheader, ListDivider } from "../widgets/List";
|
||||||
|
import ActionCode from "@material-ui/icons/Code";
|
||||||
|
import ActionHome from "@material-ui/icons/Home";
|
||||||
|
import ActionSettings from "@material-ui/icons/Settings";
|
||||||
|
import ActionJournals from "@material-ui/icons/LibraryBooks";
|
||||||
|
import ActionBugReport from "@material-ui/icons/BugReport";
|
||||||
|
import ActionQuestionAnswer from "@material-ui/icons/QuestionAnswer";
|
||||||
|
import LogoutIcon from "@material-ui/icons/PowerSettingsNew";
|
||||||
|
import IconImport from "@material-ui/icons/ImportExport";
|
||||||
|
import IconInvitation from "@material-ui/icons/MailOutline";
|
||||||
|
|
||||||
|
import logo from "../images/logo.svg";
|
||||||
|
|
||||||
|
import { routeResolver } from "../App";
|
||||||
|
|
||||||
|
import { store } from "../store";
|
||||||
|
import { logout } from "../store/actions";
|
||||||
|
|
||||||
|
import * as C from "../constants";
|
||||||
|
import { useTheme } from "@material-ui/core";
|
||||||
|
import { useCredentials } from "../credentials";
|
||||||
|
import { useHistory } from "react-router";
|
||||||
|
|
||||||
|
interface PropsType {
|
||||||
|
onCloseDrawerRequest: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SideMenu(props: PropsType) {
|
||||||
|
const theme = useTheme();
|
||||||
|
const etebase = useCredentials();
|
||||||
|
const username = etebase?.user.username ?? C.appName;
|
||||||
|
const history = useHistory();
|
||||||
|
|
||||||
|
function logoutDo() {
|
||||||
|
store.dispatch(logout(etebase!));
|
||||||
|
props.onCloseDrawerRequest();
|
||||||
|
}
|
||||||
|
|
||||||
|
let loggedInItems;
|
||||||
|
if (etebase) {
|
||||||
|
loggedInItems = (
|
||||||
|
<React.Fragment>
|
||||||
|
<ListItem
|
||||||
|
primaryText="Collections"
|
||||||
|
leftIcon={<ActionJournals />}
|
||||||
|
onClick={() => {
|
||||||
|
props.onCloseDrawerRequest();
|
||||||
|
history.push(routeResolver.getRoute("collections"));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
primaryText="Invitations"
|
||||||
|
leftIcon={<IconInvitation />}
|
||||||
|
onClick={() => {
|
||||||
|
props.onCloseDrawerRequest();
|
||||||
|
history.push(routeResolver.getRoute("collections.invitations"));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
primaryText="Import"
|
||||||
|
leftIcon={<IconImport />}
|
||||||
|
onClick={() => {
|
||||||
|
props.onCloseDrawerRequest();
|
||||||
|
history.push(routeResolver.getRoute("collections.import"));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
primaryText="Settings"
|
||||||
|
leftIcon={<ActionSettings />}
|
||||||
|
onClick={() => {
|
||||||
|
props.onCloseDrawerRequest();
|
||||||
|
history.push(routeResolver.getRoute("settings"));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ListItem primaryText="Log Out" leftIcon={<LogoutIcon />} onClick={logoutDo} />
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ overflowX: "hidden", width: 250 }}>
|
||||||
|
<div className="App-drawer-header">
|
||||||
|
<img alt="App logo" className="App-drawer-logo" src={logo} />
|
||||||
|
<div style={{ color: theme.palette.secondary.contrastText }}>
|
||||||
|
{username}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<List>
|
||||||
|
<ListItem
|
||||||
|
primaryText="Main"
|
||||||
|
leftIcon={<ActionHome />}
|
||||||
|
onClick={() => {
|
||||||
|
props.onCloseDrawerRequest();
|
||||||
|
history.push(routeResolver.getRoute("home"));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{loggedInItems}
|
||||||
|
<ListDivider />
|
||||||
|
<ListSubheader>External Links</ListSubheader>
|
||||||
|
<ListItem primaryText="Website" leftIcon={<ActionHome />} href={C.homePage} />
|
||||||
|
<ListItem primaryText="FAQ" leftIcon={<ActionQuestionAnswer />} href={C.faq} />
|
||||||
|
<ListItem primaryText="Source Code" leftIcon={<ActionCode />} href={C.sourceCode} />
|
||||||
|
<ListItem primaryText="Report Issue" leftIcon={<ActionBugReport />} href={C.reportIssue} />
|
||||||
|
</List>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,268 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as Etebase from "etebase";
|
||||||
|
import Button from "@material-ui/core/Button";
|
||||||
|
import TextField from "@material-ui/core/TextField";
|
||||||
|
import FormGroup from "@material-ui/core/FormGroup";
|
||||||
|
import FormControlLabel from "@material-ui/core/FormControlLabel";
|
||||||
|
import Switch from "@material-ui/core/Switch";
|
||||||
|
|
||||||
|
import { routeResolver } from "./App";
|
||||||
|
|
||||||
|
import Container from "./widgets/Container";
|
||||||
|
import PasswordField from "./widgets/PasswordField";
|
||||||
|
|
||||||
|
import LoadingIndicator from "./widgets/LoadingIndicator";
|
||||||
|
import Alert from "@material-ui/lab/Alert";
|
||||||
|
import { CircularProgress } from "@material-ui/core";
|
||||||
|
import { Redirect } from "react-router";
|
||||||
|
import { useCredentials } from "./credentials";
|
||||||
|
import { useDispatch } from "react-redux";
|
||||||
|
import { startTask, PASSWORD_MIN_LENGTH, enforcePasswordRules } from "./helpers";
|
||||||
|
import { login } from "./store/actions";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
import * as C from "./constants";
|
||||||
|
import ExternalLink from "./widgets/ExternalLink";
|
||||||
|
|
||||||
|
interface FormErrors {
|
||||||
|
username?: string;
|
||||||
|
email?: string;
|
||||||
|
password?: string;
|
||||||
|
server?: string;
|
||||||
|
|
||||||
|
general?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SignupPage() {
|
||||||
|
const credentials = useCredentials();
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const [username, setUsername] = React.useState("");
|
||||||
|
const [email, setEmail] = React.useState("");
|
||||||
|
const [password, setPassword] = React.useState("");
|
||||||
|
const [server, setServer] = React.useState("");
|
||||||
|
const [showAdvanced, setShowAdvanced] = React.useState(false);
|
||||||
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
const [errors, setErrors] = React.useState<FormErrors>({});
|
||||||
|
|
||||||
|
if (credentials) {
|
||||||
|
return (
|
||||||
|
<Redirect to={routeResolver.getRoute("wizard")} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signup(e: React.FormEvent<any>) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const errors: FormErrors = {};
|
||||||
|
const fieldRequired = "This field is required!";
|
||||||
|
if (!username) {
|
||||||
|
errors.username = fieldRequired;
|
||||||
|
}
|
||||||
|
if (!email) {
|
||||||
|
errors.email = fieldRequired;
|
||||||
|
}
|
||||||
|
if (!password) {
|
||||||
|
errors.password = fieldRequired;
|
||||||
|
} else {
|
||||||
|
const passwordRulesError = enforcePasswordRules(password);
|
||||||
|
if (passwordRulesError) {
|
||||||
|
errors.password = passwordRulesError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== "development") {
|
||||||
|
if (showAdvanced && !server.startsWith("https://")) {
|
||||||
|
errors.server = "Server URI must start with https://";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(errors).length) {
|
||||||
|
setErrors(errors);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
setErrors({});
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverUrl = (showAdvanced) ? server : C.defaultServerUrl;
|
||||||
|
const user: Etebase.User = {
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
};
|
||||||
|
const etebase = await startTask((async () => {
|
||||||
|
return await Etebase.Account.signup(user, password, serverUrl);
|
||||||
|
}));
|
||||||
|
dispatch(login(etebase));
|
||||||
|
} catch (e) {
|
||||||
|
if ((e instanceof Etebase.HttpError) && (e.content)) {
|
||||||
|
let found = false;
|
||||||
|
if (e.content.errors) {
|
||||||
|
for (const field of e.content.errors) {
|
||||||
|
if (field.field === "user.username") {
|
||||||
|
errors.username = field.detail;
|
||||||
|
found = true;
|
||||||
|
} else if (field.field === "user.email") {
|
||||||
|
errors.email = field.detail;
|
||||||
|
found = true;
|
||||||
|
} else if (!field.field) {
|
||||||
|
errors.general = field.detail;
|
||||||
|
found = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!found) {
|
||||||
|
errors.general = e.content.detail ?? e.toString();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
errors.general = e.toString();
|
||||||
|
}
|
||||||
|
setErrors(errors);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
form: {
|
||||||
|
},
|
||||||
|
infoAlert: {
|
||||||
|
marginTop: 20,
|
||||||
|
},
|
||||||
|
textField: {
|
||||||
|
marginTop: 20,
|
||||||
|
width: "18em",
|
||||||
|
},
|
||||||
|
submit: {
|
||||||
|
marginTop: 20,
|
||||||
|
textAlign: "right" as any,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleInputChange(func: (value: string) => void) {
|
||||||
|
return (event: React.ChangeEvent<any>) => {
|
||||||
|
func(event.target.value);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let advancedSettings = null;
|
||||||
|
if (showAdvanced) {
|
||||||
|
advancedSettings = (
|
||||||
|
<React.Fragment>
|
||||||
|
<TextField
|
||||||
|
type="url"
|
||||||
|
style={styles.textField}
|
||||||
|
error={!!errors.server}
|
||||||
|
helperText={errors.server}
|
||||||
|
label="Server"
|
||||||
|
value={server}
|
||||||
|
onChange={handleInputChange(setServer)}
|
||||||
|
/>
|
||||||
|
<br />
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ textAlign: "center" }}>
|
||||||
|
<LoadingIndicator />
|
||||||
|
<p>Deriving encryption data...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container style={{ maxWidth: "30rem" }}>
|
||||||
|
<h2 style={{ marginBottom: "0.1em" }}>Signup</h2>
|
||||||
|
<div style={{ fontSize: "90%" }}>or <Link to={routeResolver.getRoute("home")}>log in to your account</Link></div>
|
||||||
|
|
||||||
|
<Alert
|
||||||
|
style={styles.infoAlert}
|
||||||
|
severity="info"
|
||||||
|
>
|
||||||
|
<a href={C.pricing} style={{ color: "inherit", textDecoration: "inherit", display: "block" }}>
|
||||||
|
You are signing up for a free trial. Click here for pricing information.
|
||||||
|
</a>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
|
||||||
|
<form style={styles.form} onSubmit={signup}>
|
||||||
|
<TextField
|
||||||
|
type="text"
|
||||||
|
style={styles.textField}
|
||||||
|
error={!!errors.username}
|
||||||
|
helperText={errors.username}
|
||||||
|
label="Username"
|
||||||
|
value={username}
|
||||||
|
onChange={handleInputChange(setUsername)}
|
||||||
|
/>
|
||||||
|
<br />
|
||||||
|
<TextField
|
||||||
|
type="email"
|
||||||
|
style={styles.textField}
|
||||||
|
error={!!errors.email}
|
||||||
|
helperText={errors.email}
|
||||||
|
label="Email"
|
||||||
|
value={email}
|
||||||
|
onChange={handleInputChange(setEmail)}
|
||||||
|
/>
|
||||||
|
<br />
|
||||||
|
<PasswordField
|
||||||
|
style={styles.textField}
|
||||||
|
error={!!errors.password}
|
||||||
|
helperText={errors.password}
|
||||||
|
label="Password"
|
||||||
|
name="password"
|
||||||
|
inputProps={{
|
||||||
|
minLength: PASSWORD_MIN_LENGTH,
|
||||||
|
}}
|
||||||
|
value={password}
|
||||||
|
onChange={handleInputChange(setPassword)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormGroup>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
color="primary"
|
||||||
|
checked={showAdvanced}
|
||||||
|
onChange={() => setShowAdvanced(!showAdvanced)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Advanced settings"
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
{advancedSettings}
|
||||||
|
{errors.general && (
|
||||||
|
<Alert severity="error" style={styles.infoAlert}>{errors.general}</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Alert severity="warning" style={styles.infoAlert}>
|
||||||
|
Please make sure you remember your password, as it <em>can't</em> be recovered if lost!
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<p style={styles.infoAlert}>
|
||||||
|
By signing up you agree to our <ExternalLink href={C.terms}>terms of service</ExternalLink>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={styles.submit}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
type="submit"
|
||||||
|
color="secondary"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<CircularProgress />
|
||||||
|
) : "Sign Up"
|
||||||
|
}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,127 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { useSelector, useDispatch } from "react-redux";
|
||||||
|
import { Route, Switch, Redirect, useHistory } from "react-router";
|
||||||
|
|
||||||
|
import moment from "moment";
|
||||||
|
import "moment/locale/en-gb";
|
||||||
|
|
||||||
|
import { routeResolver } from "./App";
|
||||||
|
|
||||||
|
import AppBarOverride from "./widgets/AppBarOverride";
|
||||||
|
import LoadingIndicator from "./widgets/LoadingIndicator";
|
||||||
|
import Container from "./widgets/Container";
|
||||||
|
import ContactsMain from "./Contacts/Main";
|
||||||
|
import CalendarsMain from "./Calendars/Main";
|
||||||
|
import TasksMain from "./Tasks/Main";
|
||||||
|
import CollectionsMain from "./Collections/Main";
|
||||||
|
|
||||||
|
import Settings from "./Settings";
|
||||||
|
import Debug from "./Debug";
|
||||||
|
|
||||||
|
import { SyncManager } from "./sync/SyncManager";
|
||||||
|
|
||||||
|
import { StoreState } from "./store";
|
||||||
|
import { performSync } from "./store/actions";
|
||||||
|
import { useCredentials } from "./credentials";
|
||||||
|
import PimNavigationTabs from "./Pim/NavigationTabs";
|
||||||
|
import { PageNotFoundRoute } from "./PageNotFound";
|
||||||
|
|
||||||
|
export default function SyncGate() {
|
||||||
|
const etebase = useCredentials();
|
||||||
|
const settings = useSelector((state: StoreState) => state.settings);
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
|
||||||
|
// Doing this so we refresh on route changes
|
||||||
|
useHistory();
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
const syncManager = SyncManager.getManager(etebase!);
|
||||||
|
const sync = syncManager.sync();
|
||||||
|
dispatch(performSync(sync));
|
||||||
|
await sync;
|
||||||
|
setLoading(false);
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (<LoadingIndicator style={{ display: "block", margin: "40px auto" }} />);
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIXME: Shouldn't be here
|
||||||
|
moment.locale(settings.locale);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Switch>
|
||||||
|
<Route
|
||||||
|
path={routeResolver.getRoute("home")}
|
||||||
|
exact
|
||||||
|
render={() => (
|
||||||
|
<Redirect to={routeResolver.getRoute("pim")} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={routeResolver.getRoute("pim")}
|
||||||
|
>
|
||||||
|
<AppBarOverride title="EteSync" />
|
||||||
|
<Switch>
|
||||||
|
<Route
|
||||||
|
path={routeResolver.getRoute("pim")}
|
||||||
|
exact
|
||||||
|
>
|
||||||
|
<Redirect to={routeResolver.getRoute("pim.events")} />
|
||||||
|
</Route>
|
||||||
|
<Route
|
||||||
|
path={routeResolver.getRoute("pim.contacts")}
|
||||||
|
>
|
||||||
|
<PimNavigationTabs value="contacts" />
|
||||||
|
<Container>
|
||||||
|
<ContactsMain />
|
||||||
|
</Container>
|
||||||
|
</Route>
|
||||||
|
<Route
|
||||||
|
path={routeResolver.getRoute("pim.events")}
|
||||||
|
>
|
||||||
|
<PimNavigationTabs value="events" />
|
||||||
|
<Container>
|
||||||
|
<CalendarsMain />
|
||||||
|
</Container>
|
||||||
|
</Route>
|
||||||
|
<Route
|
||||||
|
path={routeResolver.getRoute("pim.tasks")}
|
||||||
|
>
|
||||||
|
<PimNavigationTabs value="tasks" />
|
||||||
|
<Container>
|
||||||
|
<TasksMain />
|
||||||
|
</Container>
|
||||||
|
</Route>
|
||||||
|
<PageNotFoundRoute container />
|
||||||
|
</Switch>
|
||||||
|
</Route>
|
||||||
|
<Route
|
||||||
|
path={routeResolver.getRoute("collections")}
|
||||||
|
>
|
||||||
|
<CollectionsMain />
|
||||||
|
</Route>
|
||||||
|
<Route
|
||||||
|
path={routeResolver.getRoute("settings")}
|
||||||
|
exact
|
||||||
|
render={() => (
|
||||||
|
<Settings />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={routeResolver.getRoute("debug")}
|
||||||
|
exact
|
||||||
|
render={() => (
|
||||||
|
<Debug />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<PageNotFoundRoute container />
|
||||||
|
</Switch>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,208 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2020 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Switch, Route, useHistory } from "react-router";
|
||||||
|
|
||||||
|
import * as Etebase from "etebase";
|
||||||
|
|
||||||
|
import { Button, useTheme } from "@material-ui/core";
|
||||||
|
import IconEdit from "@material-ui/icons/Edit";
|
||||||
|
import IconChangeHistory from "@material-ui/icons/ChangeHistory";
|
||||||
|
|
||||||
|
import { TaskType, PimType } from "../pim-types";
|
||||||
|
import { useCredentials } from "../credentials";
|
||||||
|
import { useItems, useCollections } from "../etebase-helpers";
|
||||||
|
import { routeResolver } from "../App";
|
||||||
|
import TaskList from "./TaskList";
|
||||||
|
import Task from "./Task";
|
||||||
|
import LoadingIndicator from "../widgets/LoadingIndicator";
|
||||||
|
import TaskEdit from "./TaskEdit";
|
||||||
|
import PageNotFound, { PageNotFoundRoute } from "../PageNotFound";
|
||||||
|
|
||||||
|
import { CachedCollection, getItemNavigationUid, getDecryptCollectionsFunction, getDecryptItemsFunction, PimFab, itemSave, itemDelete } from "../Pim/helpers";
|
||||||
|
import ItemChangeHistory from "../Pim/ItemChangeHistory";
|
||||||
|
|
||||||
|
const colType = "etebase.vtodo";
|
||||||
|
|
||||||
|
const decryptCollections = getDecryptCollectionsFunction(colType);
|
||||||
|
const decryptItems = getDecryptItemsFunction(colType, TaskType.parse);
|
||||||
|
|
||||||
|
export default function TasksMain() {
|
||||||
|
const [entries, setEntries] = React.useState<Map<string, Map<string, TaskType>>>();
|
||||||
|
const [cachedCollections, setCachedCollections] = React.useState<CachedCollection[]>();
|
||||||
|
const theme = useTheme();
|
||||||
|
const history = useHistory();
|
||||||
|
const etebase = useCredentials()!;
|
||||||
|
const collections = useCollections(etebase, colType);
|
||||||
|
const items = useItems(etebase, colType);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!collections || !items) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
(async () => {
|
||||||
|
const colEntries = await decryptCollections(collections);
|
||||||
|
const entries = await decryptItems(items);
|
||||||
|
|
||||||
|
setCachedCollections(colEntries);
|
||||||
|
setEntries(entries);
|
||||||
|
})();
|
||||||
|
}, [items, collections]);
|
||||||
|
|
||||||
|
if (!entries || !cachedCollections) {
|
||||||
|
return (
|
||||||
|
<LoadingIndicator />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onItemSave(item: PimType, collectionUid: string, originalItem?: PimType): Promise<void> {
|
||||||
|
const collection = collections!.find((x) => x.uid === collectionUid)!;
|
||||||
|
await itemSave(etebase, collection, items!, item, collectionUid, originalItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onItemDelete(item: PimType, collectionUid: string) {
|
||||||
|
const collection = collections!.find((x) => x.uid === collectionUid)!;
|
||||||
|
await itemDelete(etebase, collection, items!, item, collectionUid);
|
||||||
|
|
||||||
|
history.push(routeResolver.getRoute("pim.tasks"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCancel() {
|
||||||
|
history.goBack();
|
||||||
|
}
|
||||||
|
|
||||||
|
const flatEntries = [];
|
||||||
|
for (const col of entries.values()) {
|
||||||
|
for (const item of col.values()) {
|
||||||
|
flatEntries.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
button: {
|
||||||
|
marginLeft: theme.spacing(1),
|
||||||
|
},
|
||||||
|
leftIcon: {
|
||||||
|
marginRight: theme.spacing(1),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Switch>
|
||||||
|
<Route
|
||||||
|
path={routeResolver.getRoute("pim.tasks")}
|
||||||
|
exact
|
||||||
|
>
|
||||||
|
<TaskList
|
||||||
|
entries={flatEntries}
|
||||||
|
collections={cachedCollections}
|
||||||
|
onItemClick={(item: TaskType) => history.push(
|
||||||
|
routeResolver.getRoute("pim.tasks._id", { itemUid: getItemNavigationUid(item) })
|
||||||
|
)}
|
||||||
|
onItemSave={onItemSave}
|
||||||
|
/>
|
||||||
|
<PimFab
|
||||||
|
onClick={() => history.push(
|
||||||
|
routeResolver.getRoute("pim.tasks.new")
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
<Route
|
||||||
|
path={routeResolver.getRoute("pim.tasks.new")}
|
||||||
|
exact
|
||||||
|
>
|
||||||
|
<TaskEdit
|
||||||
|
collections={cachedCollections}
|
||||||
|
onSave={onItemSave}
|
||||||
|
onDelete={onItemDelete}
|
||||||
|
onCancel={onCancel}
|
||||||
|
history={history}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
<Route
|
||||||
|
path={routeResolver.getRoute("pim.tasks._id.log")}
|
||||||
|
render={({ match }) => {
|
||||||
|
// We have this path outside because we don't want the item existing check
|
||||||
|
const [colUid, itemUid] = match.params.itemUid.split("|");
|
||||||
|
const cachedCollection = cachedCollections!.find((x) => x.collection.uid === colUid)!;
|
||||||
|
if (!cachedCollection) {
|
||||||
|
return (<PageNotFound />);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ItemChangeHistory collection={cachedCollection} itemUid={itemUid} />
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={routeResolver.getRoute("pim.tasks._id")}
|
||||||
|
render={({ match }) => {
|
||||||
|
const [colUid, itemUid] = match.params.itemUid.split("|");
|
||||||
|
const item = entries.get(colUid)?.get(itemUid);
|
||||||
|
if (!item) {
|
||||||
|
return (<PageNotFound />);
|
||||||
|
}
|
||||||
|
|
||||||
|
const collection = collections!.find((x) => x.uid === colUid)!;
|
||||||
|
const readOnly = collection.accessLevel === Etebase.CollectionAccessLevel.ReadOnly;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Switch>
|
||||||
|
<Route
|
||||||
|
path={routeResolver.getRoute("pim.tasks._id.edit")}
|
||||||
|
exact
|
||||||
|
>
|
||||||
|
<TaskEdit
|
||||||
|
key={itemUid}
|
||||||
|
initialCollection={item.collectionUid}
|
||||||
|
item={item}
|
||||||
|
collections={cachedCollections}
|
||||||
|
onSave={onItemSave}
|
||||||
|
onDelete={onItemDelete}
|
||||||
|
onCancel={onCancel}
|
||||||
|
history={history}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
<Route
|
||||||
|
path={routeResolver.getRoute("pim.tasks._id")}
|
||||||
|
exact
|
||||||
|
>
|
||||||
|
<div style={{ textAlign: "right", marginBottom: 15 }}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
style={styles.button}
|
||||||
|
onClick={() =>
|
||||||
|
history.push(routeResolver.getRoute("pim.tasks._id.log", { itemUid: getItemNavigationUid(item) }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<IconChangeHistory style={styles.leftIcon} />
|
||||||
|
Change History
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
color="secondary"
|
||||||
|
variant="contained"
|
||||||
|
disabled={readOnly}
|
||||||
|
style={{ ...styles.button, marginLeft: 15 }}
|
||||||
|
onClick={() =>
|
||||||
|
history.push(routeResolver.getRoute("pim.tasks._id.edit", { itemUid: getItemNavigationUid(item) }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<IconEdit style={styles.leftIcon} />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<Task item={item} />
|
||||||
|
</Route>
|
||||||
|
<PageNotFoundRoute />
|
||||||
|
</Switch>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<PageNotFoundRoute />
|
||||||
|
</Switch>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,58 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import ICAL from "ical.js";
|
||||||
|
|
||||||
|
import uuid from "uuid";
|
||||||
|
|
||||||
|
import TextField from "@material-ui/core/TextField";
|
||||||
|
|
||||||
|
import { TaskType, PimType, TaskStatusType } from "../pim-types";
|
||||||
|
import { CachedCollection } from "../Pim/helpers";
|
||||||
|
|
||||||
|
interface PropsType {
|
||||||
|
style: React.CSSProperties;
|
||||||
|
onSubmit: (item: PimType, journalUid: string, originalItem?: PimType) => void;
|
||||||
|
defaultCollection: CachedCollection;
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuickAdd(props: PropsType) {
|
||||||
|
const [title, setTitle] = React.useState("");
|
||||||
|
const { style, onSubmit: save, defaultCollection } = props;
|
||||||
|
|
||||||
|
|
||||||
|
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
setTitle(e.target.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const task = new TaskType(null);
|
||||||
|
task.uid = uuid.v4();
|
||||||
|
task.title = title;
|
||||||
|
task.status = TaskStatusType.NeedsAction;
|
||||||
|
task.lastModified = ICAL.Time.now();
|
||||||
|
|
||||||
|
save(task, defaultCollection.collection.uid, undefined);
|
||||||
|
|
||||||
|
setTitle("");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} style={style}>
|
||||||
|
<TextField
|
||||||
|
label="Add a new task"
|
||||||
|
variant="outlined"
|
||||||
|
fullWidth
|
||||||
|
value={title}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default QuickAdd;
|
@ -0,0 +1,74 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { useSelector, useDispatch } from "react-redux";
|
||||||
|
|
||||||
|
import InboxIcon from "@material-ui/icons/Inbox";
|
||||||
|
import LabelIcon from "@material-ui/icons/LabelOutlined";
|
||||||
|
import TodayIcon from "@material-ui/icons/Today";
|
||||||
|
|
||||||
|
import { setSettings } from "../store/actions";
|
||||||
|
import { StoreState } from "../store";
|
||||||
|
|
||||||
|
import { List, ListItem, ListSubheader } from "../widgets/List";
|
||||||
|
import { TaskType, setTaskTags } from "../pim-types";
|
||||||
|
|
||||||
|
interface ListItemPropsType {
|
||||||
|
name: string | null;
|
||||||
|
icon?: React.ReactElement;
|
||||||
|
primaryText: string;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarListItem(props: ListItemPropsType) {
|
||||||
|
const { name, icon, primaryText, amount } = props;
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const taskSettings = useSelector((state: StoreState) => state.settings.taskSettings);
|
||||||
|
const { filterBy } = taskSettings;
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
dispatch(setSettings({ taskSettings: { ...taskSettings, filterBy: name } }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListItem
|
||||||
|
onClick={handleClick}
|
||||||
|
selected={name === filterBy}
|
||||||
|
leftIcon={icon}
|
||||||
|
rightIcon={<span style={{ width: "100%", textAlign: "right" }}>{(amount > 0) && amount}</span>}
|
||||||
|
primaryText={primaryText}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.memo(function Sidebar(props: { tasks: TaskType[] }) {
|
||||||
|
const { tasks } = props;
|
||||||
|
|
||||||
|
const amountDueToday = tasks.filter((x) => x.dueToday).length;
|
||||||
|
|
||||||
|
const tags = new Map<string, number>();
|
||||||
|
tasks.forEach((task) => task.tags.forEach((tag) => {
|
||||||
|
tags.set(tag, (tags.get(tag) ?? 0) + 1);
|
||||||
|
}));
|
||||||
|
// FIXME: ugly hack to support potential tags. Will be fixed very soon.
|
||||||
|
setTaskTags(Array.from(tags.keys()));
|
||||||
|
|
||||||
|
const tagsList = [...tags].sort(([a], [b]) => a.localeCompare(b)).map(([tag, amount]) => (
|
||||||
|
<SidebarListItem
|
||||||
|
key={tag}
|
||||||
|
name={`tag:${tag}`}
|
||||||
|
primaryText={tag}
|
||||||
|
icon={<LabelIcon />}
|
||||||
|
amount={amount}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<List dense>
|
||||||
|
<SidebarListItem name={null} primaryText="All" icon={<InboxIcon />} amount={tasks.length} />
|
||||||
|
<SidebarListItem name="today" primaryText="Due today" icon={<TodayIcon />} amount={amountDueToday} />
|
||||||
|
|
||||||
|
<ListSubheader>Tags</ListSubheader>
|
||||||
|
{tagsList}
|
||||||
|
</List>
|
||||||
|
);
|
||||||
|
});
|
@ -0,0 +1,54 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import PimItemHeader from "../components/PimItemHeader";
|
||||||
|
|
||||||
|
import { formatDate, formatOurTimezoneOffset } from "../helpers";
|
||||||
|
|
||||||
|
import { TaskType } from "../pim-types";
|
||||||
|
|
||||||
|
class Task extends React.PureComponent {
|
||||||
|
public props: {
|
||||||
|
item?: TaskType;
|
||||||
|
};
|
||||||
|
|
||||||
|
public render() {
|
||||||
|
if (this.props.item === undefined) {
|
||||||
|
throw Error("Task should be defined!");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { item } = this.props;
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
content: {
|
||||||
|
padding: 15,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const timezone = this.props.item.timezone;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
<PimItemHeader text={this.props.item.summary} backgroundColor={this.props.item.color}>
|
||||||
|
{item.startDate &&
|
||||||
|
<div>Start: {formatDate(item.startDate)} {timezone && <small>({formatOurTimezoneOffset()})</small>}</div>
|
||||||
|
}
|
||||||
|
{item.dueDate &&
|
||||||
|
<div>Due: {formatDate(item.dueDate)} {timezone && <small>({formatOurTimezoneOffset()})</small>}</div>
|
||||||
|
}
|
||||||
|
<br />
|
||||||
|
<div><u>{this.props.item.location}</u></div>
|
||||||
|
</PimItemHeader>
|
||||||
|
<div style={style.content}>
|
||||||
|
<p style={{ wordWrap: "break-word" }}>{this.props.item.description}</p>
|
||||||
|
{(this.props.item.attendees.length > 0) && (
|
||||||
|
<div>Attendees: {this.props.item.attendees.map((x) => (x.getFirstValue())).join(", ")}</div>)}
|
||||||
|
</div>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Task;
|
@ -0,0 +1,498 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import FormGroup from "@material-ui/core/FormGroup";
|
||||||
|
import FormControlLabel from "@material-ui/core/FormControlLabel";
|
||||||
|
import Switch from "@material-ui/core/Switch";
|
||||||
|
|
||||||
|
import Button from "@material-ui/core/Button";
|
||||||
|
import TextField from "@material-ui/core/TextField";
|
||||||
|
import Select from "@material-ui/core/Select";
|
||||||
|
import MenuItem from "@material-ui/core/MenuItem";
|
||||||
|
import FormControl from "@material-ui/core/FormControl";
|
||||||
|
import FormHelperText from "@material-ui/core/FormHelperText";
|
||||||
|
import InputLabel from "@material-ui/core/InputLabel";
|
||||||
|
import * as colors from "@material-ui/core/colors";
|
||||||
|
import FormLabel from "@material-ui/core/FormLabel";
|
||||||
|
import RadioGroup from "@material-ui/core/RadioGroup";
|
||||||
|
|
||||||
|
import Autocomplete from "@material-ui/lab/Autocomplete";
|
||||||
|
|
||||||
|
import IconDelete from "@material-ui/icons/Delete";
|
||||||
|
import IconCancel from "@material-ui/icons/Clear";
|
||||||
|
import IconSave from "@material-ui/icons/Save";
|
||||||
|
|
||||||
|
import DateTimePicker from "../widgets/DateTimePicker";
|
||||||
|
|
||||||
|
import ConfirmationDialog from "../widgets/ConfirmationDialog";
|
||||||
|
import TimezonePicker from "../widgets/TimezonePicker";
|
||||||
|
import Toast from "../widgets/Toast";
|
||||||
|
|
||||||
|
import * as uuid from "uuid";
|
||||||
|
import * as ICAL from "ical.js";
|
||||||
|
|
||||||
|
import { getCurrentTimezone, mapPriority } from "../helpers";
|
||||||
|
|
||||||
|
import { TaskType, TaskStatusType, timezoneLoadFromName, TaskPriorityType, TaskTags } from "../pim-types";
|
||||||
|
|
||||||
|
import { History } from "history";
|
||||||
|
|
||||||
|
import ColoredRadio from "../widgets/ColoredRadio";
|
||||||
|
import RRule, { RRuleOptions } from "../widgets/RRule";
|
||||||
|
import { CachedCollection } from "../Pim/helpers";
|
||||||
|
|
||||||
|
interface PropsType {
|
||||||
|
collections: CachedCollection[];
|
||||||
|
initialCollection?: string;
|
||||||
|
item?: TaskType;
|
||||||
|
onSave: (item: TaskType, collectionUid: string, originalItem?: TaskType) => Promise<void>;
|
||||||
|
onDelete: (item: TaskType, collectionUid: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
history: History<any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class TaskEdit extends React.PureComponent<PropsType> {
|
||||||
|
public state: {
|
||||||
|
uid: string;
|
||||||
|
title: string;
|
||||||
|
status: TaskStatusType;
|
||||||
|
priority: TaskPriorityType;
|
||||||
|
includeTime: boolean;
|
||||||
|
start?: Date;
|
||||||
|
due?: Date;
|
||||||
|
timezone: string | null;
|
||||||
|
rrule?: RRuleOptions;
|
||||||
|
location: string;
|
||||||
|
description: string;
|
||||||
|
tags: string[];
|
||||||
|
collectionUid: string;
|
||||||
|
|
||||||
|
error?: string;
|
||||||
|
showDeleteDialog: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(props: PropsType) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
uid: "",
|
||||||
|
title: "",
|
||||||
|
status: TaskStatusType.NeedsAction,
|
||||||
|
priority: TaskPriorityType.Undefined,
|
||||||
|
includeTime: false,
|
||||||
|
location: "",
|
||||||
|
description: "",
|
||||||
|
tags: [],
|
||||||
|
timezone: null,
|
||||||
|
|
||||||
|
collectionUid: "",
|
||||||
|
showDeleteDialog: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.props.item !== undefined) {
|
||||||
|
const task = this.props.item;
|
||||||
|
|
||||||
|
this.state.uid = task.uid;
|
||||||
|
this.state.title = task.title ? task.title : "";
|
||||||
|
this.state.status = task.status ?? TaskStatusType.NeedsAction;
|
||||||
|
this.state.priority = task.priority ?? TaskPriorityType.Undefined;
|
||||||
|
if (task.startDate) {
|
||||||
|
this.state.includeTime = !task.startDate.isDate;
|
||||||
|
this.state.start = task.startDate.convertToZone(ICAL.Timezone.localTimezone).toJSDate();
|
||||||
|
}
|
||||||
|
if (task.dueDate) {
|
||||||
|
this.state.due = task.dueDate.convertToZone(ICAL.Timezone.localTimezone).toJSDate();
|
||||||
|
}
|
||||||
|
const rrule = task.rrule;
|
||||||
|
if (rrule) {
|
||||||
|
this.state.rrule = rrule.toJSON() as any;
|
||||||
|
if (this.state.rrule && rrule.until) {
|
||||||
|
this.state.rrule.until = rrule.until;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.state.location = task.location ? task.location : "";
|
||||||
|
this.state.description = task.description ? task.description : "";
|
||||||
|
this.state.timezone = task.timezone;
|
||||||
|
this.state.tags = task.tags;
|
||||||
|
} else {
|
||||||
|
this.state.uid = uuid.v4();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state.timezone = this.state.timezone || getCurrentTimezone();
|
||||||
|
|
||||||
|
if (props.initialCollection) {
|
||||||
|
this.state.collectionUid = props.initialCollection;
|
||||||
|
} else if (props.collections[0]) {
|
||||||
|
this.state.collectionUid = props.collections[0].collection.uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.onSubmit = this.onSubmit.bind(this);
|
||||||
|
this.handleChange = this.handleChange.bind(this);
|
||||||
|
this.handleInputChange = this.handleInputChange.bind(this);
|
||||||
|
this.toggleTime = this.toggleTime.bind(this);
|
||||||
|
this.toggleRecurring = this.toggleRecurring.bind(this);
|
||||||
|
this.handleRRuleChange = this.handleRRuleChange.bind(this);
|
||||||
|
this.onDeleteRequest = this.onDeleteRequest.bind(this);
|
||||||
|
this.handleCloseToast = this.handleCloseToast.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public handleChange(name: string, value: string | number | string[]) {
|
||||||
|
this.setState({
|
||||||
|
[name]: value,
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public handleInputChange(event: React.ChangeEvent<any>) {
|
||||||
|
const name = event.target.name;
|
||||||
|
const value = event.target.value;
|
||||||
|
this.handleChange(name, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public toggleTime() {
|
||||||
|
this.setState({ includeTime: !this.state.includeTime });
|
||||||
|
}
|
||||||
|
|
||||||
|
public handleCloseToast(_event?: React.SyntheticEvent, reason?: string) {
|
||||||
|
if (reason === "clickaway") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState({ error: "" });
|
||||||
|
}
|
||||||
|
|
||||||
|
public toggleRecurring() {
|
||||||
|
const value = this.state.rrule ? undefined : { freq: "WEEKLY", interval: 1 };
|
||||||
|
this.setState({ rrule: value });
|
||||||
|
}
|
||||||
|
|
||||||
|
public handleRRuleChange(rrule: RRuleOptions): void {
|
||||||
|
this.setState({ rrule: rrule });
|
||||||
|
}
|
||||||
|
|
||||||
|
public onSubmit(e: React.FormEvent<any>) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (this.state.rrule && !(this.state.start || this.state.due)) {
|
||||||
|
this.setState({ error: "A recurring task must have either Hide Until or Due Date set!" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromDate(date: Date | undefined, includeTime: boolean) {
|
||||||
|
if (!date) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const ret = ICAL.Time.fromJSDate(date, false);
|
||||||
|
if (includeTime) {
|
||||||
|
return ret;
|
||||||
|
} else {
|
||||||
|
const data = ret.toJSON();
|
||||||
|
data.isDate = true;
|
||||||
|
return ICAL.Time.fromData(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDate = fromDate(this.state.start, this.state.includeTime);
|
||||||
|
const dueDate = fromDate(this.state.due, this.state.includeTime);
|
||||||
|
|
||||||
|
if (startDate && dueDate) {
|
||||||
|
if (startDate.compare(dueDate) >= 0) {
|
||||||
|
this.setState({ error: "End time must be later than start time!" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = (this.props.item) ?
|
||||||
|
this.props.item.clone()
|
||||||
|
:
|
||||||
|
new TaskType(null)
|
||||||
|
;
|
||||||
|
|
||||||
|
task.uid = this.state.uid;
|
||||||
|
task.summary = this.state.title;
|
||||||
|
task.status = this.state.status;
|
||||||
|
task.priority = this.state.priority;
|
||||||
|
task.tags = this.state.tags;
|
||||||
|
if (startDate) {
|
||||||
|
task.startDate = startDate;
|
||||||
|
}
|
||||||
|
task.dueDate = dueDate;
|
||||||
|
if (this.state.rrule) {
|
||||||
|
task.rrule = new ICAL.Recur(this.state.rrule);
|
||||||
|
}
|
||||||
|
task.location = this.state.location;
|
||||||
|
task.description = this.state.description;
|
||||||
|
if (this.state.timezone) {
|
||||||
|
const timezone = timezoneLoadFromName(this.state.timezone);
|
||||||
|
if (timezone) {
|
||||||
|
if (task.startDate) {
|
||||||
|
task.startDate = task.startDate.convertToZone(timezone);
|
||||||
|
}
|
||||||
|
if (task.dueDate) {
|
||||||
|
task.dueDate = task.dueDate.convertToZone(timezone);
|
||||||
|
}
|
||||||
|
if (task.completionDate) {
|
||||||
|
task.completionDate = task.completionDate.convertToZone(timezone);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
task.component.updatePropertyWithValue("last-modified", ICAL.Time.now());
|
||||||
|
|
||||||
|
this.props.onSave(task, this.state.collectionUid, this.props.item)
|
||||||
|
.then(() => {
|
||||||
|
const nextTask = task.finished && task.getNextOccurence();
|
||||||
|
if (nextTask) {
|
||||||
|
return this.props.onSave(nextTask, this.state.collectionUid);
|
||||||
|
} else {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
this.props.history.goBack();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
this.setState({ error: "Could not save task" });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public onDeleteRequest() {
|
||||||
|
this.setState({
|
||||||
|
showDeleteDialog: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public render() {
|
||||||
|
const styles = {
|
||||||
|
form: {
|
||||||
|
},
|
||||||
|
fullWidth: {
|
||||||
|
width: "100%",
|
||||||
|
boxSizing: "border-box" as any,
|
||||||
|
marginTop: 16,
|
||||||
|
},
|
||||||
|
submit: {
|
||||||
|
marginTop: 40,
|
||||||
|
marginBottom: 20,
|
||||||
|
textAlign: "right" as any,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const recurring = this.props.item && this.props.item.isRecurring();
|
||||||
|
const differentTimezone = this.state.timezone && (this.state.timezone !== getCurrentTimezone()) && timezoneLoadFromName(this.state.timezone);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
<h2>
|
||||||
|
{this.props.item ? "Edit Task" : "New Task"}
|
||||||
|
</h2>
|
||||||
|
{recurring && (
|
||||||
|
<div>
|
||||||
|
<span style={{ color: "red" }}>IMPORTANT: </span>
|
||||||
|
This is a recurring task, for now, only editing the whole series
|
||||||
|
(by editing the first instance) is supported.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Toast open={!!this.state.error} severity="error" onClose={this.handleCloseToast}>
|
||||||
|
ERROR! {this.state.error}
|
||||||
|
</Toast>
|
||||||
|
<form style={styles.form} onSubmit={this.onSubmit}>
|
||||||
|
<TextField
|
||||||
|
name="title"
|
||||||
|
placeholder="Enter title"
|
||||||
|
style={styles.fullWidth}
|
||||||
|
value={this.state.title}
|
||||||
|
onChange={this.handleInputChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormControl disabled={this.props.item !== undefined} style={styles.fullWidth}>
|
||||||
|
<InputLabel>
|
||||||
|
Saving to
|
||||||
|
</InputLabel>
|
||||||
|
<Select
|
||||||
|
name="collectionUid"
|
||||||
|
value={this.state.collectionUid}
|
||||||
|
disabled={this.props.item !== undefined}
|
||||||
|
onChange={this.handleInputChange}
|
||||||
|
>
|
||||||
|
{this.props.collections.map((x) => (
|
||||||
|
<MenuItem key={x.collection.uid} value={x.collection.uid}>{x.metadata.name}</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl style={styles.fullWidth}>
|
||||||
|
<InputLabel>
|
||||||
|
Status
|
||||||
|
</InputLabel>
|
||||||
|
<Select
|
||||||
|
name="status"
|
||||||
|
value={this.state.status}
|
||||||
|
onChange={this.handleInputChange}
|
||||||
|
>
|
||||||
|
<MenuItem value={TaskStatusType.NeedsAction}>Needs action</MenuItem>
|
||||||
|
<MenuItem value={TaskStatusType.InProcess}>In progress</MenuItem>
|
||||||
|
<MenuItem value={TaskStatusType.Completed}>Completed</MenuItem>
|
||||||
|
<MenuItem value={TaskStatusType.Cancelled}>Cancelled</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl style={styles.fullWidth}>
|
||||||
|
<FormLabel>Priority</FormLabel>
|
||||||
|
|
||||||
|
<RadioGroup
|
||||||
|
row
|
||||||
|
value={mapPriority(this.state.priority)}
|
||||||
|
onChange={(e) => this.handleChange("priority", Number(e.target.value))}
|
||||||
|
>
|
||||||
|
<ColoredRadio value={TaskPriorityType.Undefined} label="None" color={colors.grey[600]} />
|
||||||
|
<ColoredRadio value={TaskPriorityType.Low} label="Low" color={colors.blue[600]} />
|
||||||
|
<ColoredRadio value={TaskPriorityType.Medium} label="Medium" color={colors.orange[600]} />
|
||||||
|
<ColoredRadio value={TaskPriorityType.High} label="High" color={colors.red[600]} />
|
||||||
|
</RadioGroup>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl style={styles.fullWidth}>
|
||||||
|
<FormHelperText>Hide until</FormHelperText>
|
||||||
|
<DateTimePicker
|
||||||
|
dateOnly={!this.state.includeTime}
|
||||||
|
placeholder="Hide until"
|
||||||
|
value={this.state.start}
|
||||||
|
onChange={(date?: Date) => this.setState({ start: date })}
|
||||||
|
/>
|
||||||
|
{differentTimezone && this.state.start && (
|
||||||
|
<FormHelperText>{ICAL.Time.fromJSDate(this.state.start, false).convertToZone(differentTimezone!).toJSDate().toString()}</FormHelperText>
|
||||||
|
)}
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl style={styles.fullWidth}>
|
||||||
|
<FormHelperText>Due</FormHelperText>
|
||||||
|
<DateTimePicker
|
||||||
|
dateOnly={!this.state.includeTime}
|
||||||
|
placeholder="Due"
|
||||||
|
value={this.state.due}
|
||||||
|
onChange={(date?: Date) => this.setState({ due: date })}
|
||||||
|
/>
|
||||||
|
{differentTimezone && this.state.due && (
|
||||||
|
<FormHelperText>{ICAL.Time.fromJSDate(this.state.due, false).convertToZone(differentTimezone!).toJSDate().toString()}</FormHelperText>
|
||||||
|
)}
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormGroup style={styles.fullWidth}>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
name="includeTime"
|
||||||
|
checked={this.state.includeTime}
|
||||||
|
onChange={this.toggleTime}
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Include time"
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
{(this.state.includeTime) && (
|
||||||
|
<TimezonePicker style={styles.fullWidth} value={this.state.timezone} onChange={(zone) => this.setState({ timezone: zone })} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormGroup>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
name="recurring"
|
||||||
|
checked={!!this.state.rrule}
|
||||||
|
onChange={this.toggleRecurring}
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Recurring"
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
{this.state.rrule &&
|
||||||
|
<RRule
|
||||||
|
onChange={this.handleRRuleChange}
|
||||||
|
rrule={this.state.rrule ? this.state.rrule : { freq: "DAILY", interval: 1 }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
name="location"
|
||||||
|
placeholder="Add location"
|
||||||
|
style={styles.fullWidth}
|
||||||
|
value={this.state.location}
|
||||||
|
onChange={this.handleInputChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
name="description"
|
||||||
|
placeholder="Add description"
|
||||||
|
multiline
|
||||||
|
style={styles.fullWidth}
|
||||||
|
value={this.state.description}
|
||||||
|
onChange={this.handleInputChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Autocomplete
|
||||||
|
style={styles.fullWidth}
|
||||||
|
freeSolo
|
||||||
|
multiple
|
||||||
|
options={TaskTags}
|
||||||
|
value={this.state.tags}
|
||||||
|
onChange={(_e, value) => this.handleChange("tags", value)}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
variant="standard"
|
||||||
|
label="Tags"
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={styles.submit}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={this.props.onCancel}
|
||||||
|
>
|
||||||
|
<IconCancel style={{ marginRight: 8 }} />
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{this.props.item &&
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
style={{ marginLeft: 15, backgroundColor: colors.red[500], color: "white" }}
|
||||||
|
onClick={this.onDeleteRequest}
|
||||||
|
>
|
||||||
|
<IconDelete style={{ marginRight: 8 }} />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
color="secondary"
|
||||||
|
style={{ marginLeft: 15 }}
|
||||||
|
>
|
||||||
|
<IconSave style={{ marginRight: 8 }} />
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<ConfirmationDialog
|
||||||
|
title="Delete Confirmation"
|
||||||
|
labelOk="Delete"
|
||||||
|
open={this.state.showDeleteDialog}
|
||||||
|
onOk={() => this.props.onDelete(this.props.item!, this.props.initialCollection!)}
|
||||||
|
onCancel={() => this.setState({ showDeleteDialog: false })}
|
||||||
|
>
|
||||||
|
Are you sure you would like to delete this task?
|
||||||
|
</ConfirmationDialog>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,235 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { List } from "../widgets/List";
|
||||||
|
|
||||||
|
import { TaskType, PimType, TaskStatusType } from "../pim-types";
|
||||||
|
import Divider from "@material-ui/core/Divider";
|
||||||
|
import Grid from "@material-ui/core/Grid";
|
||||||
|
import { useTheme, makeStyles } from "@material-ui/core/styles";
|
||||||
|
|
||||||
|
import { useSelector, useDispatch } from "react-redux";
|
||||||
|
|
||||||
|
import Fuse from "fuse.js";
|
||||||
|
|
||||||
|
import TaskListItem from "./TaskListItem";
|
||||||
|
import Sidebar from "./Sidebar";
|
||||||
|
import Toolbar from "./Toolbar";
|
||||||
|
import QuickAdd from "./QuickAdd";
|
||||||
|
|
||||||
|
import { StoreState } from "../store";
|
||||||
|
import { formatDate } from "../helpers";
|
||||||
|
import { CachedCollection } from "../Pim/helpers";
|
||||||
|
import { pushMessage } from "../store/actions";
|
||||||
|
|
||||||
|
function sortCompleted(a: TaskType, b: TaskType) {
|
||||||
|
return (!!a.finished === !!b.finished) ? 0 : (a.finished) ? 1 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortLastModifiedDate(aIn: TaskType, bIn: TaskType) {
|
||||||
|
const a = aIn.lastModified?.toJSDate() ?? new Date(0);
|
||||||
|
const b = bIn.lastModified?.toJSDate() ?? new Date(0);
|
||||||
|
return (a > b) ? -1 : (a < b) ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortDueDate(aIn: TaskType, bIn: TaskType) {
|
||||||
|
const impossiblyLargeDate = 8640000000000000;
|
||||||
|
const a = aIn.dueDate?.toJSDate() ?? new Date(impossiblyLargeDate);
|
||||||
|
const b = bIn.dueDate?.toJSDate() ?? new Date(impossiblyLargeDate);
|
||||||
|
return (a < b) ? -1 : (a > b) ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortPriority(aIn: TaskType, bIn: TaskType) {
|
||||||
|
// Intentionally converts 0/undefined to 10 (1 more than lowest priority) to sort to back of the list
|
||||||
|
const a = aIn.priority || 10;
|
||||||
|
const b = bIn.priority || 10;
|
||||||
|
return a - b;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortTitle(aIn: TaskType, bIn: TaskType) {
|
||||||
|
const a = aIn.title ?? "";
|
||||||
|
const b = bIn.title ?? "";
|
||||||
|
return a.localeCompare(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSortFunction(sortOrder: string) {
|
||||||
|
const sortFunctions: (typeof sortTitle)[] = [sortCompleted];
|
||||||
|
|
||||||
|
switch (sortOrder) {
|
||||||
|
case "smart":
|
||||||
|
sortFunctions.push(sortPriority);
|
||||||
|
sortFunctions.push(sortDueDate);
|
||||||
|
sortFunctions.push(sortTitle);
|
||||||
|
break;
|
||||||
|
case "dueDate":
|
||||||
|
sortFunctions.push(sortDueDate);
|
||||||
|
break;
|
||||||
|
case "priority":
|
||||||
|
sortFunctions.push(sortPriority);
|
||||||
|
sortFunctions.push(sortDueDate);
|
||||||
|
break;
|
||||||
|
case "title":
|
||||||
|
sortFunctions.push(sortTitle);
|
||||||
|
break;
|
||||||
|
case "lastModifiedDate":
|
||||||
|
// Do nothing because it's the last sort function anyway
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
sortFunctions.push(sortLastModifiedDate);
|
||||||
|
|
||||||
|
return (a: TaskType, b: TaskType) => {
|
||||||
|
for (const sortFunction of sortFunctions) {
|
||||||
|
const ret = sortFunction(a, b);
|
||||||
|
if (ret !== 0) {
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme) => ({
|
||||||
|
topBar: {
|
||||||
|
backgroundColor: theme.palette.primary[500],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface PropsType {
|
||||||
|
entries: TaskType[];
|
||||||
|
collections: CachedCollection[];
|
||||||
|
onItemClick: (entry: TaskType) => void;
|
||||||
|
onItemSave: (item: PimType, journalUid: string, originalItem?: PimType) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TaskList(props: PropsType) {
|
||||||
|
const [showCompleted, setShowCompleted] = React.useState(false);
|
||||||
|
const [showHidden, setShowHidden] = React.useState(false);
|
||||||
|
const [searchTerm, setSearchTerm] = React.useState("");
|
||||||
|
const settings = useSelector((state: StoreState) => state.settings.taskSettings);
|
||||||
|
const { filterBy, sortBy } = settings;
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const theme = useTheme();
|
||||||
|
const classes = useStyles();
|
||||||
|
|
||||||
|
const { onItemClick } = props;
|
||||||
|
|
||||||
|
const handleToggleComplete = async (task: TaskType, completed: boolean) => {
|
||||||
|
const clonedTask = task.clone();
|
||||||
|
clonedTask.status = completed ? TaskStatusType.Completed : TaskStatusType.NeedsAction;
|
||||||
|
|
||||||
|
const nextTask = completed ? task.getNextOccurence() : null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await props.onItemSave(clonedTask, task.collectionUid!, task);
|
||||||
|
if (nextTask) {
|
||||||
|
dispatch(pushMessage({ message: `${nextTask.title} rescheduled for ${formatDate(nextTask.startDate ?? nextTask.dueDate)}`, severity: "success" }));
|
||||||
|
}
|
||||||
|
} catch (_e) {
|
||||||
|
dispatch(pushMessage({ message: "Failed to save changes. This may be due to a network error.", severity: "error" }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const potentialEntries = React.useMemo(
|
||||||
|
() => {
|
||||||
|
if (searchTerm) {
|
||||||
|
const result = new Fuse(props.entries, {
|
||||||
|
shouldSort: true,
|
||||||
|
threshold: 0.6,
|
||||||
|
maxPatternLength: 32,
|
||||||
|
minMatchCharLength: 2,
|
||||||
|
keys: [
|
||||||
|
"title",
|
||||||
|
"desc",
|
||||||
|
],
|
||||||
|
}).search(searchTerm);
|
||||||
|
return result.map((x) => x.item);
|
||||||
|
} else {
|
||||||
|
return props.entries.filter((x) => (showCompleted || !x.finished) && (showHidden || !x.hidden));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[showCompleted, props.entries, searchTerm, showHidden]
|
||||||
|
);
|
||||||
|
|
||||||
|
let entries;
|
||||||
|
|
||||||
|
const tagPrefix = "tag:";
|
||||||
|
if (filterBy?.startsWith(tagPrefix)) {
|
||||||
|
const tag = filterBy.slice(tagPrefix.length);
|
||||||
|
entries = potentialEntries.filter((x) => x.tags.includes(tag));
|
||||||
|
} else if (filterBy === "today") {
|
||||||
|
entries = potentialEntries.filter((x) => x.dueToday);
|
||||||
|
} else {
|
||||||
|
entries = potentialEntries;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subEntriesMap = new Map<string, TaskType[]>();
|
||||||
|
|
||||||
|
entries = entries.filter((x) => {
|
||||||
|
const relatedTo = x.relatedTo;
|
||||||
|
if (relatedTo) {
|
||||||
|
const cur = subEntriesMap.get(relatedTo) ?? [];
|
||||||
|
cur.push(x);
|
||||||
|
subEntriesMap.set(relatedTo, cur);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
function taskListItemFromTask(entry: TaskType) {
|
||||||
|
const uid = entry.uid;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TaskListItem
|
||||||
|
key={uid}
|
||||||
|
entry={entry}
|
||||||
|
nestedItems={subEntriesMap.get(uid)?.map(taskListItemFromTask)}
|
||||||
|
onClick={onItemClick}
|
||||||
|
onToggleComplete={handleToggleComplete}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedEntries = entries.sort(getSortFunction(sortBy));
|
||||||
|
|
||||||
|
const itemList = sortedEntries.map(taskListItemFromTask);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid container spacing={4}>
|
||||||
|
<Grid item xs={3} className={classes.topBar}>
|
||||||
|
{/* spacer */}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={9} className={classes.topBar}>
|
||||||
|
<Toolbar
|
||||||
|
defaultCollection={props.collections?.[0]}
|
||||||
|
onItemSave={props.onItemSave}
|
||||||
|
showCompleted={showCompleted}
|
||||||
|
setShowCompleted={setShowCompleted}
|
||||||
|
searchTerm={searchTerm}
|
||||||
|
setSearchTerm={setSearchTerm}
|
||||||
|
showHidden={showHidden}
|
||||||
|
setShowHidden={setShowHidden}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={3} style={{ borderRight: `1px solid ${theme.palette.divider}` }}>
|
||||||
|
<Sidebar tasks={potentialEntries} />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs>
|
||||||
|
|
||||||
|
{props.collections?.[0] && <QuickAdd style={{ flexGrow: 1, marginRight: "0.75em" }} onSubmit={props.onItemSave} defaultCollection={props.collections?.[0]} />}
|
||||||
|
|
||||||
|
<Divider style={{ marginTop: "1em" }} />
|
||||||
|
|
||||||
|
<List>
|
||||||
|
{itemList}
|
||||||
|
</List>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,73 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { TaskType, TaskPriorityType } from "../pim-types";
|
||||||
|
import { ListItem } from "../widgets/List";
|
||||||
|
|
||||||
|
import Checkbox from "@material-ui/core/Checkbox";
|
||||||
|
import CheckBoxOutlineBlankIcon from "@material-ui/icons/CheckBoxOutlineBlank";
|
||||||
|
import * as colors from "@material-ui/core/colors";
|
||||||
|
import Chip from "@material-ui/core/Chip";
|
||||||
|
|
||||||
|
import { mapPriority, formatDate } from "../helpers";
|
||||||
|
|
||||||
|
const checkboxColor = {
|
||||||
|
[TaskPriorityType.Undefined]: colors.grey[600],
|
||||||
|
[TaskPriorityType.Low]: colors.blue[600],
|
||||||
|
[TaskPriorityType.Medium]: colors.orange[600],
|
||||||
|
[TaskPriorityType.High]: colors.red[600],
|
||||||
|
};
|
||||||
|
|
||||||
|
const TagsList = React.memo((props: { tags: string[] }) => (
|
||||||
|
<ul>
|
||||||
|
{props.tags.map((tag, i) => tag && <Chip
|
||||||
|
key={i}
|
||||||
|
color="secondary"
|
||||||
|
size="small"
|
||||||
|
label={tag}
|
||||||
|
style={{ marginRight: "0.75em" }}
|
||||||
|
component="li"
|
||||||
|
/>)}
|
||||||
|
</ul>));
|
||||||
|
|
||||||
|
interface PropsType {
|
||||||
|
entry: TaskType;
|
||||||
|
nestedItems?: React.ReactNode[];
|
||||||
|
onClick: (task: TaskType) => void;
|
||||||
|
onToggleComplete: (task: TaskType, completed: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.memo(function TaskListItem(props: PropsType) {
|
||||||
|
const {
|
||||||
|
entry: task,
|
||||||
|
nestedItems,
|
||||||
|
onClick,
|
||||||
|
onToggleComplete,
|
||||||
|
} = props;
|
||||||
|
const title = task.title;
|
||||||
|
|
||||||
|
const dueDateText = task.dueDate ? `Due ${formatDate(task.dueDate)}` : "";
|
||||||
|
const freqText = task.rrule ? `(repeats ${task.rrule.freq.toLowerCase()})` : "";
|
||||||
|
const secondaryText = `${dueDateText} ${freqText}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListItem
|
||||||
|
primaryText={title}
|
||||||
|
secondaryText={secondaryText}
|
||||||
|
secondaryTextColor={task.overdue ? "error" : "textSecondary"}
|
||||||
|
nestedItems={nestedItems}
|
||||||
|
onClick={() => onClick(task)}
|
||||||
|
leftIcon={
|
||||||
|
<Checkbox
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onChange={(_e, checked) => onToggleComplete(task, checked)}
|
||||||
|
checked={task.finished}
|
||||||
|
icon={<CheckBoxOutlineBlankIcon style={{ color: checkboxColor[mapPriority(task.priority)] }} />}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
rightIcon={<TagsList tags={task.tags} />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
@ -0,0 +1,164 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import Switch from "@material-ui/core/Switch";
|
||||||
|
import IconButton from "@material-ui/core/IconButton";
|
||||||
|
import MoreVertIcon from "@material-ui/icons/MoreVert";
|
||||||
|
import MenuItem from "@material-ui/core/MenuItem";
|
||||||
|
import SortIcon from "@material-ui/icons/Sort";
|
||||||
|
import SearchIcon from "@material-ui/icons/Search";
|
||||||
|
import TextField from "@material-ui/core/TextField";
|
||||||
|
import { makeStyles } from "@material-ui/core/styles";
|
||||||
|
import { Transition } from "react-transition-group";
|
||||||
|
import InputAdornment from "@material-ui/core/InputAdornment";
|
||||||
|
|
||||||
|
import { PimType } from "../pim-types";
|
||||||
|
|
||||||
|
import { useSelector, useDispatch } from "react-redux";
|
||||||
|
|
||||||
|
import { setSettings } from "../store/actions";
|
||||||
|
import { StoreState } from "../store";
|
||||||
|
|
||||||
|
import Menu from "../widgets/Menu";
|
||||||
|
import { ListItemText, ListItemSecondaryAction } from "@material-ui/core";
|
||||||
|
import { CachedCollection } from "../Pim/helpers";
|
||||||
|
|
||||||
|
const transitionTimeout = 300;
|
||||||
|
|
||||||
|
const transitionStyles = {
|
||||||
|
entering: { visibility: "visible", width: "100%", overflow: "hidden" },
|
||||||
|
entered: { visibility: "visible", width: "100%" },
|
||||||
|
exiting: { visibility: "visible", width: "0%", overflow: "hidden" },
|
||||||
|
exited: { visibility: "hidden", width: "0%" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme) => ({
|
||||||
|
button: {
|
||||||
|
marginRight: theme.spacing(1),
|
||||||
|
},
|
||||||
|
textField: {
|
||||||
|
transition: `width ${transitionTimeout}ms`,
|
||||||
|
marginRight: theme.spacing(1),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface PropsType {
|
||||||
|
defaultCollection: CachedCollection;
|
||||||
|
onItemSave: (item: PimType, journalUid: string, originalItem?: PimType) => Promise<void>;
|
||||||
|
showCompleted: boolean;
|
||||||
|
showHidden: boolean;
|
||||||
|
setShowCompleted: (completed: boolean) => void;
|
||||||
|
setShowHidden: (hidden: boolean) => void;
|
||||||
|
searchTerm: string;
|
||||||
|
setSearchTerm: (term: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Toolbar(props: PropsType) {
|
||||||
|
const { showCompleted, setShowCompleted, searchTerm, setSearchTerm, showHidden, setShowHidden } = props;
|
||||||
|
|
||||||
|
const [sortAnchorEl, setSortAnchorEl] = React.useState<null | HTMLElement>(null);
|
||||||
|
const [optionsAnchorEl, setOptionsAnchorEl] = React.useState<null | HTMLElement>(null);
|
||||||
|
|
||||||
|
const showSearchField = true;
|
||||||
|
const classes = useStyles();
|
||||||
|
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const taskSettings = useSelector((state: StoreState) => state.settings.taskSettings);
|
||||||
|
const { sortBy } = taskSettings;
|
||||||
|
|
||||||
|
const handleSortChange = (sort: string) => {
|
||||||
|
dispatch(setSettings({ taskSettings: { ...taskSettings, sortBy: sort } }));
|
||||||
|
setSortAnchorEl(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SortMenuItem = React.forwardRef(function SortMenuItem(props: { name: string, label: string }, ref) {
|
||||||
|
return (
|
||||||
|
<MenuItem innerRef={ref} selected={sortBy === props.name} onClick={() => handleSortChange(props.name)}>{props.label}</MenuItem>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", justifyContent: "flex-end", alignItems: "center" }}>
|
||||||
|
<Transition in={showSearchField} timeout={transitionTimeout}>
|
||||||
|
{(state) => (
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
placeholder="Search"
|
||||||
|
value={searchTerm}
|
||||||
|
color="secondary"
|
||||||
|
variant="standard"
|
||||||
|
className={classes.textField}
|
||||||
|
style={transitionStyles[state]}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<SearchIcon />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<div className={classes.button}>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
title="Sort"
|
||||||
|
aria-label="Sort"
|
||||||
|
aria-controls="sort-menu"
|
||||||
|
aria-haspopup="true"
|
||||||
|
onClick={(e) => setSortAnchorEl(e.currentTarget)}
|
||||||
|
>
|
||||||
|
<SortIcon />
|
||||||
|
</IconButton>
|
||||||
|
<Menu
|
||||||
|
id="sort-menu"
|
||||||
|
anchorEl={sortAnchorEl}
|
||||||
|
keepMounted
|
||||||
|
open={!!sortAnchorEl}
|
||||||
|
onClose={() => setSortAnchorEl(null)}
|
||||||
|
>
|
||||||
|
<SortMenuItem name="smart" label="Smart" />
|
||||||
|
<SortMenuItem name="dueDate" label="Due Date" />
|
||||||
|
<SortMenuItem name="priority" label="Priority" />
|
||||||
|
<SortMenuItem name="title" label="Title" />
|
||||||
|
<SortMenuItem name="lastModifiedDate" label="Last Modified" />
|
||||||
|
</Menu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={classes.button}>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
title="Options"
|
||||||
|
aria-label="Options"
|
||||||
|
aria-controls="options-menu"
|
||||||
|
aria-haspopup="true"
|
||||||
|
onClick={(e) => setOptionsAnchorEl(e.currentTarget)}
|
||||||
|
>
|
||||||
|
<MoreVertIcon />
|
||||||
|
</IconButton>
|
||||||
|
<Menu
|
||||||
|
id="options-menu"
|
||||||
|
anchorEl={optionsAnchorEl}
|
||||||
|
keepMounted
|
||||||
|
open={!!optionsAnchorEl}
|
||||||
|
onClose={() => setOptionsAnchorEl(null)}
|
||||||
|
>
|
||||||
|
<MenuItem>
|
||||||
|
<ListItemText style={{ marginRight: "1.5em" }}>Show completed</ListItemText>
|
||||||
|
<ListItemSecondaryAction>
|
||||||
|
<Switch checked={showCompleted} onChange={(_e, checked) => setShowCompleted(checked)} edge="end" />
|
||||||
|
</ListItemSecondaryAction>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem>
|
||||||
|
<ListItemText style={{ marginRight: "1.5em" }}>Show hidden</ListItemText>
|
||||||
|
<ListItemSecondaryAction>
|
||||||
|
<Switch checked={showHidden} onChange={(_e, checked) => setShowHidden(checked)} edge="end" />
|
||||||
|
</ListItemSecondaryAction>
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,171 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as Etebase from "etebase";
|
||||||
|
import { Redirect, useLocation } from "react-router";
|
||||||
|
import Button from "@material-ui/core/Button";
|
||||||
|
import Alert from "@material-ui/lab/Alert";
|
||||||
|
|
||||||
|
import { routeResolver } from "./App";
|
||||||
|
|
||||||
|
import Container from "./widgets/Container";
|
||||||
|
import LoadingIndicator from "./widgets/LoadingIndicator";
|
||||||
|
import Wizard, { WizardNavigationBar, PagePropsType } from "./widgets/Wizard";
|
||||||
|
|
||||||
|
import { SyncManager } from "./sync/SyncManager";
|
||||||
|
|
||||||
|
import { store } from "./store";
|
||||||
|
import { useCredentials } from "./credentials";
|
||||||
|
|
||||||
|
import wizardWelcome from "./images/wizard-welcome.svg";
|
||||||
|
import wizardCreate from "./images/wizard-create.svg";
|
||||||
|
|
||||||
|
interface LocationState {
|
||||||
|
from: {
|
||||||
|
pathname: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const wizardPages = [
|
||||||
|
(props: PagePropsType) => (
|
||||||
|
<>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", flex: 1, justifyContent: "center", alignItems: "center" }}>
|
||||||
|
<h2 style={{ textAlign: "center" }}>Welcome to EteSync!</h2>
|
||||||
|
<p style={{ textAlign: "center" }}>
|
||||||
|
Please follow these few quick steps to help you get started.
|
||||||
|
</p>
|
||||||
|
<img src={wizardWelcome} style={{ maxWidth: "30em", marginTop: "2em" }} />
|
||||||
|
</div>
|
||||||
|
<WizardNavigationBar {...props} />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
(props: PagePropsType) => (
|
||||||
|
<SetupCollectionsPage {...props} />
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
function SetupCollectionsPage(props: PagePropsType) {
|
||||||
|
const etebase = useCredentials()!;
|
||||||
|
const [error, setError] = React.useState<Error>();
|
||||||
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
async function onNext() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const colMgr = etebase.getCollectionManager();
|
||||||
|
const types = [
|
||||||
|
["etebase.vcard", "My Contacts"],
|
||||||
|
["etebase.vevent", "My Calendar"],
|
||||||
|
["etebase.vtodo", "My Tasks"],
|
||||||
|
];
|
||||||
|
for (const [type, name] of types) {
|
||||||
|
const meta: Etebase.ItemMetadata = {
|
||||||
|
name,
|
||||||
|
mtime: (new Date()).getTime(),
|
||||||
|
};
|
||||||
|
const collection = await colMgr.create(type, meta, "");
|
||||||
|
await colMgr.upload(collection);
|
||||||
|
}
|
||||||
|
|
||||||
|
props.next?.();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = (loading) ? undefined : onNext;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", flex: 1, justifyContent: "center", alignItems: "center" }}>
|
||||||
|
<h2 style={{ textAlign: "center" }}>Setup Collections</h2>
|
||||||
|
<p style={{ textAlign: "center", maxWidth: "50em" }}>
|
||||||
|
In order to start using EteSync you need to create collections to store your data. Clicking <i>Finish</i> below will create a default calendar, address book and a task list for you.
|
||||||
|
</p>
|
||||||
|
{(loading) ? (
|
||||||
|
<LoadingIndicator style={{ display: "block", margin: "40px auto" }} />
|
||||||
|
) : (error) ? (
|
||||||
|
<>
|
||||||
|
<Alert severity="error">{error.message}</Alert>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
onClick={props.next}
|
||||||
|
>
|
||||||
|
Skip
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<img src={wizardCreate} style={{ maxWidth: "30em", marginTop: "2em" }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<WizardNavigationBar {...props} next={next} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WizardPage() {
|
||||||
|
const [tryCount, setTryCount] = React.useState(0);
|
||||||
|
const [ranWizard, setRanWizard] = React.useState(false);
|
||||||
|
const [syncError, setSyncError] = React.useState<Error>();
|
||||||
|
const etebase = useCredentials();
|
||||||
|
const location = useLocation();
|
||||||
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
setSyncError(undefined);
|
||||||
|
(async () => {
|
||||||
|
const syncManager = SyncManager.getManager(etebase!);
|
||||||
|
const sync = syncManager.sync(true);
|
||||||
|
try {
|
||||||
|
await sync;
|
||||||
|
|
||||||
|
const cachedCollection = store.getState().cache.collections;
|
||||||
|
// XXX new account - though should change test to see if there are any PIM types
|
||||||
|
if (cachedCollection.size > 0) {
|
||||||
|
setRanWizard(true);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setSyncError(e);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
})();
|
||||||
|
}, [tryCount]);
|
||||||
|
|
||||||
|
if (syncError) {
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", flex: 1, justifyContent: "center", alignItems: "center" }}>
|
||||||
|
<h2 style={{ textAlign: "center" }}>Important</h2>
|
||||||
|
<p style={{ textAlign: "center" }}>
|
||||||
|
{syncError?.message}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
onClick={() => setTryCount(tryCount + 1)}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (<LoadingIndicator style={{ display: "block", margin: "40px auto" }} />);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ranWizard) {
|
||||||
|
return (
|
||||||
|
<Wizard pages={wizardPages} onFinish={() => setRanWizard(true)} style={{ display: "flex", flexDirection: "column", flex: 1 }} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { from } = location.state as LocationState || { from: { pathname: routeResolver.getRoute("home") } };
|
||||||
|
return (
|
||||||
|
<Redirect to={from.pathname} />
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,57 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { IntegrityError } from "etebase";
|
||||||
|
import PrettyError from "../widgets/PrettyError";
|
||||||
|
|
||||||
|
interface PropsType {
|
||||||
|
children: React.ReactNode | React.ReactNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
class ErrorBoundary extends React.Component<PropsType> {
|
||||||
|
public state: {
|
||||||
|
error?: Error;
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(props: PropsType) {
|
||||||
|
super(props);
|
||||||
|
this.state = { };
|
||||||
|
}
|
||||||
|
|
||||||
|
public componentDidCatch(error: Error, _info: any) {
|
||||||
|
this.setState({ error });
|
||||||
|
}
|
||||||
|
|
||||||
|
public render() {
|
||||||
|
const { error } = this.state;
|
||||||
|
if (error) {
|
||||||
|
if (error instanceof IntegrityError) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>Integrity Error</h2>
|
||||||
|
<p>
|
||||||
|
Please log out from the menu, refresh the page and try again, and if the problem persists, contact support.
|
||||||
|
</p>
|
||||||
|
<pre>
|
||||||
|
{error.message}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>Something went wrong!</h2>
|
||||||
|
<PrettyError error={this.state.error} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ErrorBoundary;
|
@ -0,0 +1,118 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import moment from "moment";
|
||||||
|
|
||||||
|
import * as Etebase from "etebase";
|
||||||
|
|
||||||
|
import { AutoSizer, List as VirtualizedList } from "react-virtualized";
|
||||||
|
|
||||||
|
import { List, ListItem } from "../widgets/List";
|
||||||
|
import IconEdit from "@material-ui/icons/Edit";
|
||||||
|
import IconDelete from "@material-ui/icons/Delete";
|
||||||
|
import IconError from "@material-ui/icons/Error";
|
||||||
|
|
||||||
|
import { TaskType, EventType, ContactType, parseString } from "../pim-types";
|
||||||
|
|
||||||
|
export interface CachedItem {
|
||||||
|
item: Etebase.Item;
|
||||||
|
metadata: Etebase.ItemMetadata;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PropsType {
|
||||||
|
items: CachedItem[];
|
||||||
|
onItemClick: (item: CachedItem) => void;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GenericChangeHistory(props: PropsType) {
|
||||||
|
const entriesList = props.items.sort((a_, b_) => {
|
||||||
|
const a = a_.metadata.mtime ?? 0;
|
||||||
|
const b = b_.metadata.mtime ?? 0;
|
||||||
|
return a - b;
|
||||||
|
});
|
||||||
|
|
||||||
|
const onItemClick = props.onItemClick;
|
||||||
|
|
||||||
|
const rowRenderer = (params: { index: number, key: string, style: React.CSSProperties }) => {
|
||||||
|
const { key, index, style } = params;
|
||||||
|
const cacheItem = entriesList[entriesList.length - index - 1]!;
|
||||||
|
let comp;
|
||||||
|
try {
|
||||||
|
comp = parseString(cacheItem.content);
|
||||||
|
} catch (e) {
|
||||||
|
const icon = (<IconError style={{ color: "red" }} />);
|
||||||
|
return (
|
||||||
|
<ListItem
|
||||||
|
key={key}
|
||||||
|
style={style}
|
||||||
|
leftIcon={icon}
|
||||||
|
primaryText="Failed parsing item"
|
||||||
|
secondaryText="Unknown"
|
||||||
|
onClick={() => onItemClick(cacheItem)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let icon;
|
||||||
|
if (!cacheItem.item.isDeleted) {
|
||||||
|
icon = (<IconEdit style={{ color: "#16B14B" }} />);
|
||||||
|
} else {
|
||||||
|
icon = (<IconDelete style={{ color: "#F20C0C" }} />);
|
||||||
|
}
|
||||||
|
|
||||||
|
let name;
|
||||||
|
if (comp.name === "vcalendar") {
|
||||||
|
if (EventType.isEvent(comp)) {
|
||||||
|
const vevent = EventType.fromVCalendar(comp);
|
||||||
|
name = vevent.summary;
|
||||||
|
} else {
|
||||||
|
const vtodo = TaskType.fromVCalendar(comp);
|
||||||
|
name = vtodo.summary;
|
||||||
|
}
|
||||||
|
} else if (comp.name === "vcard") {
|
||||||
|
const vcard = new ContactType(comp);
|
||||||
|
name = vcard.fn;
|
||||||
|
} else {
|
||||||
|
name = "Error processing entry";
|
||||||
|
}
|
||||||
|
|
||||||
|
const mtime = (cacheItem.metadata.mtime) ? moment(cacheItem.metadata.mtime) : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListItem
|
||||||
|
key={key}
|
||||||
|
style={style}
|
||||||
|
leftIcon={icon}
|
||||||
|
primaryText={name}
|
||||||
|
secondaryText={mtime && mtime.format("llll")}
|
||||||
|
onClick={() => onItemClick(cacheItem)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<List style={{ height: "100%" }}>
|
||||||
|
{(entriesList.length > 0) ? (
|
||||||
|
<AutoSizer>
|
||||||
|
{({ height, width }) => (
|
||||||
|
<VirtualizedList
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
rowCount={entriesList.length}
|
||||||
|
rowHeight={56}
|
||||||
|
rowRenderer={rowRenderer}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AutoSizer>
|
||||||
|
) : (
|
||||||
|
<ListItem
|
||||||
|
primaryText="No entries found"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</List>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,175 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import Button from "@material-ui/core/Button";
|
||||||
|
import TextField from "@material-ui/core/TextField";
|
||||||
|
import FormGroup from "@material-ui/core/FormGroup";
|
||||||
|
import FormControlLabel from "@material-ui/core/FormControlLabel";
|
||||||
|
import Switch from "@material-ui/core/Switch";
|
||||||
|
|
||||||
|
import ExternalLink from "../widgets/ExternalLink";
|
||||||
|
import PasswordField from "../widgets/PasswordField";
|
||||||
|
|
||||||
|
import * as C from "../constants";
|
||||||
|
import LoadingIndicator from "../widgets/LoadingIndicator";
|
||||||
|
import Alert from "@material-ui/lab/Alert";
|
||||||
|
|
||||||
|
interface FormErrors {
|
||||||
|
errorEmail?: string;
|
||||||
|
errorPassword?: string;
|
||||||
|
errorEncryptionPassword?: string;
|
||||||
|
errorServer?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PropsType {
|
||||||
|
onSubmit: (username: string, password: string, serviceApiUrl?: string) => void;
|
||||||
|
loading?: boolean;
|
||||||
|
error?: Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoginForm(props: PropsType) {
|
||||||
|
const [username, setUsername] = React.useState("");
|
||||||
|
const [password, setPassword] = React.useState("");
|
||||||
|
const [server, setServer] = React.useState("");
|
||||||
|
const [showAdvanced, setShowAdvanced] = React.useState(false);
|
||||||
|
const [errors, setErrors] = React.useState<FormErrors>({});
|
||||||
|
|
||||||
|
function generateEncryption(e: React.FormEvent<any>) {
|
||||||
|
e.preventDefault();
|
||||||
|
const errors: FormErrors = {};
|
||||||
|
const fieldRequired = "This field is required!";
|
||||||
|
if (!username) {
|
||||||
|
errors.errorEmail = fieldRequired;
|
||||||
|
} else if (username.includes("@")) {
|
||||||
|
errors.errorEmail = "Please use your username (not email)";
|
||||||
|
}
|
||||||
|
if (!password) {
|
||||||
|
errors.errorPassword = fieldRequired;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== "development") {
|
||||||
|
if (showAdvanced && !server.startsWith("https://")) {
|
||||||
|
errors.errorServer = "Server URI must start with https://";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(errors).length) {
|
||||||
|
setErrors(errors);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
setErrors({});
|
||||||
|
}
|
||||||
|
|
||||||
|
props.onSubmit(username, password, (showAdvanced) ? server : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
form: {
|
||||||
|
},
|
||||||
|
forgotPassword: {
|
||||||
|
paddingTop: 20,
|
||||||
|
},
|
||||||
|
infoAlert: {
|
||||||
|
marginTop: 20,
|
||||||
|
},
|
||||||
|
textField: {
|
||||||
|
marginTop: 20,
|
||||||
|
width: "18em",
|
||||||
|
},
|
||||||
|
submit: {
|
||||||
|
marginTop: 40,
|
||||||
|
textAlign: "right" as any,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleInputChange(func: (value: string) => void) {
|
||||||
|
return (event: React.ChangeEvent<any>) => {
|
||||||
|
func(event.target.value);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let advancedSettings = null;
|
||||||
|
if (showAdvanced) {
|
||||||
|
advancedSettings = (
|
||||||
|
<React.Fragment>
|
||||||
|
<TextField
|
||||||
|
type="url"
|
||||||
|
style={styles.textField}
|
||||||
|
error={!!errors.errorServer}
|
||||||
|
helperText={errors.errorServer}
|
||||||
|
label="Server"
|
||||||
|
value={server}
|
||||||
|
onChange={handleInputChange(setServer)}
|
||||||
|
/>
|
||||||
|
<br />
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ textAlign: "center" }}>
|
||||||
|
<LoadingIndicator />
|
||||||
|
<p>Deriving encryption data...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
<form style={styles.form} onSubmit={generateEncryption}>
|
||||||
|
<TextField
|
||||||
|
type="text"
|
||||||
|
style={styles.textField}
|
||||||
|
error={!!errors.errorEmail}
|
||||||
|
helperText={errors.errorEmail}
|
||||||
|
label="Username"
|
||||||
|
value={username}
|
||||||
|
onChange={handleInputChange(setUsername)}
|
||||||
|
/>
|
||||||
|
<br />
|
||||||
|
<PasswordField
|
||||||
|
style={styles.textField}
|
||||||
|
error={!!errors.errorPassword}
|
||||||
|
helperText={errors.errorPassword}
|
||||||
|
label="Password"
|
||||||
|
name="password"
|
||||||
|
value={password}
|
||||||
|
onChange={handleInputChange(setPassword)}
|
||||||
|
/>
|
||||||
|
<div style={styles.forgotPassword}>
|
||||||
|
<ExternalLink href={C.forgotPassword}>Forgot password?</ExternalLink>
|
||||||
|
</div>
|
||||||
|
<FormGroup>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
color="primary"
|
||||||
|
checked={showAdvanced}
|
||||||
|
onChange={() => setShowAdvanced(!showAdvanced)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Advanced settings"
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
{advancedSettings}
|
||||||
|
|
||||||
|
{props.error && (
|
||||||
|
<Alert severity="error" style={styles.infoAlert}>{props.error.message}</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={styles.submit}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
type="submit"
|
||||||
|
color="secondary"
|
||||||
|
disabled={props.loading}
|
||||||
|
>
|
||||||
|
{props.loading ? "Loading…" : "Log In"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,35 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import Color from "color";
|
||||||
|
|
||||||
|
import { Theme, withTheme } from "@material-ui/core/styles";
|
||||||
|
|
||||||
|
export default withTheme((props: {text: string, backgroundColor?: string, children?: any, rightItem?: React.ReactNode, theme: Theme}) => {
|
||||||
|
const backgroundColor = props.backgroundColor ?? props.theme.palette.secondary.main;
|
||||||
|
const foregroundColor = props.theme.palette.getContrastText(Color(backgroundColor).rgb().string());
|
||||||
|
const style = {
|
||||||
|
header: {
|
||||||
|
backgroundColor,
|
||||||
|
color: foregroundColor,
|
||||||
|
padding: 15,
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
},
|
||||||
|
headerText: {
|
||||||
|
marginTop: 10,
|
||||||
|
marginBottom: 10,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={style.header}>
|
||||||
|
<div>
|
||||||
|
<h2 style={style.headerText}>{props.text}</h2>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
{props.rightItem}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
@ -0,0 +1,14 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
export const appName = "EteSync";
|
||||||
|
export const defaultServerUrl = process.env.REACT_APP_DEFAULT_API_PATH ?? "https://api.etebase.com/partner/etesync/";
|
||||||
|
export const homePage = "https://www.etesync.com/";
|
||||||
|
export const faq = homePage + "faq/";
|
||||||
|
export const pricing = homePage + "pricing/";
|
||||||
|
export const getApps = homePage + "get-apps/";
|
||||||
|
export const terms = homePage + "tos/";
|
||||||
|
export const sourceCode = "https://github.com/etesync/etesync-web";
|
||||||
|
export const reportIssue = sourceCode + "/issues";
|
||||||
|
|
||||||
|
export const forgotPassword = "https://www.etesync.com/faq/#forgot-password";
|
@ -0,0 +1,26 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 Etebase Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { useSelector } from "react-redux";
|
||||||
|
import { createSelector } from "reselect";
|
||||||
|
|
||||||
|
import * as Etebase from "etebase";
|
||||||
|
|
||||||
|
import * as store from "./store";
|
||||||
|
import { usePromiseMemo } from "./helpers";
|
||||||
|
|
||||||
|
export const credentialsSelector = createSelector(
|
||||||
|
(state: store.StoreState) => state.credentials.storedSession,
|
||||||
|
(storedSession) => {
|
||||||
|
if (storedSession) {
|
||||||
|
return Etebase.Account.restore(storedSession);
|
||||||
|
} else {
|
||||||
|
return Promise.resolve(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export function useCredentials() {
|
||||||
|
const credentialsPromise = useSelector(credentialsSelector);
|
||||||
|
return usePromiseMemo(credentialsPromise, [credentialsPromise]);
|
||||||
|
}
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,77 @@
|
|||||||
|
import memoize from "memoizee";
|
||||||
|
|
||||||
|
import * as Etebase from "etebase";
|
||||||
|
import { useSelector } from "react-redux";
|
||||||
|
|
||||||
|
import { StoreState } from "./store";
|
||||||
|
import { CacheCollectionsData, CacheItems, CacheItemsData } from "./store/reducers";
|
||||||
|
import { usePromiseMemo } from "./helpers";
|
||||||
|
|
||||||
|
export const getCollections = memoize(async function (cachedCollections: CacheCollectionsData, etebase: Etebase.Account) {
|
||||||
|
const colMgr = getCollectionManager(etebase);
|
||||||
|
const ret: Etebase.Collection[] = [];
|
||||||
|
for (const cached of cachedCollections.values()) {
|
||||||
|
ret.push(colMgr.cacheLoad(cached));
|
||||||
|
}
|
||||||
|
return ret;
|
||||||
|
}, { length: 1 });
|
||||||
|
|
||||||
|
export const getCollectionsByType = memoize(async function (cachedCollections: CacheCollectionsData, colType: string, etebase: Etebase.Account) {
|
||||||
|
const collections = await getCollections(cachedCollections, etebase);
|
||||||
|
const ret: Etebase.Collection[] = [];
|
||||||
|
for (const col of collections) {
|
||||||
|
const collectionType = col.getCollectionType();
|
||||||
|
if (collectionType === colType) {
|
||||||
|
ret.push(col);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ret;
|
||||||
|
}, { length: 2 });
|
||||||
|
|
||||||
|
export const getItems = memoize(async function (cachedItems: CacheItems, itemMgr: Etebase.ItemManager) {
|
||||||
|
const ret = new Map<string, Etebase.Item>();
|
||||||
|
for (const cached of cachedItems.values()) {
|
||||||
|
const item = itemMgr.cacheLoad(cached);
|
||||||
|
ret.set(item.uid, item);
|
||||||
|
}
|
||||||
|
return ret;
|
||||||
|
}, { length: 1 });
|
||||||
|
|
||||||
|
export const getItemsByType = memoize(async function (cachedCollections: CacheCollectionsData, cachedItems: CacheItemsData, colType: string, etebase: Etebase.Account) {
|
||||||
|
const colMgr = getCollectionManager(etebase);
|
||||||
|
const collections = await getCollectionsByType(cachedCollections, colType, etebase);
|
||||||
|
const ret = new Map<string, Map<string, Etebase.Item>>();
|
||||||
|
for (const col of collections) {
|
||||||
|
const itemMgr = colMgr.getItemManager(col);
|
||||||
|
const cachedColItems = cachedItems.get(col.uid);
|
||||||
|
if (cachedColItems) {
|
||||||
|
const items = await getItems(cachedColItems, itemMgr);
|
||||||
|
ret.set(col.uid, items);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ret;
|
||||||
|
}, { length: 3 });
|
||||||
|
|
||||||
|
export const getCollectionManager = memoize(function (etebase: Etebase.Account) {
|
||||||
|
return etebase.getCollectionManager();
|
||||||
|
});
|
||||||
|
|
||||||
|
// React specific stuff
|
||||||
|
export function useCollections(etebase: Etebase.Account, colType?: string) {
|
||||||
|
const cachedCollections = useSelector((state: StoreState) => state.cache.collections);
|
||||||
|
return usePromiseMemo(
|
||||||
|
(colType) ?
|
||||||
|
getCollectionsByType(cachedCollections, colType, etebase) :
|
||||||
|
getCollections(cachedCollections, etebase),
|
||||||
|
[etebase, cachedCollections, colType]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useItems(etebase: Etebase.Account, colType: string) {
|
||||||
|
const cachedCollections = useSelector((state: StoreState) => state.cache.collections);
|
||||||
|
const cachedItems = useSelector((state: StoreState) => state.cache.items);
|
||||||
|
return usePromiseMemo(
|
||||||
|
getItemsByType(cachedCollections, cachedItems, colType, etebase),
|
||||||
|
[etebase, cachedCollections, cachedItems, colType]
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,213 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as ICAL from "ical.js";
|
||||||
|
import moment from "moment";
|
||||||
|
import { TaskPriorityType } from "./pim-types";
|
||||||
|
|
||||||
|
// Generic handling of input changes
|
||||||
|
export function handleInputChange(self: React.Component, part?: string) {
|
||||||
|
return (event: React.ChangeEvent<any>) => {
|
||||||
|
const name = event.target.name;
|
||||||
|
const value = event.target.value;
|
||||||
|
|
||||||
|
let newState;
|
||||||
|
|
||||||
|
if (event.target.type === "checkbox") {
|
||||||
|
newState = {
|
||||||
|
[name]: event.target.checked,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
newState = {
|
||||||
|
[name]: value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part === undefined) {
|
||||||
|
self.setState(newState);
|
||||||
|
} else {
|
||||||
|
self.setState({
|
||||||
|
[part]: {
|
||||||
|
...self.state[part],
|
||||||
|
...newState,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function insertSorted<T>(array: T[] = [], newItem: T, key: string) {
|
||||||
|
if (array.length === 0) {
|
||||||
|
return [newItem];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0, len = array.length; i < len; i++) {
|
||||||
|
if (newItem[key] < array[i][key]) {
|
||||||
|
array.splice(i, 0, newItem);
|
||||||
|
return array;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
array.push(newItem);
|
||||||
|
|
||||||
|
return array;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allDayFormat = "dddd, LL";
|
||||||
|
const fullFormat = "LLLL";
|
||||||
|
|
||||||
|
export function formatDate(date: ICAL.Time) {
|
||||||
|
const mDate = moment(date.toJSDate());
|
||||||
|
if (date.isDate) {
|
||||||
|
return mDate.format(allDayFormat);
|
||||||
|
} else {
|
||||||
|
return mDate.format(fullFormat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateRange(start: ICAL.Time, end: ICAL.Time) {
|
||||||
|
const mStart = moment(start.toJSDate());
|
||||||
|
const mEnd = moment(end.toJSDate());
|
||||||
|
let strStart;
|
||||||
|
let strEnd;
|
||||||
|
|
||||||
|
// All day
|
||||||
|
if (start.isDate) {
|
||||||
|
if (mEnd.diff(mStart, "days", true) === 1) {
|
||||||
|
return mStart.format(allDayFormat);
|
||||||
|
} else {
|
||||||
|
strStart = mStart.format(allDayFormat);
|
||||||
|
strEnd = mEnd.clone().subtract(1, "day").format(allDayFormat);
|
||||||
|
}
|
||||||
|
} else if (mStart.isSame(mEnd, "day")) {
|
||||||
|
strStart = mStart.format(fullFormat);
|
||||||
|
strEnd = mEnd.format("LT");
|
||||||
|
|
||||||
|
if (mStart.isSame(mEnd)) {
|
||||||
|
return strStart;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
strStart = mStart.format(fullFormat);
|
||||||
|
strEnd = mEnd.format(fullFormat);
|
||||||
|
}
|
||||||
|
|
||||||
|
return strStart + " - " + strEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatOurTimezoneOffset() {
|
||||||
|
let offset = new Date().getTimezoneOffset();
|
||||||
|
const prefix = (offset > 0) ? "-" : "+";
|
||||||
|
offset = Math.abs(offset);
|
||||||
|
const hours = Math.floor(offset / 60);
|
||||||
|
const minutes = offset % 60;
|
||||||
|
|
||||||
|
return `GMT${prefix}${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCurrentTimezone() {
|
||||||
|
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapPriority(priority: number): TaskPriorityType {
|
||||||
|
if (priority > 0 && priority < 5) {
|
||||||
|
return TaskPriorityType.High;
|
||||||
|
} else if (priority === 5) {
|
||||||
|
return TaskPriorityType.Medium;
|
||||||
|
} else if (priority > 5 && priority < 10) {
|
||||||
|
return TaskPriorityType.Low;
|
||||||
|
} else {
|
||||||
|
return TaskPriorityType.Undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function* arrayToChunkIterator<T>(arr: T[], size: number) {
|
||||||
|
for (let i = 0 ; i < arr.length ; i += size) {
|
||||||
|
yield arr.slice(i, i + size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPromise(x: any): x is Promise<any> {
|
||||||
|
return x && typeof x.then === "function";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDefined<T>(x: T | undefined): x is T {
|
||||||
|
return x !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startTask<T = any>(func: () => Promise<T> | T, delay = 0): Promise<T> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
setTimeout(
|
||||||
|
() => {
|
||||||
|
try {
|
||||||
|
const ret = func();
|
||||||
|
if (isPromise(ret)) {
|
||||||
|
ret.then(resolve)
|
||||||
|
.catch(reject);
|
||||||
|
} else {
|
||||||
|
resolve(ret);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
delay);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePromiseMemo<T>(promise: Promise<T> | undefined | null, deps: React.DependencyList, initial: T | undefined = undefined): T | undefined {
|
||||||
|
const [val, setVal] = React.useState<T>((promise as any)._returnedValue ?? initial);
|
||||||
|
React.useEffect(() => {
|
||||||
|
let cancel = false;
|
||||||
|
if (promise === undefined || promise === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
promise.then((val) => {
|
||||||
|
(promise as any)._returnedValue = val;
|
||||||
|
if (!cancel) {
|
||||||
|
setVal(val);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancel = true;
|
||||||
|
};
|
||||||
|
}, [...deps, promise]);
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDate(prop: ICAL.Property) {
|
||||||
|
const value = prop.getFirstValue();
|
||||||
|
if ((value.day !== null) && (value.day !== undefined)) {
|
||||||
|
return {
|
||||||
|
day: value.day,
|
||||||
|
month: value.month - 1,
|
||||||
|
year: value.year ?? undefined,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const time = prop.toJSON()[3];
|
||||||
|
if (time.length === 6 && time.startsWith("--")) {
|
||||||
|
return {
|
||||||
|
day: parseInt(time.slice(4, 6)),
|
||||||
|
month: parseInt(time.slice(2, 4)) - 1,
|
||||||
|
};
|
||||||
|
} else if (time.length === 8) {
|
||||||
|
return {
|
||||||
|
day: parseInt(time.slice(6, 8)),
|
||||||
|
month: parseInt(time.slice(4, 6)) - 1,
|
||||||
|
year: parseInt(time.slice(0, 4)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PASSWORD_MIN_LENGTH = 8;
|
||||||
|
|
||||||
|
export function enforcePasswordRules(password: string): string | undefined {
|
||||||
|
|
||||||
|
if (password.length < PASSWORD_MIN_LENGTH) {
|
||||||
|
return `Passwourds should be at least ${PASSWORD_MIN_LENGTH} digits long.`;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
@ -0,0 +1,15 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import SvgIcon from "@material-ui/core/SvgIcon";
|
||||||
|
|
||||||
|
export default function CopyIcon(props: any) {
|
||||||
|
return (
|
||||||
|
<SvgIcon {...props}>
|
||||||
|
<path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z" />
|
||||||
|
</SvgIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,162 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
height="192"
|
||||||
|
viewBox="0 0 192 192"
|
||||||
|
width="192"
|
||||||
|
version="1.1"
|
||||||
|
id="svg3688"
|
||||||
|
sodipodi:docname="logo.svg"
|
||||||
|
inkscape:version="0.92.1 r"
|
||||||
|
inkscape:export-filename="/home/tom/projects/securesync/graphics/logo.png"
|
||||||
|
inkscape:export-xdpi="256"
|
||||||
|
inkscape:export-ydpi="256"
|
||||||
|
style="fill:#000000">
|
||||||
|
<metadata
|
||||||
|
id="metadata3694">
|
||||||
|
<rdf:RDF>
|
||||||
|
<cc:Work
|
||||||
|
rdf:about="">
|
||||||
|
<dc:format>image/svg+xml</dc:format>
|
||||||
|
<dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||||
|
<dc:title />
|
||||||
|
</cc:Work>
|
||||||
|
</rdf:RDF>
|
||||||
|
</metadata>
|
||||||
|
<defs
|
||||||
|
id="defs3692">
|
||||||
|
<filter
|
||||||
|
style="color-interpolation-filters:sRGB;"
|
||||||
|
inkscape:label="Drop Shadow"
|
||||||
|
id="filter4498">
|
||||||
|
<feFlood
|
||||||
|
flood-opacity="0.498039"
|
||||||
|
flood-color="rgb(0,0,0)"
|
||||||
|
result="flood"
|
||||||
|
id="feFlood4488" />
|
||||||
|
<feComposite
|
||||||
|
in="flood"
|
||||||
|
in2="SourceGraphic"
|
||||||
|
operator="in"
|
||||||
|
result="composite1"
|
||||||
|
id="feComposite4490" />
|
||||||
|
<feGaussianBlur
|
||||||
|
in="composite1"
|
||||||
|
stdDeviation="4"
|
||||||
|
result="blur"
|
||||||
|
id="feGaussianBlur4492" />
|
||||||
|
<feOffset
|
||||||
|
dx="0"
|
||||||
|
dy="4"
|
||||||
|
result="offset"
|
||||||
|
id="feOffset4494" />
|
||||||
|
<feComposite
|
||||||
|
in="SourceGraphic"
|
||||||
|
in2="offset"
|
||||||
|
operator="over"
|
||||||
|
result="composite2"
|
||||||
|
id="feComposite4496" />
|
||||||
|
</filter>
|
||||||
|
<filter
|
||||||
|
style="color-interpolation-filters:sRGB;"
|
||||||
|
inkscape:label="Drop Shadow"
|
||||||
|
id="filter4510">
|
||||||
|
<feFlood
|
||||||
|
flood-opacity="0.498039"
|
||||||
|
flood-color="rgb(0,0,0)"
|
||||||
|
result="flood"
|
||||||
|
id="feFlood4500" />
|
||||||
|
<feComposite
|
||||||
|
in="flood"
|
||||||
|
in2="SourceGraphic"
|
||||||
|
operator="in"
|
||||||
|
result="composite1"
|
||||||
|
id="feComposite4502" />
|
||||||
|
<feGaussianBlur
|
||||||
|
in="composite1"
|
||||||
|
stdDeviation="4"
|
||||||
|
result="blur"
|
||||||
|
id="feGaussianBlur4504" />
|
||||||
|
<feOffset
|
||||||
|
dx="0"
|
||||||
|
dy="4"
|
||||||
|
result="offset"
|
||||||
|
id="feOffset4506" />
|
||||||
|
<feComposite
|
||||||
|
in="SourceGraphic"
|
||||||
|
in2="offset"
|
||||||
|
operator="over"
|
||||||
|
result="composite2"
|
||||||
|
id="feComposite4508" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<sodipodi:namedview
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1"
|
||||||
|
objecttolerance="10"
|
||||||
|
gridtolerance="10"
|
||||||
|
guidetolerance="10"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:window-width="3832"
|
||||||
|
inkscape:window-height="2095"
|
||||||
|
id="namedview3690"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:zoom="4"
|
||||||
|
inkscape:cx="21.145622"
|
||||||
|
inkscape:cy="121.84403"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="61"
|
||||||
|
inkscape:window-maximized="0"
|
||||||
|
inkscape:current-layer="svg3688" />
|
||||||
|
<path
|
||||||
|
d="M 0,-2 H 192 V 190 H 0 Z"
|
||||||
|
id="path3684"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:none;stroke-width:8" />
|
||||||
|
<path
|
||||||
|
d="M 160,67.52 V 30 H 122.48 L 96,3.52 69.52,30 H 32 V 67.52 L 5.52,94 32,120.48 V 158 H 69.52 L 96,184.48 122.48,158 H 160 V 120.48 L 186.48,94 Z"
|
||||||
|
id="path3686"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
sodipodi:nodetypes="ccccccccccccccccc"
|
||||||
|
style="fill:#ffc107;fill-opacity:1;stroke-width:8;filter:url(#filter4498)" />
|
||||||
|
<g
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer2"
|
||||||
|
inkscape:label="circle"
|
||||||
|
style="display:inline"
|
||||||
|
transform="translate(0,168)">
|
||||||
|
<ellipse
|
||||||
|
style="fill:#ffd740;fill-opacity:1;fill-rule:nonzero;stroke:#ffd740;stroke-width:60.96912003;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
id="path5101"
|
||||||
|
cx="96.040611"
|
||||||
|
cy="-74.043999"
|
||||||
|
rx="16.102007"
|
||||||
|
ry="16.103439" />
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1"
|
||||||
|
inkscape:label="arrow"
|
||||||
|
style="display:inline"
|
||||||
|
transform="translate(0,168)">
|
||||||
|
<path
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
d="M 96,-120.53035 V -137.9802 L 72.705129,-114.71374 96,-91.447272 v -17.449848 c 19.2765,0 34.94231,15.646698 34.94231,34.899704 0,5.874792 -1.45593,11.458728 -4.0766,16.28652 l 8.50263,8.492256 c 4.5425,-7.154432 7.2214,-15.646688 7.2214,-24.778776 0,-25.709454 -20.8489,-46.532934 -46.58974,-46.532934 z m 0,81.43263 c -19.276507,0 -34.942311,-15.646696 -34.942311,-34.899696 0,-5.874784 1.455934,-11.458744 4.076602,-16.286532 l -8.502618,-8.492262 c -4.542509,7.15444 -7.221415,15.646698 -7.221415,24.778794 0,25.709448 20.848909,46.532928 46.589742,46.532928 v 17.449856 L 119.29487,-33.281104 96,-56.547568 Z"
|
||||||
|
id="path2"
|
||||||
|
style="fill:#448aff;fill-opacity:1;stroke-width:5.82016563;filter:url(#filter4510)" />
|
||||||
|
<path
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:none;stroke-width:8"
|
||||||
|
d="M 0,-170 H 192 V 22 H 0 Z"
|
||||||
|
id="path4" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 5.2 KiB |
@ -0,0 +1,211 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
version="1.1"
|
||||||
|
id="Capa_1"
|
||||||
|
x="0px"
|
||||||
|
y="0px"
|
||||||
|
viewBox="0 0 220 58"
|
||||||
|
xml:space="preserve"
|
||||||
|
sodipodi:docname="badge.svg"
|
||||||
|
inkscape:version="0.92.2 5c3e80d, 2017-08-06"
|
||||||
|
inkscape:export-filename="/tmp/badge.png"
|
||||||
|
inkscape:export-xdpi="99.309998"
|
||||||
|
inkscape:export-ydpi="99.309998"
|
||||||
|
width="220"
|
||||||
|
height="58"><metadata
|
||||||
|
id="metadata1502"><rdf:RDF><cc:Work
|
||||||
|
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title /></cc:Work></rdf:RDF></metadata><defs
|
||||||
|
id="defs1500" /><sodipodi:namedview
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1"
|
||||||
|
objecttolerance="10"
|
||||||
|
gridtolerance="10"
|
||||||
|
guidetolerance="10"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:window-width="3832"
|
||||||
|
inkscape:window-height="2088"
|
||||||
|
id="namedview1498"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:zoom="2.0344828"
|
||||||
|
inkscape:cx="16.03199"
|
||||||
|
inkscape:cy="64.841524"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="68"
|
||||||
|
inkscape:window-maximized="0"
|
||||||
|
inkscape:current-layer="Capa_1" /><path
|
||||||
|
style="fill:#2aa924;fill-opacity:1"
|
||||||
|
d="M 29,0 C 29,0 22.333,8 7,8 V 27.085 C 7,37.051 11.328,46.662 19.164,52.82 21.937,55 25.208,56.875 29,58 32.792,56.875 36.062,55 38.836,52.82 46.672,46.662 51,37.051 51,27.085 V 8 C 35.667,8 29,0 29,0 Z"
|
||||||
|
id="path1463"
|
||||||
|
inkscape:connector-curvature="0" /><path
|
||||||
|
style="fill:#26e61c;fill-opacity:1"
|
||||||
|
d="M 29,51.661 C 26.877,50.828 24.822,49.636 22.872,48.103 16.69,43.245 13,35.388 13,27.085 V 13.628 C 20.391,12.685 25.639,10.114 29,7.83 Z"
|
||||||
|
id="path1465"
|
||||||
|
inkscape:connector-curvature="0" /><g
|
||||||
|
id="g1467" /><g
|
||||||
|
id="g1469" /><g
|
||||||
|
id="g1471" /><g
|
||||||
|
id="g1473" /><g
|
||||||
|
id="g1475" /><g
|
||||||
|
id="g1477" /><g
|
||||||
|
id="g1479" /><g
|
||||||
|
id="g1481" /><g
|
||||||
|
id="g1483" /><g
|
||||||
|
id="g1485" /><g
|
||||||
|
id="g1487" /><g
|
||||||
|
id="g1489" /><g
|
||||||
|
id="g1491" /><g
|
||||||
|
id="g1493" /><g
|
||||||
|
id="g1495" /><g
|
||||||
|
transform="matrix(0.06459484,0,0,0.06271697,15.219767,15.620369)"
|
||||||
|
id="g2152"><g
|
||||||
|
id="g2150"><g
|
||||||
|
id="g2148"><path
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
d="m 133.529,352.213 c -12.907,-23.147 -19.733,-51.52 -19.733,-82.24 0,-51.627 44.693,-93.653 99.52,-93.653 54.933,0 99.52,42.027 99.52,93.653 0,5.867 4.8,10.667 10.667,10.667 5.867,0 10.667,-4.8 10.667,-10.667 0,-63.467 -54.187,-114.987 -120.853,-114.987 -66.666,0 -120.855,51.627 -120.855,114.987 0,34.347 7.787,66.453 22.507,92.693 14.4,25.707 24.427,37.547 42.88,56.213 2.133,2.133 4.8,3.2 7.573,3.2 2.667,0 5.44,-0.96 7.36,-3.2 4.267,-4.053 4.267,-10.88 0.107,-15.04 -16.427,-16.532 -25.6,-27.092 -39.36,-51.626 z"
|
||||||
|
id="path2138" /><path
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
d="m 94.702,51.413 c 36.587,-19.947 76.48,-30.08 118.827,-30.08 42.453,0 77.973,9.067 118.827,30.187 1.6,0.747 3.2,1.173 4.907,1.173 v 0 c 3.84,0 7.573,-2.133 9.493,-5.76 2.667,-5.227 0.64,-11.733 -4.587,-14.4 C 298.649,10.027 258.969,0 213.529,0 167.662,0 124.249,10.987 84.462,32.64 c -5.227,2.88 -7.04,9.28 -4.267,14.507 2.88,5.226 9.28,7.04 14.507,4.266 z"
|
||||||
|
id="path2140" /><path
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
d="m 212.569,103.04 c -68.8,0 -131.733,38.507 -160.213,98.027 -9.707,20.16 -14.613,43.413 -14.613,69.013 0,28.8 5.12,56.32 15.573,84.373 2.133,5.547 8.213,8.32 13.76,6.293 5.547,-2.133 8.32,-8.213 6.293,-13.76 C 60.675,312.96 59.182,286.72 59.182,270.08 c 0,-22.4 4.16,-42.56 12.48,-59.84 24.96,-52.267 80.32,-85.973 141.013,-85.973 85.227,0 154.56,65.387 154.56,145.813 0,22.933 -19.947,41.493 -44.373,41.493 -24.426,0 -44.373,-18.667 -44.373,-41.493 0,-34.667 -29.44,-62.827 -65.707,-62.827 -36.267,0 -65.707,28.16 -65.707,62.827 0,42.133 16.427,81.707 46.187,111.36 23.04,22.933 45.227,35.52 79.253,44.8 0.853,0.32 1.813,0.427 2.773,0.427 4.693,0 8.96,-3.093 10.24,-7.787 1.6,-5.653 -1.813,-11.52 -7.467,-13.12 -30.08,-8.213 -49.707,-19.307 -69.867,-39.36 -25.707,-25.6 -39.893,-59.84 -39.893,-96.213 0,-22.933 19.947,-41.493 44.373,-41.493 24.426,0 44.373,18.667 44.373,41.493 0,34.667 29.547,62.827 65.707,62.827 36.16,0 65.707,-28.16 65.707,-62.827 10e-4,-92.16 -78.932,-167.147 -175.892,-167.147 z"
|
||||||
|
id="path2142" /><path
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
d="m 403.395,147.2 c -21.227,-29.653 -48.107,-52.907 -80,-69.333 -67.2,-34.56 -152.96,-34.453 -220.053,0.213 -32,16.533 -58.987,40 -80.107,69.867 -3.413,4.8 -2.24,11.413 2.56,14.827 1.92,1.28 4.053,1.92 6.187,1.92 3.307,0 6.613,-1.493 8.747,-4.373 19.093,-27.093 43.52,-48.32 72.427,-63.253 61.12,-31.573 139.307,-31.68 200.533,-0.213 28.8,14.72 53.12,35.84 72.32,62.72 3.413,4.8 10.133,5.867 14.933,2.453 4.8,-3.415 5.867,-10.028 2.453,-14.828 z"
|
||||||
|
id="path2144" /><path
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
d="m 340.569,359.253 c -8.533,1.493 -17.173,2.027 -22.293,2.027 -21.333,0 -39.04,-5.013 -54.08,-15.253 -25.92,-17.6 -41.387,-45.973 -41.387,-75.947 0,-5.867 -4.8,-10.667 -10.667,-10.667 -5.867,0 -10.667,4.8 -10.667,10.667 0,37.12 18.987,72.107 50.667,93.653 18.453,12.48 40.747,18.88 66.133,18.88 2.987,0 13.547,-0.107 26.027,-2.347 5.76,-1.067 9.707,-6.613 8.64,-12.373 -1.067,-5.76 -6.613,-9.706 -12.373,-8.64 z"
|
||||||
|
id="path2146" /></g></g></g><flowRoot
|
||||||
|
xml:space="preserve"
|
||||||
|
id="flowRoot836"
|
||||||
|
style="font-style:normal;font-weight:normal;font-size:1.25px;line-height:25px;font-family:Sans;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"><flowRegion
|
||||||
|
id="flowRegion838"><rect
|
||||||
|
id="rect840"
|
||||||
|
width="181.6006"
|
||||||
|
height="31.801828"
|
||||||
|
x="60.475609"
|
||||||
|
y="0.47866088" /></flowRegion><flowPara
|
||||||
|
id="flowPara842" /></flowRoot><text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-style:normal;font-weight:normal;font-size:1.25px;line-height:25px;font-family:Sans;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||||
|
x="182.81706"
|
||||||
|
y="19.942076"
|
||||||
|
id="text846"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan844"
|
||||||
|
x="182.81706"
|
||||||
|
y="32.766785" /></text>
|
||||||
|
<flowRoot
|
||||||
|
xml:space="preserve"
|
||||||
|
id="flowRoot848"
|
||||||
|
style="font-style:normal;font-weight:normal;font-size:1.25px;line-height:25px;font-family:Sans;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"><flowRegion
|
||||||
|
id="flowRegion850"><rect
|
||||||
|
id="rect852"
|
||||||
|
width="160.92073"
|
||||||
|
height="37.362804"
|
||||||
|
x="61.69207"
|
||||||
|
y="1.1737828" /></flowRegion><flowPara
|
||||||
|
id="flowPara854" /></flowRoot><text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-style:normal;font-weight:normal;font-size:1.25px;line-height:25px;font-family:Sans;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||||
|
x="60.067513"
|
||||||
|
y="46.389381"
|
||||||
|
id="text882"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
x="60.067513"
|
||||||
|
y="46.389381"
|
||||||
|
id="tspan880"><tspan
|
||||||
|
x="60.067513"
|
||||||
|
y="46.389381"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:24px;font-family:'Open Sans';-inkscape-font-specification:'Open Sans, Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;letter-spacing:0px;writing-mode:lr-tb;text-anchor:start"
|
||||||
|
id="tspan878">Signed Pages</tspan></tspan></text>
|
||||||
|
<flowRoot
|
||||||
|
xml:space="preserve"
|
||||||
|
id="flowRoot864"
|
||||||
|
style="font-style:normal;font-weight:normal;font-size:1.25px;line-height:25px;font-family:Sans;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"><flowRegion
|
||||||
|
id="flowRegion866"><rect
|
||||||
|
id="rect868"
|
||||||
|
width="128.94511"
|
||||||
|
height="13.902438"
|
||||||
|
x="60.301826"
|
||||||
|
y="6.2134166" /></flowRegion><flowPara
|
||||||
|
id="flowPara870">Page s</flowPara></flowRoot><flowRoot
|
||||||
|
xml:space="preserve"
|
||||||
|
id="flowRoot872"
|
||||||
|
style="font-style:normal;font-weight:normal;font-size:1.25px;line-height:25px;font-family:Sans;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"><flowRegion
|
||||||
|
id="flowRegion874"><rect
|
||||||
|
id="rect876"
|
||||||
|
width="125.81707"
|
||||||
|
height="12.685975"
|
||||||
|
x="60.649387"
|
||||||
|
y="7.4298801" /></flowRegion><flowPara
|
||||||
|
id="flowPara878">Page</flowPara></flowRoot><flowRoot
|
||||||
|
xml:space="preserve"
|
||||||
|
id="flowRoot880"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:1.25px;line-height:25px;font-family:'Open Sans';-inkscape-font-specification:'Open Sans';letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"><flowRegion
|
||||||
|
id="flowRegion882"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'Open Sans';-inkscape-font-specification:'Open Sans'"><rect
|
||||||
|
id="rect884"
|
||||||
|
width="124.07926"
|
||||||
|
height="13.033536"
|
||||||
|
x="60.475609"
|
||||||
|
y="7.9512215"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'Open Sans';-inkscape-font-specification:'Open Sans'" /></flowRegion><flowPara
|
||||||
|
id="flowPara886">uoeuoeuoeuaeueoaueaouaeouoaeuaoeu</flowPara></flowRoot><flowRoot
|
||||||
|
xml:space="preserve"
|
||||||
|
id="flowRoot856-9"
|
||||||
|
style="font-style:normal;font-weight:normal;font-size:1.25px;line-height:25px;font-family:Sans;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||||
|
transform="translate(-2.5362099,0.85067833)"><flowRegion
|
||||||
|
id="flowRegion858-3"><rect
|
||||||
|
id="rect860-7"
|
||||||
|
width="190.11584"
|
||||||
|
height="35.277439"
|
||||||
|
x="60.82317"
|
||||||
|
y="-0.3902415" /></flowRegion><flowPara
|
||||||
|
id="flowPara862-4"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:24px;font-family:'Open Sans';-inkscape-font-specification:'Open Sans, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;writing-mode:lr-tb;text-anchor:start" /></flowRoot><flowRoot
|
||||||
|
xml:space="preserve"
|
||||||
|
id="flowRoot912"
|
||||||
|
style="font-style:normal;font-weight:normal;font-size:1.25px;line-height:25px;font-family:Sans;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"><flowRegion
|
||||||
|
id="flowRegion914"><rect
|
||||||
|
id="rect916"
|
||||||
|
width="88.106705"
|
||||||
|
height="77.158531"
|
||||||
|
x="-73.335365"
|
||||||
|
y="-110.39329" /></flowRegion><flowPara
|
||||||
|
id="flowPara918" /></flowRoot><flowRoot
|
||||||
|
xml:space="preserve"
|
||||||
|
id="flowRoot920"
|
||||||
|
style="font-style:normal;font-weight:normal;font-size:1.25px;line-height:25px;font-family:Sans;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"><flowRegion
|
||||||
|
id="flowRegion922"><rect
|
||||||
|
id="rect924"
|
||||||
|
width="148.40852"
|
||||||
|
height="10.253048"
|
||||||
|
x="61.865852"
|
||||||
|
y="42.8811" /></flowRegion><flowPara
|
||||||
|
id="flowPara926">Ver</flowPara></flowRoot><text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-style:normal;font-weight:normal;font-size:1.25px;line-height:25px;font-family:Sans;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||||
|
x="60.449413"
|
||||||
|
y="19.314518"
|
||||||
|
id="text888"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
x="60.449413"
|
||||||
|
y="19.314518"
|
||||||
|
id="tspan886"><tspan
|
||||||
|
x="60.449413"
|
||||||
|
y="19.314518"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:13.33333302px;font-family:'Open Sans';-inkscape-font-specification:'Open Sans, Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;writing-mode:lr-tb;text-anchor:start"
|
||||||
|
id="tspan884">PGP Signed With</tspan></tspan></text>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 13 KiB |
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 49 KiB |
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 40 KiB |
@ -0,0 +1,36 @@
|
|||||||
|
html,
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: sans-serif;
|
||||||
|
background-color: #ffc107;
|
||||||
|
|
||||||
|
/* Chrome only at the moment, disable pull to refresh in PWA */
|
||||||
|
overscroll-behavior-y: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
body:before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
z-index: -1;
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
background-color: white;
|
||||||
|
|
||||||
|
min-height: 100%;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #00b0ff;
|
||||||
|
}
|
@ -0,0 +1,45 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as ReactDOM from "react-dom";
|
||||||
|
import { Provider } from "react-redux";
|
||||||
|
import { PersistGate } from "redux-persist/es/integration/react";
|
||||||
|
import App from "./App";
|
||||||
|
import registerServiceWorker from "./registerServiceWorker";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
|
import * as Etebase from "etebase";
|
||||||
|
|
||||||
|
function MyPersistGate(props: React.PropsWithChildren<{}>) {
|
||||||
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
Etebase.ready.then(() => {
|
||||||
|
setLoading(false);
|
||||||
|
persistor.persist();
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (<React.Fragment />);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PersistGate persistor={persistor}>
|
||||||
|
{props.children}
|
||||||
|
</PersistGate>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
import { store, persistor } from "./store";
|
||||||
|
|
||||||
|
ReactDOM.render(
|
||||||
|
<Provider store={store}>
|
||||||
|
<MyPersistGate>
|
||||||
|
<App />
|
||||||
|
</MyPersistGate>
|
||||||
|
</Provider>,
|
||||||
|
document.getElementById("root") as HTMLElement
|
||||||
|
);
|
||||||
|
registerServiceWorker();
|
@ -0,0 +1,36 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { withRouter } from "react-router";
|
||||||
|
|
||||||
|
// FIXME: Should probably tie this to the history object, or at least based on the depth of the history
|
||||||
|
const stateCache = {};
|
||||||
|
|
||||||
|
type Constructor<T> = new(...args: any[]) => T;
|
||||||
|
|
||||||
|
export function historyPersistor(tag: string) {
|
||||||
|
return <T extends Constructor<React.Component>>(Base: T) => {
|
||||||
|
return withRouter(class extends Base {
|
||||||
|
constructor(...rest: any[]) {
|
||||||
|
const props = rest[0];
|
||||||
|
super(...rest);
|
||||||
|
const tagName = this.getKeyForTag(props, tag);
|
||||||
|
if (tagName in stateCache) {
|
||||||
|
this.state = stateCache[tagName];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public componentWillUnmount() {
|
||||||
|
if (super.componentWillUnmount) {
|
||||||
|
super.componentWillUnmount();
|
||||||
|
}
|
||||||
|
stateCache[this.getKeyForTag(this.props, tag)] = this.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getKeyForTag(props: any, tagName: string) {
|
||||||
|
return props.location.pathname + ":" + tagName;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
@ -0,0 +1,412 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as ICAL from "ical.js";
|
||||||
|
import * as zones from "./data/zones.json";
|
||||||
|
import moment from "moment";
|
||||||
|
import * as uuid from "uuid";
|
||||||
|
|
||||||
|
export const PRODID = "-//iCal.js EteSync iOS";
|
||||||
|
|
||||||
|
export interface PimType {
|
||||||
|
uid: string;
|
||||||
|
collectionUid?: string;
|
||||||
|
itemUid?: string;
|
||||||
|
toIcal(): string;
|
||||||
|
clone(): PimType;
|
||||||
|
lastModified: ICAL.Time | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function timezoneLoadFromName(timezone: string | null) {
|
||||||
|
if (!timezone) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let zone = zones.zones[timezone];
|
||||||
|
if (!zone && zones.aliases[timezone]) {
|
||||||
|
zone = zones.zones[zones.aliases[timezone]];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!zone) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ICAL.TimezoneService.has(timezone)) {
|
||||||
|
return ICAL.TimezoneService.get(timezone);
|
||||||
|
}
|
||||||
|
|
||||||
|
const component = new ICAL.Component("vtimezone");
|
||||||
|
zone.ics.forEach((zonePart: string) => {
|
||||||
|
component.addSubcomponent(new ICAL.Component(ICAL.parse(zonePart)));
|
||||||
|
});
|
||||||
|
component.addPropertyWithValue("tzid", timezone);
|
||||||
|
|
||||||
|
const retZone = new ICAL.Timezone({
|
||||||
|
component,
|
||||||
|
tzid: timezone,
|
||||||
|
});
|
||||||
|
|
||||||
|
ICAL.TimezoneService.register(timezone, retZone);
|
||||||
|
|
||||||
|
return retZone;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseString(content: string) {
|
||||||
|
content = content.replace(/^[a-zA-Z0-9]*\./gm, ""); // FIXME: ugly hack to ignore item groups.
|
||||||
|
return new ICAL.Component(ICAL.parse(content));
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EventType extends ICAL.Event implements PimType {
|
||||||
|
public collectionUid?: string;
|
||||||
|
public itemUid?: string;
|
||||||
|
|
||||||
|
public static isEvent(comp: ICAL.Component) {
|
||||||
|
return !!comp.getFirstSubcomponent("vevent");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static fromVCalendar(comp: ICAL.Component) {
|
||||||
|
const event = new EventType(comp.getFirstSubcomponent("vevent"));
|
||||||
|
// FIXME: we need to clone it so it loads the correct timezone and applies it
|
||||||
|
timezoneLoadFromName(event.timezone);
|
||||||
|
return event.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static parse(content: string) {
|
||||||
|
return EventType.fromVCalendar(parseString(content));
|
||||||
|
}
|
||||||
|
|
||||||
|
public color: string;
|
||||||
|
|
||||||
|
get timezone() {
|
||||||
|
if (this.startDate) {
|
||||||
|
return this.startDate.timezone;
|
||||||
|
} else if (this.endDate) {
|
||||||
|
return this.endDate.timezone;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
get title() {
|
||||||
|
return this.summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
set title(title: string) {
|
||||||
|
this.summary = title;
|
||||||
|
}
|
||||||
|
|
||||||
|
get start() {
|
||||||
|
return this.startDate.toJSDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
get end() {
|
||||||
|
return this.endDate.toJSDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
get allDay() {
|
||||||
|
return this.startDate.isDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
get desc() {
|
||||||
|
return this.description;
|
||||||
|
}
|
||||||
|
|
||||||
|
get lastModified() {
|
||||||
|
return this.component.getFirstPropertyValue("last-modified");
|
||||||
|
}
|
||||||
|
|
||||||
|
set lastModified(time: ICAL.Time) {
|
||||||
|
this.component.updatePropertyWithValue("last-modified", time);
|
||||||
|
}
|
||||||
|
|
||||||
|
get rrule() {
|
||||||
|
return this.component.getFirstPropertyValue("rrule");
|
||||||
|
}
|
||||||
|
|
||||||
|
set rrule(rule: ICAL.Recur) {
|
||||||
|
this.component.updatePropertyWithValue("rrule", rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
public toIcal() {
|
||||||
|
const comp = new ICAL.Component(["vcalendar", [], []]);
|
||||||
|
comp.updatePropertyWithValue("prodid", PRODID);
|
||||||
|
comp.updatePropertyWithValue("version", "2.0");
|
||||||
|
|
||||||
|
comp.addSubcomponent(this.component);
|
||||||
|
ICAL.helpers.updateTimezones(comp);
|
||||||
|
return comp.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public clone() {
|
||||||
|
const ret = new EventType(ICAL.Component.fromString(this.component.toString()));
|
||||||
|
ret.color = this.color;
|
||||||
|
ret.collectionUid = this.collectionUid;
|
||||||
|
ret.itemUid = this.itemUid;
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum TaskStatusType {
|
||||||
|
NeedsAction = "NEEDS-ACTION",
|
||||||
|
Completed = "COMPLETED",
|
||||||
|
InProcess = "IN-PROCESS",
|
||||||
|
Cancelled = "CANCELLED",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum TaskPriorityType {
|
||||||
|
Undefined = 0,
|
||||||
|
High = 1,
|
||||||
|
Medium = 5,
|
||||||
|
Low = 9
|
||||||
|
}
|
||||||
|
|
||||||
|
export let TaskTags = ["Work", "Home"];
|
||||||
|
|
||||||
|
export function setTaskTags(tags: string[]) {
|
||||||
|
TaskTags = tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TaskType extends EventType {
|
||||||
|
public collectionUid?: string;
|
||||||
|
public itemUid?: string;
|
||||||
|
|
||||||
|
public static fromVCalendar(comp: ICAL.Component) {
|
||||||
|
const task = new TaskType(comp.getFirstSubcomponent("vtodo"));
|
||||||
|
// FIXME: we need to clone it so it loads the correct timezone and applies it
|
||||||
|
timezoneLoadFromName(task.timezone);
|
||||||
|
return task.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static parse(content: string) {
|
||||||
|
return TaskType.fromVCalendar(parseString(content));
|
||||||
|
}
|
||||||
|
|
||||||
|
public color: string;
|
||||||
|
|
||||||
|
constructor(comp?: ICAL.Component | null) {
|
||||||
|
super(comp ? comp : new ICAL.Component("vtodo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
get finished() {
|
||||||
|
return this.status === TaskStatusType.Completed ||
|
||||||
|
this.status === TaskStatusType.Cancelled;
|
||||||
|
}
|
||||||
|
|
||||||
|
set status(status: TaskStatusType) {
|
||||||
|
this.component.updatePropertyWithValue("status", status);
|
||||||
|
}
|
||||||
|
|
||||||
|
get status(): TaskStatusType {
|
||||||
|
return this.component.getFirstPropertyValue("status");
|
||||||
|
}
|
||||||
|
|
||||||
|
set priority(priority: TaskPriorityType) {
|
||||||
|
this.component.updatePropertyWithValue("priority", priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
get priority() {
|
||||||
|
return this.component.getFirstPropertyValue("priority");
|
||||||
|
}
|
||||||
|
|
||||||
|
set tags(tags: string[]) {
|
||||||
|
const property = this.component.getFirstProperty("categories");
|
||||||
|
const empty = tags.length === 0;
|
||||||
|
if (property) {
|
||||||
|
if (empty) {
|
||||||
|
this.component.removeAllProperties("categories");
|
||||||
|
} else {
|
||||||
|
property.setValues(tags);
|
||||||
|
}
|
||||||
|
} else if (!empty) {
|
||||||
|
const newProp = new ICAL.Property("categories", this.component);
|
||||||
|
newProp.setValues(tags);
|
||||||
|
this.component.addProperty(newProp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get tags() {
|
||||||
|
return this.component.getFirstProperty("categories")?.getValues()?.filter((x) => x !== "") ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
set dueDate(date: ICAL.Time | undefined) {
|
||||||
|
if (date) {
|
||||||
|
this.component.updatePropertyWithValue("due", date);
|
||||||
|
} else {
|
||||||
|
this.component.removeAllProperties("due");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get dueDate() {
|
||||||
|
return this.component.getFirstPropertyValue("due");
|
||||||
|
}
|
||||||
|
|
||||||
|
set completionDate(date: ICAL.Time | undefined) {
|
||||||
|
if (date) {
|
||||||
|
this.component.updatePropertyWithValue("completed", date);
|
||||||
|
} else {
|
||||||
|
this.component.removeAllProperties("completed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get completionDate() {
|
||||||
|
return this.component.getFirstPropertyValue("completed");
|
||||||
|
}
|
||||||
|
|
||||||
|
set relatedTo(parentUid: string | undefined) {
|
||||||
|
if (parentUid !== undefined) {
|
||||||
|
this.component.updatePropertyWithValue("related-to", parentUid);
|
||||||
|
} else {
|
||||||
|
this.component.removeAllProperties("related-to");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get relatedTo(): string | undefined {
|
||||||
|
return this.component.getFirstPropertyValue("related-to");
|
||||||
|
}
|
||||||
|
|
||||||
|
get endDate() {
|
||||||
|
// XXX: A hack to override this as it shouldn't be used
|
||||||
|
return undefined as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
get allDay() {
|
||||||
|
return !!((this.startDate?.isDate) || (this.dueDate?.isDate));
|
||||||
|
}
|
||||||
|
|
||||||
|
get dueToday() {
|
||||||
|
return this.dueDate && moment(this.dueDate.toJSDate()).isSameOrBefore(moment(), "day");
|
||||||
|
}
|
||||||
|
|
||||||
|
get overdue() {
|
||||||
|
if (!this.dueDate) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dueDate = moment(this.dueDate.toJSDate());
|
||||||
|
const now = moment();
|
||||||
|
return (this.dueDate.isDate) ? dueDate.isBefore(now, "day") : dueDate.isBefore(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
get hidden() {
|
||||||
|
if (!this.startDate) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDate = moment(this.startDate.toJSDate());
|
||||||
|
const now = moment();
|
||||||
|
return startDate.isAfter(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
public clone() {
|
||||||
|
const ret = new TaskType(ICAL.Component.fromString(this.component.toString()));
|
||||||
|
ret.color = this.color;
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getNextOccurence(): TaskType | null {
|
||||||
|
if (!this.isRecurring()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rrule = this.rrule.clone();
|
||||||
|
|
||||||
|
if (rrule.count && rrule.count <= 1) {
|
||||||
|
return null; // end of reccurence
|
||||||
|
}
|
||||||
|
|
||||||
|
rrule.count = null; // clear count so we can iterate as many times as needed
|
||||||
|
const recur = rrule.iterator(this.startDate ?? this.dueDate);
|
||||||
|
let nextRecurrence = recur.next();
|
||||||
|
while ((nextRecurrence = recur.next())) {
|
||||||
|
if (nextRecurrence.compare(ICAL.Time.now()) > 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nextRecurrence) {
|
||||||
|
return null; // end of reccurence
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextStartDate = this.startDate ? nextRecurrence : undefined;
|
||||||
|
const nextDueDate = this.dueDate ? nextRecurrence : undefined;
|
||||||
|
if (nextStartDate && nextDueDate) {
|
||||||
|
const offset = this.dueDate!.subtractDateTz(this.startDate);
|
||||||
|
nextDueDate.addDuration(offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextTask = this.clone();
|
||||||
|
nextTask.uid = uuid.v4();
|
||||||
|
if (nextStartDate) {
|
||||||
|
nextTask.startDate = nextStartDate;
|
||||||
|
}
|
||||||
|
if (nextDueDate) {
|
||||||
|
nextTask.dueDate = nextDueDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.rrule.count) {
|
||||||
|
rrule.count = this.rrule.count - 1;
|
||||||
|
nextTask.rrule = rrule;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextTask.status = TaskStatusType.NeedsAction;
|
||||||
|
nextTask.lastModified = ICAL.Time.now();
|
||||||
|
|
||||||
|
return nextTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ContactType implements PimType {
|
||||||
|
public comp: ICAL.Component;
|
||||||
|
public collectionUid?: string;
|
||||||
|
public itemUid?: string;
|
||||||
|
|
||||||
|
|
||||||
|
public static parse(content: string) {
|
||||||
|
return new ContactType(parseString(content));
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(comp: ICAL.Component) {
|
||||||
|
this.comp = comp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public toIcal() {
|
||||||
|
return this.comp.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public clone() {
|
||||||
|
return new ContactType(ICAL.Component.fromString(this.comp.toString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
get uid() {
|
||||||
|
return this.comp.getFirstPropertyValue("uid");
|
||||||
|
}
|
||||||
|
|
||||||
|
set uid(uid: string) {
|
||||||
|
this.comp.updatePropertyWithValue("uid", uid);
|
||||||
|
}
|
||||||
|
|
||||||
|
get fn() {
|
||||||
|
return this.comp.getFirstPropertyValue("fn");
|
||||||
|
}
|
||||||
|
|
||||||
|
get n() {
|
||||||
|
return this.comp.getFirstPropertyValue("n");
|
||||||
|
}
|
||||||
|
|
||||||
|
get bday() {
|
||||||
|
return this.comp.getFirstPropertyValue("bday");
|
||||||
|
}
|
||||||
|
|
||||||
|
get lastModified() {
|
||||||
|
return this.comp.getFirstPropertyValue("rev");
|
||||||
|
}
|
||||||
|
|
||||||
|
get group() {
|
||||||
|
const kind = this.comp.getFirstPropertyValue("kind");
|
||||||
|
return ["group", "organization"].includes(kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
get members() {
|
||||||
|
return this.comp.getAllProperties("member").map((prop) => prop.getFirstValue<string>().replace("urn:uuid:", ""));
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,4 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
/// <reference types="react-scripts" />
|
@ -0,0 +1,117 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
// tslint:disable:no-console
|
||||||
|
// In production, we register a service worker to serve assets from local cache.
|
||||||
|
|
||||||
|
// This lets the app load faster on subsequent visits in production, and gives
|
||||||
|
// it offline capabilities. However, it also means that developers (and users)
|
||||||
|
// will only see deployed updates on the 'N+1' visit to a page, since previously
|
||||||
|
// cached resources are updated in the background.
|
||||||
|
|
||||||
|
// To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
|
||||||
|
// This link also includes instructions on opting out of this behavior.
|
||||||
|
|
||||||
|
const isLocalhost = Boolean(
|
||||||
|
window.location.hostname === "localhost" ||
|
||||||
|
// [::1] is the IPv6 localhost address.
|
||||||
|
window.location.hostname === "[::1]" ||
|
||||||
|
// 127.0.0.1/8 is considered localhost for IPv4.
|
||||||
|
window.location.hostname.match(
|
||||||
|
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function register() {
|
||||||
|
if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) {
|
||||||
|
// The URL constructor is available in all browsers that support SW.
|
||||||
|
const publicUrl = new URL(
|
||||||
|
process.env.PUBLIC_URL!,
|
||||||
|
window.location.toString()
|
||||||
|
);
|
||||||
|
if (publicUrl.origin !== window.location.origin) {
|
||||||
|
// Our service worker won't work if PUBLIC_URL is on a different origin
|
||||||
|
// from what our page is served on. This might happen if a CDN is used to
|
||||||
|
// serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("load", () => {
|
||||||
|
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
|
||||||
|
|
||||||
|
if (!isLocalhost) {
|
||||||
|
// Is not local host. Just register service worker
|
||||||
|
registerValidSW(swUrl);
|
||||||
|
} else {
|
||||||
|
// This is running on localhost. Lets check if a service worker still exists or not.
|
||||||
|
checkValidServiceWorker(swUrl);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerValidSW(swUrl: string) {
|
||||||
|
navigator.serviceWorker
|
||||||
|
.register(swUrl)
|
||||||
|
.then((registration) => {
|
||||||
|
registration.onupdatefound = () => {
|
||||||
|
const installingWorker = registration.installing;
|
||||||
|
if (installingWorker) {
|
||||||
|
installingWorker.onstatechange = () => {
|
||||||
|
if (installingWorker.state === "installed") {
|
||||||
|
if (navigator.serviceWorker.controller) {
|
||||||
|
// At this point, the old content will have been purged and
|
||||||
|
// the fresh content will have been added to the cache.
|
||||||
|
// It's the perfect time to display a 'New content is
|
||||||
|
// available; please refresh.' message in your web app.
|
||||||
|
console.log("New content is available; please refresh.");
|
||||||
|
} else {
|
||||||
|
// At this point, everything has been precached.
|
||||||
|
// It's the perfect time to display a
|
||||||
|
// 'Content is cached for offline use.' message.
|
||||||
|
console.log("Content is cached for offline use.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Error during service worker registration:", error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkValidServiceWorker(swUrl: string) {
|
||||||
|
// Check if the service worker can be found. If it can't reload the page.
|
||||||
|
fetch(swUrl)
|
||||||
|
.then((response) => {
|
||||||
|
// Ensure service worker exists, and that we really are getting a JS file.
|
||||||
|
if (
|
||||||
|
response.status === 404 ||
|
||||||
|
response.headers.get("content-type")!.indexOf("javascript") === -1
|
||||||
|
) {
|
||||||
|
// No service worker found. Probably a different app. Reload the page.
|
||||||
|
navigator.serviceWorker.ready.then((registration) => {
|
||||||
|
registration.unregister().then(() => {
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Service worker found. Proceed as normal.
|
||||||
|
registerValidSW(swUrl);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
console.log(
|
||||||
|
"No internet connection found. App is running in offline mode."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unregister() {
|
||||||
|
if ("serviceWorker" in navigator) {
|
||||||
|
navigator.serviceWorker.ready.then((registration) => {
|
||||||
|
registration.unregister();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,63 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { RouteResolver } from "./routes";
|
||||||
|
|
||||||
|
const routes = {
|
||||||
|
home: "",
|
||||||
|
post: {
|
||||||
|
_base: "post",
|
||||||
|
_id: {
|
||||||
|
_base: ":postId",
|
||||||
|
comment: "comment/:commentId",
|
||||||
|
revision: "history/:revisionId/:someOtherVar/test",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const routeResolver = new RouteResolver(routes);
|
||||||
|
|
||||||
|
it("translating routes", () => {
|
||||||
|
// Working basic resolves
|
||||||
|
expect(routeResolver.getRoute("home")).toBe("/");
|
||||||
|
expect(routeResolver.getRoute("post")).toBe("/post");
|
||||||
|
expect(routeResolver.getRoute("post._id")).toBe("/post/:postId");
|
||||||
|
expect(routeResolver.getRoute("post._id.comment")).toBe("/post/:postId/comment/:commentId");
|
||||||
|
|
||||||
|
// Working translation resolves
|
||||||
|
expect(routeResolver.getRoute("home")).toBe("/");
|
||||||
|
expect(routeResolver.getRoute("post")).toBe("/post");
|
||||||
|
expect(routeResolver.getRoute("post._id", { postId: 3 })).toBe("/post/3");
|
||||||
|
expect(routeResolver.getRoute("post._id.comment",
|
||||||
|
{ postId: 3, commentId: 5 })).toBe("/post/3/comment/5");
|
||||||
|
expect(routeResolver.getRoute("post._id.revision",
|
||||||
|
{ postId: 3, revisionId: 5, someOtherVar: "a" })).toBe("/post/3/history/5/a/test");
|
||||||
|
|
||||||
|
// Failing basic resolves
|
||||||
|
expect(() => {
|
||||||
|
routeResolver.getRoute("bad");
|
||||||
|
}).toThrow();
|
||||||
|
expect(() => {
|
||||||
|
routeResolver.getRoute("home.bad");
|
||||||
|
}).toThrow();
|
||||||
|
expect(() => {
|
||||||
|
routeResolver.getRoute("post._id.bad");
|
||||||
|
}).toThrow();
|
||||||
|
|
||||||
|
// Failing translations
|
||||||
|
expect(() => {
|
||||||
|
routeResolver.getRoute("home", { test: 4 });
|
||||||
|
}).toThrow();
|
||||||
|
expect(() => {
|
||||||
|
routeResolver.getRoute("post._id", { test: 4 });
|
||||||
|
}).toThrow();
|
||||||
|
expect(() => {
|
||||||
|
routeResolver.getRoute("post._id", { postId: 3, test: 4 });
|
||||||
|
}).toThrow();
|
||||||
|
expect(() => {
|
||||||
|
routeResolver.getRoute("post._id.comment", { postId: 3, commentId: 5, test: 4 });
|
||||||
|
}).toThrow();
|
||||||
|
expect(() => {
|
||||||
|
routeResolver.getRoute("post._id.comment", { postId: 3 });
|
||||||
|
}).toThrow();
|
||||||
|
});
|
@ -0,0 +1,52 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
export interface RouteKeysType {
|
||||||
|
[Identifier: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RouteResolver {
|
||||||
|
public routes: {};
|
||||||
|
|
||||||
|
constructor(routes: {}) {
|
||||||
|
this.routes = routes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getRoute(name: string, _keys?: RouteKeysType): string {
|
||||||
|
let dict = this.routes;
|
||||||
|
|
||||||
|
let path: string[] = [];
|
||||||
|
name.split(".").forEach((key) => {
|
||||||
|
const val = (typeof dict[key] === "string") ? dict[key] : (dict[key]._base) ? dict[key]._base : key;
|
||||||
|
path.push(val);
|
||||||
|
|
||||||
|
dict = dict[key];
|
||||||
|
});
|
||||||
|
|
||||||
|
if (_keys) {
|
||||||
|
const keys = Object.assign({}, _keys);
|
||||||
|
|
||||||
|
path = path.map((pathComponent) => {
|
||||||
|
return pathComponent.split("/").map((val) => {
|
||||||
|
if (val[0] === ":") {
|
||||||
|
const ret = keys[val.slice(1)];
|
||||||
|
if (ret === undefined) {
|
||||||
|
throw new Error("Missing key: " + val.slice(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
delete keys[val.slice(1)];
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
return val;
|
||||||
|
}).join("/");
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Object.keys(keys).length !== 0) {
|
||||||
|
throw new Error("Too many keys for route.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "/" + path.join("/");
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,190 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { createAction as origCreateAction, ActionMeta } from "redux-actions";
|
||||||
|
|
||||||
|
import * as Etebase from "etebase";
|
||||||
|
|
||||||
|
import { SettingsType } from "./";
|
||||||
|
import { Message } from "./reducers";
|
||||||
|
|
||||||
|
type FunctionAny = (...args: any[]) => any;
|
||||||
|
|
||||||
|
function createAction<Func extends FunctionAny, MetaFunc extends FunctionAny>(
|
||||||
|
actionType: string,
|
||||||
|
payloadCreator: Func,
|
||||||
|
metaCreator?: MetaFunc
|
||||||
|
): (..._params: Parameters<Func>) => ActionMeta<ReturnType<Func>, ReturnType<MetaFunc>> {
|
||||||
|
return origCreateAction(actionType, payloadCreator, metaCreator as any) as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const resetKey = createAction(
|
||||||
|
"RESET_KEY",
|
||||||
|
() => {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const logout = createAction(
|
||||||
|
"LOGOUT",
|
||||||
|
async (etebase: Etebase.Account) => {
|
||||||
|
// We don't wait on purpose, because we would like to logout and clear local data anyway
|
||||||
|
etebase.logout();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const login = createAction(
|
||||||
|
"LOGIN",
|
||||||
|
async (etebase: Etebase.Account) => {
|
||||||
|
return etebase.save();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const setCacheCollection = createAction(
|
||||||
|
"SET_CACHE_COLLECTION",
|
||||||
|
async (colMgr: Etebase.CollectionManager, col: Etebase.Collection) => {
|
||||||
|
return colMgr.cacheSave(col);
|
||||||
|
},
|
||||||
|
(_colMgr: Etebase.CollectionManager, col: Etebase.Collection) => {
|
||||||
|
return {
|
||||||
|
colUid: col.uid,
|
||||||
|
deleted: col.isDeleted,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const unsetCacheCollection = createAction(
|
||||||
|
"UNSET_CACHE_COLLECTION",
|
||||||
|
(_colMgr: Etebase.CollectionManager, colUid: string) => {
|
||||||
|
return colUid;
|
||||||
|
},
|
||||||
|
(_colMgr: Etebase.CollectionManager, colUid: string) => {
|
||||||
|
return {
|
||||||
|
colUid,
|
||||||
|
deleted: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const collectionUpload = createAction(
|
||||||
|
"COLLECTION_UPLOAD",
|
||||||
|
async (colMgr: Etebase.CollectionManager, col: Etebase.Collection) => {
|
||||||
|
await colMgr.upload(col);
|
||||||
|
return colMgr.cacheSave(col);
|
||||||
|
},
|
||||||
|
(_colMgr: Etebase.CollectionManager, col: Etebase.Collection) => {
|
||||||
|
return {
|
||||||
|
colUid: col.uid,
|
||||||
|
deleted: col.isDeleted,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const setCacheItem = createAction(
|
||||||
|
"SET_CACHE_ITEM",
|
||||||
|
async (_col: Etebase.Collection, itemMgr: Etebase.ItemManager, item: Etebase.Item) => {
|
||||||
|
return itemMgr.cacheSave(item);
|
||||||
|
},
|
||||||
|
(col: Etebase.Collection, _itemMgr: Etebase.ItemManager, item: Etebase.Item) => {
|
||||||
|
return {
|
||||||
|
colUid: col.uid,
|
||||||
|
itemUid: item.uid,
|
||||||
|
deleted: item.isDeleted,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const setCacheItemMulti = createAction(
|
||||||
|
"SET_CACHE_ITEM_MULTI",
|
||||||
|
async (_colUid: string, itemMgr: Etebase.ItemManager, items: Etebase.Item[]) => {
|
||||||
|
const ret = [];
|
||||||
|
for (const item of items) {
|
||||||
|
ret.push(itemMgr.cacheSave(item));
|
||||||
|
}
|
||||||
|
return ret;
|
||||||
|
},
|
||||||
|
(colUid: string, _itemMgr: Etebase.ItemManager, items: Etebase.Item[], _deps?: Etebase.Item[]) => {
|
||||||
|
return {
|
||||||
|
colUid,
|
||||||
|
items: items,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const itemBatch = createAction(
|
||||||
|
"ITEM_BATCH",
|
||||||
|
async (_col: Etebase.Collection, itemMgr: Etebase.ItemManager, items: Etebase.Item[], deps?: Etebase.Item[]) => {
|
||||||
|
await itemMgr.batch(items, deps);
|
||||||
|
const ret = [];
|
||||||
|
for (const item of items) {
|
||||||
|
ret.push(itemMgr.cacheSave(item));
|
||||||
|
}
|
||||||
|
return ret;
|
||||||
|
},
|
||||||
|
(col: Etebase.Collection, _itemMgr: Etebase.ItemManager, items: Etebase.Item[], _deps?: Etebase.Item[]) => {
|
||||||
|
return {
|
||||||
|
colUid: col.uid,
|
||||||
|
items: items,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const setSyncCollection = createAction(
|
||||||
|
"SET_SYNC_COLLECTION",
|
||||||
|
(uid: string, stoken: string) => {
|
||||||
|
return {
|
||||||
|
uid,
|
||||||
|
stoken,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const setSyncGeneral = createAction(
|
||||||
|
"SET_SYNC_GENERAL",
|
||||||
|
(stoken: string | null) => {
|
||||||
|
return stoken;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const performSync = createAction(
|
||||||
|
"PERFORM_SYNC",
|
||||||
|
(syncPromise: Promise<any>) => {
|
||||||
|
return syncPromise;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const appendError = createAction(
|
||||||
|
"APPEND_ERROR",
|
||||||
|
(error: Error | Error[]) => {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const clearErros = createAction(
|
||||||
|
"CLEAR_ERRORS",
|
||||||
|
(_etesync: Etebase.Account) => {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const pushMessage = createAction(
|
||||||
|
"PUSH_MESSAGE",
|
||||||
|
(message: Message) => {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const popMessage = createAction(
|
||||||
|
"POP_MESSAGE",
|
||||||
|
() => {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// FIXME: Move the rest to their own file
|
||||||
|
export const setSettings = createAction(
|
||||||
|
"SET_SETTINGS",
|
||||||
|
(settings: Partial<SettingsType>) => {
|
||||||
|
return { ...settings };
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,142 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as localforage from "localforage";
|
||||||
|
import { combineReducers } from "redux";
|
||||||
|
import { createMigrate, persistReducer, createTransform } from "redux-persist";
|
||||||
|
import session from "redux-persist/lib/storage/session";
|
||||||
|
|
||||||
|
import * as Etebase from "etebase";
|
||||||
|
|
||||||
|
import { List, Map as ImmutableMap } from "immutable";
|
||||||
|
import {
|
||||||
|
SettingsType,
|
||||||
|
fetchCount, credentials, settingsReducer, encryptionKeyReducer, errorsReducer, messagesReducer,
|
||||||
|
syncGeneral, syncCollections, collections, items,
|
||||||
|
SyncGeneralData, SyncCollectionsData, CacheCollectionsData, CacheItemsData, CredentialsData, Message,
|
||||||
|
} from "./reducers";
|
||||||
|
|
||||||
|
export interface StoreState {
|
||||||
|
fetchCount: number;
|
||||||
|
credentials: CredentialsData;
|
||||||
|
settings: SettingsType;
|
||||||
|
encryptionKey: {key: string};
|
||||||
|
sync: {
|
||||||
|
collections: SyncCollectionsData;
|
||||||
|
general: SyncGeneralData;
|
||||||
|
};
|
||||||
|
cache: {
|
||||||
|
collections: CacheCollectionsData;
|
||||||
|
items: CacheItemsData;
|
||||||
|
};
|
||||||
|
errors: List<Error>;
|
||||||
|
messages: List<Message>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const settingsMigrations = {
|
||||||
|
0: (state: any) => {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
taskSettings: {
|
||||||
|
filterBy: null,
|
||||||
|
sortBy: "smart",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const settingsPersistConfig = {
|
||||||
|
key: "settings",
|
||||||
|
version: 0,
|
||||||
|
storage: localforage,
|
||||||
|
migrate: createMigrate(settingsMigrations, { debug: false }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const credentialsPersistConfig = {
|
||||||
|
key: "credentials2",
|
||||||
|
version: 0,
|
||||||
|
storage: localforage,
|
||||||
|
};
|
||||||
|
|
||||||
|
const encryptionKeyPersistConfig = {
|
||||||
|
key: "encryptionKey",
|
||||||
|
storage: session,
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncSerialize = (state: any, key: string | number) => {
|
||||||
|
if (key === "collections") {
|
||||||
|
return state.toJS();
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncDeserialize = (state: any, key: string | number) => {
|
||||||
|
if (key === "collections") {
|
||||||
|
return ImmutableMap(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncPersistConfig = {
|
||||||
|
key: "sync",
|
||||||
|
storage: localforage,
|
||||||
|
transforms: [createTransform(syncSerialize, syncDeserialize)],
|
||||||
|
};
|
||||||
|
|
||||||
|
const cacheSerialize = (state: any, key: string | number) => {
|
||||||
|
if (key === "collections") {
|
||||||
|
const typedState = state as CacheCollectionsData;
|
||||||
|
const ret = typedState.map((x) => Etebase.toBase64(x));
|
||||||
|
return ret.toJS();
|
||||||
|
} else if (key === "items") {
|
||||||
|
const typedState = state as CacheItemsData;
|
||||||
|
const ret = typedState.map((items) => {
|
||||||
|
return items.map((x) => Etebase.toBase64(x));
|
||||||
|
});
|
||||||
|
return ret.toJS();
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cacheDeserialize = (state: any, key: string | number) => {
|
||||||
|
if (key === "collections") {
|
||||||
|
return ImmutableMap<string, string>(state).map((x) => {
|
||||||
|
return Etebase.fromBase64(x);
|
||||||
|
});
|
||||||
|
} else if (key === "items") {
|
||||||
|
return ImmutableMap(state).map((item: any) => {
|
||||||
|
return ImmutableMap<string, string>(item).map((x) => Etebase.fromBase64(x));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cachePersistConfig = {
|
||||||
|
key: "cache2",
|
||||||
|
version: 0,
|
||||||
|
storage: localforage,
|
||||||
|
transforms: [createTransform(cacheSerialize, cacheDeserialize)] as any,
|
||||||
|
};
|
||||||
|
|
||||||
|
const reducers = combineReducers({
|
||||||
|
fetchCount,
|
||||||
|
settings: persistReducer(settingsPersistConfig, settingsReducer),
|
||||||
|
credentials: persistReducer(credentialsPersistConfig, credentials),
|
||||||
|
encryptionKey: persistReducer(encryptionKeyPersistConfig, encryptionKeyReducer),
|
||||||
|
sync: persistReducer(syncPersistConfig, combineReducers({
|
||||||
|
collections: syncCollections,
|
||||||
|
general: syncGeneral,
|
||||||
|
})),
|
||||||
|
cache: persistReducer(cachePersistConfig, combineReducers({
|
||||||
|
collections,
|
||||||
|
items,
|
||||||
|
})),
|
||||||
|
errors: errorsReducer,
|
||||||
|
messages: messagesReducer,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default reducers;
|
@ -0,0 +1,45 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { createStore, applyMiddleware } from "redux";
|
||||||
|
import { persistStore } from "redux-persist";
|
||||||
|
import thunkMiddleware from "redux-thunk";
|
||||||
|
import { createLogger } from "redux-logger";
|
||||||
|
import { ActionMeta } from "redux-actions";
|
||||||
|
import { useDispatch } from "react-redux";
|
||||||
|
|
||||||
|
import promiseMiddleware from "./promise-middleware";
|
||||||
|
|
||||||
|
import reducers from "./construct";
|
||||||
|
|
||||||
|
// Workaround babel limitation
|
||||||
|
export * from "./reducers";
|
||||||
|
export * from "./construct";
|
||||||
|
|
||||||
|
const middleware = [
|
||||||
|
thunkMiddleware,
|
||||||
|
promiseMiddleware,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
middleware.push(createLogger());
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIXME: Hack, we don't actually return a promise when one is not passed.
|
||||||
|
export function asyncDispatch<T, V>(action: ActionMeta<Promise<T> | T, V>): Promise<ActionMeta<T, V>> {
|
||||||
|
return store.dispatch(action) as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAsyncDispatch() {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
return function (action: any): any {
|
||||||
|
return dispatch(action) as any;
|
||||||
|
} as typeof asyncDispatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const store = createStore(
|
||||||
|
reducers,
|
||||||
|
applyMiddleware(...middleware)
|
||||||
|
);
|
||||||
|
|
||||||
|
export const persistor = persistStore(store, { manualPersist: true } as any);
|
@ -0,0 +1,25 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
// Based on: https://github.com/acdlite/redux-promise/blob/master/src/index.js
|
||||||
|
|
||||||
|
function isPromise(val: any): val is Promise<any> {
|
||||||
|
return val && typeof val.then === "function";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function promiseMiddleware({ dispatch }: any) {
|
||||||
|
return (next: any) => (action: any) => {
|
||||||
|
if (isPromise(action.payload)) {
|
||||||
|
dispatch({ ...action, payload: undefined });
|
||||||
|
|
||||||
|
return action.payload
|
||||||
|
.then((result: any) => dispatch({ ...action, payload: result }))
|
||||||
|
.catch((error: Error) => {
|
||||||
|
dispatch({ ...action, payload: error, error: true });
|
||||||
|
return Promise.reject(error);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return next(action);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
@ -0,0 +1,264 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { Action, ActionMeta, ActionFunctionAny, combineActions, handleAction, handleActions } from "redux-actions";
|
||||||
|
|
||||||
|
import { List, Map as ImmutableMap } from "immutable";
|
||||||
|
|
||||||
|
import * as Etebase from "etebase";
|
||||||
|
|
||||||
|
import * as actions from "./actions";
|
||||||
|
|
||||||
|
interface BaseModel {
|
||||||
|
uid: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncCollectionsEntryData extends BaseModel {
|
||||||
|
stoken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SyncCollectionsData = ImmutableMap<string, SyncCollectionsEntryData>;
|
||||||
|
|
||||||
|
export type CacheItem = Uint8Array;
|
||||||
|
export type CacheItems = ImmutableMap<string, CacheItem>;
|
||||||
|
export type CacheItemsData = ImmutableMap<string, CacheItems>;
|
||||||
|
export type CacheCollection = Uint8Array;
|
||||||
|
export type CacheCollectionsData = ImmutableMap<string, Uint8Array>;
|
||||||
|
|
||||||
|
export type SyncGeneralData = {
|
||||||
|
stoken: string | null;
|
||||||
|
lastSyncDate: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Message = {
|
||||||
|
message: string;
|
||||||
|
severity: "error" | "warning" | "info" | "success";
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface CredentialsData {
|
||||||
|
storedSession?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const credentials = handleActions(
|
||||||
|
{
|
||||||
|
[actions.login.toString()]: (
|
||||||
|
state: CredentialsData, action: Action<string>) => {
|
||||||
|
if (action.error) {
|
||||||
|
return state;
|
||||||
|
} else if (action.payload === undefined) {
|
||||||
|
return state;
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
storedSession: action.payload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[actions.logout.toString()]: (_state: CredentialsData, _action: any) => {
|
||||||
|
return { storedSession: undefined };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ storedSession: undefined }
|
||||||
|
);
|
||||||
|
|
||||||
|
export const encryptionKeyReducer = handleActions(
|
||||||
|
{
|
||||||
|
},
|
||||||
|
{ key: null }
|
||||||
|
);
|
||||||
|
|
||||||
|
export const syncCollections = handleActions(
|
||||||
|
{
|
||||||
|
[actions.setSyncCollection.toString()]: (state: SyncCollectionsData, action: Action<SyncCollectionsEntryData>) => {
|
||||||
|
if (action.payload !== undefined) {
|
||||||
|
return state.set(action.payload.uid, action.payload);
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
},
|
||||||
|
[actions.logout.toString()]: (state: SyncCollectionsData, _action: any) => {
|
||||||
|
return state.clear();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ImmutableMap({})
|
||||||
|
);
|
||||||
|
|
||||||
|
export const syncGeneral = handleActions(
|
||||||
|
{
|
||||||
|
[actions.setSyncGeneral.toString()]: (state: SyncGeneralData, action: Action<string | null | undefined>) => {
|
||||||
|
if (action.payload !== undefined) {
|
||||||
|
return {
|
||||||
|
stoken: action.payload,
|
||||||
|
lastSyncDate: new Date(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
},
|
||||||
|
[actions.logout.toString()]: (_state: SyncGeneralData, _action: any) => {
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const collections = handleActions(
|
||||||
|
{
|
||||||
|
[combineActions(
|
||||||
|
actions.setCacheCollection,
|
||||||
|
actions.collectionUpload,
|
||||||
|
actions.unsetCacheCollection
|
||||||
|
).toString()]: (state: CacheCollectionsData, action: ActionMeta<CacheCollection, { colUid: string, deleted: boolean }>) => {
|
||||||
|
if (action.payload !== undefined) {
|
||||||
|
if (action.meta.deleted) {
|
||||||
|
return state.remove(action.meta.colUid);
|
||||||
|
} else {
|
||||||
|
return state.set(action.meta.colUid, action.payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
},
|
||||||
|
[actions.logout.toString()]: (state: CacheCollectionsData, _action: any) => {
|
||||||
|
return state.clear();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ImmutableMap({})
|
||||||
|
);
|
||||||
|
|
||||||
|
export const items = handleActions(
|
||||||
|
{
|
||||||
|
[combineActions(
|
||||||
|
actions.setCacheItem
|
||||||
|
).toString()]: (state: CacheItemsData, action: ActionMeta<CacheItem, { colUid: string, itemUid: string, deleted: boolean }>) => {
|
||||||
|
if (action.payload !== undefined) {
|
||||||
|
return state.setIn([action.meta.colUid, action.meta.itemUid], action.payload);
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
},
|
||||||
|
[combineActions(
|
||||||
|
actions.itemBatch,
|
||||||
|
actions.setCacheItemMulti
|
||||||
|
).toString()]: (state: CacheItemsData, action_: any) => {
|
||||||
|
// Fails without it for some reason
|
||||||
|
const action = action_ as ActionMeta<CacheItem[], { colUid: string, items: Etebase.Item[] }>;
|
||||||
|
if (action.payload !== undefined) {
|
||||||
|
return state.withMutations((state) => {
|
||||||
|
let i = 0;
|
||||||
|
for (const item of action.meta.items) {
|
||||||
|
state.setIn([action.meta.colUid, item.uid], action.payload[i]);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
},
|
||||||
|
[actions.setCacheCollection.toString()]: (state: CacheItemsData, action: ActionMeta<CacheCollection, { colUid: string }>) => {
|
||||||
|
if (action.payload !== undefined) {
|
||||||
|
if (!state.has(action.meta.colUid)) {
|
||||||
|
return state.set(action.meta.colUid, ImmutableMap());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
},
|
||||||
|
[actions.unsetCacheCollection.toString()]: (state: CacheItemsData, action: ActionMeta<string, { colUid: string }>) => {
|
||||||
|
if (action.payload !== undefined) {
|
||||||
|
return state.remove(action.meta.colUid);
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
},
|
||||||
|
[actions.logout.toString()]: (state: CacheItemsData, _action: any) => {
|
||||||
|
return state.clear();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ImmutableMap({})
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetchActions = [
|
||||||
|
] as Array<ActionFunctionAny<Action<any>>>;
|
||||||
|
|
||||||
|
for (const func in actions) {
|
||||||
|
if (func.startsWith("fetch") ||
|
||||||
|
func.startsWith("add") ||
|
||||||
|
func.startsWith("update") ||
|
||||||
|
func.startsWith("delete")) {
|
||||||
|
|
||||||
|
fetchActions.push(actions[func]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Indicates network activity, not just fetch
|
||||||
|
export const fetchCount = handleAction(
|
||||||
|
combineActions(
|
||||||
|
actions.performSync.toString(),
|
||||||
|
...fetchActions
|
||||||
|
),
|
||||||
|
(state: number, action: any) => {
|
||||||
|
if (action.payload === undefined) {
|
||||||
|
return state + 1;
|
||||||
|
} else {
|
||||||
|
return state - 1;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
export const errorsReducer = handleActions(
|
||||||
|
{
|
||||||
|
[combineActions(
|
||||||
|
actions.performSync
|
||||||
|
).toString()]: (state: List<Error>, action: Action<any>) => {
|
||||||
|
if (action.error) {
|
||||||
|
return state.push(action.payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
|
},
|
||||||
|
[actions.appendError.toString()]: (state: List<Error>, action: Action<any>) => {
|
||||||
|
if (Array.isArray(action.payload)) {
|
||||||
|
return state.push(...action.payload);
|
||||||
|
} else {
|
||||||
|
return state.push(action.payload);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[actions.clearErros.toString()]: (state: List<Error>, _action: Action<any>) => {
|
||||||
|
return state.clear();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
List([])
|
||||||
|
);
|
||||||
|
|
||||||
|
export const messagesReducer = handleActions(
|
||||||
|
{
|
||||||
|
[actions.pushMessage.toString()]: (state: List<Message>, action: Action<Message>) => {
|
||||||
|
return state.push(action.payload);
|
||||||
|
},
|
||||||
|
[actions.popMessage.toString()]: (state: List<Message>, _action: Action<unknown>) => {
|
||||||
|
return state.remove(0);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
List([])
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
// FIXME Move all the below (potentially the fetchCount ones too) to their own file
|
||||||
|
export interface SettingsType {
|
||||||
|
locale: string;
|
||||||
|
darkMode?: boolean;
|
||||||
|
taskSettings: {
|
||||||
|
filterBy: string | null;
|
||||||
|
sortBy: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const settingsReducer = handleActions(
|
||||||
|
{
|
||||||
|
[actions.setSettings.toString()]: (state: { key: string | null }, action: any) => (
|
||||||
|
{ ...state, ...action.payload }
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
locale: "en-gb",
|
||||||
|
darkMode: false,
|
||||||
|
taskSettings: {
|
||||||
|
filterBy: null,
|
||||||
|
sortBy: "smart",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,119 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2019 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
|
||||||
|
import * as Etebase from "etebase";
|
||||||
|
|
||||||
|
import { store, StoreState } from "../store";
|
||||||
|
|
||||||
|
import { credentialsSelector } from "../credentials";
|
||||||
|
import { setSyncCollection, setSyncGeneral, setCacheCollection, unsetCacheCollection, setCacheItemMulti, appendError } from "../store/actions";
|
||||||
|
|
||||||
|
const cachedSyncManager = new Map<string, SyncManager>();
|
||||||
|
export class SyncManager {
|
||||||
|
private COLLECTION_TYPES = ["etebase.vcard", "etebase.vevent", "etebase.vtodo"];
|
||||||
|
private BATCH_SIZE = 40;
|
||||||
|
|
||||||
|
public static getManager(etebase: Etebase.Account) {
|
||||||
|
const cached = cachedSyncManager.get(etebase.user.username);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ret = new SyncManager();
|
||||||
|
cachedSyncManager.set(etebase.user.username, ret);
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static removeManager(etebase: Etebase.Account) {
|
||||||
|
cachedSyncManager.delete(etebase.user.username);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected etebase: Etebase.Account;
|
||||||
|
protected isSyncing: boolean;
|
||||||
|
|
||||||
|
private async fetchCollection(col: Etebase.Collection) {
|
||||||
|
const storeState = store.getState() as unknown as StoreState;
|
||||||
|
const etebase = (await credentialsSelector(storeState))!;
|
||||||
|
const syncCollection = storeState.sync.collections.get(col.uid, undefined);
|
||||||
|
|
||||||
|
const colMgr = etebase.getCollectionManager();
|
||||||
|
const itemMgr = colMgr.getItemManager(col);
|
||||||
|
|
||||||
|
let stoken = syncCollection?.stoken;
|
||||||
|
const limit = this.BATCH_SIZE;
|
||||||
|
let done = false;
|
||||||
|
while (!done) {
|
||||||
|
const items = await itemMgr.list({ stoken, limit });
|
||||||
|
store.dispatch(setCacheItemMulti(col.uid, itemMgr, items.data));
|
||||||
|
done = items.done;
|
||||||
|
stoken = items.stoken;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (syncCollection?.stoken !== stoken) {
|
||||||
|
store.dispatch(setSyncCollection(col.uid, stoken!));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchAllCollections() {
|
||||||
|
const storeState = store.getState() as unknown as StoreState;
|
||||||
|
const etebase = (await credentialsSelector(storeState))!;
|
||||||
|
const syncGeneral = storeState.sync.general;
|
||||||
|
|
||||||
|
const colMgr = etebase.getCollectionManager();
|
||||||
|
const limit = this.BATCH_SIZE;
|
||||||
|
let stoken = syncGeneral?.stoken;
|
||||||
|
let done = false;
|
||||||
|
while (!done) {
|
||||||
|
const collections = await colMgr.list(this.COLLECTION_TYPES, { stoken, limit });
|
||||||
|
for (const col of collections.data) {
|
||||||
|
const collectionType = col.getCollectionType();
|
||||||
|
if (this.COLLECTION_TYPES.includes(collectionType)) {
|
||||||
|
store.dispatch(setCacheCollection(colMgr, col));
|
||||||
|
await this.fetchCollection(col);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (collections.removedMemberships) {
|
||||||
|
for (const removed of collections.removedMemberships) {
|
||||||
|
store.dispatch(unsetCacheCollection(colMgr, removed.uid));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
done = collections.done;
|
||||||
|
stoken = collections.stoken;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (syncGeneral?.stoken !== stoken) {
|
||||||
|
store.dispatch(setSyncGeneral(stoken));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sync(alwaysThrowErrors = false) {
|
||||||
|
if (this.isSyncing) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.isSyncing = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stoken = await this.fetchAllCollections();
|
||||||
|
return stoken;
|
||||||
|
} catch (e) {
|
||||||
|
if (alwaysThrowErrors) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e instanceof Etebase.NetworkError || e instanceof Etebase.TemporaryServerError) {
|
||||||
|
// Ignore network errors
|
||||||
|
return null;
|
||||||
|
} else if (e instanceof Etebase.PermissionDeniedError) {
|
||||||
|
store.dispatch(appendError(e));
|
||||||
|
return null;
|
||||||
|
} else if (e instanceof Etebase.HttpError) {
|
||||||
|
store.dispatch(appendError(e));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
this.isSyncing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,198 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
// Disable some style eslint rules for things we can't control
|
||||||
|
/* eslint-disable @typescript-eslint/camelcase, @typescript-eslint/class-name-casing */
|
||||||
|
|
||||||
|
declare module "ical.js" {
|
||||||
|
function parse(input: string): any[];
|
||||||
|
|
||||||
|
export class helpers {
|
||||||
|
static public updateTimezones(vcal: Component): Component;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Component {
|
||||||
|
static public fromString(str: string): Component;
|
||||||
|
|
||||||
|
public name: string;
|
||||||
|
|
||||||
|
constructor(jCal: any[] | string, parent?: Component);
|
||||||
|
|
||||||
|
public toJSON(): any[];
|
||||||
|
|
||||||
|
public getFirstSubcomponent(name?: string): Component | null;
|
||||||
|
public getAllSubcomponents(name?: string): Component[];
|
||||||
|
|
||||||
|
public getFirstPropertyValue<T = any>(name?: string): T;
|
||||||
|
|
||||||
|
public getFirstProperty(name?: string): Property;
|
||||||
|
public getAllProperties(name?: string): Property[];
|
||||||
|
|
||||||
|
public addProperty(property: Property): Property;
|
||||||
|
public addPropertyWithValue(name: string, value: string | number | object): Property;
|
||||||
|
|
||||||
|
public updatePropertyWithValue(name: string, value: string | number | object): Property;
|
||||||
|
|
||||||
|
public removeAllProperties(name?: string): boolean;
|
||||||
|
|
||||||
|
public addSubcomponent(component: Component): Component;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Event {
|
||||||
|
public uid: string;
|
||||||
|
public summary: string;
|
||||||
|
public startDate: Time;
|
||||||
|
public endDate: Time;
|
||||||
|
public description: string;
|
||||||
|
public location: string;
|
||||||
|
public attendees: Property[];
|
||||||
|
|
||||||
|
public component: Component;
|
||||||
|
|
||||||
|
public constructor(component?: Component | null, options?: {strictExceptions: boolean, exepctions: Array<Component | Event>});
|
||||||
|
|
||||||
|
public isRecurring(): boolean;
|
||||||
|
public iterator(startTime?: Time): RecurExpansion;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Property {
|
||||||
|
public name: string;
|
||||||
|
public type: string;
|
||||||
|
|
||||||
|
constructor(jCal: any[] | string, parent?: Component);
|
||||||
|
|
||||||
|
public getFirstValue<T = any>(): T;
|
||||||
|
public getValues<T = any>(): T[];
|
||||||
|
|
||||||
|
public setParameter(name: string, value: string | string[]): void;
|
||||||
|
public setValue(value: string | object): void;
|
||||||
|
public setValues(values: (string | object)[]): void;
|
||||||
|
public toJSON(): any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TimeJsonData {
|
||||||
|
year?: number;
|
||||||
|
month?: number;
|
||||||
|
day?: number;
|
||||||
|
hour?: number;
|
||||||
|
minute?: number;
|
||||||
|
second?: number;
|
||||||
|
isDate?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Time {
|
||||||
|
static public fromString(str: string): Time;
|
||||||
|
static public fromJSDate(aDate: Date | null, useUTC: boolean): Time;
|
||||||
|
static public fromData(aData: TimeJsonData): Time;
|
||||||
|
|
||||||
|
static public now(): Time;
|
||||||
|
|
||||||
|
public isDate: boolean;
|
||||||
|
public timezone: string;
|
||||||
|
public zone: Timezone;
|
||||||
|
|
||||||
|
public year: number;
|
||||||
|
public month: number;
|
||||||
|
public day: number;
|
||||||
|
public hour: number;
|
||||||
|
public minute: number;
|
||||||
|
public second: number;
|
||||||
|
|
||||||
|
constructor(data?: TimeJsonData);
|
||||||
|
public compare(aOther: Time): number;
|
||||||
|
|
||||||
|
public clone(): Time;
|
||||||
|
public convertToZone(zone: Timezone): Time;
|
||||||
|
|
||||||
|
public adjust(
|
||||||
|
aExtraDays: number, aExtraHours: number, aExtraMinutes: number, aExtraSeconds: number, aTimeopt?: Time): void;
|
||||||
|
|
||||||
|
public addDuration(aDuration: Duration): void;
|
||||||
|
public subtractDateTz(aDate: Time): Duration;
|
||||||
|
|
||||||
|
public toUnixTime(): number;
|
||||||
|
public toJSDate(): Date;
|
||||||
|
public toJSON(): TimeJsonData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Duration {
|
||||||
|
public days: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RecurExpansion {
|
||||||
|
public complete: boolean;
|
||||||
|
|
||||||
|
public next(): Time;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Timezone {
|
||||||
|
static public utcTimezone: Timezone;
|
||||||
|
static public localTimezone: Timezone;
|
||||||
|
static public convert_time(tt: Time, fromZone: Timezone, toZone: Timezone): Time;
|
||||||
|
|
||||||
|
|
||||||
|
public tzid: string;
|
||||||
|
public component: Component;
|
||||||
|
|
||||||
|
constructor(data: Component | {
|
||||||
|
component: string | Component;
|
||||||
|
tzid?: string;
|
||||||
|
location?: string;
|
||||||
|
tznames?: string;
|
||||||
|
latitude?: number;
|
||||||
|
longitude?: number;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TimezoneService {
|
||||||
|
static public get(tzid: string): Timezone | null;
|
||||||
|
static public has(tzid: string): boolean;
|
||||||
|
static public register(tzid: string, zone: Timezone | Component);
|
||||||
|
static public remove(tzid: string): Timezone | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FrequencyValues = "YEARLY" | "MONTHLY" | "WEEKLY" | "DAILY" | "HOURLY" | "MINUTELY" | "SECONDLY";
|
||||||
|
|
||||||
|
export enum WeekDay {
|
||||||
|
SU = 1,
|
||||||
|
MO,
|
||||||
|
TU,
|
||||||
|
WE,
|
||||||
|
TH,
|
||||||
|
FR,
|
||||||
|
SA,
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RecurData {
|
||||||
|
public freq?: FrequencyValues;
|
||||||
|
public interval?: number;
|
||||||
|
public wkst?: WeekDay;
|
||||||
|
public until?: Time;
|
||||||
|
public count?: number;
|
||||||
|
public bysecond?: number[] | number;
|
||||||
|
public byminute?: number[] | number;
|
||||||
|
public byhour?: number[] | number;
|
||||||
|
public byday?: string[] | string;
|
||||||
|
public bymonthday?: number[] | number;
|
||||||
|
public byyearday?: number[] | number;
|
||||||
|
public byweekno?: number[] | number;
|
||||||
|
public bymonth?: number[] | number;
|
||||||
|
public bysetpos?: number[] | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RecurIterator {
|
||||||
|
public next(): Time;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Recur {
|
||||||
|
constructor(data?: RecurData);
|
||||||
|
public until: Time | null;
|
||||||
|
public freq: FrequencyValues;
|
||||||
|
public count: number | null;
|
||||||
|
|
||||||
|
public clone(): Recur;
|
||||||
|
public toJSON(): Omit<RecurData, "until"> & { until?: string };
|
||||||
|
public iterator(startTime?: Time): RecurIterator;
|
||||||
|
public isByCount(): boolean;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,6 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
declare module "redux-persist";
|
||||||
|
declare module "redux-persist/lib/storage/session";
|
||||||
|
declare module "redux-persist/es/integration/react";
|
@ -0,0 +1,23 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as ReactDOM from "react-dom";
|
||||||
|
|
||||||
|
export default (props: {title: string, children?: React.ReactNode | React.ReactNode[]}) => {
|
||||||
|
const titleEl = document.querySelector("#appbar-title");
|
||||||
|
const buttonsEl = document.querySelector("#appbar-buttons");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{titleEl && ReactDOM.createPortal(
|
||||||
|
<span>{props.title}</span>,
|
||||||
|
titleEl
|
||||||
|
)}
|
||||||
|
{buttonsEl && props.children && ReactDOM.createPortal(
|
||||||
|
props.children,
|
||||||
|
buttonsEl
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,26 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
export const Avatar = React.memo((props: { children: React.ReactNode[] | React.ReactNode, size?: number, style?: any }) => {
|
||||||
|
const size = (props.size) ? props.size : 40;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: "grey",
|
||||||
|
color: "white",
|
||||||
|
display: "inline-flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
borderRadius: "50%",
|
||||||
|
height: size,
|
||||||
|
width: size,
|
||||||
|
...props.style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
@ -0,0 +1,18 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
interface PropsType {
|
||||||
|
color: string;
|
||||||
|
size?: number | string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ColorBox(props: PropsType) {
|
||||||
|
const size = props.size ?? 64;
|
||||||
|
const style = { ...props.style, backgroundColor: props.color, width: size, height: size };
|
||||||
|
return (
|
||||||
|
<div style={style} />
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,71 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import ColorBox from "./ColorBox";
|
||||||
|
import { TextField, ButtonBase } from "@material-ui/core";
|
||||||
|
|
||||||
|
interface PropsType {
|
||||||
|
color: string;
|
||||||
|
defaultColor: string;
|
||||||
|
label?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
error?: string;
|
||||||
|
onChange: (color: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default function ColorPicker(props: PropsType) {
|
||||||
|
const colors = [
|
||||||
|
[
|
||||||
|
"#F44336",
|
||||||
|
"#E91E63",
|
||||||
|
"#673AB7",
|
||||||
|
"#3F51B5",
|
||||||
|
"#2196F3",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#03A9F4",
|
||||||
|
"#4CAF50",
|
||||||
|
"#8BC34A",
|
||||||
|
"#FFEB3B",
|
||||||
|
"#FF9800",
|
||||||
|
],
|
||||||
|
];
|
||||||
|
const color = props.color;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{colors.map((colorGroup, idx) => (
|
||||||
|
<div key={idx} style={{ flex: 1, flexDirection: "row", justifyContent: "space-between" }}>
|
||||||
|
{colorGroup.map((colorOption) => (
|
||||||
|
<ButtonBase
|
||||||
|
style={{ margin: 5, borderRadius: 36 / 2 }}
|
||||||
|
key={colorOption}
|
||||||
|
onClick={() => props.onChange(colorOption)}
|
||||||
|
>
|
||||||
|
<ColorBox size={36} style={{ borderRadius: 36 / 2 }} color={colorOption} />
|
||||||
|
</ButtonBase>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div style={{ flex: 1, alignItems: "center", flexDirection: "row", margin: 5 }}>
|
||||||
|
<ColorBox
|
||||||
|
style={{ display: "inline-block" }}
|
||||||
|
size={36}
|
||||||
|
color={color}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
style={{ marginLeft: 10, flex: 1 }}
|
||||||
|
error={!!props.error}
|
||||||
|
onChange={(event: React.FormEvent<{ value: string }>) => props.onChange(event.currentTarget.value)}
|
||||||
|
placeholder={props.placeholder ?? "E.g. #aabbcc"}
|
||||||
|
label={props.label ?? "Color"}
|
||||||
|
value={color}
|
||||||
|
helperText={props.error}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,32 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { makeStyles } from "@material-ui/core/styles";
|
||||||
|
import Radio from "@material-ui/core/Radio";
|
||||||
|
import { Omit } from "@material-ui/types";
|
||||||
|
import FormControlLabel, { FormControlLabelProps } from "@material-ui/core/FormControlLabel";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
color: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useStyles = makeStyles({
|
||||||
|
root: {
|
||||||
|
color: (props: Props) => props.color,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function ColoredRadio(props: Props & Omit<FormControlLabelProps, keyof Props | "control">) {
|
||||||
|
const { color, label, value, ...other } = props;
|
||||||
|
const { root } = useStyles(props);
|
||||||
|
|
||||||
|
return <FormControlLabel
|
||||||
|
className={root}
|
||||||
|
label={label}
|
||||||
|
control={<Radio color="default" className={root} value={value} />}
|
||||||
|
{...other}
|
||||||
|
/>;
|
||||||
|
}
|
@ -0,0 +1,49 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import Dialog from "@material-ui/core/Dialog";
|
||||||
|
import DialogTitle from "@material-ui/core/DialogTitle";
|
||||||
|
import DialogContent from "@material-ui/core/DialogContent";
|
||||||
|
import DialogActions from "@material-ui/core/DialogActions";
|
||||||
|
import Button from "@material-ui/core/Button";
|
||||||
|
|
||||||
|
export default React.memo((_props: any) => {
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
onCancel,
|
||||||
|
onOk,
|
||||||
|
labelOk,
|
||||||
|
...props
|
||||||
|
} = _props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
onClose={onCancel}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<DialogTitle>
|
||||||
|
{title}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
{children}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
onClick={onOk}
|
||||||
|
>
|
||||||
|
{labelOk || "Confirm"}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
});
|
@ -0,0 +1,25 @@
|
|||||||
|
.Container {
|
||||||
|
margin-right: auto;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.Container {
|
||||||
|
padding-top: 40px;
|
||||||
|
padding-bottom: 40px;
|
||||||
|
width: 750px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (min-width: 992px) {
|
||||||
|
.Container {
|
||||||
|
width: 970px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
.Container {
|
||||||
|
width: 1170px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.Container-inner {
|
||||||
|
padding: 15px;
|
||||||
|
}
|
@ -0,0 +1,22 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import Paper from "@material-ui/core/Paper";
|
||||||
|
|
||||||
|
import "./Container.css";
|
||||||
|
|
||||||
|
export default (props: {style?: React.CSSProperties, children: any}) => {
|
||||||
|
const display = props.style?.display;
|
||||||
|
const flexDirection = props.style?.flexDirection;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="Container" style={props.style}>
|
||||||
|
<Paper elevation={3} style={{ display, flexDirection, flexGrow: 1 }}>
|
||||||
|
<div className="Container-inner" style={{ display, flexDirection, flexGrow: 1 }}>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
</Paper>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,52 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import MomentUtils from "@date-io/moment";
|
||||||
|
import { MuiPickersUtilsProvider, KeyboardDatePicker, KeyboardDateTimePicker } from "@material-ui/pickers";
|
||||||
|
|
||||||
|
import moment from "moment";
|
||||||
|
|
||||||
|
interface PropsType {
|
||||||
|
placeholder: string;
|
||||||
|
value?: Date;
|
||||||
|
dateOnly?: boolean;
|
||||||
|
onChange: (date?: Date) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
class DateTimePicker extends React.PureComponent<PropsType> {
|
||||||
|
constructor(props: any) {
|
||||||
|
super(props);
|
||||||
|
this.handleInputChange = this.handleInputChange.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public render() {
|
||||||
|
const Picker = (this.props.dateOnly) ? KeyboardDatePicker : KeyboardDateTimePicker;
|
||||||
|
const dateFormat = (this.props.dateOnly) ? "L" : "L LT";
|
||||||
|
return (
|
||||||
|
<MuiPickersUtilsProvider utils={MomentUtils}>
|
||||||
|
<Picker
|
||||||
|
value={this.props.value || null}
|
||||||
|
onChange={this.handleInputChange}
|
||||||
|
format={dateFormat}
|
||||||
|
ampm={false}
|
||||||
|
showTodayButton
|
||||||
|
KeyboardButtonProps={{
|
||||||
|
"aria-label": "change date",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</MuiPickersUtilsProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleInputChange(date: moment.Moment) {
|
||||||
|
if (moment.isMoment(date)) {
|
||||||
|
this.props.onChange(date.toDate());
|
||||||
|
} else {
|
||||||
|
this.props.onChange(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DateTimePicker;
|
@ -0,0 +1,12 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
export const ExternalLink = React.memo(({ children, ...props }: any) => (
|
||||||
|
<a target="_blank" rel="noopener noreferrer" {...props}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
));
|
||||||
|
|
||||||
|
export default ExternalLink;
|
@ -0,0 +1,113 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { createStyles, makeStyles } from "@material-ui/core/styles";
|
||||||
|
import MuiList from "@material-ui/core/List";
|
||||||
|
import MuiListItem from "@material-ui/core/ListItem";
|
||||||
|
import MuiListItemSecondaryAction from "@material-ui/core/ListItemSecondaryAction";
|
||||||
|
import MuiListSubheader from "@material-ui/core/ListSubheader";
|
||||||
|
import ListItemIcon from "@material-ui/core/ListItemIcon";
|
||||||
|
import ListItemText from "@material-ui/core/ListItemText";
|
||||||
|
import Divider from "@material-ui/core/Divider";
|
||||||
|
|
||||||
|
import ExternalLink from "./ExternalLink";
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme) => (createStyles({
|
||||||
|
inset: {
|
||||||
|
marginLeft: 64,
|
||||||
|
},
|
||||||
|
nested: {
|
||||||
|
paddingLeft: theme.spacing(4),
|
||||||
|
},
|
||||||
|
})));
|
||||||
|
|
||||||
|
export const List = MuiList;
|
||||||
|
|
||||||
|
export const ListSubheader = MuiListSubheader;
|
||||||
|
|
||||||
|
export const ListDivider = React.memo(function ListDivider(props: { inset?: boolean }) {
|
||||||
|
const classes = useStyles();
|
||||||
|
const insetClass = (props.inset) ? classes.inset : undefined;
|
||||||
|
return (
|
||||||
|
<Divider className={insetClass} />
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface ListItemPropsType {
|
||||||
|
leftIcon?: React.ReactElement;
|
||||||
|
rightIcon?: React.ReactElement;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
primaryText?: string;
|
||||||
|
secondaryText?: string;
|
||||||
|
children?: React.ReactNode | React.ReactNode[];
|
||||||
|
onClick?: () => void;
|
||||||
|
href?: string;
|
||||||
|
insetChildren?: boolean;
|
||||||
|
nestedItems?: React.ReactNode[];
|
||||||
|
selected?: boolean;
|
||||||
|
secondaryTextColor?: "initial" | "inherit" | "primary" | "secondary" | "textPrimary" | "textSecondary" | "error";
|
||||||
|
secondaryAction?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ListItem = React.memo(function ListItem(_props: ListItemPropsType) {
|
||||||
|
const classes = useStyles();
|
||||||
|
const {
|
||||||
|
leftIcon,
|
||||||
|
rightIcon,
|
||||||
|
primaryText,
|
||||||
|
secondaryText,
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
href,
|
||||||
|
style,
|
||||||
|
insetChildren,
|
||||||
|
nestedItems,
|
||||||
|
selected,
|
||||||
|
secondaryTextColor,
|
||||||
|
secondaryAction,
|
||||||
|
} = _props;
|
||||||
|
|
||||||
|
const extraProps = (onClick || href) ? {
|
||||||
|
button: true,
|
||||||
|
href,
|
||||||
|
onClick,
|
||||||
|
component: (href) ? ExternalLink : "div",
|
||||||
|
} : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MuiListItem
|
||||||
|
style={style}
|
||||||
|
onClick={onClick}
|
||||||
|
selected={selected}
|
||||||
|
{...(extraProps as any)}
|
||||||
|
>
|
||||||
|
{leftIcon && (
|
||||||
|
<ListItemIcon>
|
||||||
|
{leftIcon}
|
||||||
|
</ListItemIcon>
|
||||||
|
)}
|
||||||
|
<ListItemText inset={insetChildren} primary={primaryText} secondary={secondaryText} secondaryTypographyProps={{ color: secondaryTextColor }}>
|
||||||
|
{children}
|
||||||
|
</ListItemText>
|
||||||
|
{rightIcon && (
|
||||||
|
<ListItemIcon>
|
||||||
|
{rightIcon}
|
||||||
|
</ListItemIcon>
|
||||||
|
)}
|
||||||
|
{secondaryAction && (
|
||||||
|
<MuiListItemSecondaryAction>
|
||||||
|
{secondaryAction}
|
||||||
|
</MuiListItemSecondaryAction>
|
||||||
|
)}
|
||||||
|
</MuiListItem>
|
||||||
|
{nestedItems && (
|
||||||
|
<List className={classes.nested} disablePadding>
|
||||||
|
{nestedItems}
|
||||||
|
</List>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue