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