Initial commit
4
.browserslistrc
Normal file
|
@ -0,0 +1,4 @@
|
|||
> 0.5%
|
||||
last 3 versions
|
||||
firefox esr
|
||||
not dead
|
5
.editorconfig
Normal file
|
@ -0,0 +1,5 @@
|
|||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
5
.env
Normal file
|
@ -0,0 +1,5 @@
|
|||
# Default environment variables
|
||||
# https://cli.vuejs.org/guide/mode-and-env.html
|
||||
|
||||
VUE_APP_BACKEND_URL=http://localhost:8380
|
||||
PORT=8381
|
50
.eslintrc.js
Normal file
|
@ -0,0 +1,50 @@
|
|||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
},
|
||||
extends: [
|
||||
"plugin:vue/vue3-essential",
|
||||
"@vue/standard",
|
||||
"@vue/typescript/recommended",
|
||||
],
|
||||
parserOptions: {
|
||||
ecmaVersion: 2020,
|
||||
},
|
||||
rules: {
|
||||
quotes: [
|
||||
"error",
|
||||
"double",
|
||||
{
|
||||
avoidEscape: true,
|
||||
},
|
||||
],
|
||||
camelcase: "off",
|
||||
"comma-dangle": [
|
||||
"error",
|
||||
"always-multiline",
|
||||
],
|
||||
"space-before-function-paren": ["error", {
|
||||
anonymous: "always",
|
||||
named: "never",
|
||||
}],
|
||||
"padded-blocks": ["error", {
|
||||
classes: "always",
|
||||
}],
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/explicit-module-boundary-types": "off",
|
||||
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
|
||||
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: [
|
||||
"**/__tests__/*.{j,t}s?(x)",
|
||||
"**/tests/unit/**/*.spec.{j,t}s?(x)",
|
||||
],
|
||||
env: {
|
||||
mocha: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
23
.gitignore
vendored
Normal file
|
@ -0,0 +1,23 @@
|
|||
.DS_Store
|
||||
node_modules
|
||||
/dist
|
||||
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Log files
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
.vscode
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
1
.npmrc
Normal file
|
@ -0,0 +1 @@
|
|||
ignore-scripts = true
|
12
.stylelintrc.json
Normal file
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"extends": "stylelint-config-sass-guidelines",
|
||||
"rules": {
|
||||
"color-hex-case": null,
|
||||
"color-hex-length": null,
|
||||
"declaration-property-value-disallowed-list": {},
|
||||
"max-nesting-depth": 3,
|
||||
"selector-max-id": 1,
|
||||
"selector-no-qualifying-type": null,
|
||||
"string-quotes": "double"
|
||||
}
|
||||
}
|
661
LICENSE
Normal file
|
@ -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/>.
|
40
README.md
Normal file
|
@ -0,0 +1,40 @@
|
|||
# Mitra Web
|
||||
|
||||
Default frontend for [Mitra](https://codeberg.org/silverpill/mitra).
|
||||
|
||||
## Requirements
|
||||
|
||||
- node 12+
|
||||
- npm 7+
|
||||
|
||||
## Project setup
|
||||
|
||||
```
|
||||
npm install
|
||||
npx allow-scripts
|
||||
```
|
||||
|
||||
### Compiles and hot-reloads for development
|
||||
|
||||
```
|
||||
npm start
|
||||
```
|
||||
|
||||
### Compile and minify for production
|
||||
|
||||
```
|
||||
echo "VUE_APP_BACKEND_URL=https://mydomain.tld" > .env.local
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Run your unit tests
|
||||
|
||||
```
|
||||
npm run test
|
||||
```
|
||||
|
||||
### Lint files
|
||||
|
||||
```
|
||||
npm run lint
|
||||
```
|
39215
package-lock.json
generated
Normal file
72
package.json
Normal file
|
@ -0,0 +1,72 @@
|
|||
{
|
||||
"name": "mitra-web",
|
||||
"version": "0.1.0",
|
||||
"description": "Mitra web UI",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve",
|
||||
"build": "vue-cli-service build",
|
||||
"test:unit": "vue-cli-service test:unit",
|
||||
"test": "npm run test:unit",
|
||||
"lint:js": "vue-cli-service lint --no-fix",
|
||||
"lint:css": "stylelint 'src/**/*.{vue,scss}'",
|
||||
"lint": "npm run lint:js && npm run lint:css",
|
||||
"start": "npm run serve"
|
||||
},
|
||||
"dependencies": {
|
||||
"ethereum-blockies-base64": "^1.0.2",
|
||||
"ethers": "^5.1.2",
|
||||
"luxon": "^1.26.0",
|
||||
"markdown-it": "^12.2.0",
|
||||
"markdown-it-link-attributes": "^3.0.0",
|
||||
"vue": "^3.2.0",
|
||||
"vue-class-component": "^8.0.0-0",
|
||||
"vue-property-decorator": "^10.0.0-rc.3",
|
||||
"vue-router": "^4.0.0-0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lavamoat/allow-scripts": "^1.0.6",
|
||||
"@lavamoat/preinstall-always-fail": "^1.0.0",
|
||||
"@types/chai": "^4.2.11",
|
||||
"@types/luxon": "^1.26.5",
|
||||
"@types/mocha": "^5.2.4",
|
||||
"@types/sinon": "^10.0.4",
|
||||
"@types/sinon-chai": "^3.2.5",
|
||||
"@typescript-eslint/eslint-plugin": "^4.18.0",
|
||||
"@typescript-eslint/parser": "^4.18.0",
|
||||
"@vue/cli-plugin-eslint": "~4.5.0",
|
||||
"@vue/cli-plugin-router": "~4.5.0",
|
||||
"@vue/cli-plugin-typescript": "~4.5.0",
|
||||
"@vue/cli-plugin-unit-mocha": "~4.5.0",
|
||||
"@vue/cli-service": "~4.5.0",
|
||||
"@vue/compiler-sfc": "^3.0.0",
|
||||
"@vue/eslint-config-standard": "^5.1.2",
|
||||
"@vue/eslint-config-typescript": "^7.0.0",
|
||||
"@vue/test-utils": "^2.0.0-0",
|
||||
"chai": "^4.1.2",
|
||||
"eslint": "^6.7.2",
|
||||
"eslint-plugin-import": "^2.20.2",
|
||||
"eslint-plugin-node": "^11.1.0",
|
||||
"eslint-plugin-promise": "^4.2.1",
|
||||
"eslint-plugin-standard": "^4.0.0",
|
||||
"eslint-plugin-vue": "^7.0.0",
|
||||
"node-sass": "^4.12.0",
|
||||
"sass-loader": "^8.0.2",
|
||||
"sinon": "^11.1.2",
|
||||
"sinon-chai": "^3.7.0",
|
||||
"stylelint": "^13.13.1",
|
||||
"stylelint-config-sass-guidelines": "^8.0.0",
|
||||
"typescript": "~4.1.5"
|
||||
},
|
||||
"lavamoat": {
|
||||
"allowScripts": {
|
||||
"@lavamoat/preinstall-always-fail": false,
|
||||
"yorkie": false,
|
||||
"core-js": false,
|
||||
"nodent-runtime": false,
|
||||
"ejs": false,
|
||||
"node-sass": true
|
||||
}
|
||||
}
|
||||
}
|
BIN
public/favicon.ico
Normal file
After Width: | Height: | Size: 4.2 KiB |
17
public/index.html
Normal file
|
@ -0,0 +1,17 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
|
||||
<title><%= htmlWebpackPlugin.options.title %></title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
|
||||
</noscript>
|
||||
<div id="app"></div>
|
||||
<!-- built files will be auto injected -->
|
||||
</body>
|
||||
</html>
|
352
src/App.vue
Normal file
|
@ -0,0 +1,352 @@
|
|||
<template>
|
||||
<header v-if="!isPublicPage">
|
||||
<div id="header">
|
||||
<div id="nav">
|
||||
<router-link to="/" class="home-btn">
|
||||
<img :src="require('@/assets/feather/home.svg')">
|
||||
<span>Home</span>
|
||||
</router-link>
|
||||
<search />
|
||||
</div>
|
||||
<div id="profile">
|
||||
<router-link v-if="profile" class="profile-link" :to="{ name: 'profile', params: { profileId: profile.id }}">
|
||||
<avatar :profile="profile"></avatar>
|
||||
<div class="profile-name">@{{ profile.username }}</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<router-view :key="$route.fullPath" :class="{'wide': isPublicPage}" />
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Options, Vue, setup } from "vue-class-component"
|
||||
|
||||
import { User } from "@/api/users"
|
||||
import { useCurrentUser } from "@/store/user"
|
||||
import { useInstanceInfo } from "@/store/instance"
|
||||
import Avatar from "@/components/Avatar.vue"
|
||||
import Search from "@/components/Search.vue"
|
||||
|
||||
@Options({
|
||||
components: {
|
||||
Avatar,
|
||||
Search,
|
||||
},
|
||||
})
|
||||
export default class App extends Vue {
|
||||
|
||||
private store = setup(() => {
|
||||
const { currentUser } = useCurrentUser()
|
||||
const { loadInstanceInfo } = useInstanceInfo()
|
||||
return { currentUser, loadInstanceInfo }
|
||||
})
|
||||
|
||||
created() {
|
||||
this.store.loadInstanceInfo()
|
||||
}
|
||||
|
||||
get isPublicPage(): boolean {
|
||||
return (
|
||||
this.store.currentUser === null ||
|
||||
this.$route.name === "post-overlay"
|
||||
)
|
||||
}
|
||||
|
||||
get profile(): User | null {
|
||||
return this.store.currentUser
|
||||
}
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import "styles/reset";
|
||||
@import "styles/layout";
|
||||
@import "styles/theme";
|
||||
|
||||
html {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background: $background-color;
|
||||
color: $text-color;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: $text-font-size;
|
||||
}
|
||||
|
||||
a {
|
||||
color: $link-color;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
color: $link-hover-color;
|
||||
}
|
||||
}
|
||||
|
||||
.static-text p {
|
||||
a {
|
||||
text-decoration: underline;
|
||||
text-decoration-skip-ink: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
background-color: $block-background-color;
|
||||
border: 1px solid $block-background-color;
|
||||
box-shadow: none;
|
||||
box-sizing: border-box;
|
||||
color: $text-color;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: $text-font-size;
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
width: 100%;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: $secondary-text-color;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
input[type="file"] {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
padding: 2px 1px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.btn {
|
||||
background-color: $btn-background-color;
|
||||
border: none;
|
||||
border-radius: $btn-border-radius;
|
||||
color: $btn-text-color;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
font-size: $text-font-size;
|
||||
font-weight: bold;
|
||||
padding: 10px 30px;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
background-color: $btn-background-hover-color;
|
||||
color: $btn-text-hover-color;
|
||||
}
|
||||
}
|
||||
|
||||
header {
|
||||
background-color: $background-color;
|
||||
box-sizing: border-box;
|
||||
height: $header-height;
|
||||
margin-bottom: $block-outer-padding;
|
||||
padding: $body-padding;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
#header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: $content-gap;
|
||||
height: 100%;
|
||||
margin: 0 auto;
|
||||
max-width: $content-width + $content-gap + $sidebar-width;
|
||||
}
|
||||
|
||||
#nav {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: $body-padding;
|
||||
min-width: $content-min-width;
|
||||
width: $content-width;
|
||||
|
||||
.home-btn {
|
||||
align-items: center;
|
||||
background-color: $block-background-color;
|
||||
border-radius: $btn-border-radius;
|
||||
box-shadow: $shadow;
|
||||
box-sizing: border-box;
|
||||
color: $btn-text-color;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-shrink: 0;
|
||||
height: 100%;
|
||||
padding: 7px $body-padding;
|
||||
|
||||
img {
|
||||
filter: $btn-text-colorizer;
|
||||
height: 1.2em;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
span {
|
||||
padding-top: 1px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: $btn-text-color;
|
||||
color: $btn-text-hover-color;
|
||||
|
||||
img {
|
||||
filter: $btn-text-hover-colorizer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search {
|
||||
background-color: $block-background-color;
|
||||
box-shadow: $shadow;
|
||||
height: 100%;
|
||||
margin: 0 0 0 auto;
|
||||
width: 250px;
|
||||
}
|
||||
}
|
||||
|
||||
#profile {
|
||||
flex-shrink: 0;
|
||||
width: $sidebar-width;
|
||||
|
||||
.profile-link {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.profile-name {
|
||||
margin-left: 10px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
height: $avatar-size;
|
||||
width: $avatar-size;
|
||||
}
|
||||
}
|
||||
|
||||
#main {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: $content-gap;
|
||||
margin: 0 auto;
|
||||
max-width: $content-width + $content-gap + $sidebar-width;
|
||||
}
|
||||
|
||||
#main:not(.wide) {
|
||||
padding: 0 $body-padding;
|
||||
}
|
||||
|
||||
.content {
|
||||
max-width: $content-width;
|
||||
min-width: $content-min-width;
|
||||
width: $content-width;
|
||||
|
||||
h1 {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
margin: 0 0 $block-outer-padding * 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.wide {
|
||||
/* Reserve space for floating avatar */
|
||||
padding: 0 $content-gap * 1.5;
|
||||
}
|
||||
|
||||
#main.wide {
|
||||
/* main element should not have top padding to make scrollTo impl simpler */
|
||||
margin-top: $content-gap;
|
||||
max-width: $wide-content-width + $content-gap + $wide-sidebar-width;
|
||||
|
||||
.content {
|
||||
max-width: $wide-content-width;
|
||||
min-width: $content-min-width;
|
||||
width: $wide-content-width;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: $screen-breakpoint-medium) {
|
||||
#header,
|
||||
#main {
|
||||
/* Equal to header's bottom padding + margin */
|
||||
gap: $block-outer-padding + $body-padding;
|
||||
}
|
||||
|
||||
.wide {
|
||||
padding: $body-padding;
|
||||
}
|
||||
|
||||
#main.wide {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: $screen-breakpoint-small) {
|
||||
header {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
#header {
|
||||
gap: $body-padding;
|
||||
}
|
||||
|
||||
#nav {
|
||||
min-width: auto;
|
||||
width: 100%;
|
||||
|
||||
.search {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
#profile {
|
||||
width: auto;
|
||||
|
||||
.profile-name {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
#main {
|
||||
flex-direction: column-reverse;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
#main .content,
|
||||
#main.wide .content {
|
||||
max-width: none;
|
||||
min-width: auto;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: $screen-breakpoint-x-small) {
|
||||
#nav .home-btn {
|
||||
img {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
58
src/api/common.ts
Normal file
|
@ -0,0 +1,58 @@
|
|||
import { ENV } from "@/constants"
|
||||
|
||||
// Wrapped in object for easy stubbing in tests
|
||||
export const fetcher = {
|
||||
async fetch(url: string, params: RequestInit): Promise<Response> {
|
||||
return await window.fetch(url, params)
|
||||
},
|
||||
}
|
||||
|
||||
interface RequestInfo extends RequestInit {
|
||||
authToken?: string | null;
|
||||
json?: any;
|
||||
queryParams?: { [name: string]: string };
|
||||
}
|
||||
|
||||
export async function http(
|
||||
url: string | URL,
|
||||
requestInfo?: RequestInfo,
|
||||
): Promise<Response> {
|
||||
const defaults: RequestInit = {}
|
||||
if (ENV === "development") {
|
||||
// Development mode
|
||||
defaults.credentials = "include"
|
||||
} else {
|
||||
defaults.credentials = "same-origin"
|
||||
}
|
||||
|
||||
let params: RequestInit
|
||||
if (!requestInfo) {
|
||||
params = { ...defaults }
|
||||
} else {
|
||||
const { authToken, json, queryParams, ...requestParams } = { ...requestInfo }
|
||||
if (authToken) {
|
||||
requestParams.headers = {
|
||||
...requestParams.headers,
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
}
|
||||
}
|
||||
if (json) {
|
||||
requestParams.body = JSON.stringify(json)
|
||||
requestParams.headers = {
|
||||
...requestParams.headers,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
}
|
||||
if (queryParams) {
|
||||
if (!(url instanceof URL)) {
|
||||
// Convert URL string to URL object
|
||||
url = new URL(url, window.location.origin)
|
||||
}
|
||||
url.search = new URLSearchParams(queryParams).toString()
|
||||
}
|
||||
params = { ...defaults, ...requestParams }
|
||||
}
|
||||
|
||||
const response = await fetcher.fetch(url as string, params)
|
||||
return response
|
||||
}
|
22
src/api/instance.ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
import { BACKEND_URL } from "@/constants"
|
||||
import { http } from "./common"
|
||||
|
||||
export interface InstanceInfo {
|
||||
uri: string;
|
||||
title: string;
|
||||
short_description: string;
|
||||
description: string;
|
||||
registrations: boolean;
|
||||
login_message: string;
|
||||
ethereum_explorer_url: string | null;
|
||||
nft_contract_name: string | null;
|
||||
nft_contract_address: string | null;
|
||||
ipfs_gateway_url: string | null;
|
||||
}
|
||||
|
||||
export async function getInstanceInfo(): Promise<InstanceInfo> {
|
||||
const url = `${BACKEND_URL}/api/v1/instance`
|
||||
const response = await http(url)
|
||||
const data = await response.json()
|
||||
return data
|
||||
}
|
42
src/api/markers.ts
Normal file
|
@ -0,0 +1,42 @@
|
|||
import { BACKEND_URL } from "@/constants"
|
||||
|
||||
import { http } from "./common"
|
||||
|
||||
export interface Marker {
|
||||
last_read_id: string;
|
||||
version: number;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export async function getNotificationMarker(
|
||||
authToken: string,
|
||||
): Promise<Marker | null> {
|
||||
const url = `${BACKEND_URL}/api/v1/markers`
|
||||
const response = await http(url, {
|
||||
method: "GET",
|
||||
queryParams: { "timeline[]": "notifications" },
|
||||
authToken,
|
||||
})
|
||||
const data = await response.json()
|
||||
if (response.status !== 200) {
|
||||
throw new Error(data.message)
|
||||
}
|
||||
return data.notifications
|
||||
}
|
||||
|
||||
export async function updateNotificationMarker(
|
||||
authToken: string,
|
||||
lastReadId: string,
|
||||
): Promise<Marker> {
|
||||
const url = `${BACKEND_URL}/api/v1/markers`
|
||||
const response = await http(url, {
|
||||
method: "POST",
|
||||
json: { "notifications[last_read_id]": lastReadId },
|
||||
authToken,
|
||||
})
|
||||
const data = await response.json()
|
||||
if (response.status !== 200) {
|
||||
throw new Error(data.message)
|
||||
}
|
||||
return data.notifications
|
||||
}
|
91
src/api/nft.ts
Normal file
|
@ -0,0 +1,91 @@
|
|||
import { Contract, Signer } from "ethers"
|
||||
import { TransactionResponse } from "@ethersproject/abstract-provider"
|
||||
|
||||
import { BACKEND_URL } from "@/constants"
|
||||
import { http } from "./common"
|
||||
import { Post } from "./posts"
|
||||
|
||||
export async function makePermanent(
|
||||
authToken: string,
|
||||
postId: string,
|
||||
): Promise<Post> {
|
||||
const url = `${BACKEND_URL}/api/v1/statuses/${postId}/make_permanent`
|
||||
const response = await http(url, {
|
||||
method: "POST",
|
||||
authToken,
|
||||
})
|
||||
const data = await response.json()
|
||||
if (response.status !== 200) {
|
||||
throw new Error(data.message)
|
||||
} else {
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
interface Signature {
|
||||
v: number;
|
||||
r: string;
|
||||
s: string;
|
||||
}
|
||||
|
||||
export async function getSignature(
|
||||
authToken: string,
|
||||
postId: string,
|
||||
): Promise<Signature> {
|
||||
const url = `${BACKEND_URL}/api/v1/statuses/${postId}/signature`
|
||||
const response = await http(url, {
|
||||
method: "GET",
|
||||
authToken,
|
||||
})
|
||||
const data = await response.json()
|
||||
if (response.status !== 200) {
|
||||
throw new Error(data.message)
|
||||
} else {
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
export async function getContractAbi(contractName: string): Promise<any> {
|
||||
// TODO: take artifact URL from instance config
|
||||
const url = `${BACKEND_URL}/contracts/${contractName}.json`
|
||||
const response = await http(url, {
|
||||
method: "GET",
|
||||
})
|
||||
const data = await response.json()
|
||||
return data.abi
|
||||
}
|
||||
|
||||
export interface TokenMetadata {
|
||||
name: string;
|
||||
description: string;
|
||||
image: string;
|
||||
external_url: string;
|
||||
}
|
||||
|
||||
export async function mintToken(
|
||||
contractName: string,
|
||||
contractAddress: string,
|
||||
ownerAddress: string,
|
||||
tokenUri: string,
|
||||
serverSignature: Signature,
|
||||
signer: Signer,
|
||||
): Promise<TransactionResponse> {
|
||||
const Minter = await getContractAbi(contractName)
|
||||
const minter = new Contract(contractAddress, Minter, signer)
|
||||
const transaction = await minter.mint(
|
||||
ownerAddress,
|
||||
tokenUri,
|
||||
serverSignature.v,
|
||||
"0x" + serverSignature.r,
|
||||
"0x" + serverSignature.s,
|
||||
)
|
||||
return transaction
|
||||
}
|
||||
|
||||
export async function getTokenMetadata(url: string): Promise<TokenMetadata> {
|
||||
const response = await http(url, {
|
||||
method: "GET",
|
||||
credentials: "omit",
|
||||
})
|
||||
return await response.json()
|
||||
}
|
28
src/api/notifications.ts
Normal file
|
@ -0,0 +1,28 @@
|
|||
import { BACKEND_URL } from "@/constants"
|
||||
|
||||
import { http } from "./common"
|
||||
import { Post } from "./posts"
|
||||
import { Profile } from "./users"
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
type: "follow" | "reply" | "favourite";
|
||||
account: Profile;
|
||||
status: Post | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export async function getNotifications(
|
||||
authToken: string,
|
||||
): Promise<Notification[]> {
|
||||
const url = `${BACKEND_URL}/api/v1/notifications`
|
||||
const response = await http(url, {
|
||||
method: "GET",
|
||||
authToken,
|
||||
})
|
||||
const data = await response.json()
|
||||
if (response.status !== 200) {
|
||||
throw new Error(data.message)
|
||||
}
|
||||
return data
|
||||
}
|
166
src/api/posts.ts
Normal file
|
@ -0,0 +1,166 @@
|
|||
import { BACKEND_URL } from "@/constants"
|
||||
import { http } from "./common"
|
||||
import { Profile } from "./users"
|
||||
|
||||
export interface Attachment {
|
||||
id: string;
|
||||
type: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export async function uploadAttachment(
|
||||
authToken: string,
|
||||
base64data: string,
|
||||
): Promise<Attachment> {
|
||||
const url = `${BACKEND_URL}/api/v1/media`
|
||||
const response = await http(url, {
|
||||
method: "POST",
|
||||
json: { file: base64data },
|
||||
authToken,
|
||||
})
|
||||
const data = await response.json()
|
||||
if (response.status !== 200) {
|
||||
throw new Error(data.message)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
export interface Post {
|
||||
id: string;
|
||||
uri: string;
|
||||
created_at: string;
|
||||
account: Profile,
|
||||
content: string;
|
||||
in_reply_to_id: string | null;
|
||||
reblog: Post | null;
|
||||
replies_count: number;
|
||||
favourites_count: number;
|
||||
reblogs_count: number;
|
||||
media_attachments: Attachment[];
|
||||
favourited: boolean;
|
||||
reblogged: boolean;
|
||||
ipfs_cid: string | null;
|
||||
token_id: number | null;
|
||||
token_tx_id: string | null;
|
||||
}
|
||||
|
||||
export async function getPosts(authToken: string): Promise<Post[]> {
|
||||
const url = `${BACKEND_URL}/api/v1/timelines/home`
|
||||
const response = await http(url, { authToken })
|
||||
const data = await response.json()
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getPostsByAuthor(
|
||||
authToken: string | null,
|
||||
authorId: string,
|
||||
): Promise<Post[]> {
|
||||
const url = `${BACKEND_URL}/api/v1/accounts/${authorId}/statuses`
|
||||
const response = await http(url, { authToken })
|
||||
const data = await response.json()
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getPostContext(
|
||||
authToken: string | null,
|
||||
postId: string,
|
||||
): Promise<Post[]> {
|
||||
const url = `${BACKEND_URL}/api/v1/statuses/${postId}/context`
|
||||
const response = await http(url, { authToken })
|
||||
const data = await response.json()
|
||||
return data
|
||||
}
|
||||
|
||||
export interface PostData {
|
||||
content: string;
|
||||
in_reply_to_id: string | null;
|
||||
}
|
||||
|
||||
export async function createPost(
|
||||
authToken: string,
|
||||
postData: PostData,
|
||||
attachment: Attachment | null,
|
||||
): Promise<Post> {
|
||||
const url = `${BACKEND_URL}/api/v1/statuses`
|
||||
// Convert to Mastodon API Status entity
|
||||
const statusData = {
|
||||
status: postData.content,
|
||||
"media_ids[]": attachment ? [attachment.id] : null,
|
||||
in_reply_to_id: postData.in_reply_to_id,
|
||||
}
|
||||
const response = await http(url, {
|
||||
method: "POST",
|
||||
json: statusData,
|
||||
authToken,
|
||||
})
|
||||
const data = await response.json()
|
||||
if (response.status !== 201) {
|
||||
throw new Error(data.message)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getPost(
|
||||
authToken: string | null,
|
||||
postId: string,
|
||||
): Promise<Post> {
|
||||
const url = `${BACKEND_URL}/api/v1/statuses/${postId}`
|
||||
const response = await http(url, { authToken })
|
||||
const data = await response.json()
|
||||
if (response.status !== 200) {
|
||||
throw new Error(data.message)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
export async function favourite(
|
||||
authToken: string,
|
||||
postId: string,
|
||||
): Promise<Post> {
|
||||
const url = `${BACKEND_URL}/api/v1/statuses/${postId}/favourite`
|
||||
const response = await http(url, { method: "POST", authToken })
|
||||
const data = await response.json()
|
||||
if (response.status !== 200) {
|
||||
throw new Error(data.message)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
export async function unfavourite(
|
||||
authToken: string,
|
||||
postId: string,
|
||||
): Promise<Post> {
|
||||
const url = `${BACKEND_URL}/api/v1/statuses/${postId}/unfavourite`
|
||||
const response = await http(url, { method: "POST", authToken })
|
||||
const data = await response.json()
|
||||
if (response.status !== 200) {
|
||||
throw new Error(data.message)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createRepost(
|
||||
authToken: string,
|
||||
postId: string,
|
||||
): Promise<Post> {
|
||||
const url = `${BACKEND_URL}/api/v1/statuses/${postId}/reblog`
|
||||
const response = await http(url, { method: "POST", authToken })
|
||||
const data = await response.json()
|
||||
if (response.status !== 200) {
|
||||
throw new Error(data.message)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteRepost(
|
||||
authToken: string,
|
||||
postId: string,
|
||||
): Promise<Post> {
|
||||
const url = `${BACKEND_URL}/api/v1/statuses/${postId}/unreblog`
|
||||
const response = await http(url, { method: "POST", authToken })
|
||||
const data = await response.json()
|
||||
if (response.status !== 200) {
|
||||
throw new Error(data.message)
|
||||
}
|
||||
return data
|
||||
}
|
60
src/api/relationships.ts
Normal file
|
@ -0,0 +1,60 @@
|
|||
import { BACKEND_URL } from "@/constants"
|
||||
import { http } from "./common"
|
||||
|
||||
export interface Relationship {
|
||||
id: string,
|
||||
following: boolean,
|
||||
followed_by: boolean,
|
||||
requested: boolean,
|
||||
}
|
||||
|
||||
export async function follow(
|
||||
authToken: string,
|
||||
profileId: string,
|
||||
): Promise<Relationship> {
|
||||
const url = `${BACKEND_URL}/api/v1/accounts/${profileId}/follow`
|
||||
const response = await http(url, {
|
||||
method: "POST",
|
||||
authToken,
|
||||
})
|
||||
const data = await response.json()
|
||||
if (response.status !== 200) {
|
||||
throw new Error(data.message)
|
||||
} else {
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRelationship(
|
||||
authToken: string,
|
||||
profileId: string,
|
||||
): Promise<Relationship> {
|
||||
const url = `${BACKEND_URL}/api/v1/accounts/relationships`
|
||||
const response = await http(url, {
|
||||
method: "GET",
|
||||
queryParams: { "id[]": profileId },
|
||||
authToken,
|
||||
})
|
||||
const data = await response.json()
|
||||
if (response.status !== 200) {
|
||||
throw new Error(data.message)
|
||||
}
|
||||
return data[0]
|
||||
}
|
||||
|
||||
export async function unfollow(
|
||||
authToken: string,
|
||||
accountId: string,
|
||||
): Promise<Relationship> {
|
||||
const url = `${BACKEND_URL}/api/v1/accounts/${accountId}/unfollow`
|
||||
const response = await http(url, {
|
||||
method: "POST",
|
||||
authToken,
|
||||
})
|
||||
const data = await response.json()
|
||||
if (response.status !== 200) {
|
||||
throw new Error(data.message)
|
||||
} else {
|
||||
return data
|
||||
}
|
||||
}
|
27
src/api/search.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { BACKEND_URL } from "@/constants"
|
||||
|
||||
import { http } from "./common"
|
||||
import { Post } from "./posts"
|
||||
import { Profile } from "./users"
|
||||
|
||||
interface SearchResults {
|
||||
accounts: Profile[];
|
||||
statuses: Post[];
|
||||
}
|
||||
|
||||
export async function getSearchResults(
|
||||
authToken: string,
|
||||
query: string,
|
||||
): Promise<SearchResults> {
|
||||
const url = `${BACKEND_URL}/api/v2/search`
|
||||
const response = await http(url, {
|
||||
method: "GET",
|
||||
queryParams: { q: query },
|
||||
authToken,
|
||||
})
|
||||
const data = await response.json()
|
||||
if (response.status !== 200) {
|
||||
throw new Error(data.message)
|
||||
}
|
||||
return data
|
||||
}
|
144
src/api/users.ts
Normal file
|
@ -0,0 +1,144 @@
|
|||
import { BACKEND_URL } from "@/constants"
|
||||
import { http } from "./common"
|
||||
|
||||
interface ProfileField {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface Source {
|
||||
note: string | null;
|
||||
fields: ProfileField[];
|
||||
}
|
||||
|
||||
export interface Profile {
|
||||
id: string;
|
||||
username: string;
|
||||
acct: string;
|
||||
display_name: string | null;
|
||||
note: string | null;
|
||||
avatar: string | null;
|
||||
header: string | null;
|
||||
fields: ProfileField[];
|
||||
|
||||
followers_count: number;
|
||||
following_count: number;
|
||||
statuses_count: number;
|
||||
|
||||
source: Source | null;
|
||||
|
||||
wallet_address: string | null;
|
||||
}
|
||||
|
||||
export interface User extends Profile {
|
||||
source: Source;
|
||||
wallet_address: string;
|
||||
}
|
||||
|
||||
export interface UserCreateForm {
|
||||
username: string;
|
||||
password: string;
|
||||
wallet_address: string;
|
||||
invite_code: string | null;
|
||||
}
|
||||
|
||||
export interface UserLoginForm {
|
||||
signature: string;
|
||||
wallet_address: string;
|
||||
}
|
||||
|
||||
export async function createUser(userData: UserCreateForm): Promise<User> {
|
||||
const url = `${BACKEND_URL}/api/v1/accounts`
|
||||
const response = await http(url, {
|
||||
method: "POST",
|
||||
json: userData,
|
||||
})
|
||||
const data = await response.json()
|
||||
if (response.status !== 201) {
|
||||
throw new Error(data.message)
|
||||
} else {
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAccessToken(user: UserLoginForm): Promise<string> {
|
||||
const url = `${BACKEND_URL}/oauth/token`
|
||||
const tokenRequestData = {
|
||||
grant_type: "password",
|
||||
username: user.wallet_address,
|
||||
password: user.signature,
|
||||
}
|
||||
const response = await http(url, {
|
||||
method: "POST",
|
||||
json: tokenRequestData,
|
||||
})
|
||||
const data = await response.json()
|
||||
if (response.status !== 200) {
|
||||
throw new Error(data.message)
|
||||
} else {
|
||||
return data.access_token
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCurrentUser(authToken: string): Promise<User | null> {
|
||||
const url = `${BACKEND_URL}/api/v1/accounts/verify_credentials`
|
||||
const response = await http(url, { authToken })
|
||||
if (response.status !== 200) {
|
||||
return null
|
||||
}
|
||||
const data = await response.json()
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getProfile(
|
||||
authToken: string | null,
|
||||
profileId: string,
|
||||
): Promise<Profile> {
|
||||
const url = `${BACKEND_URL}/api/v1/accounts/${profileId}`
|
||||
const response = await http(url, { authToken })
|
||||
const data = await response.json()
|
||||
if (response.status !== 200) {
|
||||
throw new Error(data.message)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getProfiles(authToken: string): Promise<Profile[]> {
|
||||
const url = `${BACKEND_URL}/api/v1/directory`
|
||||
const response = await http(url, { authToken })
|
||||
const data = await response.json()
|
||||
return data
|
||||
}
|
||||
|
||||
export interface ProfileFieldAttrs {
|
||||
name: string;
|
||||
value: string;
|
||||
value_source: string;
|
||||
}
|
||||
|
||||
export interface ProfileUpdateData {
|
||||
display_name: string | null;
|
||||
note: string | null;
|
||||
note_source: string | null;
|
||||
avatar: string | null;
|
||||
header: string | null;
|
||||
fields_attributes: ProfileFieldAttrs[];
|
||||
}
|
||||
|
||||
export async function updateProfile(
|
||||
authToken: string,
|
||||
profileData: ProfileUpdateData,
|
||||
): Promise<User> {
|
||||
const url = `${BACKEND_URL}/api/v1/accounts/update_credentials`
|
||||
const response = await http(url, {
|
||||
method: "PATCH",
|
||||
json: profileData,
|
||||
authToken,
|
||||
})
|
||||
const profileOrError = await response.json()
|
||||
if (response.status !== 200) {
|
||||
throw new Error(profileOrError.message)
|
||||
} else {
|
||||
return profileOrError
|
||||
}
|
||||
}
|
8
src/assets/cryptoicons/README.md
Normal file
|
@ -0,0 +1,8 @@
|
|||
These icons are based on images from Cryptocurrency Icons project.
|
||||
|
||||
Website: https://github.com/spothq/cryptocurrency-icons
|
||||
|
||||
License: CC0 1.0 Universal
|
||||
|
||||
|
||||
|
2
src/assets/cryptoicons/bch.svg
Normal file
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg enable-background="new 0 0 24 24" version="1.1" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><style type="text/css">.st0{fill-rule:evenodd;clip-rule:evenodd;}</style><path class="st0" d="m14.5 7.9c0.6 2.2-2.9 2.9-3.9 3.2l-1.1-3.9c1-0.3 4.4-1.6 5 0.7zm2.6 5.7c0.7 2.4-3.5 3.3-4.8 3.7l-1.2-4.3c1.3-0.4 5.3-1.9 6 0.6zm0.8-7.6c-0.9-2.2-3.1-2.4-5.6-2l-0.9-3.2-2 0.6 0.9 3.1c-0.5 0.2-1 0.3-1.5 0.5l-0.9-3.1-1.9 0.5 0.9 3.2c-0.5 0.1-3.9 1.1-3.9 1.1l0.6 2.1 1.4-0.4c0.8-0.2 1.2 0.2 1.3 0.5 0 0 1 3.5 1 3.6l1.4 5.1c0 0.3 0 0.7-0.5 0.8l-1.4 0.4 0.3 2.4 2.5-0.7c0.5-0.1 0.9-0.3 1.4-0.4l0.9 3.2 1.9-0.6-0.8-3.1c0.5-0.1 1-0.3 1.5-0.4l0.9 3.2 1.9-0.6-0.9-3.2c3.2-1.1 5.2-2.6 4.6-5.7-0.5-2.5-1.9-3.3-3.9-3.2 1-0.9 1.4-2.1 0.8-3.7z"/></svg>
|
After Width: | Height: | Size: 779 B |
2
src/assets/cryptoicons/btc.svg
Normal file
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg enable-background="new 0 0 24 24" version="1.1" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><style type="text/css">.st0{fill-rule:evenodd;clip-rule:evenodd;}</style><path class="st0" d="m16.4 9.7c-0.6 2.2-3.9 1.1-5 0.8l1-3.9c1 0.3 4.5 0.9 4 3.1zm-0.6 6.3c-0.6 2.4-4.7 1.1-6 0.8l1.1-4.3c1.3 0.3 5.5 1 4.9 3.5zm4.4-6.2c0.4-2.4-1.4-3.6-3.9-4.5l0.8-3.2-1.9-0.5-0.8 3.1c-0.5-0.1-1-0.3-1.6-0.4l0.8-3.1-1.9-0.5-0.8 3.3c-0.5-0.1-0.9-0.2-1.3-0.3l-2.7-0.7-0.5 2.1 1.4 0.4c0.8 0.2 0.9 0.7 0.9 1.1l-0.9 3.7-1.3 5.1c-0.1 0.2-0.3 0.6-0.9 0.5l-1.4-0.4-1 2.2 2.5 0.6c0.5 0.1 0.9 0.2 1.4 0.4l-0.8 3.2 1.9 0.5 0.8-3.2c0.5 0.1 1 0.3 1.5 0.4l-0.8 3.2 1.9 0.5 0.8-3.2c3.3 0.6 5.8 0.4 6.9-2.6 0.8-2.4 0-3.8-1.8-4.7 1.5-0.5 2.4-1.3 2.7-3z"/></svg>
|
After Width: | Height: | Size: 780 B |
2
src/assets/cryptoicons/dash.svg
Normal file
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg enable-background="new 0 0 32 32" version="1.1" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg"><path d="m20.6 4h-10.9l-0.9 5h9.8c4.8 0 6.3 1.8 6.2 4.7 0 1.5-0.7 4-0.9 4.8-0.7 2.2-2.3 4.7-8 4.7h-9.6l-0.9 5.1h10.9c3.8 0 5.5-0.4 7.2-1.2 3.8-1.8 6.1-5.6 7-10.5 1.4-7.4-0.3-12.6-9.9-12.6zm-4.9 11.6c0.4-1.5 0.5-2.1 0.5-2.1h-11.2c-2.9 0-3.3 1.9-3.5 3-0.4 1.5-0.5 2.1-0.5 2.1h11.2c2.8 0 3.2-1.8 3.5-3z"/></svg>
|
After Width: | Height: | Size: 456 B |
2
src/assets/cryptoicons/doge.svg
Normal file
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg enable-background="new 0 0 24 24" version="1.1" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><style type="text/css">.st0{fill-rule:evenodd;clip-rule:evenodd;}</style><path class="st0" d="m8.7 10.4v-4.8h3.1c1.2 0 2.2 0.2 3 0.5s1.4 0.8 1.8 1.4c0.5 0.6 0.8 1.3 0.9 2 0.2 0.8 0.2 1.6 0.2 2.5 0 0.8-0.1 1.7-0.2 2.5-0.2 0.8-0.5 1.5-0.9 2-0.4 0.6-1 1-1.8 1.4-0.8 0.3-1.8 0.5-3 0.5h-3.1v-5.4h4.9v-2.6h-4.9zm-3.1 2.6v8h7.4c1.4 0 2.5-0.2 3.5-0.7s1.8-1.1 2.4-1.9 1.1-1.8 1.4-2.9 0.5-2.3 0.5-3.5-0.1-2.4-0.5-3.5c-0.3-1.1-0.8-2-1.4-2.9-0.6-0.8-1.4-1.5-2.4-1.9-1-0.5-2.2-0.7-3.5-0.7h-7.4v7.4h-1.7v2.6h1.7z"/></svg>
|
After Width: | Height: | Size: 655 B |
3
src/assets/cryptoicons/eth.svg
Normal file
|
@ -0,0 +1,3 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg enable-background="new 0 0 32 32" version="1.1" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg"><style type="text/css">.st0{fill:#2D2D2D;}
|
||||
.st1{fill:#757575;}</style><polygon class="st0" points="16 24 16 32 26 18.1"/><polygon class="st1" points="16 11.9 16 22.1 26 16.3"/><polygon class="st0" points="6 16.3 16 22.1 16 11.9"/><polygon class="st0" points="16 0 16 11.9 26 16.3"/><polygon points="16 0 16 11.9 6 16.3"/><polygon points="16 24 16 32 6 18.1"/></svg>
|
After Width: | Height: | Size: 514 B |
2
src/assets/cryptoicons/ltc.svg
Normal file
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg enable-background="new 0 0 32 32" version="1.1" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg"><path d="m7.6 20.8-1.7 7.2h19.3l1.3-5.4h-11.6l1.3-5.2 2.1-0.9 1-4.1-2.1 0.9 2.3-9.3h-7.7l-3.1 12.6-2.2 0.9-1 4.1c0 0.1 2.1-0.8 2.1-0.8z"/></svg>
|
After Width: | Height: | Size: 292 B |
2
src/assets/cryptoicons/xmr.svg
Normal file
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg enable-background="new 0 0 24 24" version="1.1" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><style type="text/css">.st0{fill-rule:evenodd;clip-rule:evenodd;}</style><path class="st0" d="m10.2 15.3-3.4-3.4v6.3h-5c2.1 3.5 5.9 5.8 10.2 5.8s8.1-2.3 10.2-5.7h-5v-6.3l-3.4 3.4-1.8 1.7-1.8-1.8zm1.8-15.3c-3.2 0-6.2 1.3-8.5 3.5-2.2 2.3-3.5 5.3-3.5 8.5 0 1.3 0.2 2.6 0.6 3.8h3.6v-10.1l7.8 7.8 7.8-7.8v10.1h3.6c0.4-1.2 0.6-2.5 0.6-3.8 0-6.6-5.4-12-12-12z"/></svg>
|
After Width: | Height: | Size: 509 B |
2
src/assets/cryptoicons/zec.svg
Normal file
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg enable-background="new 0 0 24 24" version="1.1" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><style type="text/css">.st0{fill-rule:evenodd;clip-rule:evenodd;}</style><path class="st0" d="m11 16.3c0.6-0.6 1.2-1.3 1.7-2 1.4-1.7 2.8-3.4 4.2-5.2 0.4-0.5 1.1-1.1 1.2-1.7 0.2-1.2 0.1-2.4 0.1-3.7h-4.4v-2.9h-3.5v3.2h-4v3.8h6.7c-0.6 0.7-1.2 1.3-1.7 2-1.4 1.7-2.8 3.4-4.2 5.2-0.4 0.5-1.1 1.1-1.2 1.7-0.2 1.2-0.1 2.4-0.1 3.6h4.4v3.1h3.7c-0.1-1.1-0.1-2.1-0.2-3.2h4.4v-3.8l-7.1-0.1z"/></svg>
|
After Width: | Height: | Size: 534 B |
5
src/assets/feather/README.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
These icons are part of Feather project.
|
||||
|
||||
Website: https://feathericons.com/
|
||||
|
||||
License: MIT
|
1
src/assets/feather/arrow-left.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="feather feather-arrow-left"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg>
|
After Width: | Height: | Size: 314 B |
1
src/assets/feather/bell.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="feather feather-bell"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path><path d="M13.73 21a2 2 0 0 1-3.46 0"></path></svg>
|
After Width: | Height: | Size: 323 B |
1
src/assets/feather/delete.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="feather feather-delete"><path d="M21 4H8l-7 8 7 8h13a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2z"></path><line x1="18" y1="9" x2="12" y2="15"></line><line x1="12" y1="9" x2="18" y2="15"></line></svg>
|
After Width: | Height: | Size: 376 B |
1
src/assets/feather/globe.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="feather feather-globe"><circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path></svg>
|
After Width: | Height: | Size: 411 B |
1
src/assets/feather/help-circle.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="feather feather-help-circle"><circle cx="12" cy="12" r="10"></circle><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>
|
After Width: | Height: | Size: 367 B |
1
src/assets/feather/home.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="feather feather-home"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path><polyline points="9 22 9 12 15 12 15 22"></polyline></svg>
|
After Width: | Height: | Size: 334 B |
1
src/assets/feather/lock.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="feather feather-lock"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>
|
After Width: | Height: | Size: 323 B |
1
src/assets/feather/log-out.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="feather feather-log-out"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line></svg>
|
After Width: | Height: | Size: 369 B |
1
src/assets/feather/paperclip.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="feather feather-paperclip"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"></path></svg>
|
After Width: | Height: | Size: 354 B |
1
src/assets/feather/plus-circle.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="feather feather-plus-circle"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="16"></line><line x1="8" y1="12" x2="16" y2="12"></line></svg>
|
After Width: | Height: | Size: 353 B |
1
src/assets/feather/repeat.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="feather feather-repeat"><polyline points="17 1 21 5 17 9"></polyline><path d="M3 11V9a4 4 0 0 1 4-4h14"></path><polyline points="7 23 3 19 7 15"></polyline><path d="M21 13v2a4 4 0 0 1-4 4H3"></path></svg>
|
After Width: | Height: | Size: 394 B |
1
src/assets/feather/search.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="feather feather-search"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
|
After Width: | Height: | Size: 310 B |
1
src/assets/feather/user-plus.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="feather feather-user-plus"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="8.5" cy="7" r="4"></circle><line x1="20" y1="8" x2="20" y2="14"></line><line x1="23" y1="11" x2="17" y2="11"></line></svg>
|
After Width: | Height: | Size: 410 B |
1
src/assets/feather/users.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="feather feather-users"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>
|
After Width: | Height: | Size: 402 B |
1
src/assets/feather/x-circle.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="feather feather-x-circle"><circle cx="12" cy="12" r="10"></circle><line x1="15" y1="9" x2="9" y2="15"></line><line x1="9" y1="9" x2="15" y2="15"></line></svg>
|
After Width: | Height: | Size: 348 B |
5
src/assets/forkawesome/README.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
These icons are part of Fork Aweseome project.
|
||||
|
||||
Website: https://forkaweso.me/Fork-Awesome/
|
||||
|
||||
License: SIL OFL 1.1
|
2
src/assets/forkawesome/comment-o.svg
Normal file
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="1536" height="1536" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="m768 219.43c-356.57 0-658.29 200.57-658.29 438.86 0 127.71 85.714 249.43 234 333.43l74.571 42.857-23.143 82.286c-16.286 60.857-37.714 108-60 147.43 86.571-36 165.43-84.857 235.71-146.57l36.857-32.571 48.857 5.1428c36.857 4.2857 74.571 6.8572 111.43 6.8572 356.57 0 658.29-200.57 658.29-438.86 0-238.29-301.71-438.86-658.29-438.86zm768 438.86c0 303.43-343.71 548.57-768 548.57-42 0-84-2.5714-124.29-6.8571-112.29 99.429-246 169.71-394.29 207.43-30.857 8.5714-64.286 14.571-97.714 18.857h-4.2857c-17.143 0-32.571-13.714-36.857-32.571v-0.8572c-4.2857-21.428 10.286-34.286 23.143-49.714 54-60.857 115.71-112.29 156-255.43-176.57-100.29-289.71-255.43-289.71-429.43 0-303.43 343.71-548.57 768-548.57 424.29 0 768 245.14 768 548.57z" stroke-width=".85714"/></svg>
|
After Width: | Height: | Size: 886 B |
2
src/assets/forkawesome/diamond.svg
Normal file
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="1536" height="1536" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="m159.05 624.01 467.21 498.71-224.98-498.71zm608.95 578.95 261.73-578.95h-523.46zm-364.47-674.94 152.99-287.98h-196.48l-215.98 287.98zm506.21 594.7 467.21-498.71h-242.23zm-397.47-594.7h511.46l-152.99-287.98h-205.48zm620.2 0h259.48l-215.98-287.98h-196.48zm105.74-364.47 287.98 383.97c14.249 17.999 12.749 44.246-2.9997 61.495l-719.94 767.94c-8.9993 9.7492-21.748 14.999-35.247 14.999s-26.248-5.2496-35.247-14.999l-719.94-767.94c-15.749-17.249-17.249-43.496-2.9998-61.495l287.98-383.97c8.9993-12.749 23.248-19.498 38.247-19.498h863.93c14.999 0 29.248 6.7494 38.247 19.498z" stroke-width=".74994"/></svg>
|
After Width: | Height: | Size: 730 B |
4
src/assets/forkawesome/ethereum.svg
Normal file
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="svg4" width="1064" height="1792" version="1.1" viewBox="0 0 1064 1792" xmlns="http://www.w3.org/2000/svg">
|
||||
<path id="path2" d="M 1063.65,912.8 532,1237.6 0,912.8 532,0 Z M 532,1341.9 0,1017.1 532,1792 1064,1017.1 Z" fill="currentColor"/>
|
||||
</svg>
|
After Width: | Height: | Size: 294 B |
2
src/assets/forkawesome/files-o.svg
Normal file
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="1536" height="1536" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="m1280 440.32c33.92 0 61.44 27.52 61.44 61.44v778.24c0 33.92-27.52 61.44-61.44 61.44h-614.4c-33.92 0-61.44-27.52-61.44-61.44v-184.32h-348.16c-33.92 0-61.44-27.52-61.44-61.44v-430.08c0-33.92 19.84-81.28 43.52-104.96l261.12-261.12c23.68-23.68 71.04-43.52 104.96-43.52h266.24c33.92 0 61.44 27.52 61.44 61.44v209.92c24.96-14.72 56.96-25.6 81.92-25.6zm-348.16 136.32-191.36 191.36h191.36zm-409.6-245.76-191.36 191.36h191.36zm125.44 414.08 202.24-202.24v-266.24h-245.76v266.24c0 33.92-27.52 61.44-61.44 61.44h-266.24v409.6h327.68v-163.84c0-33.92 19.84-81.28 43.52-104.96zm611.84 514.56v-737.28h-245.76v266.24c0 33.92-27.52 61.44-61.44 61.44h-266.24v409.6z" stroke-width=".64"/></svg>
|
After Width: | Height: | Size: 806 B |
2
src/assets/forkawesome/thumbs-o-up.svg
Normal file
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="1536" height="1536" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="m334.66 1201.3c0-29.622-24.544-54.166-54.166-54.166-29.622 0-54.166 24.544-54.166 54.166 0 29.622 24.544 54.166 54.166 54.166 29.622 0 54.166-24.544 54.166-54.166zm975.01-487.5c0-57.552-51.628-108.33-108.33-108.33h-297.91c0-99.024 81.249-170.96 81.249-270.83 0-99.024-19.466-162.5-135.42-162.5-54.166 55.013-26.237 184.5-108.33 270.83-23.698 24.544-44.01 50.782-65.17 77.018-38.085 49.088-138.8 193.81-205.67 193.81h-27.083v541.66h27.083c47.395 0 125.26 30.468 170.96 46.549 93.1 32.162 189.58 61.784 289.45 61.784h102.41c95.64 0 162.5-38.085 162.5-141.34 0-16.081-1.6927-32.162-4.2317-47.396 35.546-19.466 55.013-67.708 55.013-106.64 0-20.312-5.0782-40.626-15.234-58.398 28.776-27.083 44.856-60.938 44.856-100.72 0-27.083-11.849-66.862-29.622-87.174 39.779-0.84635 63.476-77.018 63.476-108.33zm108.33-0.84636c0 49.088-14.388 97.327-41.472 137.95 5.0781 18.62 7.6172 38.933 7.6172 58.398 0 42.318-11.002 84.636-32.162 121.87 1.6927 11.849 2.539 24.544 2.539 36.393 0 54.166-17.774 108.33-50.782 150.66 1.6927 159.96-107.49 253.9-264.06 253.9h-109.18c-120.18 0-231.9-35.546-343.62-74.48-24.544-8.4635-93.1-33.854-116.79-33.854h-243.76c-60.091 0-108.33-48.242-108.33-108.33v-541.66c0-60.091 48.242-108.33 108.33-108.33h231.9c33.008-22.006 90.559-98.18 115.95-131.19 28.776-37.239 58.398-73.634 90.559-108.33 50.782-54.166 23.698-187.89 108.33-270.83 20.312-19.466 47.395-31.316 76.172-31.316 88.021 0 172.66 31.316 214.13 113.41 26.237 51.628 29.622 100.72 29.622 157.42 0 59.245-15.234 110.02-40.625 162.5h148.96c116.79 0 216.66 99.024 216.66 215.82z" stroke-width=".84636"/></svg>
|
After Width: | Height: | Size: 1.7 KiB |
4
src/assets/ipfs.svg
Normal file
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="svg4" role="img" version="1.1" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path id="path2" d="M12 0L1.608 6v12L12 24l10.392-6V6zm-1.073 1.445h.001a1.8 1.8 0 0 0 2.138 0l7.534 4.35a1.794 1.794 0 0 0 0 .403l-7.535 4.35a1.8 1.8 0 0 0-2.137 0l-7.536-4.35a1.795 1.795 0 0 0 0-.402zM21.324 7.4c.109.08.226.147.349.201v8.7a1.8 1.8 0 0 0-1.069 1.852l-7.535 4.35a1.8 1.8 0 0 0-.349-.2l-.009-8.653a1.8 1.8 0 0 0 1.07-1.851zm-18.648.048l7.535 4.35a1.8 1.8 0 0 0 1.069 1.852v8.7c-.124.054-.24.122-.349.202l-7.535-4.35a1.8 1.8 0 0 0-1.069-1.852v-8.7a1.85 1.85 0 0 0 .35-.202z"/>
|
||||
</svg>
|
After Width: | Height: | Size: 635 B |
BIN
src/assets/startpage.png
Normal file
After Width: | Height: | Size: 277 KiB |
5
src/assets/tabler/README.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
These icons are part of Tabler project.
|
||||
|
||||
Website: https://tabler-icons.io/
|
||||
|
||||
License: MIT
|
2
src/assets/tabler/arrow-forward.svg
Normal file
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg class="icon icon-tabler icon-tabler-arrow-forward" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" version="1.1" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M0 0h24v24H0z" fill="none" stroke="none"/><path d="m15.525 10.7 4.6 4.6-4.6 4.6m4.6-4.6h-12.65a4.6 4.6 0 0 1 0-9.2h1.15" stroke-width="2.3"/></svg>
|
After Width: | Height: | Size: 442 B |
6
src/assets/tabler/corner-left-up.svg
Normal file
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-corner-left-up" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<path d="M18 18h-6a3 3 0 0 1 -3 -3v-10l-4 4m8 0l-4 -4" />
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 357 B |
45
src/components/Avatar.vue
Normal file
|
@ -0,0 +1,45 @@
|
|||
<template>
|
||||
<div class="avatar">
|
||||
<img :src="avatarUrl">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Vue } from "vue-class-component"
|
||||
import { Prop } from "vue-property-decorator"
|
||||
import makeBlockie from "ethereum-blockies-base64"
|
||||
|
||||
import { Profile } from "@/api/users"
|
||||
|
||||
export default class Avatar extends Vue {
|
||||
|
||||
@Prop()
|
||||
profile!: Profile
|
||||
|
||||
get avatarUrl(): string {
|
||||
if (this.profile.avatar) {
|
||||
return this.profile.avatar
|
||||
} else {
|
||||
return makeBlockie(this.profile.acct)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../styles/theme";
|
||||
|
||||
.avatar {
|
||||
background-color: $block-background-color;
|
||||
border-radius: 50%;
|
||||
box-sizing: border-box;
|
||||
|
||||
img {
|
||||
border-radius: inherit;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
67
src/components/CryptoAddress.vue
Normal file
|
@ -0,0 +1,67 @@
|
|||
<template>
|
||||
<div class="crypto-address">
|
||||
<input :value="address" readonly>
|
||||
<button class="copy-btn" title="Copy address" @click="copyAddress()">
|
||||
<img :src="require('@/assets/forkawesome/files-o.svg')">
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Vue } from "vue-class-component"
|
||||
import { Prop } from "vue-property-decorator"
|
||||
|
||||
export default class CryptoAddress extends Vue {
|
||||
|
||||
@Prop()
|
||||
address!: string
|
||||
|
||||
copyAddress() {
|
||||
navigator.clipboard.writeText(this.address)
|
||||
}
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../styles/layout";
|
||||
@import "../styles/theme";
|
||||
|
||||
.crypto-address {
|
||||
background-color: #eee;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
|
||||
input {
|
||||
background-color: inherit;
|
||||
border: none;
|
||||
border-radius: 10px 0 0 10px;
|
||||
color: $text-color;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
max-width: 200px;
|
||||
padding: 0 7px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
border: none;
|
||||
border-radius: 0 10px 10px 0;
|
||||
cursor: pointer;
|
||||
height: $icon-size;
|
||||
min-width: $icon-size;
|
||||
padding: 3px 7px 3px 0;
|
||||
width: $icon-size;
|
||||
|
||||
img {
|
||||
filter: $link-colorizer;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&:hover img {
|
||||
filter: $link-hover-colorizer;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
60
src/components/Loader.vue
Normal file
|
@ -0,0 +1,60 @@
|
|||
<template>
|
||||
<div class="loader">
|
||||
<div></div>
|
||||
<div></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Vue } from "vue-class-component"
|
||||
|
||||
export default class Loader extends Vue {
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../styles/theme";
|
||||
|
||||
/* https://github.com/loadingio/css-spinner/blob/master/dist/ripple.html */
|
||||
|
||||
$loader-color: #999;
|
||||
$loader-size: 80px;
|
||||
$loader-width: 4px;
|
||||
|
||||
.loader {
|
||||
height: $loader-size;
|
||||
position: relative;
|
||||
width: $loader-size;
|
||||
}
|
||||
|
||||
.loader div {
|
||||
animation: loader 1s cubic-bezier(0, 0.2, 0.8, 1) infinite;
|
||||
border: $loader-width solid $loader-color;
|
||||
border-radius: 50%;
|
||||
opacity: 1;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.loader div:nth-child(2) {
|
||||
animation-delay: -0.5s;
|
||||
}
|
||||
|
||||
@keyframes loader {
|
||||
0% {
|
||||
height: 0;
|
||||
left: $loader-size / 2 - $loader-width;
|
||||
opacity: 1;
|
||||
top: $loader-size / 2 - $loader-width;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
height: $loader-size - $loader-width * 2;
|
||||
left: 0;
|
||||
opacity: 0;
|
||||
top: 0;
|
||||
width: $loader-size - $loader-width * 2;
|
||||
}
|
||||
}
|
||||
</style>
|
434
src/components/Post.vue
Normal file
|
@ -0,0 +1,434 @@
|
|||
<template>
|
||||
<div class="post" :class="{ 'highlighted': highlighted }" :data-post-id="post.id" :id="post.id">
|
||||
<div class="post-header">
|
||||
<router-link class="floating-avatar" :to="{ name: 'profile', params: { profileId: post.account.id }}">
|
||||
<avatar :profile="post.account"></avatar>
|
||||
</router-link>
|
||||
<router-link class="display-name" :to="{ name: 'profile', params: { profileId: post.account.id }}">
|
||||
{{ post.account.display_name || post.account.username }}
|
||||
</router-link>
|
||||
<div class="username">@{{ post.account.acct }}</div>
|
||||
<a
|
||||
class="icon icon-small"
|
||||
:href="post.uri"
|
||||
:title="'Visibility: ' + post.visibility"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<img v-if="post.visibility === 'public'" :src="require('@/assets/feather/globe.svg')">
|
||||
<img v-else-if="post.visibility === 'direct'" :src="require('@/assets/feather/lock.svg')">
|
||||
</a>
|
||||
<a
|
||||
v-if="inThread && post.in_reply_to_id"
|
||||
class="icon"
|
||||
title="Go to previous post"
|
||||
@mouseover="highlight(post.in_reply_to_id)"
|
||||
@mouseleave="highlight(null)"
|
||||
@click="navigateTo(post.in_reply_to_id)"
|
||||
>
|
||||
<img :src="require('@/assets/tabler/corner-left-up.svg')">
|
||||
</a>
|
||||
<a
|
||||
class="timestamp"
|
||||
@click="navigateTo(post.id)"
|
||||
>
|
||||
{{ formatDate(post.created_at) }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="post-content" v-html="post.content"></div>
|
||||
<div class="post-attachment" v-if="post.media_attachments.length === 1">
|
||||
<img :src="post.media_attachments[0].url">
|
||||
</div>
|
||||
<div class="post-footer">
|
||||
<router-link
|
||||
v-if="!inThread"
|
||||
class="icon"
|
||||
title="View comments"
|
||||
:to="{ name: 'post', params: { postId: post.id }}"
|
||||
>
|
||||
<img :src="require('@/assets/forkawesome/comment-o.svg')">
|
||||
<span>{{ post.replies_count }}</span>
|
||||
</router-link>
|
||||
<a
|
||||
v-if="inThread && canReply()"
|
||||
class="icon"
|
||||
title="Reply"
|
||||
@click="commentFormVisible = !commentFormVisible"
|
||||
>
|
||||
<img :src="require('@/assets/tabler/arrow-forward.svg')">
|
||||
</a>
|
||||
<a
|
||||
class="icon"
|
||||
:class="{ 'highlighted': post.reblogged }"
|
||||
title="Repost"
|
||||
@click="toggleRepost()"
|
||||
>
|
||||
<img :src="require('@/assets/feather/repeat.svg')">
|
||||
<span>{{ post.reblogs_count }}</span>
|
||||
</a>
|
||||
<a
|
||||
class="icon"
|
||||
:class="{ 'highlighted': post.favourited }"
|
||||
title="Like"
|
||||
@click="toggleLike()"
|
||||
>
|
||||
<img :src="require('@/assets/forkawesome/thumbs-o-up.svg')">
|
||||
<span>{{ post.favourites_count }}</span>
|
||||
</a>
|
||||
<router-link
|
||||
v-if="isTokenized"
|
||||
class="icon tokenized"
|
||||
title="View token"
|
||||
:to="{ name: 'post-overlay', params: { postId: post.id }}"
|
||||
>
|
||||
<img :src="require('@/assets/forkawesome/diamond.svg')">
|
||||
</router-link>
|
||||
<a
|
||||
v-if="canTokenize()"
|
||||
class="icon"
|
||||
:class="{'waiting': isWaitingForToken}"
|
||||
title="Tokenize post"
|
||||
@click="tokenize()"
|
||||
>
|
||||
<img :src="require('@/assets/forkawesome/diamond.svg')">
|
||||
</a>
|
||||
<div class="crypto-widget">
|
||||
<crypto-address
|
||||
v-if="selectedPaymentAddress"
|
||||
:address="selectedPaymentAddress"
|
||||
></crypto-address>
|
||||
<a
|
||||
v-for="option in getPaymentOptions()"
|
||||
:key="option.code"
|
||||
class="icon"
|
||||
:title="'Send '+ option.name"
|
||||
@click="togglePaymentAddress(option)"
|
||||
>
|
||||
<img :src="require('@/assets/cryptoicons/' + option.code.toLowerCase() + '.svg')">
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<post-editor
|
||||
v-if="commentFormVisible"
|
||||
:in-reply-to="post.id"
|
||||
@post-created="onCommentCreated"
|
||||
>
|
||||
</post-editor>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Options, Vue, setup } from "vue-class-component"
|
||||
import { Prop } from "vue-property-decorator"
|
||||
|
||||
import { makePermanent, getSignature, mintToken } from "@/api/nft"
|
||||
import { Post, getPost, favourite, unfavourite, createRepost, deleteRepost } from "@/api/posts"
|
||||
import Avatar from "@/components/Avatar.vue"
|
||||
import CryptoAddress from "@/components/CryptoAddress.vue"
|
||||
import PostEditor from "@/components/PostEditor.vue"
|
||||
import { useInstanceInfo } from "@/store/instance"
|
||||
import { useCurrentUser } from "@/store/user"
|
||||
import { CRYPTOCURRENCIES } from "@/utils/cryptocurrencies"
|
||||
import { getSigner } from "@/utils/ethereum"
|
||||
import { formatDate } from "@/utils/format"
|
||||
|
||||
interface PaymentOption {
|
||||
code: string;
|
||||
name: string;
|
||||
address: string;
|
||||
}
|
||||
|
||||
@Options({
|
||||
components: {
|
||||
Avatar,
|
||||
CryptoAddress,
|
||||
PostEditor,
|
||||
},
|
||||
})
|
||||
export default class PostComponent extends Vue {
|
||||
|
||||
@Prop()
|
||||
post!: Post
|
||||
|
||||
@Prop()
|
||||
highlighted = false
|
||||
|
||||
@Prop()
|
||||
inThread = false
|
||||
|
||||
commentFormVisible = false
|
||||
|
||||
private store = setup(() => {
|
||||
const { currentUser, ensureAuthToken } = useCurrentUser()
|
||||
const { instance } = useInstanceInfo()
|
||||
return { currentUser, ensureAuthToken, instance }
|
||||
})
|
||||
|
||||
highlight(postId: string | null) {
|
||||
this.$emit("highlight", postId)
|
||||
}
|
||||
|
||||
navigateTo(postId: string) {
|
||||
if (this.inThread) {
|
||||
this.$emit("navigate-to", postId)
|
||||
} else {
|
||||
this.$router.push({ name: "post", params: { postId: postId } })
|
||||
}
|
||||
}
|
||||
|
||||
formatDate(isoDate: string): string {
|
||||
return formatDate(isoDate)
|
||||
}
|
||||
|
||||
canReply(): boolean {
|
||||
return this.store.currentUser !== null
|
||||
}
|
||||
|
||||
onCommentCreated(post: Post) {
|
||||
this.commentFormVisible = false
|
||||
this.$emit("comment-created", post)
|
||||
}
|
||||
|
||||
async toggleRepost() {
|
||||
if (!this.store.currentUser) {
|
||||
return
|
||||
}
|
||||
const authToken = this.store.ensureAuthToken()
|
||||
let post
|
||||
try {
|
||||
if (this.post.reblogged) {
|
||||
post = await deleteRepost(authToken, this.post.id)
|
||||
} else {
|
||||
post = await createRepost(authToken, this.post.id)
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
return
|
||||
}
|
||||
this.post.reblogs_count = post.reblogs_count
|
||||
this.post.reblogged = post.reblogged
|
||||
}
|
||||
|
||||
async toggleLike() {
|
||||
if (!this.store.currentUser) {
|
||||
return
|
||||
}
|
||||
const authToken = this.store.ensureAuthToken()
|
||||
let post
|
||||
try {
|
||||
if (this.post.favourited) {
|
||||
post = await unfavourite(authToken, this.post.id)
|
||||
} else {
|
||||
post = await favourite(authToken, this.post.id)
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
return
|
||||
}
|
||||
this.post.favourites_count = post.favourites_count
|
||||
this.post.favourited = post.favourited
|
||||
}
|
||||
|
||||
getPaymentOptions(): PaymentOption[] {
|
||||
const items = []
|
||||
for (const [code, name] of CRYPTOCURRENCIES) {
|
||||
const symbol = `$${code}`
|
||||
const field = this.post.account.fields.find(item => item.name === symbol)
|
||||
if (!field) {
|
||||
continue
|
||||
}
|
||||
const address = field.value.trim()
|
||||
items.push({ code, name, address })
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
selectedPaymentAddress: string | null = null
|
||||
|
||||
togglePaymentAddress(payment: PaymentOption) {
|
||||
this.selectedPaymentAddress = this.selectedPaymentAddress === payment.address ? null : payment.address
|
||||
}
|
||||
|
||||
get isTokenized(): boolean {
|
||||
return this.post.token_id !== null
|
||||
}
|
||||
|
||||
canTokenize(): boolean {
|
||||
return this.post.account.id === this.store.currentUser?.id && !this.isTokenized
|
||||
}
|
||||
|
||||
isWaitingForToken = false
|
||||
|
||||
async tokenize() {
|
||||
const { currentUser, instance } = this.store
|
||||
if (!currentUser || !instance || !instance.nft_contract_name || !instance.nft_contract_address) {
|
||||
return
|
||||
}
|
||||
if (this.isTokenized || this.isWaitingForToken) {
|
||||
return
|
||||
}
|
||||
const authToken = this.store.ensureAuthToken()
|
||||
this.isWaitingForToken = true
|
||||
const { ipfs_cid } = await makePermanent(authToken, this.post.id)
|
||||
const tokenUri = `ipfs://${ipfs_cid}`
|
||||
console.info("token URI:", tokenUri)
|
||||
let signature
|
||||
try {
|
||||
signature = await getSignature(authToken, this.post.id)
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
this.isWaitingForToken = false
|
||||
return
|
||||
}
|
||||
const signer = await getSigner()
|
||||
if (!signer) {
|
||||
this.isWaitingForToken = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
await mintToken(
|
||||
instance.nft_contract_name,
|
||||
instance.nft_contract_address,
|
||||
currentUser.wallet_address,
|
||||
tokenUri,
|
||||
signature,
|
||||
signer,
|
||||
)
|
||||
} catch (error) {
|
||||
// User has rejected tx
|
||||
this.isWaitingForToken = false
|
||||
return
|
||||
}
|
||||
// Wait until the server sees the tx
|
||||
const intervalId = setInterval(async () => {
|
||||
const post = await getPost(authToken, this.post.id)
|
||||
if (post.token_id) {
|
||||
clearInterval(intervalId)
|
||||
this.isWaitingForToken = false
|
||||
// Update post
|
||||
this.post.ipfs_cid = post.ipfs_cid
|
||||
this.post.token_id = post.token_id
|
||||
this.post.token_tx_id = post.token_tx_id
|
||||
}
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../styles/layout";
|
||||
@import "../styles/theme";
|
||||
@import "../styles/mixins";
|
||||
|
||||
.post {
|
||||
background-color: $block-background-color;
|
||||
border-radius: $block-border-radius;
|
||||
text-align: left;
|
||||
|
||||
&.highlighted {
|
||||
outline: 1px solid #FFA500;
|
||||
}
|
||||
}
|
||||
|
||||
.post-header {
|
||||
@include post-icon;
|
||||
|
||||
align-items: center;
|
||||
color: $secondary-text-color;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding: $block-inner-padding $block-inner-padding 0;
|
||||
|
||||
.floating-avatar {
|
||||
@include floating-avatar;
|
||||
}
|
||||
|
||||
.display-name {
|
||||
color: $text-color;
|
||||
font-weight: bold;
|
||||
margin-right: $block-inner-padding / 2;
|
||||
}
|
||||
|
||||
.username {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
color: $secondary-text-color;
|
||||
text-align: right;
|
||||
|
||||
&:hover {
|
||||
color: $secondary-text-hover-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.post-content {
|
||||
color: $text-color;
|
||||
line-height: 1.5;
|
||||
padding: $block-inner-padding;
|
||||
}
|
||||
|
||||
.post-attachment {
|
||||
padding: 0 $block-inner-padding $block-inner-padding;
|
||||
|
||||
img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.post-footer {
|
||||
@include post-icon;
|
||||
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: $block-inner-padding / 2 0;
|
||||
padding: 0 $block-inner-padding $block-inner-padding;
|
||||
|
||||
.icon {
|
||||
&.tokenized img {
|
||||
filter: invert(51%) sepia(48%) saturate(437%) hue-rotate(222deg) brightness(92%) contrast(84%);
|
||||
}
|
||||
|
||||
&.waiting img {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.crypto-widget {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
justify-content: right;
|
||||
|
||||
.crypto-address {
|
||||
margin-right: 10px;
|
||||
max-width: 200px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.icon img {
|
||||
/* Make filled icons lighter to match line icons */
|
||||
opacity: 0.75;
|
||||
}
|
||||
}
|
||||
|
||||
.post-form {
|
||||
border-top: 1px solid #f3f2ed;
|
||||
}
|
||||
|
||||
@media screen and (max-width: $screen-breakpoint-small) {
|
||||
.post-footer {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
235
src/components/PostEditor.vue
Normal file
|
@ -0,0 +1,235 @@
|
|||
<template>
|
||||
<form class="post-form" :class="{'reply': inReplyTo}">
|
||||
<router-link
|
||||
v-if="author"
|
||||
class="floating-avatar"
|
||||
:to="{ name: 'profile', params: { profileId: author.id }}"
|
||||
>
|
||||
<avatar :profile="author"></avatar>
|
||||
</router-link>
|
||||
<div class="textarea-group">
|
||||
<textarea
|
||||
id="content"
|
||||
ref="postFormContent"
|
||||
v-model="content"
|
||||
rows="1"
|
||||
required
|
||||
:placeholder="inReplyTo ? 'Your reply' : 'What\'s on your mind?'"
|
||||
></textarea>
|
||||
<div v-if="attachment" class="attachment">
|
||||
<img :src="attachment.url">
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<a class="icon" title="Attach image" @click="selectAttachment()">
|
||||
<img :src="require('@/assets/feather/paperclip.svg')">
|
||||
<input
|
||||
type="file"
|
||||
ref="attachmentUploadInput"
|
||||
accept="image/*"
|
||||
style="display: none;"
|
||||
@change="uploadAttachment($event.target.files)"
|
||||
>
|
||||
</a>
|
||||
<div class="character-counter" title="Characters left">
|
||||
{{ characterCounter }}
|
||||
</div>
|
||||
<a
|
||||
v-if="inReplyTo"
|
||||
class="submit-btn-small"
|
||||
@click="publish($event)"
|
||||
>
|
||||
Publish
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!inReplyTo" class="submit-btn-wrapper">
|
||||
<div class="error-message" v-if="errorMessage">{{ errorMessage }}</div>
|
||||
<button
|
||||
class="btn"
|
||||
type="submit"
|
||||
:disabled="characterCounter < 0"
|
||||
@click.prevent="publish()"
|
||||
>Publish</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Options, Vue, setup } from "vue-class-component"
|
||||
import { Prop } from "vue-property-decorator"
|
||||
|
||||
import { createPost, Attachment, uploadAttachment } from "@/api/posts"
|
||||
import { User } from "@/api/users"
|
||||
import Avatar from "@/components/Avatar.vue"
|
||||
import { useCurrentUser } from "@/store/user"
|
||||
import { setupAutoResize } from "@/utils/autoresize"
|
||||
import { renderMarkdownLite } from "@/utils/markdown"
|
||||
import { fileToDataUrl, dataUrlToBase64 } from "@/utils/upload"
|
||||
|
||||
const POST_CHARACTER_LIMIT = 1000
|
||||
|
||||
@Options({
|
||||
components: {
|
||||
Avatar,
|
||||
},
|
||||
})
|
||||
export default class PostEditor extends Vue {
|
||||
|
||||
@Prop()
|
||||
inReplyTo: string | null = null
|
||||
|
||||
content = ""
|
||||
attachment: Attachment | null = null
|
||||
errorMessage: string | null = null
|
||||
|
||||
$refs!: {
|
||||
postFormContent: HTMLTextAreaElement,
|
||||
attachmentUploadInput: HTMLInputElement,
|
||||
}
|
||||
|
||||
private store = setup(() => {
|
||||
const { currentUser, ensureAuthToken } = useCurrentUser()
|
||||
return { currentUser, ensureAuthToken }
|
||||
})
|
||||
|
||||
get author(): User | null {
|
||||
return this.store.currentUser
|
||||
}
|
||||
|
||||
mounted() {
|
||||
setupAutoResize(this.$refs.postFormContent)
|
||||
}
|
||||
|
||||
selectAttachment() {
|
||||
this.$refs.attachmentUploadInput.click()
|
||||
}
|
||||
|
||||
async uploadAttachment(files: FileList) {
|
||||
const imageDataUrl = await fileToDataUrl(files[0])
|
||||
const imageBase64 = dataUrlToBase64(imageDataUrl)
|
||||
this.attachment = await uploadAttachment(
|
||||
this.store.ensureAuthToken(),
|
||||
imageBase64,
|
||||
)
|
||||
}
|
||||
|
||||
get characterCounter(): number {
|
||||
return (POST_CHARACTER_LIMIT - this.content.length)
|
||||
}
|
||||
|
||||
async publish() {
|
||||
const content = renderMarkdownLite(this.content)
|
||||
const postData = {
|
||||
content,
|
||||
in_reply_to_id: this.inReplyTo,
|
||||
}
|
||||
let post
|
||||
try {
|
||||
post = await createPost(
|
||||
this.store.ensureAuthToken(),
|
||||
postData,
|
||||
this.attachment,
|
||||
)
|
||||
} catch (error) {
|
||||
this.errorMessage = error.message
|
||||
return
|
||||
}
|
||||
// Refresh editor
|
||||
this.errorMessage = null
|
||||
this.attachment = null
|
||||
this.content = ""
|
||||
this.$emit("post-created", post)
|
||||
}
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../styles/layout";
|
||||
@import "../styles/mixins";
|
||||
@import "../styles/theme";
|
||||
|
||||
$line-height: 1.5;
|
||||
|
||||
.post-form {
|
||||
position: relative;
|
||||
|
||||
.floating-avatar {
|
||||
@include floating-avatar;
|
||||
|
||||
left: $block-inner-padding;
|
||||
margin-top: $line-height * 1em / 2;
|
||||
position: absolute;
|
||||
top: $block-inner-padding;
|
||||
|
||||
@media screen and (max-width: $screen-breakpoint-medium) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.textarea-group {
|
||||
background-color: $block-background-color;
|
||||
border-radius: $block-border-radius;
|
||||
}
|
||||
|
||||
textarea {
|
||||
border-radius: $block-border-radius $block-border-radius 0 0;
|
||||
height: 100px;
|
||||
line-height: $line-height;
|
||||
padding: $block-inner-padding;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.attachment {
|
||||
padding: $block-inner-padding / 1.5 $block-inner-padding;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
@include post-icon;
|
||||
|
||||
align-items: center;
|
||||
border-radius: 0 0 $block-border-radius $block-border-radius;
|
||||
border-top: 1px solid $separator-color;
|
||||
color: $secondary-text-color;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
font-weight: bold;
|
||||
padding: $block-inner-padding / 1.5 $block-inner-padding;
|
||||
|
||||
.character-counter {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.submit-btn-small {
|
||||
margin-left: $block-inner-padding;
|
||||
}
|
||||
}
|
||||
|
||||
.submit-btn-wrapper {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
margin-top: $block-inner-padding / 1.5;
|
||||
|
||||
.error-message {
|
||||
color: $error-color;
|
||||
margin-right: $block-inner-padding;
|
||||
}
|
||||
|
||||
button {
|
||||
box-shadow: $btn-shadow;
|
||||
}
|
||||
}
|
||||
|
||||
.post-form.reply {
|
||||
textarea {
|
||||
height: calc(1.5em + #{2 * $block-inner-padding});
|
||||
}
|
||||
}
|
||||
</style>
|
31
src/components/PostOrRepost.vue
Normal file
|
@ -0,0 +1,31 @@
|
|||
<template>
|
||||
<template v-if="post.reblog">
|
||||
<div class="action">
|
||||
<img :src="require('@/assets/feather/repeat.svg')">
|
||||
<span>{{ post.account.display_name || post.account.username }} reposted</span>
|
||||
</div>
|
||||
<post :post="post.reblog"></post>
|
||||
</template>
|
||||
<post v-else :post="post"></post>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/* eslint-disable no-undef */
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import type { Post as PostObject } from "@/api/posts"
|
||||
import Post from "@/components/Post.vue"
|
||||
|
||||
const props = defineProps<{
|
||||
post: PostObject,
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../styles/layout";
|
||||
@import "../styles/mixins";
|
||||
@import "../styles/theme";
|
||||
|
||||
.action {
|
||||
@include post-action;
|
||||
}
|
||||
</style>
|
155
src/components/ProfileCard.vue
Normal file
|
@ -0,0 +1,155 @@
|
|||
<template>
|
||||
<div class="profile">
|
||||
<div class="profile-header">
|
||||
<img v-if="profile.header" :src="profile.header">
|
||||
</div>
|
||||
<div class="profile-info">
|
||||
<div class="avatar-row">
|
||||
<avatar :profile="profile"></avatar>
|
||||
<div class="name-group">
|
||||
<div class="display-name">{{ profile.display_name || profile.username }}</div>
|
||||
<div class="account-uri">{{ getAcct() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bio" v-html="profile.note"></div>
|
||||
<div v-if="!compact" class="bottom-row">
|
||||
<div class="post-count">
|
||||
<div class="value">{{ profile.statuses_count }}</div>
|
||||
<div class="name">posts</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Options, Vue, setup } from "vue-class-component"
|
||||
import { Prop } from "vue-property-decorator"
|
||||
|
||||
import { Profile } from "@/api/users"
|
||||
import Avatar from "@/components/Avatar.vue"
|
||||
import { useInstanceInfo } from "@/store/instance"
|
||||
|
||||
@Options({
|
||||
components: {
|
||||
Avatar,
|
||||
},
|
||||
})
|
||||
export default class ProfileCard extends Vue {
|
||||
|
||||
@Prop()
|
||||
profile!: Profile
|
||||
|
||||
@Prop()
|
||||
compact = false
|
||||
|
||||
private store = setup(() => {
|
||||
const { instance } = useInstanceInfo()
|
||||
return { instance }
|
||||
})
|
||||
|
||||
getAcct(): string {
|
||||
if (this.profile.acct.includes("@")) {
|
||||
// Remote account
|
||||
return `@${this.profile.acct}`
|
||||
}
|
||||
if (!this.store.instance) {
|
||||
return `@${this.profile.username}`
|
||||
}
|
||||
return `@${this.profile.username}@${this.store.instance.uri}`
|
||||
}
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../styles/layout";
|
||||
@import "../styles/theme";
|
||||
|
||||
$avatar-size: 90px;
|
||||
$profile-padding: $block-inner-padding / 2;
|
||||
|
||||
.profile {
|
||||
background-color: $block-background-color;
|
||||
border-radius: $block-border-radius;
|
||||
|
||||
.profile-header {
|
||||
background-color: $text-color;
|
||||
border-radius: $block-border-radius $block-border-radius 0 0;
|
||||
height: 100px;
|
||||
|
||||
img {
|
||||
border-radius: inherit;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.profile-info {
|
||||
padding: $profile-padding;
|
||||
}
|
||||
|
||||
.avatar-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin-bottom: $profile-padding;
|
||||
|
||||
.avatar {
|
||||
height: $avatar-size;
|
||||
margin-right: $profile-padding;
|
||||
margin-top: -($profile-padding + $avatar-size / 3);
|
||||
min-width: $avatar-size;
|
||||
padding: 4px;
|
||||
width: $avatar-size;
|
||||
}
|
||||
|
||||
.name-group {
|
||||
overflow-x: hidden;
|
||||
|
||||
.display-name {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.account-uri {
|
||||
color: $secondary-text-color;
|
||||
overflow-x: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bio {
|
||||
height: 1.2em;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
|
||||
:deep(p) {
|
||||
margin: 0;
|
||||
overflow-x: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin-top: $profile-padding;
|
||||
|
||||
.post-count {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
font-weight: bold;
|
||||
|
||||
.value {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.name {
|
||||
color: $secondary-text-color;
|
||||
margin-left: 0.3em;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
73
src/components/Search.vue
Normal file
|
@ -0,0 +1,73 @@
|
|||
<template>
|
||||
<form class="search" @submit.prevent="search($event)">
|
||||
<input type="text" placeholder="Search..." v-model="q">
|
||||
<button v-if="q" type="button" class="btn" @click="clear()">
|
||||
<img :src="require('@/assets/feather/delete.svg')">
|
||||
</button>
|
||||
<button type="submit" class="btn" :disabled="!q">
|
||||
<img :src="require('@/assets/feather/search.svg')">
|
||||
</button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Vue } from "vue-class-component"
|
||||
|
||||
export default class Search extends Vue {
|
||||
|
||||
q = ""
|
||||
|
||||
clear() {
|
||||
this.q = ""
|
||||
}
|
||||
|
||||
search() {
|
||||
this.$router.push({ name: "search", query: { q: this.q } })
|
||||
}
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../styles/theme";
|
||||
|
||||
.search {
|
||||
border-radius: $btn-border-radius;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
padding: 7px 15px;
|
||||
}
|
||||
|
||||
input {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
box-sizing: border-box;
|
||||
font-size: 15px;
|
||||
height: 100%;
|
||||
min-width: 0; /* fix for firefox 78 */
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: transparent;
|
||||
border-radius: 0 $btn-border-radius $btn-border-radius 0;
|
||||
font-size: 15px;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
|
||||
img {
|
||||
filter: $text-colorizer;
|
||||
height: 1.2em;
|
||||
min-width: 1.2em;
|
||||
object-fit: contain;
|
||||
vertical-align: middle;
|
||||
width: 1.2em;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
</style>
|
196
src/components/Sidebar.vue
Normal file
|
@ -0,0 +1,196 @@
|
|||
<template>
|
||||
<div v-if="isUserAuthenticated" class="sidebar">
|
||||
<router-link class="sidebar-link" to="/notifications">
|
||||
<div class="icon">
|
||||
<img :src="require('@/assets/feather/bell.svg')">
|
||||
<div v-if="unreadNotificationCount > 0" class="icon-badge">{{ unreadNotificationCount }}</div>
|
||||
</div>
|
||||
<span>Notifications</span>
|
||||
</router-link>
|
||||
<router-link class="sidebar-link" to="/profile-directory">
|
||||
<div class="icon"><img :src="require('@/assets/feather/users.svg')"></div>
|
||||
<span>Profile directory</span>
|
||||
</router-link>
|
||||
<router-link class="sidebar-link" to="/about">
|
||||
<div class="icon"><img :src="require('@/assets/feather/help-circle.svg')"></div>
|
||||
<span>About</span>
|
||||
</router-link>
|
||||
<a class="sidebar-link" @click="logout()">
|
||||
<div class="icon"><img :src="require('@/assets/feather/log-out.svg')"></div>
|
||||
<span>Logout</span>
|
||||
</a>
|
||||
</div>
|
||||
<div v-else-if="!isUserAuthenticated && instance" class="sidebar wide">
|
||||
<h1 class="instance-title">{{ instance.title }}</h1>
|
||||
<div class="instance-description">{{ instance.short_description }}</div>
|
||||
<router-link class="btn" :to="{name: 'about-public'}">Learn more</router-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Options, Vue, setup } from "vue-class-component"
|
||||
|
||||
import { InstanceInfo } from "@/api/instance"
|
||||
import Avatar from "@/components/Avatar.vue"
|
||||
import { useInstanceInfo } from "@/store/instance"
|
||||
import { useNotifications } from "@/store/notifications"
|
||||
import { useCurrentUser } from "@/store/user"
|
||||
|
||||
@Options({
|
||||
components: { Avatar },
|
||||
})
|
||||
export default class Sidebar extends Vue {
|
||||
|
||||
private store = setup(() => {
|
||||
const { instance } = useInstanceInfo()
|
||||
const { loadNotifications, getUnreadNotificationCount } = useNotifications()
|
||||
const { currentUser, setCurrentUser, ensureAuthToken, setAuthToken } = useCurrentUser()
|
||||
return {
|
||||
instance,
|
||||
currentUser,
|
||||
setCurrentUser,
|
||||
ensureAuthToken,
|
||||
setAuthToken,
|
||||
loadNotifications,
|
||||
getUnreadNotificationCount,
|
||||
}
|
||||
})
|
||||
|
||||
async created() {
|
||||
if (this.isUserAuthenticated) {
|
||||
// TODO: reload notifications periodically
|
||||
await this.store.loadNotifications(this.store.ensureAuthToken())
|
||||
}
|
||||
}
|
||||
|
||||
get isUserAuthenticated(): boolean {
|
||||
return this.store.currentUser !== null
|
||||
}
|
||||
|
||||
get unreadNotificationCount(): number {
|
||||
return this.store.getUnreadNotificationCount()
|
||||
}
|
||||
|
||||
async logout() {
|
||||
this.store.setCurrentUser(null)
|
||||
this.store.setAuthToken(null)
|
||||
this.$router.push({ name: "landing-page" })
|
||||
}
|
||||
|
||||
get instance(): InstanceInfo | null {
|
||||
return this.store.instance
|
||||
}
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../styles/layout";
|
||||
@import "../styles/mixins";
|
||||
@import "../styles/theme";
|
||||
|
||||
.sidebar {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar:not(.wide) {
|
||||
background-color: $background-color;
|
||||
flex-shrink: 0;
|
||||
gap: $block-outer-padding * 1.5;
|
||||
position: sticky;
|
||||
top: $header-height + $block-outer-padding;
|
||||
width: $sidebar-width;
|
||||
z-index: 2; /* header + 1 */
|
||||
}
|
||||
|
||||
.sidebar-link {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
font-size: 18px;
|
||||
|
||||
.icon {
|
||||
height: 20px;
|
||||
margin-left: 8px;
|
||||
margin-right: 10px;
|
||||
position: relative;
|
||||
text-align: center;
|
||||
width: 25px;
|
||||
|
||||
img {
|
||||
filter: $link-colorizer;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.icon-badge {
|
||||
background-color: $block-background-color;
|
||||
border-radius: 50%;
|
||||
font-size: 0.8rem;
|
||||
height: 1em;
|
||||
line-height: 1em;
|
||||
padding: 1px;
|
||||
position: absolute;
|
||||
right: -0.5em;
|
||||
top: -0.5em;
|
||||
width: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
img {
|
||||
filter: $link-hover-colorizer;
|
||||
}
|
||||
}
|
||||
|
||||
&.router-link-exact-active {
|
||||
color: $link-hover-color;
|
||||
|
||||
img {
|
||||
filter: $link-hover-colorizer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar.wide {
|
||||
@include block-btn;
|
||||
|
||||
background-color: $block-background-color;
|
||||
border-radius: $block-border-radius;
|
||||
flex-shrink: 1;
|
||||
gap: $block-inner-padding;
|
||||
padding: $block-inner-padding;
|
||||
width: $wide-sidebar-width;
|
||||
|
||||
h1 {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: min-content;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: $screen-breakpoint-small) {
|
||||
.sidebar:not(.wide) {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
padding-bottom: $body-padding;
|
||||
top: $header-height;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sidebar-link {
|
||||
span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar.wide {
|
||||
margin-bottom: $body-padding;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
3
src/constants.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export const BACKEND_URL = process.env.VUE_APP_BACKEND_URL
|
||||
|
||||
export const ENV = process.env.NODE_ENV
|
8
src/main.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import { createApp } from "vue"
|
||||
|
||||
import App from "./App.vue"
|
||||
import router from "./router"
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(router).mount("#app")
|
109
src/router/index.ts
Normal file
|
@ -0,0 +1,109 @@
|
|||
import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router"
|
||||
|
||||
import AboutPage from "@/views/About.vue"
|
||||
import AboutPublicPage from "@/views/AboutPublic.vue"
|
||||
import LandingPage from "../views/LandingPage.vue"
|
||||
import NotificationList from "../views/NotificationList.vue"
|
||||
import ProfileDirectory from "../views/ProfileDirectory.vue"
|
||||
import ProfileView from "@/views/Profile.vue"
|
||||
import ProfileForm from "@/views/ProfileForm.vue"
|
||||
import PostList from "@/views/PostList.vue"
|
||||
import PostDetail from "@/views/PostDetail.vue"
|
||||
import PostOverlay from "@/views/PostOverlay.vue"
|
||||
import SearchResultList from "@/views/SearchResultList.vue"
|
||||
|
||||
import { useCurrentUser } from "@/store/user"
|
||||
|
||||
async function authGuard(to: any) {
|
||||
const { isAuthenticated } = useCurrentUser()
|
||||
const isUserAuthenticated = await isAuthenticated()
|
||||
const onlyGuest = to.matched.some((record: RouteRecordRaw) => {
|
||||
return record.meta?.onlyGuest
|
||||
})
|
||||
const onlyAuthenticated = to.matched.some((record: RouteRecordRaw) => {
|
||||
return record.meta?.onlyAuthenticated
|
||||
})
|
||||
if (onlyGuest && isUserAuthenticated) {
|
||||
return { name: "home" }
|
||||
} else if (onlyAuthenticated && !isUserAuthenticated) {
|
||||
return { name: "landing-page" }
|
||||
}
|
||||
}
|
||||
|
||||
const routes: Array<RouteRecordRaw> = [
|
||||
{
|
||||
path: "/",
|
||||
name: "landing-page",
|
||||
component: LandingPage,
|
||||
meta: { onlyGuest: true },
|
||||
},
|
||||
{
|
||||
path: "/about-public",
|
||||
name: "about-public",
|
||||
component: AboutPublicPage,
|
||||
meta: { onlyGuest: true },
|
||||
},
|
||||
{
|
||||
path: "/about",
|
||||
name: "about",
|
||||
component: AboutPage,
|
||||
meta: { onlyAuthenticated: true },
|
||||
},
|
||||
{
|
||||
path: "/home",
|
||||
name: "home",
|
||||
component: PostList,
|
||||
meta: { onlyAuthenticated: true },
|
||||
},
|
||||
{
|
||||
path: "/post/:postId",
|
||||
name: "post",
|
||||
component: PostDetail,
|
||||
meta: { },
|
||||
},
|
||||
{
|
||||
path: "/post-overlay/:postId",
|
||||
name: "post-overlay",
|
||||
component: PostOverlay,
|
||||
meta: { },
|
||||
},
|
||||
{
|
||||
path: "/notifications",
|
||||
name: "notifications",
|
||||
component: NotificationList,
|
||||
meta: { onlyAuthenticated: true },
|
||||
},
|
||||
{
|
||||
path: "/profile/:profileId",
|
||||
name: "profile",
|
||||
component: ProfileView,
|
||||
meta: { },
|
||||
},
|
||||
{
|
||||
path: "/profile-directory",
|
||||
name: "profile-directory",
|
||||
component: ProfileDirectory,
|
||||
meta: { onlyAuthenticated: true },
|
||||
},
|
||||
{
|
||||
path: "/search",
|
||||
name: "search",
|
||||
component: SearchResultList,
|
||||
meta: { onlyAuthenticated: true },
|
||||
},
|
||||
{
|
||||
path: "/settings",
|
||||
name: "settings",
|
||||
component: ProfileForm,
|
||||
meta: { onlyAuthenticated: true },
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
router.beforeEach(authGuard)
|
||||
|
||||
export default router
|
6
src/shims-vue.d.ts
vendored
Normal file
|
@ -0,0 +1,6 @@
|
|||
/* eslint-disable */
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
17
src/store/instance.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
import { ref } from "vue"
|
||||
|
||||
import { InstanceInfo, getInstanceInfo } from "@/api/instance"
|
||||
|
||||
const instance = ref<InstanceInfo | null>(null)
|
||||
|
||||
export function useInstanceInfo() {
|
||||
|
||||
async function loadInstanceInfo(): Promise<void> {
|
||||
instance.value = await getInstanceInfo()
|
||||
}
|
||||
|
||||
return {
|
||||
instance,
|
||||
loadInstanceInfo,
|
||||
}
|
||||
}
|
41
src/store/notifications.ts
Normal file
|
@ -0,0 +1,41 @@
|
|||
import { ref } from "vue"
|
||||
|
||||
import { getNotificationMarker } from "@/api/markers"
|
||||
import { Notification, getNotifications } from "@/api/notifications"
|
||||
|
||||
const notifications = ref<Notification[]>([])
|
||||
const lastReadId = ref<string | null>(null)
|
||||
|
||||
export function useNotifications() {
|
||||
|
||||
async function loadNotifications(authToken: string): Promise<void> {
|
||||
const notifications_ = await getNotifications(authToken)
|
||||
const marker = await getNotificationMarker(authToken)
|
||||
// Don't update reactive object until marker is loaded
|
||||
notifications.value = notifications_
|
||||
if (marker) {
|
||||
lastReadId.value = marker.last_read_id
|
||||
}
|
||||
}
|
||||
|
||||
function getUnreadNotificationCount(): number {
|
||||
let unreadCount = 0
|
||||
if (lastReadId.value) {
|
||||
for (const notification of notifications.value) {
|
||||
if (notification.id === lastReadId.value) {
|
||||
break
|
||||
}
|
||||
unreadCount += 1
|
||||
}
|
||||
} else {
|
||||
unreadCount = notifications.value.length
|
||||
}
|
||||
return unreadCount
|
||||
}
|
||||
|
||||
return {
|
||||
notifications,
|
||||
loadNotifications,
|
||||
getUnreadNotificationCount,
|
||||
}
|
||||
}
|
60
src/store/user.ts
Normal file
|
@ -0,0 +1,60 @@
|
|||
import { ref } from "vue"
|
||||
|
||||
import { User, getCurrentUser } from "@/api/users"
|
||||
|
||||
const AUTH_TOKEN_STORAGE_KEY = "auth_token"
|
||||
|
||||
const currentUser = ref<User | null>(null)
|
||||
const isAuthChecked = ref(false)
|
||||
const authToken = ref<string | null>(null)
|
||||
|
||||
export function useCurrentUser() {
|
||||
function ensureCurrentUser(): User {
|
||||
if (currentUser.value === null) {
|
||||
throw new Error("user must be authenticated")
|
||||
}
|
||||
return currentUser.value
|
||||
}
|
||||
|
||||
function setCurrentUser(user: User | null) {
|
||||
currentUser.value = user
|
||||
}
|
||||
|
||||
function ensureAuthToken(): string {
|
||||
if (authToken.value === null) {
|
||||
throw new Error("user must be authenticated")
|
||||
}
|
||||
return authToken.value
|
||||
}
|
||||
|
||||
function setAuthToken(token: string | null) {
|
||||
if (token) {
|
||||
localStorage.setItem(AUTH_TOKEN_STORAGE_KEY, token)
|
||||
} else {
|
||||
localStorage.removeItem(AUTH_TOKEN_STORAGE_KEY)
|
||||
}
|
||||
authToken.value = token
|
||||
}
|
||||
|
||||
async function isAuthenticated(): Promise<boolean> {
|
||||
if (!isAuthChecked.value) {
|
||||
const token = localStorage.getItem(AUTH_TOKEN_STORAGE_KEY)
|
||||
if (token) {
|
||||
authToken.value = token
|
||||
currentUser.value = await getCurrentUser(token)
|
||||
}
|
||||
isAuthChecked.value = true
|
||||
}
|
||||
return currentUser.value !== null
|
||||
}
|
||||
|
||||
return {
|
||||
currentUser,
|
||||
ensureCurrentUser,
|
||||
setCurrentUser,
|
||||
authToken,
|
||||
ensureAuthToken,
|
||||
setAuthToken,
|
||||
isAuthenticated,
|
||||
}
|
||||
}
|
21
src/styles/_layout.css
Normal file
|
@ -0,0 +1,21 @@
|
|||
$body-padding: 15px;
|
||||
|
||||
$header-height: 75px;
|
||||
|
||||
$content-width: 600px;
|
||||
$content-min-width: 450px;
|
||||
$sidebar-width: 200px;
|
||||
$content-gap: 50px;
|
||||
|
||||
$block-outer-padding: 20px;
|
||||
$block-inner-padding: 20px;
|
||||
|
||||
$wide-content-width: 600px;
|
||||
$wide-sidebar-width: 400px;
|
||||
|
||||
$avatar-size: 42px;
|
||||
$icon-size: $block-inner-padding;
|
||||
|
||||
$screen-breakpoint-medium: 1000px;
|
||||
$screen-breakpoint-small: 700px;
|
||||
$screen-breakpoint-x-small: 400px;
|
95
src/styles/_mixins.scss
Normal file
|
@ -0,0 +1,95 @@
|
|||
@mixin floating-avatar {
|
||||
display: block;
|
||||
height: 0;
|
||||
|
||||
.avatar {
|
||||
float: left;
|
||||
height: $avatar-size;
|
||||
margin-left: -$avatar-size - $block-inner-padding * 2;
|
||||
margin-top: -$avatar-size / 2;
|
||||
width: $avatar-size;
|
||||
}
|
||||
|
||||
@media screen and (max-width: $screen-breakpoint-medium) {
|
||||
height: auto;
|
||||
|
||||
.avatar {
|
||||
float: none;
|
||||
height: $icon-size;
|
||||
margin-left: 0;
|
||||
margin-right: $icon-size / 2;
|
||||
margin-top: 0;
|
||||
width: $icon-size;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@mixin post-action {
|
||||
display: flex;
|
||||
font-weight: bold;
|
||||
margin: 0 $block-outer-padding $block-outer-padding / 2;
|
||||
|
||||
img {
|
||||
filter: $text-colorizer;
|
||||
height: $icon-size;
|
||||
margin-right: $icon-size / 2;
|
||||
min-width: $icon-size;
|
||||
object-fit: contain;
|
||||
vertical-align: middle;
|
||||
width: $icon-size;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin post-icon {
|
||||
.icon {
|
||||
align-items: center;
|
||||
color: $secondary-text-color;
|
||||
display: flex;
|
||||
gap: 0.25em;
|
||||
margin-right: $icon-size / 2;
|
||||
|
||||
img {
|
||||
filter: $secondary-text-colorizer;
|
||||
height: $icon-size;
|
||||
min-width: $icon-size;
|
||||
object-fit: contain;
|
||||
vertical-align: middle;
|
||||
width: $icon-size;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&.highlighted {
|
||||
color: $secondary-text-hover-color;
|
||||
|
||||
img {
|
||||
filter: $secondary-text-hover-colorizer;
|
||||
}
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-small img {
|
||||
$icon-size-small: $icon-size * 0.8;
|
||||
|
||||
height: $icon-size-small;
|
||||
min-width: $icon-size-small;
|
||||
width: $icon-size-small;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin block-btn {
|
||||
.btn {
|
||||
background-color: $btn-background-hover-color;
|
||||
border: 1px solid transparent;
|
||||
color: $btn-text-hover-color;
|
||||
|
||||
&:hover {
|
||||
background-color: transparent;
|
||||
border: 1px solid $btn-text-color;
|
||||
color: $btn-text-color;
|
||||
}
|
||||
}
|
||||
}
|
67
src/styles/_reset.scss
Normal file
|
@ -0,0 +1,67 @@
|
|||
/*! minireset.css v0.0.6 | MIT License | github.com/jgthms/minireset.css */
|
||||
html,
|
||||
body,
|
||||
p,
|
||||
ol,
|
||||
ul,
|
||||
li,
|
||||
dl,
|
||||
dt,
|
||||
dd,
|
||||
blockquote,
|
||||
figure,
|
||||
fieldset,
|
||||
legend,
|
||||
textarea,
|
||||
pre,
|
||||
iframe,
|
||||
hr,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-size: 100%;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
img,
|
||||
video {
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
iframe {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
td,
|
||||
th {
|
||||
padding: 0;
|
||||
}
|
29
src/styles/_theme.scss
Normal file
|
@ -0,0 +1,29 @@
|
|||
/* color-to-filter converter https://codepen.io/sosuke/pen/Pjoqqp */
|
||||
|
||||
$text-font-size: 15px;
|
||||
$text-color: #333;
|
||||
$text-colorizer: invert(17%) sepia(0%) saturate(0%) hue-rotate(356deg) brightness(101%) contrast(91%);
|
||||
$secondary-text-color: #999;
|
||||
$secondary-text-colorizer: invert(54%) sepia(57%) saturate(0%) hue-rotate(198deg) brightness(99%) contrast(101%);
|
||||
$secondary-text-hover-color: #666;
|
||||
$secondary-text-hover-colorizer: invert(42%) sepia(1%) saturate(220%) hue-rotate(314deg) brightness(92%) contrast(94%);
|
||||
$link-color: #484747;
|
||||
$link-colorizer: invert(27%) sepia(2%) saturate(0%) hue-rotate(58deg) brightness(96%) contrast(94%);
|
||||
$link-hover-color: #000000;
|
||||
$link-hover-colorizer: invert(0%) sepia(0%) saturate(0%) hue-rotate(324deg) brightness(96%) contrast(104%);
|
||||
$error-color: #FF4D4D;
|
||||
|
||||
$background-color: #f3f2ed;
|
||||
$btn-background-color: #CCCBC6;
|
||||
$btn-background-hover-color: $text-color;
|
||||
$btn-text-color: $text-color;
|
||||
$btn-text-colorizer: $text-colorizer;
|
||||
$btn-text-hover-color: #fff;
|
||||
$btn-text-hover-colorizer: invert(99%) sepia(74%) saturate(252%) hue-rotate(137deg) brightness(116%) contrast(100%);
|
||||
$btn-border-radius: 8px;
|
||||
$btn-shadow: 2px 5px 10px -5px #B7B5B5;
|
||||
|
||||
$block-border-radius: 15px;
|
||||
$block-background-color: #fff;
|
||||
$separator-color: $background-color;
|
||||
$shadow: 0 2px 16px -9px #C3C4C7;
|
8
src/utils/autoresize.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
export function setupAutoResize(textarea: HTMLTextAreaElement) {
|
||||
textarea.style.minHeight = `${textarea.offsetHeight}px`
|
||||
textarea.style.overflowY = "hidden"
|
||||
textarea.addEventListener("input", () => {
|
||||
textarea.style.height = "0px"
|
||||
textarea.style.height = `${textarea.scrollHeight}px`
|
||||
}, false)
|
||||
}
|
24
src/utils/cryptocurrencies.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
// Currency code; currency name; payment URI scheme
|
||||
export const CRYPTOCURRENCIES = [
|
||||
// https://github.com/bitcoin/bips/blob/master/bip-0021.mediawiki
|
||||
["BTC", "Bitcoin", "bitcoin"],
|
||||
|
||||
// https://bitcoincashstandards.org/
|
||||
["BCH", "Bitcoin Cash", "bitcoincash"],
|
||||
|
||||
["DASH", "Dash", "dash"],
|
||||
["DOGE", "Dogecoin", "dogecoin"],
|
||||
|
||||
// https://eips.ethereum.org/EIPS/eip-681
|
||||
// Not supported by MetaMask https://github.com/MetaMask/metamask-extension/issues/5125
|
||||
["ETH", "Ethereum", "ethereum"],
|
||||
|
||||
// https://electrum-ltc.org/litecoin_URIs.html
|
||||
["LTC", "Litecoin", "litecoin"],
|
||||
|
||||
// https://github.com/monero-project/monero/wiki/URI-Formatting
|
||||
["XMR", "Monero", "monero"],
|
||||
|
||||
// https://zips.z.cash/zip-0321
|
||||
["ZEC", "Zcash", "zcash"],
|
||||
]
|
23
src/utils/ethereum.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { Signer } from "ethers"
|
||||
import { Web3Provider } from "@ethersproject/providers"
|
||||
|
||||
export function getProvider(): Web3Provider | null {
|
||||
const provider = (window as any).ethereum
|
||||
return new Web3Provider(provider)
|
||||
}
|
||||
|
||||
export async function getSigner(): Promise<Signer | null> {
|
||||
const provider = getProvider()
|
||||
if (!provider) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
await provider.send("eth_requestAccounts", [])
|
||||
} catch (error) {
|
||||
console.log("metamask error:", error)
|
||||
// Access denied
|
||||
return null
|
||||
}
|
||||
const signer = provider.getSigner()
|
||||
return signer
|
||||
}
|
21
src/utils/format.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
import { DateTime } from "luxon"
|
||||
|
||||
export function formatDate(isoDate: string): string {
|
||||
const date = DateTime.fromISO(isoDate)
|
||||
const now = DateTime.now()
|
||||
const diff = now.diff(date)
|
||||
if (diff.as("minutes") < 60) {
|
||||
const minutes = Math.round(diff.as("minutes"))
|
||||
return `${minutes} minutes ago`
|
||||
} else if (diff.as("hours") < 24) {
|
||||
const hours = Math.round(diff.as("hours"))
|
||||
return `${hours} hours ago`
|
||||
} else if (diff.as("days") < 7) {
|
||||
const days = Math.round(diff.as("days"))
|
||||
return `${days} days ago`
|
||||
} else if (date.year === now.year) {
|
||||
return date.toFormat("dd LLL")
|
||||
} else {
|
||||
return date.toFormat("dd LLL y")
|
||||
}
|
||||
}
|
25
src/utils/markdown.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
const MarkdownIt = require("markdown-it") /* eslint-disable-line @typescript-eslint/no-var-requires */
|
||||
const MarkdownItLinkAttrs = require("markdown-it-link-attributes") /* eslint-disable-line @typescript-eslint/no-var-requires */
|
||||
|
||||
// Default renderer
|
||||
const markdown = new MarkdownIt({ linkify: true, breaks: true })
|
||||
.use(
|
||||
MarkdownItLinkAttrs,
|
||||
{ attrs: { target: "_blank", rel: "noopener" } },
|
||||
)
|
||||
|
||||
// Minimal renderer
|
||||
const markdownLite = new MarkdownIt({ linkify: true, breaks: true })
|
||||
.disable(["backticks", "emphasis", "strikethrough", "image"])
|
||||
.use(
|
||||
MarkdownItLinkAttrs,
|
||||
{ attrs: { target: "_blank", rel: "noopener" } },
|
||||
)
|
||||
|
||||
export function renderMarkdown(text: string): string {
|
||||
return markdown.render(text)
|
||||
}
|
||||
|
||||
export function renderMarkdownLite(text: string): string {
|
||||
return markdownLite.renderInline(text)
|
||||
}
|
15
src/utils/upload.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
export async function fileToDataUrl(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.readAsDataURL(file)
|
||||
reader.onload = () => {
|
||||
const result = reader.result as string
|
||||
resolve(result)
|
||||
}
|
||||
reader.onerror = error => reject(error)
|
||||
})
|
||||
}
|
||||
|
||||
export function dataUrlToBase64(dataUrl: string): string {
|
||||
return dataUrl.replace(/^data:.+;base64,/, "")
|
||||
}
|
50
src/views/About.vue
Normal file
|
@ -0,0 +1,50 @@
|
|||
<template>
|
||||
<div id="main">
|
||||
<div class="content about" v-if="instance">
|
||||
<h1>{{ instance.title }}</h1>
|
||||
<div class="description static-text" v-html="renderMarkdown(instance.description)"></div>
|
||||
</div>
|
||||
<sidebar></sidebar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Options, Vue, setup } from "vue-class-component"
|
||||
|
||||
import { InstanceInfo } from "@/api/instance"
|
||||
import Sidebar from "@/components/Sidebar.vue"
|
||||
import { useInstanceInfo } from "@/store/instance"
|
||||
import { renderMarkdown } from "@/utils/markdown"
|
||||
|
||||
@Options({
|
||||
components: {
|
||||
Sidebar,
|
||||
},
|
||||
})
|
||||
export default class AboutPage extends Vue {
|
||||
|
||||
private store = setup(() => {
|
||||
const { instance } = useInstanceInfo()
|
||||
return { instance }
|
||||
})
|
||||
|
||||
get instance(): InstanceInfo | null {
|
||||
return this.store.instance
|
||||
}
|
||||
|
||||
renderMarkdown(description: string): string {
|
||||
return renderMarkdown(description)
|
||||
}
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../styles/layout";
|
||||
|
||||
.description {
|
||||
font-size: 18px;
|
||||
font-weight: lighter;
|
||||
line-height: 2;
|
||||
}
|
||||
</style>
|
85
src/views/AboutPublic.vue
Normal file
|
@ -0,0 +1,85 @@
|
|||
<template>
|
||||
<div class="about-page">
|
||||
<div class="about" v-if="instance">
|
||||
<router-link class="back" to="/" title="Back">
|
||||
<img :src="require('@/assets/feather/arrow-left.svg')">
|
||||
</router-link>
|
||||
<h1>{{ instance.title }}</h1>
|
||||
<div class="description static-text" v-html="renderMarkdown(instance.description)"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Vue, setup } from "vue-class-component"
|
||||
|
||||
import { InstanceInfo } from "@/api/instance"
|
||||
import { useInstanceInfo } from "@/store/instance"
|
||||
import { renderMarkdown } from "@/utils/markdown"
|
||||
|
||||
export default class AboutPublicPage extends Vue {
|
||||
|
||||
private store = setup(() => {
|
||||
const { instance } = useInstanceInfo()
|
||||
return { instance }
|
||||
})
|
||||
|
||||
get instance(): InstanceInfo | null {
|
||||
return this.store.instance
|
||||
}
|
||||
|
||||
renderMarkdown(description: string): string {
|
||||
return renderMarkdown(description)
|
||||
}
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../styles/layout";
|
||||
@import "../styles/theme";
|
||||
|
||||
.about-page {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: $content-gap;
|
||||
margin: 0 auto;
|
||||
max-width: $wide-content-width + $content-gap + $wide-sidebar-width;
|
||||
padding-top: 20vh;
|
||||
}
|
||||
|
||||
.about {
|
||||
max-width: $wide-content-width;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.back {
|
||||
position: absolute;
|
||||
top: $body-padding;
|
||||
|
||||
img {
|
||||
filter: $btn-text-colorizer;
|
||||
width: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 90px;
|
||||
font-weight: bold;
|
||||
margin: 0 0 20px;
|
||||
text-transform: uppercase;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 24px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
@media screen and (max-width: $screen-breakpoint-small) {
|
||||
.about-page {
|
||||
padding-top: $content-gap;
|
||||
}
|
||||
}
|
||||
</style>
|
410
src/views/LandingPage.vue
Normal file
|
@ -0,0 +1,410 @@
|
|||
<template>
|
||||
<div class="landing-page">
|
||||
<div v-if="instance" class="instance-info">
|
||||
<h1 class="instance-title">{{ instance.title }}</h1>
|
||||
<div class="instance-description">
|
||||
{{ instance.short_description }}
|
||||
<br>
|
||||
<router-link :to="{name: 'about-public'}">Learn more <span class="arrow">>></span></router-link>
|
||||
</div>
|
||||
<div class="login">
|
||||
<button @click="login()">Sign In</button>
|
||||
<div v-if="loginErrorMessage" class="error-message">{{ loginErrorMessage }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<form v-if="instance" class="registration-form">
|
||||
<div v-if="isLoading" class="registration-form-loader">
|
||||
<loader></loader>
|
||||
</div>
|
||||
<div class="form-title">Want to join?</div>
|
||||
<div class="form-control">
|
||||
<div class="input-group">
|
||||
<input id="username" v-model="username" required placeholder="Username">
|
||||
<div class="addon">@{{ instance.uri }}</div>
|
||||
</div>
|
||||
<div class="form-message">Only letters, numbers and underscores are allowed.</div>
|
||||
</div>
|
||||
<div class="form-control" v-if="!instance.registrations">
|
||||
<input
|
||||
id="invite-token"
|
||||
v-model="inviteCode"
|
||||
required
|
||||
placeholder="Enter the invite code"
|
||||
>
|
||||
</div>
|
||||
<div class="wallet-required">
|
||||
<img :src="require('@/assets/forkawesome/ethereum.svg')">
|
||||
<a
|
||||
href="https://ethereum.org/en/wallets/find-wallet/?filters=has_explore_dapps"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>Ethereum Wallet</a> is required
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="!username"
|
||||
@click.prevent="register()"
|
||||
>Sign Up</button>
|
||||
<div v-if="registrationErrorMessage" class="error-message">{{ registrationErrorMessage }}</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Options, Vue, setup } from "vue-class-component"
|
||||
import { Web3Provider } from "@ethersproject/providers"
|
||||
|
||||
import { createUser, getAccessToken, getCurrentUser } from "@/api/users"
|
||||
import { InstanceInfo } from "@/api/instance"
|
||||
import Loader from "@/components/Loader.vue"
|
||||
import { useInstanceInfo } from "@/store/instance"
|
||||
import { useCurrentUser } from "@/store/user"
|
||||
import { getProvider } from "@/utils/ethereum"
|
||||
|
||||
@Options({
|
||||
components: { Loader },
|
||||
})
|
||||
export default class LandingPage extends Vue {
|
||||
|
||||
username = ""
|
||||
inviteCode: string | null = null
|
||||
isLoading = false
|
||||
loginErrorMessage: string | null = null
|
||||
registrationErrorMessage: string | null = null
|
||||
|
||||
private store = setup(() => {
|
||||
const { setCurrentUser, setAuthToken } = useCurrentUser()
|
||||
const { instance } = useInstanceInfo()
|
||||
return { setCurrentUser, setAuthToken, instance }
|
||||
})
|
||||
|
||||
get instance(): InstanceInfo | null {
|
||||
return this.store.instance
|
||||
}
|
||||
|
||||
private async getWalletAddress(provider: Web3Provider): Promise<string | null> {
|
||||
let walletAddress
|
||||
try {
|
||||
[walletAddress] = await provider.send("eth_requestAccounts", [])
|
||||
} catch (error) {
|
||||
// Access denied
|
||||
console.warn(error)
|
||||
return null
|
||||
}
|
||||
return walletAddress
|
||||
}
|
||||
|
||||
private async getSignature(provider: Web3Provider, walletAddress: string, message: string): Promise<string | null> {
|
||||
let signature
|
||||
try {
|
||||
signature = await provider.send(
|
||||
"personal_sign",
|
||||
[message, walletAddress],
|
||||
)
|
||||
} catch (error) {
|
||||
// Signature request rejected
|
||||
console.warn(error)
|
||||
return null
|
||||
}
|
||||
return signature
|
||||
}
|
||||
|
||||
async register() {
|
||||
this.registrationErrorMessage = null
|
||||
const provider = getProvider()
|
||||
if (!provider || !this.store.instance) {
|
||||
return
|
||||
}
|
||||
const loginMessage = this.store.instance.login_message
|
||||
const walletAddress = await this.getWalletAddress(provider)
|
||||
if (!walletAddress) {
|
||||
return
|
||||
}
|
||||
const signature = await this.getSignature(provider, walletAddress, loginMessage)
|
||||
if (!signature) {
|
||||
return
|
||||
}
|
||||
this.isLoading = true
|
||||
let user
|
||||
let authToken
|
||||
try {
|
||||
user = await createUser({
|
||||
username: this.username,
|
||||
password: signature,
|
||||
wallet_address: walletAddress,
|
||||
invite_code: this.inviteCode,
|
||||
})
|
||||
authToken = await getAccessToken({ wallet_address: walletAddress, signature })
|
||||
} catch (error) {
|
||||
this.isLoading = false
|
||||
this.registrationErrorMessage = error.message
|
||||
return
|
||||
}
|
||||
this.store.setCurrentUser(user)
|
||||
this.store.setAuthToken(authToken)
|
||||
this.isLoading = false
|
||||
this.$router.push({ name: "home" })
|
||||
}
|
||||
|
||||
async login() {
|
||||
this.loginErrorMessage = null
|
||||
const provider = getProvider()
|
||||
if (!provider || !this.store.instance) {
|
||||
return
|
||||
}
|
||||
const loginMessage = this.store.instance.login_message
|
||||
const walletAddress = await this.getWalletAddress(provider)
|
||||
if (!walletAddress) {
|
||||
return
|
||||
}
|
||||
const signature = await this.getSignature(provider, walletAddress, loginMessage)
|
||||
if (!signature) {
|
||||
return
|
||||
}
|
||||
const loginData = { wallet_address: walletAddress, signature }
|
||||
let user
|
||||
let authToken
|
||||
try {
|
||||
authToken = await getAccessToken(loginData)
|
||||
user = await getCurrentUser(authToken)
|
||||
} catch (error) {
|
||||
this.loginErrorMessage = error.message
|
||||
return
|
||||
}
|
||||
this.store.setCurrentUser(user)
|
||||
this.store.setAuthToken(authToken)
|
||||
this.$router.push({ name: "home" })
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../styles/layout";
|
||||
@import "../styles/theme";
|
||||
|
||||
$text-color: #fff;
|
||||
|
||||
button {
|
||||
background-color: #000;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
color: $text-color;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
padding: 10px 60px;
|
||||
}
|
||||
|
||||
.landing-page {
|
||||
align-items: flex-start;
|
||||
background-color: #000;
|
||||
background-image: url("../assets/startpage.png");
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
box-sizing: border-box;
|
||||
color: $text-color;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: $content-gap;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding-top: 20vh;
|
||||
}
|
||||
|
||||
.instance-info {
|
||||
max-width: $wide-content-width;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.instance-title {
|
||||
font-size: 90px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 20px;
|
||||
text-transform: uppercase;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.instance-description {
|
||||
font-size: 28px;
|
||||
line-height: 1.75;
|
||||
|
||||
a {
|
||||
color: $text-color;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
color: #7DFF54;
|
||||
|
||||
&:hover {
|
||||
color: $text-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.login {
|
||||
display: inline-block;
|
||||
margin-top: 30px;
|
||||
text-align: center;
|
||||
|
||||
button {
|
||||
border: 1px solid #979797;
|
||||
box-shadow: 0 2px 16px -5px #6E6E6E;
|
||||
|
||||
&:hover {
|
||||
background-color: #fff;
|
||||
border-color: #fff;
|
||||
color: #000;
|
||||
}
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: $error-color;
|
||||
margin-top: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.registration-form {
|
||||
border: 1px solid #979797;
|
||||
border-radius: 10px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: $wide-sidebar-width - 50px;
|
||||
padding: 25px 40px;
|
||||
position: relative;
|
||||
width: $wide-sidebar-width;
|
||||
|
||||
.form-title {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 25px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
input,
|
||||
.addon {
|
||||
background-color: #201f1f;
|
||||
border: none;
|
||||
line-height: 18px;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
input {
|
||||
border-radius: 10px;
|
||||
color: $text-color;
|
||||
min-width: 100px;
|
||||
|
||||
&::placeholder {
|
||||
color: #B3B3B3;
|
||||
}
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
input {
|
||||
border-radius: 10px 0 0 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.addon {
|
||||
border-radius: 0 10px 10px 0;
|
||||
color: #B3B3B3;
|
||||
padding-left: 0;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.form-message {
|
||||
font-size: 12px;
|
||||
margin-top: 3px;
|
||||
padding: 0 15px;
|
||||
}
|
||||
|
||||
button {
|
||||
background: linear-gradient(to right, #FF5959, #FF5EAD, #D835FE, #D963FF);
|
||||
box-shadow: 0 2px 16px -5px #BB5CC7;
|
||||
height: 48px;
|
||||
margin-top: 5px;
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(to right, #FF7373, #FF78BA, #DD4FFE, #DF7DFF);
|
||||
}
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: $error-color;
|
||||
margin-top: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.wallet-required {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 0.4em;
|
||||
justify-content: center;
|
||||
margin-bottom: 15px;
|
||||
|
||||
img {
|
||||
filter: $btn-text-hover-colorizer;
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
a {
|
||||
color: $text-color;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.registration-form-loader {
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
|
||||
.loader {
|
||||
margin-bottom: auto;
|
||||
margin-top: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.loader {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@media screen and (max-width: $screen-breakpoint-medium) {
|
||||
.registration-form {
|
||||
padding: 25px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: $screen-breakpoint-small) {
|
||||
.landing-page {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
padding-top: $content-gap;
|
||||
}
|
||||
|
||||
.registration-form {
|
||||
margin-right: auto;
|
||||
min-width: auto;
|
||||
|
||||
.form-title {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
115
src/views/NotificationList.vue
Normal file
|
@ -0,0 +1,115 @@
|
|||
<template>
|
||||
<div id="main">
|
||||
<div class="content">
|
||||
<div class="notification" v-for="notification in notifications" :key="notification.id">
|
||||
<div class="action">
|
||||
<template v-if="notification.type === 'follow'">
|
||||
<img :src="require('@/assets/feather/user-plus.svg')">
|
||||
<span>{{ getSenderName(notification) }} followed you</span>
|
||||
</template>
|
||||
<template v-else-if="notification.type === 'reply'">
|
||||
<img :src="require('@/assets/forkawesome/comment-o.svg')">
|
||||
<span>{{ getSenderName(notification) }} replied to your post</span>
|
||||
</template>
|
||||
<template v-else-if="notification.type === 'favourite'">
|
||||
<img :src="require('@/assets/forkawesome/thumbs-o-up.svg')">
|
||||
<span>{{ getSenderName(notification) }} liked your post</span>
|
||||
</template>
|
||||
</div>
|
||||
<post v-if="notification.status" :post="notification.status"></post>
|
||||
<router-link
|
||||
v-else
|
||||
class="profile"
|
||||
:to="{ name: 'profile', params: { profileId: notification.account.id }}"
|
||||
>
|
||||
<div class="floating-avatar">
|
||||
<avatar :profile="notification.account"></avatar>
|
||||
</div>
|
||||
<div class="display-name">{{ getSenderName(notification) }}</div>
|
||||
<div class="username">@{{ notification.account.acct }}</div>
|
||||
<div class="timestamp">{{ formatDate(notification.created_at) }}</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<sidebar></sidebar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import { ref, onMounted } from "vue"
|
||||
import { updateNotificationMarker } from "@/api/markers"
|
||||
import { getNotifications } from "@/api/notifications"
|
||||
import Avatar from "@/components/Avatar.vue"
|
||||
import Post from "@/components/Post.vue"
|
||||
import Sidebar from "@/components/Sidebar.vue"
|
||||
import { useNotifications } from "@/store/notifications"
|
||||
import { useCurrentUser } from "@/store/user"
|
||||
import { formatDate } from "@/utils/format"
|
||||
|
||||
const { notifications } = useNotifications()
|
||||
|
||||
onMounted(async () => {
|
||||
const { ensureAuthToken } = useCurrentUser()
|
||||
// Update notification timeline marker
|
||||
const firstNotification = notifications.value[0]
|
||||
if (firstNotification) {
|
||||
await updateNotificationMarker(
|
||||
ensureAuthToken(),
|
||||
firstNotification.id,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
function getSenderName(notification: Notification): string {
|
||||
const sender = notification.account
|
||||
return sender.display_name || sender.username
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../styles/layout";
|
||||
@import "../styles/mixins";
|
||||
@import "../styles/theme";
|
||||
|
||||
.notification {
|
||||
margin-bottom: $block-outer-padding;
|
||||
}
|
||||
|
||||
.action {
|
||||
@include post-action;
|
||||
}
|
||||
|
||||
.profile {
|
||||
align-items: center;
|
||||
background-color: $block-background-color;
|
||||
border-radius: $block-border-radius;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
padding: $block-inner-padding;
|
||||
|
||||
.floating-avatar {
|
||||
@include floating-avatar;
|
||||
}
|
||||
|
||||
.display-name {
|
||||
color: $text-color;
|
||||
font-weight: bold;
|
||||
margin-right: $block-inner-padding / 2;
|
||||
}
|
||||
|
||||
.username {
|
||||
color: $secondary-text-color;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
color: $secondary-text-color;
|
||||
text-align: right;
|
||||
|
||||
&:hover {
|
||||
color: $secondary-text-hover-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
111
src/views/PostDetail.vue
Normal file
|
@ -0,0 +1,111 @@
|
|||
<template>
|
||||
<div id="main">
|
||||
<div class="content posts">
|
||||
<post
|
||||
v-for="(post, index) in thread"
|
||||
:key="post.id"
|
||||
:post="post"
|
||||
:highlighted="isHighlighted(post)"
|
||||
:in-thread="true"
|
||||
@highlight="onPostHighlight($event)"
|
||||
@navigate-to="onPostNavigate($event)"
|
||||
@comment-created="onCommentCreated(index, $event)"
|
||||
></post>
|
||||
</div>
|
||||
<sidebar></sidebar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Options, Vue, setup } from "vue-class-component"
|
||||
|
||||
import { Post, getPostContext } from "@/api/posts"
|
||||
import Avatar from "@/components/Avatar.vue"
|
||||
import PostComponent from "@/components/Post.vue"
|
||||
import Sidebar from "@/components/Sidebar.vue"
|
||||
import { useCurrentUser } from "@/store/user"
|
||||
|
||||
@Options({
|
||||
components: {
|
||||
Avatar,
|
||||
Post: PostComponent,
|
||||
Sidebar,
|
||||
},
|
||||
})
|
||||
export default class PostDetail extends Vue {
|
||||
|
||||
private store = setup(() => {
|
||||
const { authToken } = useCurrentUser()
|
||||
return { authToken }
|
||||
})
|
||||
|
||||
private loader!: Promise<Post[]>
|
||||
private selectedId: string | null = null
|
||||
private highlightedId: string | null = null
|
||||
|
||||
thread: Post[] = []
|
||||
|
||||
created() {
|
||||
this.selectedId = this.$route.params.postId as string
|
||||
this.loader = getPostContext(this.store.authToken, this.selectedId)
|
||||
}
|
||||
|
||||
async mounted() {
|
||||
this.thread = await this.loader
|
||||
this.$nextTick(() => {
|
||||
// TODO: scrolls to wrong position if posts above it have images
|
||||
this.scrollTo(this.selectedId as string)
|
||||
})
|
||||
}
|
||||
|
||||
private scrollTo(postId: string, options: any = {}) {
|
||||
const containerOffset = this.$el.offsetTop // sticky header height or top margin
|
||||
const postElem = this.$el.querySelector(`div[data-post-id="${postId}"]`)
|
||||
window.scroll({
|
||||
top: (postElem.offsetTop - containerOffset),
|
||||
left: 0,
|
||||
...options,
|
||||
})
|
||||
if (this.selectedId === postId) {
|
||||
return
|
||||
}
|
||||
// Update postId in page URL
|
||||
history.pushState(
|
||||
{},
|
||||
"",
|
||||
location.pathname.replace(this.selectedId as string, postId),
|
||||
)
|
||||
this.selectedId = postId
|
||||
}
|
||||
|
||||
isHighlighted(post: Post): boolean {
|
||||
if (this.thread.length === 1) {
|
||||
return false
|
||||
}
|
||||
return post.id === this.selectedId || post.id === this.highlightedId
|
||||
}
|
||||
|
||||
onPostHighlight(postId: string | null) {
|
||||
this.highlightedId = postId
|
||||
}
|
||||
|
||||
onPostNavigate(postId: string) {
|
||||
this.scrollTo(postId, { behavior: "smooth" })
|
||||
}
|
||||
|
||||
onCommentCreated(index: number, post: Post) {
|
||||
// Insert comment after parent post
|
||||
this.thread.splice(index + 1, 0, post)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../styles/layout";
|
||||
|
||||
.post {
|
||||
margin: 0 0 $block-outer-padding;
|
||||
}
|
||||
</style>
|
61
src/views/PostList.vue
Normal file
|
@ -0,0 +1,61 @@
|
|||
<template>
|
||||
<div id="main">
|
||||
<div class="content posts">
|
||||
<post-editor @post-created="insertPost"></post-editor>
|
||||
<post-or-repost v-for="post in posts" :post="post" :key="post.id"></post-or-repost>
|
||||
</div>
|
||||
<sidebar></sidebar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Options, Vue, setup } from "vue-class-component"
|
||||
|
||||
import { Post, getPosts } from "@/api/posts"
|
||||
import Avatar from "@/components/Avatar.vue"
|
||||
import PostOrRepost from "@/components/PostOrRepost.vue"
|
||||
import PostEditor from "@/components/PostEditor.vue"
|
||||
import Sidebar from "@/components/Sidebar.vue"
|
||||
import { useCurrentUser } from "@/store/user"
|
||||
|
||||
@Options({
|
||||
components: {
|
||||
Avatar,
|
||||
PostOrRepost,
|
||||
PostEditor,
|
||||
Sidebar,
|
||||
},
|
||||
})
|
||||
export default class PostList extends Vue {
|
||||
|
||||
private store = setup(() => {
|
||||
const { ensureAuthToken } = useCurrentUser()
|
||||
return { ensureAuthToken }
|
||||
})
|
||||
|
||||
posts: Post[] = []
|
||||
|
||||
async created() {
|
||||
const authToken = this.store.ensureAuthToken()
|
||||
this.posts = await getPosts(authToken)
|
||||
}
|
||||
|
||||
insertPost(post: Post) {
|
||||
this.posts = [post, ...this.posts]
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../styles/layout";
|
||||
|
||||
.post-form {
|
||||
margin-bottom: $block-outer-padding * 2;
|
||||
}
|
||||
|
||||
:deep(.post) {
|
||||
margin-bottom: $block-outer-padding;
|
||||
}
|
||||
</style>
|
241
src/views/PostOverlay.vue
Normal file
|
@ -0,0 +1,241 @@
|
|||
<template>
|
||||
<div v-if="post && token" class="post-overlay">
|
||||
<div v-if="canGoBack()" class="back-btn-wrapper">
|
||||
<a class="back-btn" title="Back" @click="goBack()">
|
||||
<img :src="require('@/assets/feather/arrow-left.svg')">
|
||||
</a>
|
||||
</div>
|
||||
<div class="token">
|
||||
<div class="token-content">
|
||||
<div class="token-description" v-html="token.description"></div>
|
||||
<div class="token-image">
|
||||
<img :src="imageUrl">
|
||||
</div>
|
||||
</div>
|
||||
<div class="token-info">
|
||||
<router-link class="profile" :to="{ name: 'profile', params: { profileId: post.account.id }}">
|
||||
<avatar :profile="post.account"></avatar>
|
||||
<div class="account-uri">@{{ post.account.acct }}</div>
|
||||
</router-link>
|
||||
<a
|
||||
v-if="transactionUrl"
|
||||
class="token-tx-id"
|
||||
:href="transactionUrl"
|
||||
title="Mint Tx"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<img :src="require('@/assets/forkawesome/ethereum.svg')">
|
||||
<div>0x{{ post.token_tx_id }}</div>
|
||||
</a>
|
||||
<a
|
||||
v-if="metadataUrl"
|
||||
class="token-ipfs-cid"
|
||||
title="Metadata IPFS CID"
|
||||
:href="metadataUrl"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<img :src="require('@/assets/ipfs.svg')">
|
||||
<div>{{ post.ipfs_cid }}</div>
|
||||
</a>
|
||||
<div class="created-at">
|
||||
<div class="label">Created:</div>
|
||||
<router-link :to="{ name: 'post', params: { postId: post.id }}">
|
||||
{{ formatDate(post.created_at) }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Options, Vue, setup } from "vue-class-component"
|
||||
|
||||
import { DateTime } from "luxon"
|
||||
|
||||
import { getTokenMetadata, TokenMetadata } from "@/api/nft"
|
||||
import { Post, getPost } from "@/api/posts"
|
||||
import Avatar from "@/components/Avatar.vue"
|
||||
import { useInstanceInfo } from "@/store/instance"
|
||||
import { useCurrentUser } from "@/store/user"
|
||||
|
||||
@Options({
|
||||
components: {
|
||||
Avatar,
|
||||
},
|
||||
})
|
||||
export default class PostOverlay extends Vue {
|
||||
|
||||
post: Post | null = null
|
||||
token: TokenMetadata | null = null
|
||||
|
||||
private store = setup(() => {
|
||||
const { instance } = useInstanceInfo()
|
||||
const { currentUser, authToken } = useCurrentUser()
|
||||
return { instance, currentUser, authToken }
|
||||
})
|
||||
|
||||
async created() {
|
||||
this.post = await getPost(
|
||||
this.store.authToken,
|
||||
this.$route.params.postId as string,
|
||||
)
|
||||
const metadataUrl = this.metadataUrl
|
||||
if (metadataUrl) {
|
||||
this.token = await getTokenMetadata(metadataUrl)
|
||||
}
|
||||
}
|
||||
|
||||
canGoBack(): boolean {
|
||||
return this.store.currentUser !== null
|
||||
}
|
||||
|
||||
goBack() {
|
||||
this.$router.back()
|
||||
}
|
||||
|
||||
get transactionUrl(): string | null {
|
||||
const explorerUrl = this.store.instance?.ethereum_explorer_url
|
||||
if (!explorerUrl || !this.post?.token_tx_id) {
|
||||
return null
|
||||
}
|
||||
return `${explorerUrl}/tx/0x${this.post.token_tx_id}`
|
||||
}
|
||||
|
||||
get metadataUrl(): string | null {
|
||||
const gatewayUrl = this.store.instance?.ipfs_gateway_url
|
||||
if (!gatewayUrl || !this.post) {
|
||||
return null
|
||||
}
|
||||
return `${gatewayUrl}/ipfs/${this.post.ipfs_cid}`
|
||||
}
|
||||
|
||||
get imageUrl(): string | null {
|
||||
const gatewayUrl = this.store.instance?.ipfs_gateway_url
|
||||
if (!gatewayUrl || !this.token) {
|
||||
return null
|
||||
}
|
||||
return this.token.image.replace("ipfs://", `${gatewayUrl}/ipfs/`)
|
||||
}
|
||||
|
||||
formatDate(isoDate: string): string {
|
||||
const date = DateTime.fromISO(isoDate)
|
||||
return date.toLocaleString(DateTime.DATE_FULL)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../styles/layout";
|
||||
@import "../styles/theme";
|
||||
|
||||
$page-width: $wide-content-width + $content-gap + $wide-sidebar-width;
|
||||
|
||||
.post-overlay {
|
||||
background-color: #fff;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.back-btn-wrapper {
|
||||
left: 0;
|
||||
padding-left: inherit;
|
||||
padding-right: inherit;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: $body-padding;
|
||||
|
||||
.back-btn {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
max-width: 100%;
|
||||
width: $page-width;
|
||||
|
||||
img {
|
||||
filter: $btn-text-colorizer;
|
||||
width: 40px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.token {
|
||||
margin: 0 auto;
|
||||
max-width: 100%;
|
||||
width: $page-width;
|
||||
}
|
||||
|
||||
.token-content {
|
||||
font-size: 24px;
|
||||
line-height: 1.5;
|
||||
padding-bottom: 5%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.token-image {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.token-info {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: $block-outer-padding 70px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.profile {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
|
||||
.avatar {
|
||||
height: $avatar-size;
|
||||
width: $avatar-size;
|
||||
}
|
||||
}
|
||||
|
||||
.token-tx-id,
|
||||
.token-ipfs-cid {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
line-height: 20px;
|
||||
max-width: 150px;
|
||||
|
||||
img {
|
||||
filter: $text-colorizer;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
div {
|
||||
overflow-x: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.created-at {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
.label {
|
||||
font-weight: bold;
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: $screen-breakpoint-medium) {
|
||||
.token-info {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
261
src/views/Profile.vue
Normal file
|
@ -0,0 +1,261 @@
|
|||
<template>
|
||||
<div id="main" v-if="profile">
|
||||
<div class="content posts">
|
||||
<div class="profile">
|
||||
<div class="profile-header">
|
||||
<img v-if="profile.header" :src="profile.header">
|
||||
</div>
|
||||
<div class="profile-info">
|
||||
<avatar :profile="profile"></avatar>
|
||||
<div class="name-group">
|
||||
<div class="display-name">{{ profile.display_name || profile.username }}</div>
|
||||
<div class="account-uri">@{{ profile.acct }}</div>
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<router-link v-if="isCurrentUser()" class="edit-profile btn" to="/settings">Edit profile</router-link>
|
||||
<a v-if="canFollow()" class="follow btn" @click="follow()">Follow</a>
|
||||
<a v-if="canUnfollow()" class="unfollow btn" @click="unfollow()">
|
||||
<template v-if="isFollowRequestPending()">Cancel follow request</template>
|
||||
<template v-else>Unfollow</template>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bio" v-html="profile.note"></div>
|
||||
<div class="extra-fields" v-if="profile.fields.length > 0">
|
||||
<dl v-for="field in profile.fields" :key="field.name">
|
||||
<dt>{{ field.name }}</dt>
|
||||
<dd v-html="field.value"></dd>
|
||||
</dl>
|
||||
</div>
|
||||
<dl class="stats">
|
||||
<dt>{{ profile.statuses_count }}</dt><dd>posts</dd>
|
||||
<dt>{{ profile.following_count }}</dt><dd>following</dd>
|
||||
<dt>{{ profile.followers_count }}</dt><dd>followers</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<post-or-repost v-for="post in posts" :post="post" :key="post.id"></post-or-repost>
|
||||
</div>
|
||||
<sidebar></sidebar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Options, Vue, setup } from "vue-class-component"
|
||||
|
||||
import { Profile, getProfile } from "@/api/users"
|
||||
import { Post, getPostsByAuthor } from "@/api/posts"
|
||||
import {
|
||||
follow,
|
||||
unfollow,
|
||||
Relationship,
|
||||
getRelationship,
|
||||
} from "@/api/relationships"
|
||||
import Avatar from "@/components/Avatar.vue"
|
||||
import PostOrRepost from "@/components/PostOrRepost.vue"
|
||||
import Sidebar from "@/components/Sidebar.vue"
|
||||
import { useCurrentUser } from "@/store/user"
|
||||
|
||||
@Options({
|
||||
components: {
|
||||
Avatar,
|
||||
PostOrRepost,
|
||||
Sidebar,
|
||||
},
|
||||
})
|
||||
export default class ProfileView extends Vue {
|
||||
|
||||
private store = setup(() => {
|
||||
const { currentUser, authToken, ensureAuthToken } = useCurrentUser()
|
||||
return { currentUser, authToken, ensureAuthToken }
|
||||
})
|
||||
|
||||
profile: Profile | null = null
|
||||
relationship: Relationship | null = null
|
||||
posts: Post[] = []
|
||||
|
||||
async created() {
|
||||
this.profile = await getProfile(
|
||||
this.store.authToken,
|
||||
this.$route.params.profileId as string,
|
||||
)
|
||||
if (this.store.currentUser && !this.isCurrentUser()) {
|
||||
this.relationship = await getRelationship(
|
||||
this.store.ensureAuthToken(),
|
||||
this.profile.id,
|
||||
)
|
||||
}
|
||||
this.posts = await getPostsByAuthor(
|
||||
this.store.authToken,
|
||||
this.profile.id,
|
||||
)
|
||||
}
|
||||
|
||||
isCurrentUser(): boolean {
|
||||
if (!this.store.currentUser || !this.profile) {
|
||||
return false
|
||||
}
|
||||
return this.store.currentUser.id === this.profile.id
|
||||
}
|
||||
|
||||
canFollow(): boolean {
|
||||
if (!this.relationship) {
|
||||
return false
|
||||
}
|
||||
return !this.relationship.following && !this.relationship.requested
|
||||
}
|
||||
|
||||
canUnfollow(): boolean {
|
||||
if (!this.relationship) {
|
||||
return false
|
||||
}
|
||||
return (this.relationship.following || this.relationship.requested)
|
||||
}
|
||||
|
||||
isFollowRequestPending(): boolean {
|
||||
if (!this.relationship) {
|
||||
return false
|
||||
}
|
||||
return this.relationship.requested
|
||||
}
|
||||
|
||||
async follow() {
|
||||
if (!this.store.currentUser || !this.profile) {
|
||||
return
|
||||
}
|
||||
this.relationship = await follow(
|
||||
this.store.ensureAuthToken(),
|
||||
this.profile.id,
|
||||
)
|
||||
}
|
||||
|
||||
async unfollow() {
|
||||
if (!this.store.currentUser || !this.profile) {
|
||||
return
|
||||
}
|
||||
this.relationship = await unfollow(
|
||||
this.store.ensureAuthToken(),
|
||||
this.profile.id,
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../styles/layout";
|
||||
@import "../styles/mixins";
|
||||
@import "../styles/theme";
|
||||
|
||||
$avatar-size: 170px;
|
||||
|
||||
.profile {
|
||||
background-color: $block-background-color;
|
||||
border-radius: $block-border-radius;
|
||||
margin-bottom: $block-outer-padding;
|
||||
|
||||
.profile-header {
|
||||
background-color: $text-color;
|
||||
border-radius: $block-border-radius $block-border-radius 0 0;
|
||||
height: 200px;
|
||||
|
||||
img {
|
||||
border-radius: inherit;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.profile-info {
|
||||
@include block-btn;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: $block-inner-padding;
|
||||
padding: $block-inner-padding;
|
||||
|
||||
.avatar {
|
||||
height: $avatar-size;
|
||||
margin-top: -($avatar-size / 2 + $block-inner-padding);
|
||||
min-width: $avatar-size;
|
||||
padding: 7px;
|
||||
width: $avatar-size;
|
||||
}
|
||||
|
||||
.name-group {
|
||||
flex-grow: 1;
|
||||
overflow-x: hidden;
|
||||
|
||||
.display-name {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.account-uri {
|
||||
color: $secondary-text-color;
|
||||
overflow-x: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bio {
|
||||
padding: 0 $block-inner-padding $block-inner-padding;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.extra-fields {
|
||||
border-bottom: 1px solid $separator-color;
|
||||
margin-bottom: $block-inner-padding;
|
||||
|
||||
dl {
|
||||
display: flex;
|
||||
|
||||
dt,
|
||||
dd {
|
||||
border-top: 1px solid $separator-color;
|
||||
padding: $block-inner-padding / 2 $block-inner-padding;
|
||||
}
|
||||
|
||||
dt {
|
||||
font-weight: bold;
|
||||
min-width: 120px;
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
dd {
|
||||
flex-grow: 1;
|
||||
overflow-x: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
font-weight: bold;
|
||||
padding: 0 $block-inner-padding $block-inner-padding;
|
||||
text-align: center;
|
||||
|
||||
dt {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
dd {
|
||||
align-self: flex-end;
|
||||
color: $secondary-text-color;
|
||||
margin-left: 5px;
|
||||
margin-right: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
.action {
|
||||
@include post-action;
|
||||
}
|
||||
|
||||
:deep(.post) {
|
||||
margin-bottom: $block-outer-padding;
|
||||
}
|
||||
</style>
|
61
src/views/ProfileDirectory.vue
Normal file
|
@ -0,0 +1,61 @@
|
|||
<template>
|
||||
<div id="main">
|
||||
<div class="content profile-list">
|
||||
<router-link
|
||||
v-for="profile in profiles"
|
||||
class="profile-list-item"
|
||||
:to="{ name: 'profile', params: { profileId: profile.id }}"
|
||||
:key="profile.id"
|
||||
>
|
||||
<profile-card :profile="profile"></profile-card>
|
||||
</router-link>
|
||||
</div>
|
||||
<sidebar></sidebar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Options, Vue, setup } from "vue-class-component"
|
||||
|
||||
import { Profile, getProfiles } from "@/api/users"
|
||||
import ProfileCard from "@/components/ProfileCard.vue"
|
||||
import Sidebar from "@/components/Sidebar.vue"
|
||||
import { useCurrentUser } from "@/store/user"
|
||||
|
||||
@Options({
|
||||
components: {
|
||||
ProfileCard,
|
||||
Sidebar,
|
||||
},
|
||||
})
|
||||
export default class ProfileDirectory extends Vue {
|
||||
|
||||
private store = setup(() => {
|
||||
const { ensureAuthToken } = useCurrentUser()
|
||||
return { ensureAuthToken }
|
||||
})
|
||||
|
||||
profiles: Profile[] = []
|
||||
|
||||
async created() {
|
||||
const authToken = this.store.ensureAuthToken()
|
||||
this.profiles = await getProfiles(authToken)
|
||||
}
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../styles/layout";
|
||||
@import "../styles/theme";
|
||||
|
||||
.profile-list {
|
||||
display: grid;
|
||||
gap: $block-outer-padding;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
}
|
||||
|
||||
.profile-list-item {
|
||||
color: $text-color;
|
||||
}
|
||||
</style>
|
315
src/views/ProfileForm.vue
Normal file
|
@ -0,0 +1,315 @@
|
|||
<template>
|
||||
<div id="main">
|
||||
<form class="content settings" @submit.prevent="save()">
|
||||
<h1>Edit profile</h1>
|
||||
<div class="input-group">
|
||||
<label for="display-name">Display name</label>
|
||||
<input id="display-name" v-model.trim="form.display_name">
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label for="bio">Bio</label>
|
||||
<textarea
|
||||
id="bio"
|
||||
ref="bioInput"
|
||||
:value="form.note_source"
|
||||
@input="updateNote($event.target.value)"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="image-upload-group">
|
||||
<profile-card :profile="profilePreview" :compact="true"></profile-card>
|
||||
<div class="image-upload-inputs">
|
||||
<div class="input-group">
|
||||
<label for="avatar">Avatar</label>
|
||||
<input
|
||||
type="file"
|
||||
id="avatar"
|
||||
accept="image/*"
|
||||
@change="onFilePicked('avatar', $event.target.files)"
|
||||
>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label for="banner">Banner</label>
|
||||
<input
|
||||
type="file"
|
||||
id="banner"
|
||||
accept="image/*"
|
||||
@change="onFilePicked('header', $event.target.files)"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="extra-fields input-group">
|
||||
<label>
|
||||
Additional info
|
||||
<div class="sub-label">You can have up to {{ extraFieldMaxCount }} items displayed as a table on your profile</div>
|
||||
</label>
|
||||
<div
|
||||
v-for="(field, index) in form.fields_attributes"
|
||||
:key="index"
|
||||
class="extra-field"
|
||||
:class="{'error': !isValidExtraField(index)}"
|
||||
>
|
||||
<input v-model.trim="field.name" placeholder="Label">
|
||||
<input
|
||||
:value="field.value_source"
|
||||
@input="updateExtraFieldValue(field, $event.target.value)"
|
||||
placeholder="Content"
|
||||
>
|
||||
<a
|
||||
class="remove-extra-field"
|
||||
title="Remove item"
|
||||
@click="removeExtraField(index)"
|
||||
>
|
||||
<img :src="require('@/assets/feather/x-circle.svg')">
|
||||
</a>
|
||||
</div>
|
||||
<a
|
||||
v-if="form.fields_attributes.length <= extraFieldMaxCount"
|
||||
class="add-extra-field"
|
||||
@click="addExtraField()"
|
||||
>
|
||||
<img :src="require('@/assets/feather/plus-circle.svg')">
|
||||
Add new item
|
||||
</a>
|
||||
</div>
|
||||
<button type="submit" class="btn">Save</button>
|
||||
</form>
|
||||
<sidebar></sidebar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Options, Vue, setup } from "vue-class-component"
|
||||
|
||||
import {
|
||||
Profile,
|
||||
User,
|
||||
ProfileFieldAttrs,
|
||||
ProfileUpdateData,
|
||||
updateProfile,
|
||||
} from "@/api/users"
|
||||
import ProfileCard from "@/components/ProfileCard.vue"
|
||||
import Sidebar from "@/components/Sidebar.vue"
|
||||
import { useCurrentUser } from "@/store/user"
|
||||
import { setupAutoResize } from "@/utils/autoresize"
|
||||
import { renderMarkdownLite } from "@/utils/markdown"
|
||||
import { fileToDataUrl, dataUrlToBase64 } from "@/utils/upload"
|
||||
|
||||
@Options({
|
||||
components: {
|
||||
ProfileCard,
|
||||
Sidebar,
|
||||
},
|
||||
})
|
||||
export default class ProfileForm extends Vue {
|
||||
|
||||
private store = setup(() => {
|
||||
const { ensureCurrentUser, setCurrentUser, ensureAuthToken } = useCurrentUser()
|
||||
return { ensureCurrentUser, setCurrentUser, ensureAuthToken }
|
||||
})
|
||||
|
||||
form: ProfileUpdateData = {
|
||||
display_name: null,
|
||||
note: null,
|
||||
note_source: null,
|
||||
avatar: null,
|
||||
header: null,
|
||||
fields_attributes: [],
|
||||
}
|
||||
|
||||
images: {
|
||||
avatar: string | null,
|
||||
header: string | null,
|
||||
} = { avatar: null, header: null }
|
||||
|
||||
extraFieldMaxCount = 10
|
||||
|
||||
private get profile(): User {
|
||||
return this.store.ensureCurrentUser()
|
||||
}
|
||||
|
||||
$refs!: { bioInput: HTMLTextAreaElement }
|
||||
|
||||
created() {
|
||||
const fields_attributes = []
|
||||
for (let index = 0; index < this.profile.fields.length; index++) {
|
||||
const field_attributes = {
|
||||
name: this.profile.fields[index].name,
|
||||
value: this.profile.fields[index].value,
|
||||
value_source: this.profile.source.fields[index].value,
|
||||
}
|
||||
fields_attributes.push(field_attributes)
|
||||
}
|
||||
this.form = {
|
||||
...this.form,
|
||||
display_name: this.profile.display_name,
|
||||
note: this.profile.note,
|
||||
note_source: this.profile.source.note,
|
||||
fields_attributes: fields_attributes,
|
||||
}
|
||||
this.images = {
|
||||
avatar: this.profile.avatar,
|
||||
header: this.profile.header,
|
||||
}
|
||||
}
|
||||
|
||||
mounted() {
|
||||
setupAutoResize(this.$refs.bioInput)
|
||||
}
|
||||
|
||||
updateNote(value: string) {
|
||||
this.form.note_source = value
|
||||
this.form.note = renderMarkdownLite(this.form.note_source)
|
||||
}
|
||||
|
||||
get profilePreview(): Profile {
|
||||
return {
|
||||
...this.profile,
|
||||
display_name: this.form.display_name,
|
||||
note: this.form.note,
|
||||
avatar: this.images.avatar,
|
||||
header: this.images.header,
|
||||
}
|
||||
}
|
||||
|
||||
async onFilePicked(fieldName: "avatar" | "header", files: File[]) {
|
||||
const imageDataUrl = await fileToDataUrl(files[0])
|
||||
this.images[fieldName] = imageDataUrl
|
||||
this.form[fieldName] = dataUrlToBase64(imageDataUrl)
|
||||
}
|
||||
|
||||
updateExtraFieldValue(field: ProfileFieldAttrs, value: string) {
|
||||
field.value_source = value
|
||||
field.value = renderMarkdownLite(field.value_source)
|
||||
}
|
||||
|
||||
isValidExtraField(index: number): boolean {
|
||||
const field = this.form.fields_attributes[index]
|
||||
for (let prevIndex = 0; prevIndex < index; prevIndex++) {
|
||||
const prevField = this.form.fields_attributes[prevIndex]
|
||||
if (field.name === prevField.name) {
|
||||
// Label is not unique
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
removeExtraField(index: number) {
|
||||
this.form.fields_attributes.splice(index, 1)
|
||||
}
|
||||
|
||||
addExtraField() {
|
||||
this.form.fields_attributes.push({ name: "", value: "", value_source: "" })
|
||||
}
|
||||
|
||||
async save() {
|
||||
const authToken = this.store.ensureAuthToken()
|
||||
const profile = await updateProfile(authToken, this.form)
|
||||
this.store.setCurrentUser(profile)
|
||||
this.$router.push({ name: "profile", params: { profileId: profile.id } })
|
||||
}
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../styles/layout";
|
||||
@import "../styles/theme";
|
||||
|
||||
$form-inner-padding: 10px;
|
||||
|
||||
.input-group {
|
||||
margin-bottom: $block-outer-padding;
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
margin-bottom: $form-inner-padding;
|
||||
}
|
||||
|
||||
.sub-label {
|
||||
color: $secondary-text-color;
|
||||
font-size: 12px;
|
||||
font-weight: normal;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
border-radius: $btn-border-radius;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.image-upload-group {
|
||||
display: grid;
|
||||
gap: $form-inner-padding;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
margin-bottom: $block-outer-padding;
|
||||
|
||||
.input-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.extra-field {
|
||||
display: flex;
|
||||
gap: $form-inner-padding;
|
||||
margin-bottom: $form-inner-padding;
|
||||
position: relative;
|
||||
|
||||
input {
|
||||
flex-basis: 50%;
|
||||
}
|
||||
|
||||
.remove-extra-field {
|
||||
$icon-size: 15px;
|
||||
|
||||
display: none;
|
||||
height: $icon-size * 2;
|
||||
line-height: $icon-size * 2;
|
||||
position: absolute;
|
||||
right: -$icon-size;
|
||||
text-align: center;
|
||||
top: -$icon-size;
|
||||
width: $icon-size * 2;
|
||||
|
||||
img {
|
||||
background-color: $block-background-color;
|
||||
border-radius: 50%;
|
||||
filter: $link-hover-colorizer;
|
||||
height: $icon-size;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .remove-extra-field {
|
||||
display: block;
|
||||
}
|
||||
|
||||
&.error input {
|
||||
border: 1px solid $error-color;
|
||||
}
|
||||
}
|
||||
|
||||
.add-extra-field {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
|
||||
img {
|
||||
filter: $link-colorizer;
|
||||
height: 20px;
|
||||
margin-right: 5px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
&:hover img {
|
||||
filter: $link-hover-colorizer;
|
||||
}
|
||||
}
|
||||
|
||||
button[type="submit"] {
|
||||
box-shadow: $btn-shadow;
|
||||
}
|
||||
</style>
|
134
src/views/SearchResultList.vue
Normal file
|
@ -0,0 +1,134 @@
|
|||
<template>
|
||||
<div id="main">
|
||||
<div class="content search-result-group">
|
||||
<loader v-if="isLoading"></loader>
|
||||
<div v-if="!isLoading" class="search-message">
|
||||
<template v-if="errorMessage">{{ errorMessage }}</template>
|
||||
<template v-else-if="profiles.length > 0">{{ profiles.length }} people</template>
|
||||
<template v-else-if="posts.length > 0">{{ posts.length }} posts</template>
|
||||
<template v-else>No results</template>
|
||||
</div>
|
||||
<div v-if="!isLoading" class="search-result-list">
|
||||
<div class="search-result" v-for="profile in profiles" :key="profile.id">
|
||||
<router-link class="profile" :to="{ name: 'profile', params: { profileId: profile.id }}">
|
||||
<avatar :profile="profile"></avatar>
|
||||
<div class="name">
|
||||
<div class="display-name">{{ profile.display_name || profile.username }}</div>
|
||||
<div class="username">@{{ profile.acct }}</div>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
<post :post="post" v-for="post in posts" :key="post.id"></post>
|
||||
</div>
|
||||
</div>
|
||||
<sidebar></sidebar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Options, Vue, setup } from "vue-class-component"
|
||||
import { Post } from "@/api/posts"
|
||||
import { getSearchResults } from "@/api/search"
|
||||
import { Profile } from "@/api/users"
|
||||
import Avatar from "@/components/Avatar.vue"
|
||||
import Loader from "@/components/Loader.vue"
|
||||
import PostComponent from "@/components/Post.vue"
|
||||
import Sidebar from "@/components/Sidebar.vue"
|
||||
import { useCurrentUser } from "@/store/user"
|
||||
|
||||
@Options({
|
||||
components: {
|
||||
Avatar,
|
||||
Loader,
|
||||
Post: PostComponent,
|
||||
Sidebar,
|
||||
},
|
||||
})
|
||||
export default class SearchResultList extends Vue {
|
||||
|
||||
private store = setup(() => {
|
||||
const { ensureAuthToken } = useCurrentUser()
|
||||
return { ensureAuthToken }
|
||||
})
|
||||
|
||||
searchQuery: string | null = null
|
||||
isLoading = false
|
||||
errorMessage = ""
|
||||
|
||||
profiles: Profile[] = []
|
||||
posts: Post[] = []
|
||||
|
||||
async created() {
|
||||
const searchQuery = this.$route.query?.q
|
||||
if (typeof searchQuery === "string") {
|
||||
this.isLoading = true
|
||||
this.searchQuery = searchQuery
|
||||
try {
|
||||
const results = await getSearchResults(
|
||||
this.store.ensureAuthToken(),
|
||||
this.searchQuery,
|
||||
)
|
||||
this.profiles = results.accounts
|
||||
this.posts = results.statuses
|
||||
} catch (error) {
|
||||
this.errorMessage = error.message
|
||||
}
|
||||
this.isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../styles/layout";
|
||||
@import "../styles/theme";
|
||||
|
||||
.search-message,
|
||||
.search-result-list {
|
||||
background-color: $block-background-color;
|
||||
border-radius: $block-border-radius;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.search-message {
|
||||
padding: $block-inner-padding;
|
||||
}
|
||||
|
||||
.loader {
|
||||
margin: $block-outer-padding auto;
|
||||
}
|
||||
|
||||
.search-result-list {
|
||||
margin-top: $block-outer-padding;
|
||||
}
|
||||
|
||||
.search-result {
|
||||
border-bottom: 1px solid $separator-color;
|
||||
padding: $block-inner-padding;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.profile {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
.avatar {
|
||||
height: $avatar-size;
|
||||
margin-right: $block-inner-padding;
|
||||
width: $avatar-size;
|
||||
}
|
||||
|
||||
.display-name {
|
||||
color: $text-color;
|
||||
}
|
||||
|
||||
.username {
|
||||
color: $secondary-text-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
13
tests/unit/avatar.spec.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { expect } from "chai"
|
||||
import { shallowMount } from "@vue/test-utils"
|
||||
|
||||
import Avatar from "@/components/Avatar.vue"
|
||||
|
||||
describe("Avatar.vue", () => {
|
||||
// Not working due to Vue bug https://github.com/vuejs/vue-next/issues/3590
|
||||
it.skip("Renders component", () => {
|
||||
const profile = { avatar: "https://test.com" }
|
||||
const wrapper = shallowMount(Avatar as any, { props: { profile } })
|
||||
expect(wrapper.text()).to.include("")
|
||||
})
|
||||
})
|
68
tests/unit/http.spec.ts
Normal file
|
@ -0,0 +1,68 @@
|
|||
import { expect, use } from "chai"
|
||||
import sinon from "sinon"
|
||||
import sinonChai from "sinon-chai"
|
||||
|
||||
import { fetcher, http } from "@/api/common"
|
||||
|
||||
use(sinonChai)
|
||||
|
||||
describe("Fetch API wrapper", () => {
|
||||
const url = "http://localhost"
|
||||
let fetchStub: sinon.SinonStubbedMember<any>
|
||||
|
||||
beforeEach(() => {
|
||||
fetchStub = sinon.stub(fetcher, "fetch")
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
it("Should set defaults", async () => {
|
||||
await http(url)
|
||||
expect(fetchStub).to.have.been.calledWith(url, {
|
||||
credentials: "same-origin",
|
||||
})
|
||||
})
|
||||
|
||||
it("Should merge request parameters with defaults", async () => {
|
||||
await http(url, { method: "POST" })
|
||||
expect(fetchStub).to.have.been.calledWith(url, {
|
||||
credentials: "same-origin",
|
||||
method: "POST",
|
||||
})
|
||||
})
|
||||
|
||||
it("Should add Authorization header", async () => {
|
||||
await http(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
authToken: "123",
|
||||
})
|
||||
expect(fetchStub).to.have.been.calledWith(url, {
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
Authorization: "Bearer 123",
|
||||
"Content-Type": "text/plain",
|
||||
},
|
||||
method: "POST",
|
||||
})
|
||||
})
|
||||
|
||||
it("Should send JSON data", async () => {
|
||||
await http(url, {
|
||||
method: "POST",
|
||||
json: { key: "value" },
|
||||
authToken: "123",
|
||||
})
|
||||
expect(fetchStub).to.have.been.calledWith(url, {
|
||||
body: '{"key":"value"}',
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
Authorization: "Bearer 123",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
})
|
||||
})
|
||||
})
|
10
tests/unit/markdown.spec.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
import { expect } from "chai"
|
||||
import { renderMarkdownLite } from "@/utils/markdown"
|
||||
|
||||
describe("Render markdown", () => {
|
||||
it("Render markdown lite", () => {
|
||||
const text = "test **bold** ~~strike~~ with `code` and https://example.com\nand a new line"
|
||||
const html = renderMarkdownLite(text)
|
||||
expect(html).to.equal('test **bold** ~~strike~~ with `code` and <a href="https://example.com" target="_blank" rel="noopener">https://example.com</a><br>\nand a new line')
|
||||
})
|
||||
})
|
43
tsconfig.json
Normal file
|
@ -0,0 +1,43 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"module": "esnext",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"importHelpers": true,
|
||||
"moduleResolution": "node",
|
||||
"experimentalDecorators": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"sourceMap": true,
|
||||
"baseUrl": ".",
|
||||
"types": [
|
||||
"webpack-env",
|
||||
"mocha",
|
||||
"chai"
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"src/*"
|
||||
]
|
||||
},
|
||||
"lib": [
|
||||
"esnext",
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"scripthost"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
"src/**/*.vue",
|
||||
"tests/**/*.ts",
|
||||
"tests/**/*.tsx"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|