forked from kaverti/website
Initial Commit
This commit is contained in:
parent
7513ee9ac3
commit
199e2e07cc
|
@ -0,0 +1,72 @@
|
|||
<<<<<<< HEAD
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (http://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules
|
||||
jspm_packages
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
|
||||
# temp directory
|
||||
tmp
|
||||
=======
|
||||
.DS_Store
|
||||
node_modules/
|
||||
dist/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
>>>>>>> frontend
|
771
LICENSE
771
LICENSE
|
@ -1,121 +1,674 @@
|
|||
Creative Commons Legal Code
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
CC0 1.0 Universal
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
|
||||
LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
|
||||
ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
|
||||
INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
|
||||
REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
|
||||
PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
|
||||
THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
|
||||
HEREUNDER.
|
||||
Preamble
|
||||
|
||||
Statement of Purpose
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The laws of most jurisdictions throughout the world automatically confer
|
||||
exclusive Copyright and Related Rights (defined below) upon the creator
|
||||
and subsequent owner(s) (each and all, an "owner") of an original work of
|
||||
authorship and/or a database (each, a "Work").
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is 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. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
Certain owners wish to permanently relinquish those rights to a Work for
|
||||
the purpose of contributing to a commons of creative, cultural and
|
||||
scientific works ("Commons") that the public can reliably and without fear
|
||||
of later claims of infringement build upon, modify, incorporate in other
|
||||
works, reuse and redistribute as freely as possible in any form whatsoever
|
||||
and for any purposes, including without limitation commercial purposes.
|
||||
These owners may contribute to the Commons to promote the ideal of a free
|
||||
culture and the further production of creative, cultural and scientific
|
||||
works, or to gain reputation or greater distribution for their Work in
|
||||
part through the use and efforts of others.
|
||||
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.
|
||||
|
||||
For these and/or other purposes and motivations, and without any
|
||||
expectation of additional consideration or compensation, the person
|
||||
associating CC0 with a Work (the "Affirmer"), to the extent that he or she
|
||||
is an owner of Copyright and Related Rights in the Work, voluntarily
|
||||
elects to apply CC0 to the Work and publicly distribute the Work under its
|
||||
terms, with knowledge of his or her Copyright and Related Rights in the
|
||||
Work and the meaning and intended legal effect of CC0 on those rights.
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
1. Copyright and Related Rights. A Work made available under CC0 may be
|
||||
protected by copyright and related or neighboring rights ("Copyright and
|
||||
Related Rights"). Copyright and Related Rights include, but are not
|
||||
limited to, the following:
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
i. the right to reproduce, adapt, distribute, perform, display,
|
||||
communicate, and translate a Work;
|
||||
ii. moral rights retained by the original author(s) and/or performer(s);
|
||||
iii. publicity and privacy rights pertaining to a person's image or
|
||||
likeness depicted in a Work;
|
||||
iv. rights protecting against unfair competition in regards to a Work,
|
||||
subject to the limitations in paragraph 4(a), below;
|
||||
v. rights protecting the extraction, dissemination, use and reuse of data
|
||||
in a Work;
|
||||
vi. database rights (such as those arising under Directive 96/9/EC of the
|
||||
European Parliament and of the Council of 11 March 1996 on the legal
|
||||
protection of databases, and under any national implementation
|
||||
thereof, including any amended or successor version of such
|
||||
directive); and
|
||||
vii. other similar, equivalent or corresponding rights throughout the
|
||||
world based on applicable law or treaty, and any national
|
||||
implementations thereof.
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
2. Waiver. To the greatest extent permitted by, but not in contravention
|
||||
of, applicable law, Affirmer hereby overtly, fully, permanently,
|
||||
irrevocably and unconditionally waives, abandons, and surrenders all of
|
||||
Affirmer's Copyright and Related Rights and associated claims and causes
|
||||
of action, whether now known or unknown (including existing as well as
|
||||
future claims and causes of action), in the Work (i) in all territories
|
||||
worldwide, (ii) for the maximum duration provided by applicable law or
|
||||
treaty (including future time extensions), (iii) in any current or future
|
||||
medium and for any number of copies, and (iv) for any purpose whatsoever,
|
||||
including without limitation commercial, advertising or promotional
|
||||
purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
|
||||
member of the public at large and to the detriment of Affirmer's heirs and
|
||||
successors, fully intending that such Waiver shall not be subject to
|
||||
revocation, rescission, cancellation, termination, or any other legal or
|
||||
equitable action to disrupt the quiet enjoyment of the Work by the public
|
||||
as contemplated by Affirmer's express Statement of Purpose.
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
3. Public License Fallback. Should any part of the Waiver for any reason
|
||||
be judged legally invalid or ineffective under applicable law, then the
|
||||
Waiver shall be preserved to the maximum extent permitted taking into
|
||||
account Affirmer's express Statement of Purpose. In addition, to the
|
||||
extent the Waiver is so judged Affirmer hereby grants to each affected
|
||||
person a royalty-free, non transferable, non sublicensable, non exclusive,
|
||||
irrevocable and unconditional license to exercise Affirmer's Copyright and
|
||||
Related Rights in the Work (i) in all territories worldwide, (ii) for the
|
||||
maximum duration provided by applicable law or treaty (including future
|
||||
time extensions), (iii) in any current or future medium and for any number
|
||||
of copies, and (iv) for any purpose whatsoever, including without
|
||||
limitation commercial, advertising or promotional purposes (the
|
||||
"License"). The License shall be deemed effective as of the date CC0 was
|
||||
applied by Affirmer to the Work. Should any part of the License for any
|
||||
reason be judged legally invalid or ineffective under applicable law, such
|
||||
partial invalidity or ineffectiveness shall not invalidate the remainder
|
||||
of the License, and in such case Affirmer hereby affirms that he or she
|
||||
will not (i) exercise any of his or her remaining Copyright and Related
|
||||
Rights in the Work or (ii) assert any associated claims and causes of
|
||||
action with respect to the Work, in either case contrary to Affirmer's
|
||||
express Statement of Purpose.
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
4. Limitations and Disclaimers.
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
a. No trademark or patent rights held by Affirmer are waived, abandoned,
|
||||
surrendered, licensed or otherwise affected by this document.
|
||||
b. Affirmer offers the Work as-is and makes no representations or
|
||||
warranties of any kind concerning the Work, express, implied,
|
||||
statutory or otherwise, including without limitation warranties of
|
||||
title, merchantability, fitness for a particular purpose, non
|
||||
infringement, or the absence of latent or other defects, accuracy, or
|
||||
the present or absence of errors, whether or not discoverable, all to
|
||||
the greatest extent permissible under applicable law.
|
||||
c. Affirmer disclaims responsibility for clearing rights of other persons
|
||||
that may apply to the Work or any use thereof, including without
|
||||
limitation any person's Copyright and Related Rights in the Work.
|
||||
Further, Affirmer disclaims responsibility for obtaining any necessary
|
||||
consents, permissions or other rights required for any use of the
|
||||
Work.
|
||||
d. Affirmer understands and acknowledges that Creative Commons is not a
|
||||
party to this document and has no duty or obligation with respect to
|
||||
this CC0 or use of the Work.
|
||||
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 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. Use with the GNU Affero General Public License.
|
||||
|
||||
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 Affero 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 special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU 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 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 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 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 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 General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
{project} Copyright (C) {year} {fullname}
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
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 GPL, see
|
||||
<http://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"development": {
|
||||
"username": "root",
|
||||
"password": "",
|
||||
"database": "kaverti",
|
||||
"host": "127.0.0.1",
|
||||
"dialect": "mysql"
|
||||
},
|
||||
"test": {
|
||||
"username": "root",
|
||||
"password": "",
|
||||
"database": "kaverti",
|
||||
"host": "127.0.0.1",
|
||||
"dialect": "mysql"
|
||||
},
|
||||
"production": {
|
||||
"username": "root",
|
||||
"password": "",
|
||||
"database": "kaverti",
|
||||
"host": "127.0.0.1",
|
||||
"dialect": "mysql"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
module.exports = {
|
||||
port: process.env.PORT || 3000,
|
||||
sessionSecret: process.env.SESSION_SECRET || 'session secret'
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
|
@ -0,0 +1,8 @@
|
|||
// https://github.com/michael-ciniawsky/postcss-load-config
|
||||
|
||||
module.exports = {
|
||||
"plugins": {
|
||||
// to edit target browsers: use "browserslist" field in package.json
|
||||
"autoprefixer": {}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,674 @@
|
|||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is 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. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
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.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
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 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. Use with the GNU Affero General Public License.
|
||||
|
||||
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 Affero 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 special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU 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 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 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 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 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 General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
{project} Copyright (C) {year} {fullname}
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
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 GPL, see
|
||||
<http://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
|
|
@ -0,0 +1,21 @@
|
|||
# frontend
|
||||
|
||||
> A Vue.js project
|
||||
|
||||
## Build Setup
|
||||
|
||||
``` bash
|
||||
# install dependencies
|
||||
npm install
|
||||
|
||||
# serve with hot reload at localhost:8080
|
||||
npm run dev
|
||||
|
||||
# build for production with minification
|
||||
npm run build
|
||||
|
||||
# build for production and view the bundle analyzer report
|
||||
npm run build --report
|
||||
```
|
||||
|
||||
For detailed explanation on how things work, checkout the [guide](http://vuejs-templates.github.io/webpack/) and [docs for vue-loader](http://vuejs.github.io/vue-loader).
|
|
@ -0,0 +1,5 @@
|
|||
module.exports = {
|
||||
presets: [
|
||||
'@vue/cli-plugin-babel/preset'
|
||||
]
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
require('./check-versions')()
|
||||
|
||||
process.env.NODE_ENV = 'production'
|
||||
|
||||
var ora = require('ora')
|
||||
var rm = require('rimraf')
|
||||
var path = require('path')
|
||||
var chalk = require('chalk')
|
||||
var webpack = require('webpack')
|
||||
var config = require('../config')
|
||||
var webpackConfig = require('./webpack.prod.conf')
|
||||
|
||||
var spinner = ora('building for production...')
|
||||
spinner.start()
|
||||
|
||||
rm(path.join(config.build.assetsRoot, config.build.assetsSubDirectory), err => {
|
||||
if (err) throw err
|
||||
webpack(webpackConfig, function (err, stats) {
|
||||
spinner.stop()
|
||||
if (err) throw err
|
||||
process.stdout.write(stats.toString({
|
||||
colors: true,
|
||||
modules: false,
|
||||
children: false,
|
||||
chunks: false,
|
||||
chunkModules: false
|
||||
}) + '\n\n')
|
||||
|
||||
if (stats.hasErrors()) {
|
||||
console.log(chalk.red(' Build failed with errors.\n'))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(chalk.cyan(' Build complete.\n'))
|
||||
console.log(chalk.yellow(
|
||||
' Tip: built files are meant to be served over an HTTP server.\n' +
|
||||
' Opening index.html over file:// won\'t work.\n'
|
||||
))
|
||||
})
|
||||
})
|
|
@ -0,0 +1,48 @@
|
|||
var chalk = require('chalk')
|
||||
var semver = require('semver')
|
||||
var packageConfig = require('../package.json')
|
||||
var shell = require('shelljs')
|
||||
function exec (cmd) {
|
||||
return require('child_process').execSync(cmd).toString().trim()
|
||||
}
|
||||
|
||||
var versionRequirements = [
|
||||
{
|
||||
name: 'node',
|
||||
currentVersion: semver.clean(process.version),
|
||||
versionRequirement: packageConfig.engines.node
|
||||
}
|
||||
]
|
||||
|
||||
if (shell.which('npm')) {
|
||||
versionRequirements.push({
|
||||
name: 'npm',
|
||||
currentVersion: exec('npm --version'),
|
||||
versionRequirement: packageConfig.engines.npm
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = function () {
|
||||
var warnings = []
|
||||
for (var i = 0; i < versionRequirements.length; i++) {
|
||||
var mod = versionRequirements[i]
|
||||
if (!semver.satisfies(mod.currentVersion, mod.versionRequirement)) {
|
||||
warnings.push(mod.name + ': ' +
|
||||
chalk.red(mod.currentVersion) + ' should be ' +
|
||||
chalk.green(mod.versionRequirement)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (warnings.length) {
|
||||
console.log('')
|
||||
console.log(chalk.yellow('To use this template, you must update following to modules:'))
|
||||
console.log()
|
||||
for (var i = 0; i < warnings.length; i++) {
|
||||
var warning = warnings[i]
|
||||
console.log(' ' + warning)
|
||||
}
|
||||
console.log()
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
/* eslint-disable */
|
||||
require('eventsource-polyfill')
|
||||
var hotClient = require('webpack-hot-middleware/client?noInfo=true&reload=true')
|
||||
|
||||
hotClient.subscribe(function (event) {
|
||||
if (event.action === 'reload') {
|
||||
window.location.reload()
|
||||
}
|
||||
})
|
|
@ -0,0 +1,90 @@
|
|||
require('./check-versions')()
|
||||
|
||||
var config = require('../config')
|
||||
if (!process.env.NODE_ENV) {
|
||||
process.env.NODE_ENV = JSON.parse(config.dev.env.NODE_ENV)
|
||||
}
|
||||
|
||||
var opn = require('opn')
|
||||
var path = require('path')
|
||||
var express = require('express')
|
||||
var webpack = require('webpack')
|
||||
var proxyMiddleware = require('http-proxy-middleware')
|
||||
var webpackConfig = require('./webpack.dev.conf')
|
||||
|
||||
// default port where dev server listens for incoming traffic
|
||||
var port = process.env.PORT || config.dev.port
|
||||
// automatically open browser, if not set will be false
|
||||
var autoOpenBrowser = !!config.dev.autoOpenBrowser
|
||||
// Define HTTP proxies to your custom API backend
|
||||
// https://github.com/chimurai/http-proxy-middleware
|
||||
var proxyTable = config.dev.proxyTable
|
||||
|
||||
var app = express()
|
||||
var compiler = webpack(webpackConfig)
|
||||
|
||||
var devMiddleware = require('webpack-dev-middleware')(compiler, {
|
||||
publicPath: webpackConfig.output.publicPath,
|
||||
quiet: true
|
||||
})
|
||||
|
||||
var hotMiddleware = require('webpack-hot-middleware')(compiler, {
|
||||
log: false,
|
||||
heartbeat: 2000
|
||||
})
|
||||
// force page reload when html-webpack-plugin template changes
|
||||
compiler.plugin('compilation', function (compilation) {
|
||||
compilation.plugin('html-webpack-plugin-after-emit', function (data, cb) {
|
||||
hotMiddleware.publish({ action: 'reload' })
|
||||
cb()
|
||||
})
|
||||
})
|
||||
|
||||
// proxy api requests
|
||||
Object.keys(proxyTable).forEach(function (context) {
|
||||
var options = proxyTable[context]
|
||||
if (typeof options === 'string') {
|
||||
options = { target: options }
|
||||
}
|
||||
app.use(proxyMiddleware(options.filter || context, options))
|
||||
})
|
||||
|
||||
// handle fallback for HTML5 history API
|
||||
app.use(require('connect-history-api-fallback')())
|
||||
|
||||
// serve webpack bundle output
|
||||
app.use(devMiddleware)
|
||||
|
||||
// enable hot-reload and state-preserving
|
||||
// compilation error display
|
||||
app.use(hotMiddleware)
|
||||
|
||||
// serve pure static assets
|
||||
var staticPath = path.posix.join(config.dev.assetsPublicPath, config.dev.assetsSubDirectory)
|
||||
app.use(staticPath, express.static('./static'))
|
||||
|
||||
var uri = 'http://localhost:' + port
|
||||
|
||||
var _resolve
|
||||
var readyPromise = new Promise(resolve => {
|
||||
_resolve = resolve
|
||||
})
|
||||
|
||||
console.log('> Starting dev server...')
|
||||
devMiddleware.waitUntilValid(() => {
|
||||
console.log('> Listening at ' + uri + '\n')
|
||||
// when env is testing, don't need open it
|
||||
if (autoOpenBrowser && process.env.NODE_ENV !== 'testing') {
|
||||
opn(uri)
|
||||
}
|
||||
_resolve()
|
||||
})
|
||||
|
||||
var server = app.listen(port)
|
||||
|
||||
module.exports = {
|
||||
ready: readyPromise,
|
||||
close: () => {
|
||||
server.close()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,71 @@
|
|||
var path = require('path')
|
||||
var config = require('../config')
|
||||
var ExtractTextPlugin = require('extract-text-webpack-plugin')
|
||||
|
||||
exports.assetsPath = function (_path) {
|
||||
var assetsSubDirectory = process.env.NODE_ENV === 'production'
|
||||
? config.build.assetsSubDirectory
|
||||
: config.dev.assetsSubDirectory
|
||||
return path.posix.join(assetsSubDirectory, _path)
|
||||
}
|
||||
|
||||
exports.cssLoaders = function (options) {
|
||||
options = options || {}
|
||||
|
||||
var cssLoader = {
|
||||
loader: 'css-loader',
|
||||
options: {
|
||||
minimize: process.env.NODE_ENV === 'production',
|
||||
sourceMap: options.sourceMap
|
||||
}
|
||||
}
|
||||
|
||||
// generate loader string to be used with extract text plugin
|
||||
function generateLoaders (loader, loaderOptions) {
|
||||
var loaders = [cssLoader]
|
||||
if (loader) {
|
||||
loaders.push({
|
||||
loader: loader + '-loader',
|
||||
options: Object.assign({}, loaderOptions, {
|
||||
sourceMap: options.sourceMap
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Extract CSS when that option is specified
|
||||
// (which is the case during production build)
|
||||
if (options.extract) {
|
||||
return ExtractTextPlugin.extract({
|
||||
use: loaders,
|
||||
fallback: 'vue-style-loader'
|
||||
})
|
||||
} else {
|
||||
return ['vue-style-loader'].concat(loaders)
|
||||
}
|
||||
}
|
||||
|
||||
// https://vue-loader.vuejs.org/en/configurations/extract-css.html
|
||||
return {
|
||||
css: generateLoaders(),
|
||||
postcss: generateLoaders(),
|
||||
less: generateLoaders('less'),
|
||||
sass: generateLoaders('sass', { indentedSyntax: true }),
|
||||
scss: generateLoaders('sass'),
|
||||
stylus: generateLoaders('stylus'),
|
||||
styl: generateLoaders('stylus')
|
||||
}
|
||||
}
|
||||
|
||||
// Generate loaders for standalone style files (outside of .vue)
|
||||
exports.styleLoaders = function (options) {
|
||||
var output = []
|
||||
var loaders = exports.cssLoaders(options)
|
||||
for (var extension in loaders) {
|
||||
var loader = loaders[extension]
|
||||
output.push({
|
||||
test: new RegExp('\\.' + extension + '$'),
|
||||
use: loader
|
||||
})
|
||||
}
|
||||
return output
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
var utils = require('./utils')
|
||||
var config = require('../config')
|
||||
var isProduction = process.env.NODE_ENV === 'production'
|
||||
|
||||
module.exports = {
|
||||
loaders: utils.cssLoaders({
|
||||
sourceMap: isProduction
|
||||
? config.build.productionSourceMap
|
||||
: config.dev.cssSourceMap,
|
||||
extract: isProduction
|
||||
}),
|
||||
transformToRequire: {
|
||||
video: 'src',
|
||||
source: 'src',
|
||||
img: 'src',
|
||||
image: 'xlink:href'
|
||||
}
|
||||
}
|
|
@ -0,0 +1,66 @@
|
|||
var path = require('path')
|
||||
var utils = require('./utils')
|
||||
var config = require('../config')
|
||||
var vueLoaderConfig = require('./vue-loader.conf')
|
||||
|
||||
function resolve (dir) {
|
||||
return path.join(__dirname, '..', dir)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
entry: {
|
||||
app: './src/main.js'
|
||||
},
|
||||
output: {
|
||||
path: config.build.assetsRoot,
|
||||
filename: '[name].js',
|
||||
publicPath: process.env.NODE_ENV === 'production'
|
||||
? config.build.assetsPublicPath
|
||||
: config.dev.assetsPublicPath
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.js', '.vue', '.json'],
|
||||
alias: {
|
||||
'vue$': 'vue/dist/vue.esm.js',
|
||||
'@': resolve('src'),
|
||||
}
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.vue$/,
|
||||
loader: 'vue-loader',
|
||||
options: vueLoaderConfig
|
||||
},
|
||||
{
|
||||
test: /\.js$/,
|
||||
loader: 'babel-loader',
|
||||
include: [resolve('src'), resolve('test')]
|
||||
},
|
||||
{
|
||||
test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
|
||||
loader: 'url-loader',
|
||||
options: {
|
||||
limit: 10000,
|
||||
name: utils.assetsPath('img/[name].[hash:7].[ext]')
|
||||
}
|
||||
},
|
||||
{
|
||||
test: /\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/,
|
||||
loader: 'url-loader',
|
||||
options: {
|
||||
limit: 10000,
|
||||
name: utils.assetsPath('media/[name].[hash:7].[ext]')
|
||||
}
|
||||
},
|
||||
{
|
||||
test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
|
||||
loader: 'url-loader',
|
||||
options: {
|
||||
limit: 10000,
|
||||
name: utils.assetsPath('fonts/[name].[hash:7].[ext]')
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
var utils = require('./utils')
|
||||
var webpack = require('webpack')
|
||||
var config = require('../config')
|
||||
var merge = require('webpack-merge')
|
||||
var baseWebpackConfig = require('./webpack.base.conf')
|
||||
var HtmlWebpackPlugin = require('html-webpack-plugin')
|
||||
var FriendlyErrorsPlugin = require('friendly-errors-webpack-plugin')
|
||||
|
||||
// add hot-reload related code to entry chunks
|
||||
Object.keys(baseWebpackConfig.entry).forEach(function (name) {
|
||||
baseWebpackConfig.entry[name] = ['./build/dev-client'].concat(baseWebpackConfig.entry[name])
|
||||
})
|
||||
|
||||
module.exports = merge(baseWebpackConfig, {
|
||||
module: {
|
||||
rules: utils.styleLoaders({ sourceMap: config.dev.cssSourceMap })
|
||||
},
|
||||
// cheap-module-eval-source-map is faster for development
|
||||
devtool: '#cheap-module-eval-source-map',
|
||||
plugins: [
|
||||
new webpack.DefinePlugin({
|
||||
'process.env': config.dev.env
|
||||
}),
|
||||
// https://github.com/glenjamin/webpack-hot-middleware#installation--usage
|
||||
new webpack.HotModuleReplacementPlugin(),
|
||||
new webpack.NoEmitOnErrorsPlugin(),
|
||||
// https://github.com/ampedandwired/html-webpack-plugin
|
||||
new HtmlWebpackPlugin({
|
||||
filename: 'index.html',
|
||||
template: 'index.html',
|
||||
inject: true
|
||||
}),
|
||||
new FriendlyErrorsPlugin()
|
||||
]
|
||||
})
|
|
@ -0,0 +1,122 @@
|
|||
var path = require('path')
|
||||
var utils = require('./utils')
|
||||
var webpack = require('webpack')
|
||||
var config = require('../config')
|
||||
var merge = require('webpack-merge')
|
||||
var baseWebpackConfig = require('./webpack.base.conf')
|
||||
var CopyWebpackPlugin = require('copy-webpack-plugin')
|
||||
var HtmlWebpackPlugin = require('html-webpack-plugin')
|
||||
var ExtractTextPlugin = require('extract-text-webpack-plugin')
|
||||
var OptimizeCSSPlugin = require('optimize-css-assets-webpack-plugin')
|
||||
|
||||
var env = config.build.env
|
||||
|
||||
var webpackConfig = merge(baseWebpackConfig, {
|
||||
module: {
|
||||
rules: utils.styleLoaders({
|
||||
sourceMap: config.build.productionSourceMap,
|
||||
extract: true
|
||||
})
|
||||
},
|
||||
devtool: config.build.productionSourceMap ? '#source-map' : false,
|
||||
output: {
|
||||
path: config.build.assetsRoot,
|
||||
filename: utils.assetsPath('js/[name].[chunkhash].js'),
|
||||
chunkFilename: utils.assetsPath('js/[id].[chunkhash].js')
|
||||
},
|
||||
plugins: [
|
||||
// http://vuejs.github.io/vue-loader/en/workflow/production.html
|
||||
new webpack.DefinePlugin({
|
||||
'process.env': env
|
||||
}),
|
||||
new webpack.optimize.UglifyJsPlugin({
|
||||
compress: {
|
||||
warnings: false
|
||||
},
|
||||
sourceMap: true
|
||||
}),
|
||||
// extract css into its own file
|
||||
new ExtractTextPlugin({
|
||||
filename: utils.assetsPath('css/[name].[contenthash].css')
|
||||
}),
|
||||
// Compress extracted CSS. We are using this plugin so that possible
|
||||
// duplicated CSS from different components can be deduped.
|
||||
new OptimizeCSSPlugin({
|
||||
cssProcessorOptions: {
|
||||
safe: true
|
||||
}
|
||||
}),
|
||||
// generate dist index.html with correct asset hash for caching.
|
||||
// you can customize output by editing /index.html
|
||||
// see https://github.com/ampedandwired/html-webpack-plugin
|
||||
new HtmlWebpackPlugin({
|
||||
filename: config.build.index,
|
||||
template: 'index.html',
|
||||
inject: true,
|
||||
minify: {
|
||||
removeComments: true,
|
||||
collapseWhitespace: true,
|
||||
removeAttributeQuotes: true
|
||||
// more options:
|
||||
// https://github.com/kangax/html-minifier#options-quick-reference
|
||||
},
|
||||
// necessary to consistently work with multiple chunks via CommonsChunkPlugin
|
||||
chunksSortMode: 'dependency'
|
||||
}),
|
||||
// keep module.id stable when vender modules does not change
|
||||
new webpack.HashedModuleIdsPlugin(),
|
||||
// split vendor js into its own file
|
||||
new webpack.optimize.CommonsChunkPlugin({
|
||||
name: 'vendor',
|
||||
minChunks: function (module, count) {
|
||||
// any required modules inside node_modules are extracted to vendor
|
||||
return (
|
||||
module.resource &&
|
||||
/\.js$/.test(module.resource) &&
|
||||
module.resource.indexOf(
|
||||
path.join(__dirname, '../node_modules')
|
||||
) === 0
|
||||
)
|
||||
}
|
||||
}),
|
||||
// extract webpack runtime and module manifest to its own file in order to
|
||||
// prevent vendor hash from being updated whenever app bundle is updated
|
||||
new webpack.optimize.CommonsChunkPlugin({
|
||||
name: 'manifest',
|
||||
chunks: ['vendor']
|
||||
}),
|
||||
// copy custom static assets
|
||||
new CopyWebpackPlugin([
|
||||
{
|
||||
from: path.resolve(__dirname, '../static'),
|
||||
to: config.build.assetsSubDirectory,
|
||||
ignore: ['.*']
|
||||
}
|
||||
])
|
||||
]
|
||||
})
|
||||
|
||||
if (config.build.productionGzip) {
|
||||
var CompressionWebpackPlugin = require('compression-webpack-plugin')
|
||||
|
||||
webpackConfig.plugins.push(
|
||||
new CompressionWebpackPlugin({
|
||||
asset: '[path].gz[query]',
|
||||
algorithm: 'gzip',
|
||||
test: new RegExp(
|
||||
'\\.(' +
|
||||
config.build.productionGzipExtensions.join('|') +
|
||||
')$'
|
||||
),
|
||||
threshold: 10240,
|
||||
minRatio: 0.8
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (config.build.bundleAnalyzerReport) {
|
||||
var BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin
|
||||
webpackConfig.plugins.push(new BundleAnalyzerPlugin())
|
||||
}
|
||||
|
||||
module.exports = webpackConfig
|
|
@ -0,0 +1,10 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" name="viewport" content="width=device-width, initial-scale=1">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<!-- built files will be auto injected -->
|
||||
</body>
|
||||
</html>
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,60 @@
|
|||
{
|
||||
"name": "frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve",
|
||||
"build": "vue-cli-service build",
|
||||
"lint": "vue-cli-service lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^1.2.28",
|
||||
"@fortawesome/free-regular-svg-icons": "^5.13.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^5.13.0",
|
||||
"@fortawesome/vue-fontawesome": "^0.1.9",
|
||||
"axios": "^0.19.0",
|
||||
"child_process": "^1.0.2",
|
||||
"core-js": "^3.6.4",
|
||||
"d3": "^4.9.1",
|
||||
"fs": "0.0.1-security",
|
||||
"highlight.js": "^9.10.0",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
"marked": "^0.8.2",
|
||||
"node-sass": "^4.13.1",
|
||||
"nprogress": "^0.2.0",
|
||||
"sass-loader": "^8.0.2",
|
||||
"socket.io-client": "^2.1.1",
|
||||
"vue": "^2.6.11",
|
||||
"vue-axios": "^2.0.2",
|
||||
"vue-router": "^2.7.0",
|
||||
"vuex": "^2.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vue/cli-plugin-babel": "~4.3.0",
|
||||
"@vue/cli-plugin-eslint": "~4.3.0",
|
||||
"@vue/cli-service": "~4.3.0",
|
||||
"babel-eslint": "^10.1.0",
|
||||
"eslint": "^6.7.2",
|
||||
"eslint-plugin-vue": "^6.2.2",
|
||||
"vue-template-compiler": "^2.6.11"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"root": true,
|
||||
"env": {
|
||||
"node": true
|
||||
},
|
||||
"extends": [
|
||||
"plugin:vue/essential",
|
||||
"eslint:recommended"
|
||||
],
|
||||
"parserOptions": {
|
||||
"parser": "babel-eslint"
|
||||
},
|
||||
"rules": {}
|
||||
},
|
||||
"browserslist": [
|
||||
"> 1%",
|
||||
"last 2 versions",
|
||||
"not dead"
|
||||
]
|
||||
}
|
|
@ -0,0 +1,572 @@
|
|||
<template>
|
||||
<div id='app'>
|
||||
<modal-window v-model='showAjaxErrorsModal' style='z-index: 100' width='25rem' :no-padding='true'>
|
||||
<div slot='main'>
|
||||
<p :key='error' v-for='error in this.$store.state.ajaxErrors' style='margin: 1rem;'>{{error}}</p>
|
||||
</div>
|
||||
<button
|
||||
slot='footer'
|
||||
class='button button--modal'
|
||||
@click='showAjaxErrorsModal = false'
|
||||
ref='ajaxErrorsModalButton'
|
||||
>
|
||||
OK
|
||||
</button>
|
||||
</modal-window>
|
||||
<modal-window
|
||||
v-model='showAccountModal'
|
||||
@input='closeAccountModal'
|
||||
:no-padding='true'
|
||||
:hide-footer='true'
|
||||
>
|
||||
<tab-view
|
||||
:tabs='["Sign up", "Login"]'
|
||||
v-model="showAccountTab"
|
||||
padding='true'
|
||||
slot='main'
|
||||
>
|
||||
<template slot='Sign up'>
|
||||
<p style='margin-top: 0;' v-if='$store.state.token'>
|
||||
<strong>Providing the token is still valid, this will create an admin account</strong>
|
||||
</p>
|
||||
<p style='margin-top: 0;' v-else>
|
||||
Sign up to create and post in threads.
|
||||
<br/>It only takes a few seconds
|
||||
</p>
|
||||
|
||||
<form @submit.prevent='createAccount'>
|
||||
|
||||
<fancy-input
|
||||
v-model='signup.username'
|
||||
:error='signup.errors.username'
|
||||
placeholder='Username'
|
||||
width='100%'
|
||||
>
|
||||
</fancy-input>
|
||||
<fancy-input
|
||||
v-model='signup.password'
|
||||
:error='signup.errors.hash'
|
||||
placeholder='Password'
|
||||
type='password'
|
||||
width='100%'
|
||||
>
|
||||
</fancy-input>
|
||||
<fancy-input
|
||||
v-model='signup.confirmPassword'
|
||||
:error='signup.errors.confirmPassword'
|
||||
placeholder='Confirm password'
|
||||
type='password'
|
||||
width='100%'
|
||||
>
|
||||
</fancy-input>
|
||||
|
||||
<div style='margin-top: 0.5rem;'>
|
||||
<loading-button
|
||||
class='button--green button--margin'
|
||||
:loading='signup.loading'
|
||||
@click='createAccount'
|
||||
>
|
||||
Sign up
|
||||
</loading-button>
|
||||
<div class='button button--borderless' @click='closeAccountModal'>
|
||||
Cancel
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
<template slot='Login'>
|
||||
<p style='margin-top: 0;'>
|
||||
Login to create and post in threads.
|
||||
</p>
|
||||
<form @submit.prevent='doLogin'>
|
||||
<fancy-input
|
||||
v-model='login.username'
|
||||
:error='login.errors.username'
|
||||
placeholder='Username'
|
||||
width='100%'
|
||||
>
|
||||
</fancy-input>
|
||||
<fancy-input
|
||||
v-model='login.password'
|
||||
:error='login.errors.hash'
|
||||
placeholder='Password'
|
||||
type='password'
|
||||
width='100%'
|
||||
>
|
||||
</fancy-input>
|
||||
|
||||
<div style='margin-top: 0.5rem;'>
|
||||
<loading-button
|
||||
class='button button--green button--margin'
|
||||
:loading='login.loading'
|
||||
@click='doLogin'
|
||||
>
|
||||
<font-awesome-icon :icon='["fa", "unlock-alt"]' style='margin-right: 0.25rem;' />
|
||||
Log in
|
||||
</loading-button>
|
||||
<div class='button button--borderless' @click='closeAccountModal'>
|
||||
Cancel
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
</tab-view>
|
||||
</modal-window>
|
||||
|
||||
<header class='header'>
|
||||
<div class='header__group'>
|
||||
<router-link class='logo' to='/'>{{name}}</router-link>
|
||||
</div>
|
||||
<div class='header__group' :class='{ "header__group--show": showMenu }'>
|
||||
<template v-if='$store.state.username'>
|
||||
<notification-button></notification-button>
|
||||
<router-link
|
||||
to='/admin'
|
||||
class='button button--thin_text'
|
||||
v-if='$store.state.admin'
|
||||
>
|
||||
Admin settings
|
||||
</router-link>
|
||||
<router-link
|
||||
to='/settings'
|
||||
class='button button--thin_text'
|
||||
>
|
||||
Settings
|
||||
</router-link>
|
||||
<loading-button
|
||||
@click='logout'
|
||||
:loading='loadingLogout'
|
||||
class='button--thin_text'
|
||||
>
|
||||
Log out
|
||||
</loading-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class='button button--green button--thin_text' @click='showAccountModalTab(0)'>
|
||||
Sign up
|
||||
</div>
|
||||
<div class='button button--thin_text' @click='showAccountModalTab(1)'>
|
||||
Login
|
||||
</div>
|
||||
</template>
|
||||
<search-box header-bar='true'></search-box>
|
||||
</div>
|
||||
<div class='header__overlay' :class='{ "header__overlay--show": showMenu }' @click='toggleMenu'></div>
|
||||
<span class='header__menu_button' @click='toggleMenu'>
|
||||
<font-awesome-icon :icon='["fa", "bars"]' />
|
||||
</span>
|
||||
</header>
|
||||
<not-found v-show='$store.state.show404Page'></not-found>
|
||||
|
||||
<transition name='fade'>
|
||||
<router-view v-show='!$store.state.show404Page'></router-view>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ModalWindow from './components/ModalWindow'
|
||||
import TabView from './components/TabView'
|
||||
import FancyInput from './components/FancyInput'
|
||||
import LoadingButton from './components/LoadingButton'
|
||||
import NotificationButton from './components/NotificationButton'
|
||||
import SearchBox from './components/SearchBox'
|
||||
|
||||
import NotFound from './components/routes/NotFound'
|
||||
|
||||
import AjaxErrorHandler from './assets/js/errorHandler'
|
||||
|
||||
export default {
|
||||
name: 'app',
|
||||
components: {
|
||||
ModalWindow,
|
||||
TabView,
|
||||
FancyInput,
|
||||
LoadingButton,
|
||||
NotificationButton,
|
||||
SearchBox,
|
||||
NotFound
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
signup: {
|
||||
username: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
|
||||
loading: false,
|
||||
|
||||
errors: {
|
||||
username: '',
|
||||
hash: '',
|
||||
confirmPassword: ''
|
||||
}
|
||||
},
|
||||
login: {
|
||||
username: '',
|
||||
password: '',
|
||||
|
||||
loading: false,
|
||||
|
||||
errors: {
|
||||
username: '',
|
||||
hash: ''
|
||||
}
|
||||
},
|
||||
loadingLogout: false,
|
||||
showMenu: false,
|
||||
ajaxErrorHandler: AjaxErrorHandler(this.$store)
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
name () {
|
||||
return this.$store.state.meta.name
|
||||
},
|
||||
showAccountModal: {
|
||||
get () { return this.$store.state.accountModal },
|
||||
set (val) {
|
||||
this.$store.commit('setAccountModalState', val);
|
||||
}
|
||||
},
|
||||
showAjaxErrorsModal: {
|
||||
get () { return this.$store.state.ajaxErrorsModal },
|
||||
set (val) { this.$store.commit('setAjaxErrorsModalState', val) }
|
||||
},
|
||||
showAccountTab : {
|
||||
get () { return this.$store.state.accountTabs },
|
||||
set (index) { this.$store.commit('setAccountTabs', index) }
|
||||
},
|
||||
categories() {
|
||||
return this.$store.state.meta.categories
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
showAccountModalTab (index) {
|
||||
this.toggleMenu()
|
||||
this.showAccountModal = true
|
||||
this.showAccountTab = index
|
||||
},
|
||||
toggleMenu () {
|
||||
this.showMenu = !this.showMenu
|
||||
},
|
||||
logout () {
|
||||
this.toggleMenu()
|
||||
this.loadingLogout = true
|
||||
|
||||
this.axios.post(
|
||||
'/api/v1/user/' +
|
||||
this.$store.state.username +
|
||||
'/logout'
|
||||
).then(res => {
|
||||
this.loadingLogout = false
|
||||
this.$store.commit('setUsername', '')
|
||||
this.$store.commit('setAdmin', res.data.admin)
|
||||
|
||||
this.$socket.emit('accountEvent')
|
||||
|
||||
this.$router.push('/')
|
||||
}).catch(err => {
|
||||
this.loadingLogout = false
|
||||
this.ajaxErrorHandler(err)
|
||||
})
|
||||
},
|
||||
clearSignup () {
|
||||
this.signup.username = ''
|
||||
this.signup.password = ''
|
||||
this.signup.confirmPassword = ''
|
||||
|
||||
this.$store.commit('setToken', null)
|
||||
},
|
||||
clearSignupErrors () {
|
||||
this.signup.errors.username = ''
|
||||
this.signup.errors.hash = ''
|
||||
this.signup.errors.confirmPassword = ''
|
||||
},
|
||||
clearLogin () {
|
||||
this.login.username = ''
|
||||
this.login.password = ''
|
||||
},
|
||||
clearLoginErrors () {
|
||||
this.login.errors.username = ''
|
||||
this.login.errors.hash = ''
|
||||
},
|
||||
closeAccountModal () {
|
||||
this.showAccountModal = false
|
||||
this.clearLogin()
|
||||
this.clearSignup()
|
||||
this.clearLoginErrors()
|
||||
this.clearSignupErrors()
|
||||
},
|
||||
createAccount () {
|
||||
this.clearSignupErrors()
|
||||
|
||||
let postParams = {
|
||||
username: this.signup.username,
|
||||
password: this.signup.password
|
||||
}
|
||||
if(this.$store.state.token) {
|
||||
postParams.admin = true
|
||||
postParams.token = this.$store.state.token
|
||||
}
|
||||
|
||||
if(this.signup.password !== this.signup.confirmPassword) {
|
||||
this.signup.errors.confirmPassword = 'Passwords must match'
|
||||
} else {
|
||||
this.signup.loading = true
|
||||
|
||||
this.axios.post('/api/v1/user', postParams).then(res => {
|
||||
this.signup.loading = false
|
||||
this.$store.commit('setUsername', res.data.username)
|
||||
this.$store.commit('setAdmin', res.data.admin)
|
||||
this.closeAccountModal()
|
||||
|
||||
this.$socket.emit('accountEvent')
|
||||
}).catch(e => {
|
||||
this.signup.loading = false
|
||||
|
||||
this.ajaxErrorHandler(e, (error) => {
|
||||
let path = error.path
|
||||
|
||||
if(this.signup.errors[path] !== undefined && this.signup.errors[path] !== undefined) {
|
||||
this.signup.errors[path] = error.message
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
doLogin () {
|
||||
this.clearSignupErrors()
|
||||
|
||||
if(!this.login.username.trim().length) {
|
||||
this.login.errors.username = 'Username must not be blank'
|
||||
return
|
||||
}
|
||||
|
||||
this.login.loading = true
|
||||
|
||||
this.axios.post(`/api/v1/user/${this.login.username}/login`, {
|
||||
password: this.login.password
|
||||
}).then(res => {
|
||||
this.login.loading = false
|
||||
this.$store.commit('setUsername', res.data.username)
|
||||
this.$store.commit('setAdmin', res.data.admin)
|
||||
this.closeAccountModal()
|
||||
|
||||
this.$socket.emit('accountEvent')
|
||||
}).catch(e => {
|
||||
this.login.loading = false
|
||||
this.ajaxErrorHandler(e, (error) => {
|
||||
let path = error.path
|
||||
|
||||
if(this.signup.errors[path] !== undefined && this.signup.errors[path] !== undefined) {
|
||||
this.signup.errors[path] = error.message
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.axios.get('/api/v1/settings')
|
||||
.then(res => {
|
||||
this.$store.commit('setSettings', res.data)
|
||||
this.$store.dispatch('setTitle', this.$store.state.meta.title)
|
||||
}).catch(err => {
|
||||
if(err.response.data.errors[0].name === 'noSettings') {
|
||||
this.$router.push('/start')
|
||||
} else {
|
||||
this.ajaxErrorHandler(err)
|
||||
}
|
||||
})
|
||||
|
||||
this.axios.get('/api/v1/category')
|
||||
.then(res => {
|
||||
this.$store.commit('addCategories', res.data)
|
||||
|
||||
//Need categories to have loaded to set
|
||||
//the title of the index page
|
||||
//but if we're on another page (i.e. title is not set)
|
||||
//don't overwrite the title
|
||||
if(!this.$store.state.meta.title.length && this.$route.params.category) {
|
||||
let selectedCategory = this.$route.params.category.toUpperCase()
|
||||
let category = this.categories.find(c => c.value === selectedCategory)
|
||||
|
||||
this.$store.dispatch('setTitle', category.name)
|
||||
}
|
||||
})
|
||||
.catch(this.ajaxErrorHandler)
|
||||
},
|
||||
watch: {
|
||||
$route () {
|
||||
this.showMenu = false
|
||||
},
|
||||
'$store.state.ajaxErrorsModal': function(val) {
|
||||
if(val) {
|
||||
this.$refs.ajaxErrorsModalButton.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss'>
|
||||
@import url('https://fonts.googleapis.com/css?family=Lato:300,300i,400,400i,700');
|
||||
@import './assets/scss/variables.scss';
|
||||
@import './assets/scss/elementStyles.scss';
|
||||
@import './assets/scss/nprogress.scss';
|
||||
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
color: $color__text--primary;
|
||||
@include text;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.route_container {
|
||||
width: 80%;
|
||||
max-width: 1250px;
|
||||
margin: 0 auto;
|
||||
margin-top: 2rem;
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
|
||||
#app {
|
||||
padding-top: 4.5rem;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.header {
|
||||
width: 100%;
|
||||
padding: 0.5rem 2rem;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
border-bottom: 0.125rem solid $color__gray--primary;
|
||||
background-color: #fff;
|
||||
|
||||
@at-root #{&}__group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
> * { margin: 0 0.5rem; }
|
||||
> *:first-child { margin-left: 0; }
|
||||
> *:last-child { margin-right: 0; }
|
||||
}
|
||||
|
||||
@at-root #{&}__menu_button {
|
||||
position: fixed;
|
||||
left: 1rem;
|
||||
z-index: 1;
|
||||
font-size: 1.5rem;
|
||||
top: 1rem;
|
||||
display: none;
|
||||
}
|
||||
|
||||
@at-root #{&}__overlay {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
background-color: hsla(215, 13%, 25%, 0.5);
|
||||
transition: all 0.4s;
|
||||
}
|
||||
}
|
||||
|
||||
.logo {
|
||||
@include text($font--role-emphasis, 2rem, 600);
|
||||
@include user-select(none);
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 20rem;
|
||||
|
||||
|
||||
&:hover, &:visited, &:active {
|
||||
outline: none;
|
||||
color: $color__text--primary;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 870px) {
|
||||
.route_container {
|
||||
width: calc(100% - 2rem);
|
||||
margin: 0 1rem;
|
||||
margin-top: 0rem;
|
||||
}
|
||||
|
||||
.logo {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
max-width: calc(100vw - 7rem);
|
||||
}
|
||||
|
||||
.header__menu_button {
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.header__overlay--show {
|
||||
pointer-events: all;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.header__group:first-child {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
.header__group:nth-child(2) {
|
||||
position: fixed;
|
||||
padding-top: 1.5rem;
|
||||
width: 17rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 2;
|
||||
background: #fff;
|
||||
top: 0;
|
||||
left: calc(-100% - 2rem);
|
||||
height: 100%;
|
||||
box-shadow: none;
|
||||
transition: left 0.4s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.4s ease-in;
|
||||
|
||||
> .button {
|
||||
width: 100%;
|
||||
border-radius: 0;
|
||||
margin: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 0.3rem;
|
||||
content: '';
|
||||
background: linear-gradient(to right, hsl(200, 98%, 43%), hsla(193, 98%, 48%, 1));
|
||||
}
|
||||
}
|
||||
.header__group:nth-child(2).header__group--show {
|
||||
left: 0;
|
||||
box-shadow: 0 0 1rem rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.search_box {
|
||||
margin: 0;
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,25 @@
|
|||
module.exports = function(vuex) {
|
||||
return function (res, ignorePathErrorCb) {
|
||||
let errors = []
|
||||
|
||||
if(res.response === undefined || res.response.data.errors === undefined) {
|
||||
errors.push('An error occured. Try again later')
|
||||
} else {
|
||||
res.response.data.errors.forEach(error => {
|
||||
let path = error.path
|
||||
|
||||
if(path && ignorePathErrorCb) {
|
||||
ignorePathErrorCb(error, errors)
|
||||
return
|
||||
}
|
||||
errors.push(error.message[0].toUpperCase() + error.message.slice(1))
|
||||
})
|
||||
}
|
||||
|
||||
if(errors.length) {
|
||||
vuex.commit('setAjaxErrors', errors)
|
||||
vuex.commit('setAjaxErrorsModalState', true)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
let cache = {};
|
||||
|
||||
export default {
|
||||
install (Vue) {
|
||||
//Takes a HTML string then parses it and replaces appropriate
|
||||
//links with the relevant expansion
|
||||
//Returns a callback with the 'expanded' HTML string
|
||||
Vue.prototype.$linkExpander = function (HTML, cb) {
|
||||
let completed = 0;
|
||||
let completedAPICall = () => {
|
||||
completed++;
|
||||
|
||||
if(completed === links.length) {
|
||||
cb(parsed.innerHTML);
|
||||
}
|
||||
};
|
||||
|
||||
let replaceLink = (html, link) => {
|
||||
if(html.length) {
|
||||
let div = document.createElement('div');
|
||||
div.innerHTML = html;
|
||||
|
||||
link.parentNode.replaceChild(
|
||||
div.children[0],
|
||||
link
|
||||
);
|
||||
}
|
||||
|
||||
completedAPICall();
|
||||
};
|
||||
|
||||
let parsed = document.createElement('div');
|
||||
parsed.innerHTML = HTML;
|
||||
|
||||
let links = Array
|
||||
.from(parsed.querySelectorAll('p a[href]'))
|
||||
.filter(a => {
|
||||
return (
|
||||
a.parentNode.parentNode === parsed &&
|
||||
a.parentNode.childNodes.length === 1 &&
|
||||
a.innerHTML === a.href
|
||||
)
|
||||
});
|
||||
|
||||
links.forEach(link => {
|
||||
let cached = cache[link.href];
|
||||
|
||||
if(cached) {
|
||||
replaceLink(cached, link);
|
||||
} else {
|
||||
Vue.axios
|
||||
.get('/api/v1/link_preview?url=' + link.href)
|
||||
.then(res => {
|
||||
cache[link.href] = res.data;
|
||||
replaceLink(res.data, link);
|
||||
})
|
||||
.catch(completedAPICall);
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
import axios from 'axios'
|
||||
|
||||
export default function (route, resourceId) {
|
||||
//In which case resourceId is really the username
|
||||
if(route === 'userPosts' || route === 'userThreads') {
|
||||
axios
|
||||
.get('/api/v1/user/' + resourceId)
|
||||
.then(res => {
|
||||
return axios
|
||||
.post('/api/v1/log', {
|
||||
route,
|
||||
resourceId: res.data.id
|
||||
})
|
||||
})
|
||||
.catch(console.log)
|
||||
} else {
|
||||
axios
|
||||
.post('/api/v1/log', {
|
||||
route,
|
||||
resourceId
|
||||
})
|
||||
.catch(console.log)
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -0,0 +1,315 @@
|
|||
@import './variables.scss';
|
||||
|
||||
body {
|
||||
background-color: rgba(245, 245, 245, 0.5);
|
||||
}
|
||||
|
||||
pre {
|
||||
background-color: $color__gray--primary;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 700;
|
||||
color: $color__darkgray--darker;
|
||||
position: relative;
|
||||
background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkqAcAAIUAgUW0RjgAAAAASUVORK5CYII=) repeat-x 100% 100%;
|
||||
text-decoration: none;
|
||||
|
||||
|
||||
&:hover, &:visited {
|
||||
color: $color__darkgray--primary;
|
||||
}
|
||||
&:active {
|
||||
color: $color__darkgray--darker;
|
||||
}
|
||||
}
|
||||
|
||||
blockquote {
|
||||
background-color: $color__gray--primary;
|
||||
border-left: thick solid $color__gray--darkest;
|
||||
|
||||
& * {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
b, strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.picture_circle {
|
||||
border-radius: 100%;
|
||||
background-position: center center;
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
position: relative;
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: calc(100% - 4px);
|
||||
height: calc(100% - 4px);
|
||||
left: 0;
|
||||
top: 0;
|
||||
border: 2px solid rgba(150, 150, 150, 0.25);
|
||||
border-radius: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.button {
|
||||
border: 1.5px solid $color__gray--darkest;
|
||||
display: inline-block;
|
||||
border-radius: 0.25rem;
|
||||
text-align: center;
|
||||
@include text($font--role-default, 1rem, bold);
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.25px;
|
||||
background: none;
|
||||
background-color: #fff;
|
||||
color: lighten($color__text--primary, 30%) !important;
|
||||
transition: background-color 0.2s, border-color 0.2s, filter 0.2s;
|
||||
outline: none;
|
||||
|
||||
&:hover {
|
||||
background-color: $color__lightgray--primary;
|
||||
border-color: $color__gray--darkest;
|
||||
}
|
||||
&:active {
|
||||
background-color: $color__lightgray--darker;
|
||||
border-color: $color__gray--darkest;
|
||||
}
|
||||
|
||||
&::-moz-focus-inner { border: 0; }
|
||||
|
||||
@at-root #{&}--borderless {
|
||||
color: $color__text--secondary;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
@at-root #{&}--thin_text {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
@at-root #{&}--margin {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
@at-root #{&}--modal {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
padding: 0.25rem 0.5rem;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
@at-root #{&}--color_input {
|
||||
width: 10rem;
|
||||
margin-bottom: 1rem;
|
||||
height: 31px;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
@at-root #{&}--disabled {
|
||||
position: relative;
|
||||
pointer-events: none;
|
||||
|
||||
@include user-select(none);
|
||||
|
||||
filter: saturate(80%) grayscale(40%) brightness(110%);
|
||||
}
|
||||
|
||||
@at-root #{&}--lightblue {
|
||||
border-color: $color__blue--primary;
|
||||
|
||||
&:hover, &:active {
|
||||
border-color: $color__blue--darker;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin filled_button($background, $border, $text: #fff) {
|
||||
background-color: $background;
|
||||
border-color: $border;
|
||||
color: $text !important;
|
||||
|
||||
&:hover {
|
||||
background-color: darken($background, 5%);
|
||||
border-color: rgba($border, 0.6);
|
||||
color: darken(#fff, 5%) !important;
|
||||
}
|
||||
&:active {
|
||||
background-color: darken($background, 10%);
|
||||
border-color: rgba($border, 0.6);
|
||||
color: darken(#fff, 10%) !important;
|
||||
}
|
||||
}
|
||||
@at-root #{&}--blue {
|
||||
@include filled_button(
|
||||
$color__blue--primary,
|
||||
$color__blue--darker
|
||||
);
|
||||
}
|
||||
@at-root #{&}--green {
|
||||
@include filled_button($color__green--primary, $color__green--darker);
|
||||
}
|
||||
@at-root #{&}--red {
|
||||
@include filled_button($color__red--primary, $color__red--darker);
|
||||
}
|
||||
}
|
||||
@media (max-width: 420px) {
|
||||
.button {
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
.input {
|
||||
border: 1.5px solid $color__gray--darkest;
|
||||
border-radius: 0.25rem;
|
||||
@include text;
|
||||
padding: 0.25rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.h1 {
|
||||
@include text($font--role-default, 3rem);
|
||||
}
|
||||
.h3 {
|
||||
@include text($font--role-default, 1.5rem);
|
||||
font-weight: 500;
|
||||
|
||||
@at-root #{&}--margin_top {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.p--condensed {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.category_widget {
|
||||
@at-root #{&}__box {
|
||||
background-color: #fff;
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.25rem;
|
||||
margin-bottom: 1rem;
|
||||
border: thin solid $color__gray--darker;
|
||||
}
|
||||
|
||||
@at-root #{&}__text {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
@at-root #{&}__title {
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: bold;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.overlay_message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-right: 5rem;
|
||||
font-size: 2rem;
|
||||
user-select: none;
|
||||
cursor: default;
|
||||
transition: none;
|
||||
color: $color__gray--darkest;
|
||||
|
||||
span {
|
||||
font-size: 4rem;
|
||||
color: $color__gray--darker;
|
||||
}
|
||||
|
||||
@at-root #{&}__loading {
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
@media (max-width: 420px) {
|
||||
div.overlay_message {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
padding: 0;
|
||||
margin-top: 5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.admin_badge {
|
||||
background: $color__darkgray--primary;
|
||||
border-radius: 0.25rem;
|
||||
color: #fff;
|
||||
cursor: default;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 400;
|
||||
padding: 0.1rem 0.3rem;
|
||||
|
||||
@at-root #{&}--large {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.link_preview {
|
||||
border: thick solid $color__gray--primary;
|
||||
padding: 1rem;
|
||||
|
||||
h1, h2, p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1rem;
|
||||
font-weight: normal;
|
||||
color: $color__darkgray--primary;
|
||||
}
|
||||
p {
|
||||
font-weight: 300;
|
||||
display: flex;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
img {
|
||||
max-width: 100px;
|
||||
max-height: 100px;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
#{&}__partial {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
blockquote.twitter-tweet {
|
||||
padding: 1rem;
|
||||
padding-top: 0;
|
||||
background-color: unset;
|
||||
margin: 0;
|
||||
border: thick solid $color__gray--primary;
|
||||
|
||||
* {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
//Vue transition class
|
||||
.fade-enter-active, .fade-leave-active {
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.fade-enter, .fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-enter-active, .slide-leave-active {
|
||||
transition: opacity 0.2s, max-height 0.2s;
|
||||
overflow: hidden;
|
||||
}
|
||||
.slide-enter, .slide-leave-to {
|
||||
max-height: 0;
|
||||
}
|
||||
.slide-enter-to, .slide-leave {
|
||||
max-height: 20rem;
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
@import './variables.scss';
|
||||
|
||||
$color: $color__blue--primary;
|
||||
|
||||
/* Make clicks pass-through */
|
||||
#nprogress {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#nprogress .bar {
|
||||
background: $color;
|
||||
|
||||
position: fixed;
|
||||
z-index: 1031;
|
||||
top: 0;
|
||||
left: 0;
|
||||
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
/* Fancy blur effect */
|
||||
#nprogress .peg {
|
||||
display: block;
|
||||
position: absolute;
|
||||
right: 0px;
|
||||
width: 100px;
|
||||
height: 100%;
|
||||
box-shadow: 0 0 10px $color, 0 0 5px $color;
|
||||
opacity: 1.0;
|
||||
|
||||
-webkit-transform: rotate(3deg) translate(0px, -4px);
|
||||
-ms-transform: rotate(3deg) translate(0px, -4px);
|
||||
transform: rotate(3deg) translate(0px, -4px);
|
||||
}
|
||||
|
||||
.nprogress-custom-parent {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nprogress-custom-parent #nprogress .spinner,
|
||||
.nprogress-custom-parent #nprogress .bar {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
@-webkit-keyframes nprogress-spinner {
|
||||
0% { -webkit-transform: rotate(0deg); }
|
||||
100% { -webkit-transform: rotate(360deg); }
|
||||
}
|
||||
@keyframes nprogress-spinner {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
|
@ -0,0 +1,220 @@
|
|||
$font--role-default: 'Lato', sans-serif;
|
||||
$font--role-emphasis: 'Lato', sans-serif;
|
||||
|
||||
$color__text--primary: rgba(0, 0, 0, 0.87);
|
||||
$color__text--secondary: rgba(0, 0, 0, 0.54);
|
||||
|
||||
$color__lightgray--primary: #F5F5F5;
|
||||
$color__lightgray--darker: #EEEEEE;
|
||||
$color__lightgray--darkest: #E0E0E0;
|
||||
|
||||
$color__gray--primary: #EEEEEE;
|
||||
$color__gray--darker: #E0E0E0;
|
||||
$color__gray--darkest: #BDBDBD;
|
||||
|
||||
$color__darkgray--primary: #757575;
|
||||
$color__darkgray--darker: #525252;
|
||||
$color__darkgray--darkest: #424242;
|
||||
|
||||
$color__orange--primary: #F57C00;
|
||||
$color__orange--darker: #EF6C00;
|
||||
$color__orange--darkest: #de621c;
|
||||
|
||||
$color__green--primary: rgba(76, 175, 80, 0.86);
|
||||
$color__green--darker: #349238;
|
||||
$color__green--darkest: #1B5E20;
|
||||
|
||||
$color__blue--primary: #42A7FF;
|
||||
$color__blue--darker: #0079E5;
|
||||
$color__blue--darkest: #0D47A1;
|
||||
|
||||
$color__red--primary: #e74860;
|
||||
$color__red--darker: #B71C1C;
|
||||
|
||||
|
||||
//Breakpoints
|
||||
$breakpoint--large_screen: 1200px;
|
||||
$breakpoint--tablet: 870px;
|
||||
$breakpoint--phone: 550px;
|
||||
|
||||
//Breakpoints
|
||||
$breakpoint--large_screen-thread: 1150px;
|
||||
$breakpoint--tablet-thread: 850px;
|
||||
$breakpoint--phone-thread: 500px;
|
||||
|
||||
@mixin thread_mobile_breakpoint ($selector) {
|
||||
@media (max-width: 1150px) and (min-width: $breakpoint--tablet-thread) {
|
||||
#{selector} {
|
||||
width: calc(80% - 5rem);
|
||||
}
|
||||
}
|
||||
@media (max-width: $breakpoint--phone-thread) {
|
||||
#{$selector} {
|
||||
border-radius: 0;
|
||||
border-left: 0;
|
||||
border-right: 0;
|
||||
}
|
||||
}
|
||||
@media (min-width: $breakpoint--tablet-thread) and (max-width: 1150px) {
|
||||
#{$selector} {
|
||||
width: calc(80% - 5rem);
|
||||
}
|
||||
}
|
||||
@media (min-width: $breakpoint--phone-thread) and (max-width: $breakpoint--tablet-thread) {
|
||||
#{$selector} {
|
||||
margin-left: 2rem;
|
||||
margin-riɡht: 2rem;
|
||||
width: calc(100% - 4rem);
|
||||
}
|
||||
}
|
||||
@media (max-width: $breakpoint--phone-thread) {
|
||||
#{$selector} {
|
||||
width: 100%;
|
||||
border-left: 0;
|
||||
border-right: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes flash {
|
||||
0% {
|
||||
background-color: $color__gray--darker;
|
||||
}
|
||||
50% {
|
||||
background-color: $color__lightgray--darkest;
|
||||
}
|
||||
75% {
|
||||
background-color: $color__gray--primary;
|
||||
}
|
||||
to {
|
||||
background-color: $color__gray--darker;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin flash {
|
||||
animation-name: flash;
|
||||
animation-duration: 1s;
|
||||
animation-iteration-count: infinite;
|
||||
animation-timing-function: linear;
|
||||
}
|
||||
|
||||
@mixin loading-overlay($background-color: #fff, $border-radius: 0.25rem) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
background-color: $background-color;
|
||||
z-index: 1;
|
||||
top: 0;
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: all 0.2s;
|
||||
@include user-select(none);
|
||||
cursor: default;
|
||||
border-radius: $border-radius;
|
||||
|
||||
@at-root #{&}--show {
|
||||
opacity: 1;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
@at-root #{&}__message {
|
||||
font-size: 1.5rem;
|
||||
color: #fff;
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin text($family: $font--role-default, $size: 1rem, $weight: 300) {
|
||||
font-family: $family;
|
||||
font-size: $size;
|
||||
font-weight: $weight;
|
||||
}
|
||||
|
||||
@mixin optional-at-root($sel) {
|
||||
@at-root #{if(not &, $sel, selector-append(&, $sel))} {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin placeholder {
|
||||
@include optional-at-root('::-webkit-input-placeholder') {
|
||||
@content;
|
||||
}
|
||||
|
||||
@include optional-at-root(':-moz-placeholder') {
|
||||
@content;
|
||||
}
|
||||
|
||||
@include optional-at-root('::-moz-placeholder') {
|
||||
@content;
|
||||
}
|
||||
|
||||
@include optional-at-root(':-ms-input-placeholder') {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin user-select($select) {
|
||||
@each $pre in -webkit-, -moz-, -ms-, -o- {
|
||||
#{$pre + user-select}: #{$select};
|
||||
}
|
||||
#{user-select}: #{$select};
|
||||
}
|
||||
|
||||
.shadow_border {
|
||||
box-shadow: 0 0 0.3rem rgba(175, 175, 175, 0.25);
|
||||
}
|
||||
.shadow_border--hover {
|
||||
box-shadow: 0 0 0.3rem rgba(175, 175, 175, 0.25), 0 0.2rem 0.35rem rgba(175, 175, 175, 0.25);
|
||||
}
|
||||
|
||||
|
||||
.tab_button {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 3rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
margin-right: 0.5rem;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
top: -0.1rem;
|
||||
|
||||
@include user-select(none);
|
||||
|
||||
&:hover {
|
||||
background-color: $color__lightgray--darker;
|
||||
}
|
||||
&:active {
|
||||
background-color: #dcdcdc;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
background-color: $color__blue--primary;
|
||||
width: calc(100% - 1rem);
|
||||
left: 0.5rem;
|
||||
bottom: -0.3rem;
|
||||
height: 0.2rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
@at-root #{&}--selected {
|
||||
cursor: default;
|
||||
font-weight: bold;
|
||||
|
||||
&:active, &:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
&::after {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,289 @@
|
|||
<template>
|
||||
<div class='admin_categories'>
|
||||
<modal-window v-model='showAddModal' :loading='loading'>
|
||||
<div slot='main'>
|
||||
<p>Add a category</p>
|
||||
<fancy-input v-model='add.name' placeholder='Category name'></fancy-input>
|
||||
<colour-picker v-model='add.color'></colour-picker>
|
||||
</div>
|
||||
<div slot='footer'>
|
||||
<button class='button button--modal button--green' @click='addCategory'>Add category</button>
|
||||
<button class='button button--modal' @click='toggleAddModal'>Cancel</button>
|
||||
</div>
|
||||
</modal-window>
|
||||
|
||||
<modal-window v-model='showEditModal' :loading='loading'>
|
||||
<div slot='main'>
|
||||
<p>Edit this category</p>
|
||||
<div>
|
||||
<fancy-input v-model='edit.name' placeholder='Category name'></fancy-input>
|
||||
</div>
|
||||
<div>
|
||||
<colour-picker v-model='edit.color'></colour-picker>
|
||||
</div>
|
||||
<div>
|
||||
<toggle-switch v-model='edit.locked'></toggle-switch>
|
||||
<span style="display:flex">Category is locked</span>
|
||||
</div>
|
||||
</div>
|
||||
<div slot='footer'>
|
||||
<button class='button button--modal button--green' @click='editCategory'>Update category</button>
|
||||
<button class='button button--modal' @click='toggleEditModal(null)'>Cancel</button>
|
||||
</div>
|
||||
</modal-window>
|
||||
|
||||
<div class='category_widget__box'>
|
||||
<div class='category_widget__text'>
|
||||
<div class='category_widget__text__title'>Categories</div>
|
||||
Hover to remove or edit a category. <br/>
|
||||
Removing a category will place any threads in that category into 'Other'
|
||||
</div>
|
||||
|
||||
<transition-group name='slide'>
|
||||
<div
|
||||
class='admin_categories__category'
|
||||
v-for='(category, $index) in categories'
|
||||
:key='"category-box-" + category.id'
|
||||
>
|
||||
<div class='admin_categories__category__actions_holder'>
|
||||
<div class='admin_categories__category__actions'>
|
||||
<div
|
||||
class='admin_categories__category__action'
|
||||
@click='removeCateogry(category.id, $index)'
|
||||
>Remove</div>
|
||||
<div
|
||||
class='admin_categories__category__action'
|
||||
@click='toggleEditModal(category, $index)'
|
||||
>Edit</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class='admin_categories__category__color'
|
||||
:style='{ "background-color": category.color }'
|
||||
></div>
|
||||
<div class='admin_categories__category__name'>{{category.name}}</div>
|
||||
</div>
|
||||
</transition-group>
|
||||
<div style="margin-top: 0.5rem;">
|
||||
<div class='admin_categories__category admin_categories__category--add' @click='toggleAddModal'>
|
||||
<div class='admin_categories__category__color'>
|
||||
<font-awesome-icon :icon='["fa", "plus"]' />
|
||||
</div>
|
||||
<div class='admin_categories__category__name'>Add new category</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ModalWindow from './ModalWindow'
|
||||
import FancyInput from './FancyInput'
|
||||
import ColourPicker from './ColourPicker'
|
||||
import ToggleSwitch from './ToggleSwitch'
|
||||
|
||||
import AjaxErrorHandler from '../assets/js/errorHandler'
|
||||
|
||||
export default {
|
||||
name: 'AdminCategories',
|
||||
components: {
|
||||
ModalWindow,
|
||||
FancyInput,
|
||||
ColourPicker,
|
||||
ToggleSwitch
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
loading: false,
|
||||
showAddModal: false,
|
||||
showEditModal: false,
|
||||
|
||||
add: {
|
||||
name: '',
|
||||
locked: '',
|
||||
color: '#ffffff'
|
||||
},
|
||||
edit: {
|
||||
name: '',
|
||||
color: '#ffffff',
|
||||
locked: '',
|
||||
id: null,
|
||||
index: null
|
||||
},
|
||||
|
||||
categories: []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggleAddModal () {
|
||||
this.add.name = ''
|
||||
this.add.color = '#ffffff'
|
||||
this.add.locked = ''
|
||||
this.showAddModal = !this.showAddModal
|
||||
},
|
||||
toggleEditModal (category, index) {
|
||||
if(category) {
|
||||
this.edit.name = category.name
|
||||
this.edit.color = category.color
|
||||
this.edit.id = category.id
|
||||
this.edit.index = index
|
||||
this.edit.locked = category.locked
|
||||
} else {
|
||||
this.edit.name = ''
|
||||
this.edit.color = '#ffffff'
|
||||
this.edit.id = null
|
||||
this.edit.index = null
|
||||
this.edit.locked = ''
|
||||
}
|
||||
|
||||
this.showEditModal = !this.showEditModal
|
||||
},
|
||||
addCategory () {
|
||||
this.loading = true
|
||||
|
||||
this.axios
|
||||
.post('/api/v1/category', { name: this.add.name, color: this.add.color, locked: this.add.locked })
|
||||
.then(res => {
|
||||
this.toggleAddModal()
|
||||
this.loading = false
|
||||
this.categories.push(res.data)
|
||||
this.$store.commit('addCategories', res.data)
|
||||
})
|
||||
.catch(AjaxErrorHandler(this.$store))
|
||||
},
|
||||
removeCateogry (id, index) {
|
||||
this.axios
|
||||
.delete('/api/v1/category/' + id)
|
||||
.then(res => {
|
||||
this.categories.splice(index, 1)
|
||||
this.$store.commit('removeCategory', id)
|
||||
|
||||
if(res.data.otherCategoryCreated) {
|
||||
this.$store.commit('addCategories', res.data.otherCategoryCreated)
|
||||
}
|
||||
})
|
||||
.catch(AjaxErrorHandler(this.$store))
|
||||
},
|
||||
editCategory () {
|
||||
this.loading = true
|
||||
|
||||
this.axios
|
||||
.put('/api/v1/category/' + this.edit.id ,{
|
||||
name: this.edit.name,
|
||||
color: this.edit.color,
|
||||
locked: this.edit.locked
|
||||
})
|
||||
.then(res => {
|
||||
this.loading = false
|
||||
this.categories.splice(this.edit.index, 1, res.data)
|
||||
this.$store.commit('updateCategory', res.data)
|
||||
this.toggleEditModal()
|
||||
})
|
||||
.catch(AjaxErrorHandler(this.$store))
|
||||
}
|
||||
|
||||
},
|
||||
mounted () {
|
||||
this.axios
|
||||
.get('/api/v1/category')
|
||||
.then(res => {
|
||||
this.categories = res.data.filter(c => c.name !== 'Other')
|
||||
})
|
||||
.catch(AjaxErrorHandler(this.$store))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../assets/scss/variables.scss';
|
||||
|
||||
.slide-enter-active, .slide-leave-active, .slice-move {
|
||||
transition: all 1s;
|
||||
}
|
||||
.slide-enter, .slide-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.admin_categories {
|
||||
|
||||
|
||||
@at-root #{&}__category {
|
||||
display: inline-flex;
|
||||
position: relative;
|
||||
align-items: center;
|
||||
background-color: rgba($color__lightgray--primary, 0.5);
|
||||
justify-content: center;
|
||||
border-radius: 5rem;
|
||||
padding: 0.25rem 0.5rem 0.25rem 0.5rem;
|
||||
border: thin solid $color__gray--darker;
|
||||
margin-right: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
transition: all 0.2s;
|
||||
cursor: default;
|
||||
|
||||
&:hover {
|
||||
background-color: $color__lightgray--primary;
|
||||
|
||||
& .admin_categories__category__actions_holder {
|
||||
opacity: 1;
|
||||
margin-top: 0;
|
||||
pointer-events: all;
|
||||
}
|
||||
}
|
||||
|
||||
@at-root #{&}--add {
|
||||
top: -0.25rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@at-root #{&}__actions_holder {
|
||||
position: absolute;
|
||||
top: -2.25rem;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
margin-top: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
@at-root #{&}__actions {
|
||||
border-radius: 3rem;
|
||||
border: thin solid $color__gray--darker;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 0.2rem 3px 0px rgba(224, 224, 224, 0.4);
|
||||
}
|
||||
@at-root #{&}__action {
|
||||
padding: 0.25rem 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:first-of-type {
|
||||
border-right: 0.1rem solid $color__gray--primary;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: $color__lightgray--primary;
|
||||
}
|
||||
}
|
||||
|
||||
@at-root #{&}__color {
|
||||
height: 1.25rem;
|
||||
width: 1.25rem;
|
||||
border-radius: 100%;
|
||||
margin-left: 0rem;
|
||||
margin-right: 0.25rem;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@at-root #{&}__name {
|
||||
position: relative;
|
||||
bottom: 0.1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,117 @@
|
|||
<template>
|
||||
<div class='admin_forum_info category_widget__box'>
|
||||
<div class='category_widget__text'>
|
||||
<div class='category_widget__text__title'>Site settings</div>
|
||||
</div>
|
||||
<div>
|
||||
<fancy-input
|
||||
placeholder='Site name'
|
||||
v-model='name'
|
||||
:error='errors.forumName'
|
||||
></fancy-input>
|
||||
</div>
|
||||
<div>
|
||||
<fancy-input
|
||||
placeholder='Site description'
|
||||
v-model='description'
|
||||
:error='errors.forumDescription'
|
||||
></fancy-input>
|
||||
</div>
|
||||
<div class='admin_forum_info__label'>
|
||||
<toggle-switch v-model='showDescription'></toggle-switch>
|
||||
<span>Show forum description on homepage</span>
|
||||
</div>
|
||||
<loading-button :loading='loading' @click='save'>Save settings</loading-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FancyInput from './FancyInput'
|
||||
import LoadingButton from './LoadingButton'
|
||||
import ToggleSwitch from './ToggleSwitch'
|
||||
|
||||
import AjaxErrorHandler from '../assets/js/errorHandler'
|
||||
|
||||
export default {
|
||||
name: 'AdminForumInfo',
|
||||
components: {
|
||||
FancyInput,
|
||||
LoadingButton,
|
||||
ToggleSwitch
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
name: '',
|
||||
description: '',
|
||||
showDescription: false,
|
||||
loading: false,
|
||||
errors: {
|
||||
forumName: '',
|
||||
forumDescription: ''
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
save () {
|
||||
this.errors.forumName = ''
|
||||
this.errors.forumDescription = ''
|
||||
|
||||
if(!this.name.trim().length) {
|
||||
this.errors.forumName = 'Forum name can\'t be blank'
|
||||
return
|
||||
}
|
||||
|
||||
this.loading = true
|
||||
|
||||
let settingsReq = this.axios.put('/api/v1/settings', {
|
||||
forumName: this.name,
|
||||
forumDescription: this.description || '',
|
||||
showDescription: this.showDescription
|
||||
})
|
||||
|
||||
settingsReq.then(res => {
|
||||
this.loading = false
|
||||
|
||||
this.$store.commit('setSettings', res.data)
|
||||
}).catch(e => {
|
||||
this.loading = false
|
||||
|
||||
AjaxErrorHandler(this.$store)(e, (error, modalErrors) => {
|
||||
if(this.errors[error.path] !== undefined) {
|
||||
this.errors[error.path] = error.message
|
||||
} else {
|
||||
modalErrors.push(error.message)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.axios
|
||||
.get('/api/v1/settings')
|
||||
.then(res => {
|
||||
this.name = res.data.forumName || ''
|
||||
this.description = res.data.forumDescription || ''
|
||||
this.showDescription = res.data.showDescription
|
||||
})
|
||||
.catch(AjaxErrorHandler(this.$store))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../assets/scss/variables.scss';
|
||||
|
||||
.admin_forum_info {
|
||||
@at-root #{&}__label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
& > span {
|
||||
font-size: 0.9rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,109 @@
|
|||
<template>
|
||||
<div class='admin_new_admin'>
|
||||
<modal-window v-model='showModal'>
|
||||
<div slot='main' style='padding-top: 1rem;'>
|
||||
<fancy-input
|
||||
:value='link'
|
||||
placeholder='Admin login link'
|
||||
width='100%'
|
||||
style='margin-bottom: 0.5rem;'
|
||||
></fancy-input>
|
||||
</div>
|
||||
<button
|
||||
class='button button--modal'
|
||||
slot='footer'
|
||||
@click='toggleModal'
|
||||
>
|
||||
OK
|
||||
</button>
|
||||
</modal-window>
|
||||
|
||||
<div class='category_widget__box'>
|
||||
<div class='category_widget__text'>
|
||||
<div class='category_widget__text__title'>Add other admin users</div>
|
||||
Click to generate a login link for a new admin account - this will expire after 24 hours
|
||||
|
||||
<div v-if='!filteredAdmins'>Loading...</div>
|
||||
<div v-else>
|
||||
<strong v-if='filteredAdmins.length === 0'>There are no other admins</strong>
|
||||
<template v-else>
|
||||
Current admins are you,
|
||||
<span v-for='(admin, $index) in filteredAdmins' :key='admin.username'>
|
||||
<router-link :to='"/user/" + admin.username'>{{admin.username}}</router-link>
|
||||
<template v-if='$index !== filteredAdmins.length-1'>,</template>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<loading-button :loading='loading' @click='getLink'>Generate link</loading-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ModalWindow from './ModalWindow'
|
||||
import FancyInput from './FancyInput'
|
||||
import LoadingButton from './LoadingButton'
|
||||
|
||||
import AjaxErrorHandler from '../assets/js/errorHandler'
|
||||
|
||||
export default {
|
||||
name: 'AdminNewAdmin',
|
||||
components: {
|
||||
ModalWindow,
|
||||
FancyInput,
|
||||
LoadingButton
|
||||
},
|
||||
methods: {
|
||||
toggleModal () {
|
||||
this.showModal = !this.showModal
|
||||
},
|
||||
getLink () {
|
||||
this.axios
|
||||
.post('/api/v1/admin_token')
|
||||
.then(res => {
|
||||
this.link = window.location.origin + '/?token=' + res.data.token
|
||||
this.toggleModal()
|
||||
})
|
||||
.catch(AjaxErrorHandler(this.$store))
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
loading: false,
|
||||
showModal: false,
|
||||
link: '',
|
||||
admins: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
filteredAdmins () {
|
||||
if(!this.admins) {
|
||||
return null
|
||||
} else {
|
||||
return this.admins.filter(a => {
|
||||
return a.username !== this.$store.state.username
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.axios
|
||||
.get('/api/v1/user?role=admin')
|
||||
.then(res => {
|
||||
this.admins = res.data
|
||||
})
|
||||
.catch(AjaxErrorHandler(this.$store))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../assets/scss/variables.scss';
|
||||
|
||||
@at-root .admin_new_admin {
|
||||
@at-root #{&}__modal {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,172 @@
|
|||
<template>
|
||||
<info-tooltip class='avatar_icon' :noEvents='user === null'>
|
||||
|
||||
<template slot='content'>
|
||||
|
||||
<template v-if='userData'>
|
||||
<div class='avatar_icon__header'>
|
||||
<div
|
||||
class='avatar_icon__icon avatar_icon__icon--small picture_circle'
|
||||
:style='{
|
||||
"background-color": proxyUser.color,
|
||||
"background-image": pictureURL,
|
||||
}'
|
||||
@click='goToUser'
|
||||
>
|
||||
{{letter}}
|
||||
</div>
|
||||
<div class='avatar_icon__header_info'>
|
||||
<span class='avatar_icon__username' @click.stop='goToUser'>
|
||||
{{proxyUser.username}}
|
||||
<span class='admin_badge' v-if='proxyUser.admin'>admin</span>
|
||||
</span>
|
||||
<span class='avatar_icon__date'>User since {{proxyUser.createdAt | formatDate('date') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class='avatar_icon__description' v-if='proxyUser.description'>
|
||||
{{proxyUser.description}}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>Loading...</template>
|
||||
</template>
|
||||
|
||||
<div
|
||||
slot='display'
|
||||
class='avatar_icon__icon picture_circle'
|
||||
:class='{
|
||||
"avatar_icon__icon--small": size === "small",
|
||||
"avatar_icon__icon--tiny": size === "tiny"
|
||||
}'
|
||||
:style='{
|
||||
"background-color": proxyUser.color,
|
||||
"background-image": pictureURL
|
||||
}'
|
||||
@click.stop='goToUser'
|
||||
>
|
||||
{{letter}}
|
||||
</div>
|
||||
|
||||
</info-tooltip>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import InfoTooltip from './InfoTooltip'
|
||||
import AjaxErrorHandler from '../assets/js/errorHandler'
|
||||
|
||||
export default {
|
||||
name: 'AvatarIcon',
|
||||
props: ['user', 'size'],
|
||||
components: { InfoTooltip },
|
||||
data () {
|
||||
return {
|
||||
userData: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
//So that you never access a null variable
|
||||
proxyUser () {
|
||||
if(this.userData) {
|
||||
//Data loaded via api
|
||||
return this.userData;
|
||||
} else if (this.user) {
|
||||
//Data provided as a prop
|
||||
return this.user;
|
||||
}
|
||||
|
||||
return {};
|
||||
},
|
||||
letter () {
|
||||
if(this.proxyUser.username && !this.proxyUser.picture) {
|
||||
return this.proxyUser.username[0].toUpperCase();
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
},
|
||||
pictureURL () {
|
||||
if(this.proxyUser.picture) {
|
||||
return "url(" + this.proxyUser.picture + ")";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
loadUser () {
|
||||
//If user is already loaded or no user provided as a prop
|
||||
if(this.userData || this.user === null) return;
|
||||
|
||||
this.axios
|
||||
.get('/api/v1/user/' + this.proxyUser.username)
|
||||
.then((res) => {
|
||||
this.userData = res.data;
|
||||
})
|
||||
.catch(AjaxErrorHandler(this.$store));
|
||||
},
|
||||
goToUser () {
|
||||
if(this.user === null) return;
|
||||
|
||||
this.$router.push('/user/' + this.user.username)
|
||||
}
|
||||
},
|
||||
mounted() { this.loadUser(); }
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss'>
|
||||
@import '../assets/scss/variables.scss';
|
||||
|
||||
.avatar_icon {
|
||||
@at-root #{&}__icon {
|
||||
font-size: 0.7rem;
|
||||
margin-right: 0.25rem;
|
||||
color: rgba(0, 0, 0, 0.87);
|
||||
}
|
||||
|
||||
@at-root #{&}__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@at-root #{&}__icon {
|
||||
height: 3rem;
|
||||
width: 3rem;
|
||||
line-height: 3rem;
|
||||
cursor: pointer;
|
||||
@include text($font--role-emphasis, 2rem)
|
||||
text-align: center;
|
||||
border-radius: 100%;
|
||||
background-color: $color__gray--darkest;
|
||||
color: #fff;
|
||||
|
||||
@at-root #{&}--small {
|
||||
height: 2.5rem;
|
||||
width: 2.5rem;
|
||||
font-size: 1.75rem;
|
||||
line-height: 2.5rem;
|
||||
}
|
||||
@at-root #{&}--tiny {
|
||||
height: 1.5rem;
|
||||
width: 1.5rem;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
}
|
||||
@at-root #{&}__header_info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 2.5rem;
|
||||
}
|
||||
@at-root #{&}__username {
|
||||
cursor: pointer;
|
||||
}
|
||||
@at-root #{&}__date {
|
||||
color: $color__darkgray--primary;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
@at-root #{&}__description {
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,339 @@
|
|||
<template>
|
||||
<div class='colour_picker'>
|
||||
<div class='colour_picker__selected_header'>
|
||||
<div class='colour_picker__selected_header__text'>Selected colour</div>
|
||||
<div
|
||||
class='colour_picker__selected'
|
||||
:style='{
|
||||
"background-color": colour
|
||||
}'
|
||||
></div>
|
||||
</div>
|
||||
<div class='colour_picker__selector_divider'>
|
||||
<div
|
||||
class='colour_picker__palette_picker'
|
||||
:style='{
|
||||
left: palettePicker.left + "px",
|
||||
top: palettePicker.top + "px"
|
||||
}'
|
||||
|
||||
@mousedown.prevent.stop='palettePicker.dragging = true'
|
||||
@mouseup.prevent.stop='palettePicker.dragging = false; emit()'
|
||||
></div>
|
||||
<canvas
|
||||
class='colour_picker__palette'
|
||||
ref='palette'
|
||||
:width='dimensions'
|
||||
:height='dimensions'
|
||||
@click='(e) => { updatePalettePicker(e); emit(); }'
|
||||
>
|
||||
</canvas>
|
||||
</div>
|
||||
|
||||
<div class='colour_picker__selector_divider'>
|
||||
<div
|
||||
class='colour_picker__hue_picker'
|
||||
:style='{
|
||||
left: huePicker.left + "px"
|
||||
}'
|
||||
|
||||
@mousedown.prevent.stop='huePicker.dragging = true'
|
||||
@mouseup.prevent.stop='huePicker.dragging = false; emit();'
|
||||
></div>
|
||||
<canvas
|
||||
class='colour_picker__hue'
|
||||
ref='hue'
|
||||
:width='dimensions'
|
||||
:height='hueHeight'
|
||||
@click='(e) => { updateHuePicker(e); emit(); }'
|
||||
>
|
||||
</canvas>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'ColourPicker',
|
||||
props: ['value'],
|
||||
data () {
|
||||
return {
|
||||
dimensions: 100,
|
||||
hueHeight: 20,
|
||||
palettePicker: {
|
||||
left: 0,
|
||||
top: 0,
|
||||
dragging: false
|
||||
},
|
||||
huePicker: {
|
||||
left: 0,
|
||||
dragging: false
|
||||
},
|
||||
|
||||
hue_: 0,
|
||||
saturation_: 0,
|
||||
lightness_: 0
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
colour () {
|
||||
let hsl = (
|
||||
'hsl(' +
|
||||
this.hue +
|
||||
', ' +
|
||||
this.saturation +
|
||||
'%, ' +
|
||||
this.lightness +
|
||||
'%)'
|
||||
);
|
||||
|
||||
return hsl;
|
||||
},
|
||||
hue: {
|
||||
get () {
|
||||
return this.hue_;
|
||||
},
|
||||
set (val) {
|
||||
this.hue_ = val;
|
||||
|
||||
if(!this.$refs.hue) return;
|
||||
let width = this.$refs.hue.getBoundingClientRect().width;
|
||||
this.huePicker.left = Math.round((val * width) / 360 - 2);
|
||||
}
|
||||
},
|
||||
saturation: {
|
||||
get () {
|
||||
return this.saturation_;
|
||||
},
|
||||
set (val) {
|
||||
this.saturation_ = val;
|
||||
|
||||
if(!this.$refs.palette) return;
|
||||
this.$refs.palette.getBoundingClientRect()
|
||||
let width = this.$refs.palette.getBoundingClientRect().width;
|
||||
this.palettePicker.left = Math.round((val * width) / 100 - 8);
|
||||
}
|
||||
},
|
||||
lightness: {
|
||||
get () {
|
||||
return this.lightness_;
|
||||
},
|
||||
set (val) {
|
||||
this.lightness_ = val;
|
||||
|
||||
if(!this.$refs.palette) return;
|
||||
let height = this.$refs.palette.getBoundingClientRect().height;
|
||||
this.palettePicker.top = Math.round(height - (val * height) / 100 - 8);
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
value () {
|
||||
let e = document.createElement('span');
|
||||
e.style.backgroundColor = this.value;
|
||||
|
||||
let rgbString = e.style.backgroundColor;
|
||||
let rgbArray = this.rgbStringToArray(rgbString);
|
||||
let hslArray = this.rgbToHsl(rgbArray);
|
||||
|
||||
[this.hue, this.saturation, this.lightness] = hslArray;
|
||||
this.drawPalette();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
emit () {
|
||||
let e = document.createElement('span');
|
||||
e.style.backgroundColor = this.colour;
|
||||
this.$emit('input', e.style.backgroundColor);
|
||||
},
|
||||
drawPalette () {
|
||||
const ctx = this.$refs.palette.getContext('2d');
|
||||
|
||||
ctx.clearRect(0, 0, this.dimensions, this.dimensions);
|
||||
|
||||
|
||||
for(let x = 0; x <= this.dimensions; x++) {
|
||||
for(let y = 0; y <= this.dimensions; y++) {
|
||||
let saturation = 100 * x / this.dimensions + '%';
|
||||
let lightness = 100 * (this.dimensions - y) / this.dimensions + '%';
|
||||
|
||||
ctx.fillStyle = 'hsl(' + this.hue + ', ' + saturation + ', ' + lightness + ')';
|
||||
|
||||
ctx.fillRect(x, y, 1, 1);
|
||||
}
|
||||
}
|
||||
},
|
||||
drawHue () {
|
||||
const ctx = this.$refs.hue.getContext('2d');
|
||||
|
||||
for(let x = 0; x <= this.dimensions; x++) {
|
||||
let angle = (x / this.dimensions * 360);
|
||||
ctx.fillStyle = 'hsl(' + angle + ', 100%, 50%)'
|
||||
ctx.fillRect(x, 0, 1, this.hueHeight);
|
||||
}
|
||||
},
|
||||
updatePalettePicker (e) {
|
||||
//If the canvas is not loaded
|
||||
//Or there's no dragging and not a click event
|
||||
if(
|
||||
!this.$refs.palette ||
|
||||
(!this.palettePicker.dragging && e.type !== 'click')
|
||||
) return;
|
||||
|
||||
|
||||
let rect = this.$refs.palette.getBoundingClientRect();
|
||||
|
||||
let left = e.clientX - rect.left - 8;
|
||||
let top = e.clientY - rect.top - 8;
|
||||
|
||||
|
||||
if (e.clientX > rect.right) left = rect.width - 8;
|
||||
if (e.clientX < rect.left) left = -8;
|
||||
|
||||
if (e.clientY > rect.bottom) top = rect.height - 8;
|
||||
if (e.clientY < rect.top) top = -8;
|
||||
|
||||
this.palettePicker.left = left;
|
||||
this.palettePicker.top = top;
|
||||
|
||||
let centerX = left + 8;
|
||||
let centerY = top + 8;
|
||||
|
||||
this.saturation_ = Math.round(100 * centerX / rect.width);
|
||||
this.lightness_ = Math.round(100 * (rect.height - centerY) / rect.height);
|
||||
},
|
||||
updateHuePicker (e) {
|
||||
//If the canvas is not loaded
|
||||
//Or there's no dragging and not a click event
|
||||
if(
|
||||
!this.$refs.hue ||
|
||||
(!this.huePicker.dragging && e.type !== 'click')
|
||||
) return;
|
||||
|
||||
|
||||
let rect = this.$refs.hue.getBoundingClientRect();
|
||||
let left = e.clientX - rect.left - 2;
|
||||
|
||||
if (e.clientX > rect.right) left = rect.width - 2;
|
||||
if (e.clientX < rect.left) left = -2;
|
||||
|
||||
this.huePicker.left = left;
|
||||
this.hue_ = Math.round(360 * (left + 2) / rect.width);
|
||||
this.drawPalette();
|
||||
},
|
||||
//rgb(1,2,3) => [1,2,3]
|
||||
rgbStringToArray (str) {
|
||||
return str.slice(4, -1).split(',').map(Number);
|
||||
},
|
||||
//[1,2,3] => [210, 50, 0.8]
|
||||
rgbToHsl (rgb) {
|
||||
let h, s, l;
|
||||
let normalised = rgb.map(v => v / 255);
|
||||
let [r, g, b] = normalised;
|
||||
let max = Math.max(...normalised);
|
||||
let min = Math.min(...normalised);
|
||||
|
||||
l = 100 * (max + min) / 2;
|
||||
|
||||
if(max === min) {
|
||||
return [this.hue, 0, l];
|
||||
} else {
|
||||
if(l < 50) {
|
||||
s = (max - min) / (max + min);
|
||||
} else {
|
||||
s = (max - min) / (2 - max - min);
|
||||
}
|
||||
|
||||
//Turn into percentage
|
||||
s *= 100;
|
||||
}
|
||||
|
||||
if(r === max) {
|
||||
h = (g - b) / (max - min);
|
||||
} else if (g === max) {
|
||||
h = 2 + (b - r) / (max - min);
|
||||
} else {
|
||||
h = 4 + (r - g) / (max - min);
|
||||
}
|
||||
//Convert to degrees
|
||||
h *= 60;
|
||||
|
||||
return [h, s, l].map(v => Math.round(v));
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.drawPalette();
|
||||
this.drawHue();
|
||||
|
||||
document.addEventListener('mousemove', e => {
|
||||
this.updatePalettePicker(e);
|
||||
this.updateHuePicker(e);
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../assets/scss/variables.scss';
|
||||
|
||||
.colour_picker {
|
||||
@at-root #{&}__selected_header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: middle;
|
||||
|
||||
@at-root #{&}__text {
|
||||
height: 1.5rem;
|
||||
line-height: 1.4rem;
|
||||
@include user-select(none);
|
||||
}
|
||||
}
|
||||
|
||||
@at-root #{&}__selected {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border: thin solid $color__gray--darkest;
|
||||
border-radius: 0.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
@at-root #{&}__selector_divider {
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@at-root #{&}__palette_picker, #{&}__hue_picker {
|
||||
position: absolute;
|
||||
background-color: #fff;
|
||||
border: thin solid rgba($color__darkgray--darker, 0.5);
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.2s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 0 1px rgba(black, 0.3);
|
||||
}
|
||||
}
|
||||
@at-root #{&}__palette_picker {
|
||||
height: 15px;
|
||||
width: 15px;
|
||||
border-radius: 100%;
|
||||
}
|
||||
@at-root #{&}__hue_picker {
|
||||
height: 1.75rem;
|
||||
width: 5px;
|
||||
top: -0.125rem;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
@at-root #{&}__palette, #{&}__hue {
|
||||
width: 100%;
|
||||
height: 8rem;
|
||||
border: thin solid $color__gray--darkest;
|
||||
border-radius: 0.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
@at-root #{&}__hue {
|
||||
height: 1.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,55 @@
|
|||
<template>
|
||||
<modal-window :value='showModal' @input='setShowModal'>
|
||||
<div slot='main' style='padding-top: 1rem;'>
|
||||
<slot></slot>
|
||||
</div>
|
||||
<div slot='footer'>
|
||||
<button class='button button--modal' :class='buttonColor' @click='confirm'>{{text || 'OK'}}</button>
|
||||
<button class='button button--modal' @click='setShowModal(false)'>Cancel</button>
|
||||
</div>
|
||||
</modal-window>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ModalWindow from './ModalWindow'
|
||||
|
||||
export default {
|
||||
name: 'ConfirmModal',
|
||||
props: ['value', 'color', 'text'],
|
||||
components: {
|
||||
ModalWindow
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
showModal: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
buttonColor () {
|
||||
if(this.color) {
|
||||
return 'button--' + this.color
|
||||
} else {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
value (val) {
|
||||
this.showModal = val
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setShowModal (val) {
|
||||
this.showModal = val
|
||||
this.$emit('input', val)
|
||||
},
|
||||
confirm () {
|
||||
this.$emit('confirm')
|
||||
this.setShowModal(false)
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.setShowModal(this.value)
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,152 @@
|
|||
<template>
|
||||
<div class='emoji_selector'>
|
||||
<div
|
||||
class='emoji_selector__overlay'
|
||||
:class='{ "emoji_selector__overlay--show" : value }'
|
||||
@click='$emit("input", false)'
|
||||
></div>
|
||||
|
||||
<div class='emoji_selector__context'>
|
||||
<div
|
||||
class='emoji_selector__tooltip'
|
||||
:class='{
|
||||
"emoji_selector__tooltip--show" : value,
|
||||
"emoji_selector__tooltip--right" : rightAlign
|
||||
}'
|
||||
>
|
||||
<div v-for='(row, $index) in emojis' :key="'emoji-row-' + $index">
|
||||
<div class='emoji_selector__title'>
|
||||
{{row.title}}
|
||||
</div>
|
||||
<div class='emoji_selector__row' ref='emoji_row'>
|
||||
<span
|
||||
class='emoji_selector__emoji'
|
||||
v-for='emoji in row.emojis'
|
||||
:key='emoji'
|
||||
@click='emitEmoji(emoji)'
|
||||
>{{emoji}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'EmojiSelector',
|
||||
props: ['value', 'right-align'],
|
||||
data () {
|
||||
return {
|
||||
stickyIndex: 0,
|
||||
emojis: [
|
||||
{ title: 'smileys', emojis: [
|
||||
'😀' , '😃' , '😄' , '😁' , '😆' , '😅' , '😂' , '🤣' , '😊' , '😇' , '🙂' , '🙃' , '😉' , '😌' , '😍' , '😘' , '😗' , '😙' , '😚' , '😋' , '😜' , '😝' , '😛' , '🤑' , '🤗' , '🤓' , '😎' , '🤡' , '🤠' , '😏' , '😒' , '😞' , '😔' , '😟' , '😕' , '🙁' , '😣' , '😖' , '😫' , '😩' , '😤' , '😠' , '😡' , '😶' , '😐' , '😑' , '😯' , '😦' , '😧' , '😮' , '😲' , '😵' , '😳' , '😱' , '😨' , '😰' , '😢' , '😥' , '🤤' , '😭' , '😓' , '😪' , '😴' , '🙄' , '🤔' , '🤥' , '😬' , '🤐'
|
||||
]},
|
||||
{ title: 'people', emojis: [
|
||||
'👶' , '👦' , '👧' , '👨' , '👩' , '👱♀️' , '👱' , '👴' , '👵' , '👲' , '👳♀️' , '👳' , '👮♀️' , '👮', '💁', '💁♂️', '🙅', '🙅♂️', '🙆', '🙆♂️', '🙋', '🙋♂️', '💃', '🕺', '👯', '👯♂️', '🚶♀️', '🚶', '🏃♀️'
|
||||
]},
|
||||
{ title: 'animals', emojis: [
|
||||
'🐶' , '🐱' , '🐭' , '🐹' , '🐰' , '🦊' , '🐻' , '🐼' , '🐨' , '🐯' , '🦁' , '🐮' , '🐷' , '🐽' , '🐸' , '🐵' , '🙊' , '🙉' , '🙊' , '🐒' , '🐔' , '🐧' , '🐦' , '🐤' , '🐣' , '🐥' , '🦆' , '🦅' , '🦉' , '🦇' , '🐺' , '🐗' , '🐴' , '🦄' , '🐝' , '🐛' , '🦋' , '🐌' , '🐞' , '🐜' , '🕷' , '🐢' , '🐍'
|
||||
]},
|
||||
]
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
emitEmoji (emoji) {
|
||||
this.$emit('input', false)
|
||||
this.$emit('emoji', emoji)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../assets/scss/variables.scss';
|
||||
|
||||
.emoji_selector {
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
|
||||
@at-root #{&}__context {
|
||||
transform: translateZ(0);
|
||||
position: relative;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
@at-root #{&}__overlay {
|
||||
pointer-events: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 3;
|
||||
|
||||
@at-root #{&}--show {
|
||||
pointer-events: all;
|
||||
}
|
||||
}
|
||||
|
||||
@at-root #{&}__tooltip {
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
bottom: calc(100% + 3rem);
|
||||
transition: all 0.2s;
|
||||
|
||||
position: absolute;
|
||||
width: 14rem;
|
||||
height: 7rem;
|
||||
border-radius: 0.25rem;
|
||||
border: 0.125rem solid $color__gray--primary;
|
||||
background-color: #fff;
|
||||
|
||||
left: 0.25rem;
|
||||
box-shadow: 0 10px 10px rgba(0, 0, 0, 0.22);
|
||||
cursor: default;
|
||||
overflow-y: auto;
|
||||
padding: 0 0.375rem;
|
||||
z-index: 4;
|
||||
|
||||
@at-root #{&}--show {
|
||||
pointer-events: all;
|
||||
opacity: 1;
|
||||
bottom: calc(100% + 2rem);
|
||||
}
|
||||
@at-root #{&}--right {
|
||||
left: 22.5rem;
|
||||
}
|
||||
}
|
||||
@at-root #{&}__row {
|
||||
display: block;
|
||||
text-align: left;
|
||||
line-height: 1.6rem;
|
||||
}
|
||||
@at-root #{&}__title {
|
||||
font-weight: bold;
|
||||
font-variant: small-caps;
|
||||
font-size: 0.9rem;
|
||||
text-align: left;
|
||||
color: $color__text--primary;
|
||||
padding-left: 0.375rem;
|
||||
}
|
||||
@at-root #{&}__emoji {
|
||||
padding: 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: $color__gray--primary;
|
||||
}
|
||||
&:active {
|
||||
background-color: $color__gray--darker;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.emoji_selector {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,53 @@
|
|||
<template>
|
||||
<div
|
||||
class='error_tooltip'
|
||||
:class='{"error_tooltip--show": error }'
|
||||
>
|
||||
{{delayed_error}}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'ErrorTooltip',
|
||||
props: ['error'],
|
||||
data () {
|
||||
return {
|
||||
delayed_error: this.error
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
error (val) {
|
||||
if(!val) {
|
||||
setTimeout(() => {
|
||||
this.delayed_error = ''
|
||||
}, 205)
|
||||
} else {
|
||||
this.delayed_error = val
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../assets/scss/variables.scss';
|
||||
|
||||
.error_tooltip {
|
||||
font-size: .85rem;
|
||||
opacity: 0;
|
||||
max-height: 0px;
|
||||
overflow: hidden;
|
||||
color: $color__red--primary;
|
||||
padding: 0;
|
||||
transition: all 0.2s cubic-bezier(0.23, 1, 0.32, 1);
|
||||
|
||||
&:first-letter{ text-transform: capitalize; }
|
||||
|
||||
@at-root #{&}--show {
|
||||
max-height: 2rem;
|
||||
padding-top: 0.125rem;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,103 @@
|
|||
<template>
|
||||
<div class='fancy_input' v-bind:style='{width: width || "auto"}'>
|
||||
<div
|
||||
style='position: relative; display: inline-block;'
|
||||
>
|
||||
<div
|
||||
class='fancy_input__placeholder'
|
||||
:class='{
|
||||
"fancy_input__placeholder--active": active || value.length,
|
||||
"fancy_input__placeholder--large": large
|
||||
}'
|
||||
>
|
||||
{{placeholder}}
|
||||
</div>
|
||||
<input
|
||||
v-bind:type='type || "text"'
|
||||
class='fancy_input__input input'
|
||||
:class='{
|
||||
"fancy_input__input--large": large,
|
||||
"fancy_input__input--error": error
|
||||
}'
|
||||
v-bind:value='value'
|
||||
v-on:input='updateValue($event.target.value)'
|
||||
@focus='addActive'
|
||||
@blur='removeActive'
|
||||
>
|
||||
</div>
|
||||
<error-tooltip :error='error'></error-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ErrorTooltip from './ErrorTooltip'
|
||||
|
||||
export default {
|
||||
name: 'FancyInput',
|
||||
props: ['value', 'placeholder', 'width', 'type', 'error', 'large'],
|
||||
components: {
|
||||
ErrorTooltip
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
active: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateValue (val) {
|
||||
this.$emit('input', val);
|
||||
},
|
||||
addActive () {
|
||||
this.active = true;
|
||||
},
|
||||
removeActive () {
|
||||
this.active = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../assets/scss/variables.scss';
|
||||
|
||||
.fancy_input {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
@at-root #{&}__input {
|
||||
transition: border-color 0.2s;
|
||||
width: 100%;
|
||||
|
||||
@at-root #{&}--large {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
@at-root #{&}--error {
|
||||
border-color: $color__red--primary;
|
||||
}
|
||||
}
|
||||
|
||||
@at-root #{&}__placeholder {
|
||||
position: absolute;
|
||||
top: 0.35rem;
|
||||
background-color: #fff;
|
||||
left: 0.35rem;
|
||||
color: $color__gray--darkest;
|
||||
pointer-events: none;
|
||||
transition: top 0.2s, font-size 0.2s;
|
||||
|
||||
@at-root #{&}--large {
|
||||
top: 0.55rem;
|
||||
left: 0.6rem;
|
||||
}
|
||||
|
||||
@at-root #{&}--active {
|
||||
top: -0.5rem;
|
||||
font-size: 0.75rem;
|
||||
transition: top 0.2s, font-size 0.2s;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,129 @@
|
|||
<template>
|
||||
<div class='fancy_textarea'>
|
||||
<div
|
||||
class='fancy_textarea__container'
|
||||
style='position: relative; display: inline-block;'
|
||||
v-bind:style='{width: width || "20rem"}'
|
||||
>
|
||||
<div
|
||||
class='fancy_textarea__placeholder'
|
||||
:class='{"fancy_textarea__placeholder--active": active || value.length}'
|
||||
>
|
||||
{{placeholder}}
|
||||
</div>
|
||||
<textarea
|
||||
class='input fancy_textarea__textarea'
|
||||
v-bind:value='value'
|
||||
v-on:input='updateValue($event.target.value)'
|
||||
@focus='addActive'
|
||||
@blur='removeActive'
|
||||
>
|
||||
</textarea>
|
||||
</div>
|
||||
<error-tooltip :error='error'></error-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ErrorTooltip from './ErrorTooltip'
|
||||
|
||||
export default {
|
||||
name: 'FancyTextarea',
|
||||
props: ['value', 'placeholder', 'width', 'error'],
|
||||
components: {
|
||||
ErrorTooltip
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
active: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateValue (val) {
|
||||
this.$emit('input', val);
|
||||
},
|
||||
addActive () {
|
||||
this.active = true;
|
||||
},
|
||||
removeActive () {
|
||||
this.active = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../assets/scss/variables.scss';
|
||||
|
||||
.fancy_textarea {
|
||||
position: relative;
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
@at-root #{&}__textarea {
|
||||
height: 5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@at-root #{&}__placeholder {
|
||||
position: absolute;
|
||||
top: 0.35rem;
|
||||
background-color: #fff;
|
||||
left: 0.35rem;
|
||||
color: $color__gray--darkest;
|
||||
pointer-events: none;
|
||||
transition: top 0.2s, font-size 0.2s;
|
||||
|
||||
@at-root #{&}--active {
|
||||
top: -0.5rem;
|
||||
font-size: 0.75rem;
|
||||
transition: top 0.2s, font-size 0.2s;
|
||||
}
|
||||
}
|
||||
|
||||
@at-root #{&}__error {
|
||||
position: absolute;
|
||||
background-color: #ffeff1;
|
||||
border: 0.125rem solid #D32F2F;
|
||||
max-width: 100%;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.1rem 0.25rem;
|
||||
top: -1.75rem;
|
||||
right: 0;
|
||||
|
||||
&:first-letter{ text-transform: capitalize; }
|
||||
|
||||
opacity: 0;
|
||||
pointer-events: 0;
|
||||
margin-top: -1rem;
|
||||
transition: opacity 0.2s, margin-top 0.2s;
|
||||
|
||||
@at-root #{&}--show {
|
||||
opacity: 1;
|
||||
pointer-events: all;
|
||||
margin-top: 0;
|
||||
transition: opacity 0.2s, margin-top 0.2s;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: relative;
|
||||
width: 0;
|
||||
height: 0;
|
||||
display: inline-block;
|
||||
bottom: -0.65rem;
|
||||
border-left: 0.3rem solid transparent;
|
||||
border-right: 0.3rem solid transparent;
|
||||
border-top: 0.3rem solid #D32F2F;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.fancy_textarea {
|
||||
@at-root #{&}__container {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,205 @@
|
|||
<template>
|
||||
<div class='heart_button'>
|
||||
<modal-window
|
||||
v-model='showModal'
|
||||
:close-button='true'
|
||||
:hide-footer='true'
|
||||
:no-padding='true'
|
||||
>
|
||||
<div class='heart_button__modal' slot='main'>
|
||||
<div class='heart_button__modal__header'>Likes</div>
|
||||
<div
|
||||
class='heart_button__modal__user'
|
||||
v-for='user in likes'
|
||||
:key='"heart-user-" + user.id'
|
||||
@click='$router.push("/user/" + user.username)'
|
||||
>
|
||||
<avatar-icon
|
||||
:user='user'
|
||||
size='small'
|
||||
></avatar-icon>
|
||||
<div class='heart_button__modal__username'>{{user.username}}</div>
|
||||
</div>
|
||||
|
||||
<div class='heart_button__modal__empty' v-if='!likes.length'>
|
||||
No likes
|
||||
</div>
|
||||
</div>
|
||||
</modal-window>
|
||||
|
||||
<font-awesome-icon :icon='["fa", "heart"]'
|
||||
class='heart_button__heart'
|
||||
:class='{
|
||||
"heart_button__heart--unlikeable": !likeable,
|
||||
"heart_button__heart--liked": liked
|
||||
}'
|
||||
@click='changeLike'
|
||||
/>
|
||||
<span class='heart_button__count' @click='showModal = true'>{{likes.length}}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ModalWindow from './ModalWindow'
|
||||
import AvatarIcon from './AvatarIcon'
|
||||
|
||||
import AjaxErrorHandler from '../assets/js/errorHandler'
|
||||
|
||||
export default {
|
||||
name: 'HeartButton',
|
||||
props: ['post'],
|
||||
components: {
|
||||
ModalWindow,
|
||||
AvatarIcon
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
likes: this.post.Likes,
|
||||
showModal: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
likeable () {
|
||||
let postUsername = this.post.User ?
|
||||
this.post.User.username :
|
||||
null
|
||||
|
||||
return (
|
||||
this.$store.state.username &&
|
||||
postUsername !== this.$store.state.username
|
||||
)
|
||||
},
|
||||
liked () {
|
||||
return this.likes.some(u => {
|
||||
return u.username === this.$store.state.username
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getIndexOfUser () {
|
||||
let index
|
||||
|
||||
for(let i = 0; i < this.likes.length; i++) {
|
||||
let user = this.likes[i]
|
||||
|
||||
if(user.username === this.$store.state.username) {
|
||||
index = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return index
|
||||
},
|
||||
changeLike () {
|
||||
let id = this.post.id
|
||||
|
||||
if(!this.likeable) return
|
||||
|
||||
if(!this.liked) {
|
||||
this.axios
|
||||
.put('/api/v1/post/' + id + '/like')
|
||||
.then(() => {
|
||||
return this.axios
|
||||
.get('/api/v1/user/' + this.$store.state.username)
|
||||
})
|
||||
.then(res => {
|
||||
this.likes.push(res.data)
|
||||
})
|
||||
.catch(AjaxErrorHandler(this.$store))
|
||||
} else {
|
||||
this.axios
|
||||
.delete('/api/v1/post/' + id + '/like')
|
||||
.then(() => {
|
||||
this.likes.splice(this.getIndexOfUser(), 1)
|
||||
})
|
||||
.catch(AjaxErrorHandler(this.$store))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../assets/scss/variables.scss';
|
||||
|
||||
.heart_button {
|
||||
@at-root #{&}__modal {
|
||||
padding: 1rem;
|
||||
|
||||
@at-root #{&}__header {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
@at-root #{&}__user {
|
||||
display: flex;
|
||||
align-items: centre;
|
||||
line-height: 2.2rem;
|
||||
max-height: 3rem;
|
||||
padding: 0.25rem;
|
||||
margin: 0 -0.25rem;
|
||||
border-bottom: solid thin $color__gray--primary;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: $color__lightgray--primary;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
@at-root #{&}__username {
|
||||
cursor: default;
|
||||
font-size: 1.25rem;
|
||||
margin-left: 0.5rem;
|
||||
margin-bottom: -0.4rem;
|
||||
}
|
||||
@at-root #{&}__empty {
|
||||
text-align: center;
|
||||
font-size: 1.5rem;
|
||||
user-select: none;
|
||||
cursor: default;
|
||||
color: $color__gray--darkest;
|
||||
font-style: italic;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@at-root #{&}__count {
|
||||
@include user-select(none);
|
||||
|
||||
cursor: default;
|
||||
font-size: 0.85rem;
|
||||
position: relative;
|
||||
bottom: 0.1rem;
|
||||
}
|
||||
|
||||
@at-root #{&}__heart {
|
||||
@include user-select(none);
|
||||
|
||||
cursor: pointer;
|
||||
color: $color__gray--darkest;
|
||||
font-size: 1rem;
|
||||
margin-right: 0.25rem;
|
||||
transition: transform 0.2s, text-shadow 0.2s, color 0.2s, filter 0.2s;
|
||||
|
||||
&:hover {
|
||||
filter: brightness(0.9);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
@at-root #{&}--liked {
|
||||
color: #E91E63;
|
||||
}
|
||||
@at-root #{&}--unlikeable {
|
||||
cursor: default;
|
||||
|
||||
&:hover {
|
||||
filter: none;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,100 @@
|
|||
<template>
|
||||
<div
|
||||
class='info_tooltip'
|
||||
@mouseenter='setState(true)'
|
||||
@mouseleave='setState(false)'
|
||||
>
|
||||
<div
|
||||
class='info_tooltip__content'
|
||||
:class="{
|
||||
'info_tooltip__content--show': show,
|
||||
'info_tooltip__content--pointer_events': pointerEvents,
|
||||
}"
|
||||
>
|
||||
<slot name='content'></slot>
|
||||
</div>
|
||||
<div
|
||||
class='info_tooltip__display'
|
||||
:class="{
|
||||
'info_tooltip__display--hover': show
|
||||
}"
|
||||
>
|
||||
<slot name='display'></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'InfoTooltip',
|
||||
props: ['noEvents'],
|
||||
data () {
|
||||
return {
|
||||
show: false,
|
||||
pointerEvents: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setState (val) {
|
||||
if(this.noEvents) return
|
||||
|
||||
if(val) {
|
||||
this.pointerEvents = true
|
||||
this.show = true
|
||||
this.$emit('hover')
|
||||
} else {
|
||||
this.show = false;
|
||||
setTimeout(() => {
|
||||
if(this.show) return;
|
||||
|
||||
this.pointerEvents = false
|
||||
}, 300)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss'>
|
||||
@import '../assets/scss/variables.scss';
|
||||
|
||||
.info_tooltip {
|
||||
position: relative;
|
||||
|
||||
@at-root #{&}__content {
|
||||
opacity: 0;
|
||||
max-height: 7.5rem;
|
||||
border-radius: 0.25rem;
|
||||
pointer-events: none;
|
||||
width: 17.5rem;
|
||||
z-index: 2;
|
||||
overflow-y: auto;
|
||||
position: absolute;
|
||||
bottom: calc(100% + -0.5rem);
|
||||
background-color: #fff;
|
||||
padding: 0.5rem;
|
||||
border: 0.125rem solid $color__gray--darker;
|
||||
box-shadow: none;
|
||||
transition: all 0.2s;
|
||||
transition-delay: 0.3s;
|
||||
|
||||
@at-root #{&}--show {
|
||||
bottom: calc(100% + 0.5rem);
|
||||
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.03), 0 3px 6px rgba(0,0,0,0.06);
|
||||
opacity: 1;
|
||||
display: initial;
|
||||
transition: all 0.2s;
|
||||
transition-delay: 0.5s;
|
||||
}
|
||||
@at-root #{&}--pointer_events {
|
||||
pointer-events: all;
|
||||
}
|
||||
}
|
||||
|
||||
@at-root #{&}__display {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,214 @@
|
|||
<template>
|
||||
<div
|
||||
class='input_editor input_editor--float'
|
||||
:class='{
|
||||
"input_editor--hidden": !show,
|
||||
"input_editor--focus-input": focusInput
|
||||
}'
|
||||
>
|
||||
<div class='input_editor__overlay' :class='{ "input_editor__overlay--show" : loading }'>
|
||||
<loading-icon></loading-icon>
|
||||
</div>
|
||||
|
||||
<div class='input_editor__reply_username' v-if='replyUsername'>Replying to <strong>{{replyUsername}}</strong></div>
|
||||
<div class='input_editor__close input_editor__format_button' @click='closeEditor'>×</div>
|
||||
|
||||
<tab-view :tabs='["Editor", "Preview"]' v-model='showTab' :small-tabs='true'>
|
||||
<template slot='Editor'>
|
||||
<input-editor-core
|
||||
:value='value'
|
||||
:right-align-emoji='true'
|
||||
:error='error'
|
||||
|
||||
@input='emitInput'
|
||||
@mentions='emitMentions'
|
||||
@focus='setFocusInput(true)'
|
||||
@blur='setFocusInput(false)'
|
||||
></input-editor-core>
|
||||
</template>
|
||||
<template slot='Preview'>
|
||||
<input-editor-preview :value='value' :mentions='mentions'></input-editor-preview>
|
||||
</template>
|
||||
</tab-view>
|
||||
|
||||
|
||||
<div class='input_editor__submit_bar'>
|
||||
<button class='button button--thin_text' @click='submit'>Submit</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import InputEditorCore from './InputEditorCore'
|
||||
import InputEditorPreview from './InputEditorPreview'
|
||||
import LoadingIcon from './LoadingIcon'
|
||||
import TabView from './TabView'
|
||||
|
||||
|
||||
export default {
|
||||
name: 'InputEditor',
|
||||
props: ['value', 'error', 'replyUsername', 'show', 'loading'],
|
||||
components: {
|
||||
InputEditorCore,
|
||||
InputEditorPreview,
|
||||
LoadingIcon,
|
||||
TabView
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
showTab: 0,
|
||||
mentions: [],
|
||||
focusInput: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
submit () {
|
||||
if(this.value.trim().length) {
|
||||
this.$emit('submit');
|
||||
}
|
||||
},
|
||||
closeEditor () {
|
||||
this.emitInput('')
|
||||
this.$emit('close')
|
||||
},
|
||||
emitMentions (mentions) {
|
||||
this.mentions = mentions
|
||||
this.$emit('mentions', mentions)
|
||||
},
|
||||
emitInput (val) {
|
||||
this.$emit('input', val)
|
||||
},
|
||||
setFocusInput (val) {
|
||||
this.focusInput = val
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show (val) {
|
||||
let textarea
|
||||
|
||||
if(val) this.showTab = 0
|
||||
|
||||
textarea = this.$el.querySelector('textarea')
|
||||
if(textarea) textarea.focus()
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../assets/scss/variables.scss';
|
||||
|
||||
.input_editor {
|
||||
width: 35rem;
|
||||
border: 0.125rem solid $color__gray--darker;
|
||||
border-bottom: none;
|
||||
border-radius: 0.25rem 0.25rem 0 0;
|
||||
margin-bottom: 0;
|
||||
pointer-events: all;
|
||||
transition: margin-bottom 0.2s, filter 0.2s, border-color 0.2s;
|
||||
outline: none;
|
||||
position: fixed;
|
||||
|
||||
z-index: 2;
|
||||
bottom: 0;
|
||||
|
||||
@at-root #{&}--focus-input {
|
||||
border-color: $color__gray--darkest;
|
||||
}
|
||||
|
||||
|
||||
@at-root #{&}--hidden {
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
margin-bottom: -3rem;
|
||||
transition: margin-bottom 0.2s, opacity 0.2s;
|
||||
}
|
||||
|
||||
@at-root #{&}__overlay {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
z-index: 5;
|
||||
background-color: rgba(0, 0, 0, 0.15);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
|
||||
transition: all 0.2s;
|
||||
|
||||
@at-root #{&}--show {
|
||||
pointer-events: all;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@at-root #{&}__close {
|
||||
position: absolute;
|
||||
right: 0.3rem;
|
||||
top: 0.5rem;
|
||||
height: 1.5rem;
|
||||
width: 1.5rem;
|
||||
text-align: center;
|
||||
line-height: 1.4rem;
|
||||
cursor: pointer;
|
||||
@include user-select(none);
|
||||
@include text($font--role-default, 1rem, 600);
|
||||
color: $color__darkgray--primary;
|
||||
border: thin solid $color__gray--primary;
|
||||
border-radius: 0.25rem;
|
||||
transition: background-color 0.2s;
|
||||
margin: 0;
|
||||
|
||||
&:hover {
|
||||
background-color: $color__gray--darker;
|
||||
}
|
||||
&:active {
|
||||
background-color: $color__gray--darkest;
|
||||
}
|
||||
}
|
||||
|
||||
@at-root #{&}__reply_username {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
top: 0.5rem;
|
||||
}
|
||||
|
||||
@at-root #{&}__submit_bar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
height: 2rem;
|
||||
align-items: center;
|
||||
padding-right: 0.3rem;
|
||||
background-color: $color__gray--primary;
|
||||
|
||||
button {
|
||||
font-size: 0.8rem;
|
||||
height: 1.5rem;
|
||||
padding: 0 0.25rem;
|
||||
border-radius: 3px;
|
||||
border-color: $color__gray--darkest;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.input_editor {
|
||||
width: 100%;
|
||||
left: 0;
|
||||
|
||||
@at-root #{&}__reply_username {
|
||||
top: auto;
|
||||
bottom: 0.5rem;
|
||||
left: 0.5rem;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,385 @@
|
|||
<template>
|
||||
<div
|
||||
class='input_editor_core'
|
||||
>
|
||||
<div>
|
||||
<emoji-selector
|
||||
v-model='emojiSelectorVisible'
|
||||
@emoji='addEmoji'
|
||||
:right-align='rightAlignEmoji'
|
||||
></emoji-selector>
|
||||
|
||||
<div class='input_editor_core__format_bar'>
|
||||
<div
|
||||
class='input_editor_core__format_button input_editor_core__format_button--emoji'
|
||||
title='Emoji'
|
||||
@click='emojiSelectorVisible = true'
|
||||
>
|
||||
<font-awesome-icon :icon='["fa", "grin"]' />
|
||||
</div>
|
||||
<div
|
||||
class='input_editor_core__format_button'
|
||||
title='Bold (ctrl + b)'
|
||||
@click='replaceSelectedText("__", "__")'
|
||||
>
|
||||
B
|
||||
</div>
|
||||
<div
|
||||
class='input_editor_core__format_button'
|
||||
title='Italic (ctrl + i)'
|
||||
@click='replaceSelectedText("*", "*")'
|
||||
>
|
||||
I
|
||||
</div>
|
||||
<div
|
||||
class='input_editor_core__format_button'
|
||||
title='Link (ctrl + l)'
|
||||
@click='setModalState("link", true)'
|
||||
>
|
||||
<font-awesome-icon :icon='["fa", "link"]' />
|
||||
</div>
|
||||
<div
|
||||
class='input_editor_core__format_button'
|
||||
style='margin-left: 0.25rem;'
|
||||
title='Code (ctrl + k)'
|
||||
@click='formatCode'
|
||||
>
|
||||
<font-awesome-icon :icon='["fa", "code"]' />
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
class='input_editor_core__input'
|
||||
placeholder='Type here - you can format using Markdown'
|
||||
|
||||
ref='textarea'
|
||||
:value='value'
|
||||
|
||||
@input='setEditor($event.target.value)'
|
||||
@focus='$emit("focus")'
|
||||
@blur='$emit("blur")'
|
||||
|
||||
@keydown.ctrl.66.prevent='replaceSelectedText("__", "__")'
|
||||
@keydown.ctrl.73.prevent='replaceSelectedText("*", "*")'
|
||||
@keydown.ctrl.76.prevent='setModalState("link", true)'
|
||||
@keydown.ctrl.75.prevent='formatCode'
|
||||
>
|
||||
</textarea>
|
||||
</div>
|
||||
|
||||
<modal-window v-model='linkModalVisible'>
|
||||
<div slot='main'>
|
||||
<p>
|
||||
Enter the web address in the input box below
|
||||
</p>
|
||||
<fancy-input placeholder='Text for link' width='100%' v-model='linkText'></fancy-input>
|
||||
<fancy-input placeholder='Web address for link' width='100%' v-model='linkURL'></fancy-input>
|
||||
</div>
|
||||
|
||||
<div slot='footer'>
|
||||
<button class='button button--green button--modal' @click='addLink'>
|
||||
Add link
|
||||
</button>
|
||||
<button class='button button--modal' @click='setModalState("link", false)'>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</modal-window>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ModalWindow from './ModalWindow'
|
||||
import FancyInput from './FancyInput'
|
||||
import EmojiSelector from './EmojiSelector'
|
||||
|
||||
let usernames = {}
|
||||
|
||||
export default {
|
||||
name: 'InputEditorCore',
|
||||
props: ['value', 'error', 'right-align-emoji'],
|
||||
components: {
|
||||
ModalWindow,
|
||||
FancyInput,
|
||||
EmojiSelector
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
linkText: '',
|
||||
linkURL: '',
|
||||
linkModalVisible: false,
|
||||
imageModalVisible: false,
|
||||
emojiSelectorVisible: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setModalState (modal, state) {
|
||||
if(modal === 'link') {
|
||||
this.linkModalVisible = state
|
||||
|
||||
if(state) {
|
||||
this.linkText = this.getSelectionData().val;
|
||||
} else {
|
||||
this.linkText = '';
|
||||
this.linkURL = '';
|
||||
}
|
||||
} else if(modal === 'image') {
|
||||
this.imageModalVisible = state
|
||||
}
|
||||
},
|
||||
checkUsernames (matches) {
|
||||
let doneCount = 0
|
||||
let mentions = []
|
||||
|
||||
let done = res => {
|
||||
doneCount++
|
||||
|
||||
if(res) mentions.push(res)
|
||||
|
||||
if(doneCount === matches.length) {
|
||||
this.$emit('mentions', mentions)
|
||||
}
|
||||
}
|
||||
|
||||
matches.forEach(match => {
|
||||
this.checkUsername(match, done)
|
||||
})
|
||||
|
||||
},
|
||||
checkUsername (match, cb) {
|
||||
let username = match.trim().slice(1)
|
||||
let checkedUsername = usernames[username]
|
||||
|
||||
if(checkedUsername !== undefined) {
|
||||
cb(checkedUsername)
|
||||
} else if(checkedUsername === undefined) {
|
||||
this.axios
|
||||
.get('/api/v1/user/' + username)
|
||||
.then(() => {
|
||||
usernames[username] = username
|
||||
cb(username)
|
||||
})
|
||||
.catch(() => {
|
||||
usernames[username] = null
|
||||
cb(null)
|
||||
})
|
||||
}
|
||||
},
|
||||
setEditor (value) {
|
||||
let matches = value.match(/(^|\s)@[^\s]+/g) || []
|
||||
this.checkUsernames(matches)
|
||||
|
||||
this.$emit('input', value)
|
||||
},
|
||||
getSelectionData () {
|
||||
var el = this.$refs.textarea,
|
||||
start = el.selectionStart,
|
||||
end = el.selectionEnd;
|
||||
|
||||
return {
|
||||
val: el.value.slice(start, end),
|
||||
start,
|
||||
end
|
||||
};
|
||||
|
||||
},
|
||||
replaceSelectedText (before, after) {
|
||||
var selectionData = this.getSelectionData();
|
||||
var el = this.$refs.textarea;
|
||||
|
||||
if(
|
||||
this.value.substr(selectionData.start - before.length, before.length) === before &&
|
||||
this.value.substr(selectionData.end, after.length) === after
|
||||
) {
|
||||
this.setEditor(
|
||||
this.value.slice(0, selectionData.start - before.length) +
|
||||
selectionData.val +
|
||||
this.value.slice(selectionData.end + after.length)
|
||||
);
|
||||
setTimeout(function() {
|
||||
el.selectionStart = selectionData.start - before.length;
|
||||
el.selectionEnd = selectionData.end - after.length;
|
||||
}, 0);
|
||||
} else {
|
||||
this.setEditor(
|
||||
this.value.slice(0, selectionData.start) +
|
||||
before + selectionData.val + after +
|
||||
this.value.slice(selectionData.end)
|
||||
);
|
||||
setTimeout(function() {
|
||||
el.selectionStart = selectionData.start + before.length;
|
||||
el.selectionEnd = selectionData.end + after.length;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
el.focus();
|
||||
},
|
||||
addLink () {
|
||||
var linkTextLength = this.linkText.length;
|
||||
var selectionData = this.getSelectionData();
|
||||
var el = this.$refs.textarea;
|
||||
|
||||
this.setEditor(
|
||||
this.value.slice(0, selectionData.start) +
|
||||
'[' + this.linkText + '](' + this.linkURL + ')' +
|
||||
this.value.slice(selectionData.end)
|
||||
);
|
||||
el.focus();
|
||||
|
||||
setTimeout(function() {
|
||||
el.selectionStart = selectionData.start + 1;
|
||||
el.selectionEnd = selectionData.start + 1 + linkTextLength;
|
||||
}, 0);
|
||||
|
||||
this.setModalState('link', false);
|
||||
},
|
||||
addEmoji (emoji) {
|
||||
var selectionData = this.getSelectionData();
|
||||
var el = this.$refs.textarea;
|
||||
|
||||
this.setEditor(
|
||||
this.value.slice(0, selectionData.start) +
|
||||
emoji +
|
||||
this.value.slice(selectionData.end)
|
||||
);
|
||||
el.focus();
|
||||
|
||||
setTimeout(function() {
|
||||
el.selectionStart = selectionData.start + emoji.length;
|
||||
el.selectionEnd = selectionData.start + emoji.length;
|
||||
}, 0);
|
||||
},
|
||||
formatCode (e) {
|
||||
e.preventDefault()
|
||||
|
||||
var selectionData = this.getSelectionData();
|
||||
|
||||
if(this.value[selectionData.start-1] === '\n' || selectionData.start === 0) {
|
||||
var el = this.$refs.textarea;
|
||||
var matches = ( selectionData.val.match(/\n/g) || [] ).length
|
||||
var replacedText = ' ' + selectionData.val.replace(/\n/g, '\n ')
|
||||
|
||||
this.setEditor(
|
||||
this.value.slice(0, selectionData.start) +
|
||||
replacedText +
|
||||
this.value.slice(selectionData.end)
|
||||
);
|
||||
el.focus();
|
||||
|
||||
setTimeout(function() {
|
||||
el.selectionStart = selectionData.start + 4;
|
||||
el.selectionEnd = selectionData.end + (matches + 1)*4;
|
||||
}, 0);
|
||||
} else {
|
||||
this.replaceSelectedText('`', '`');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../assets/scss/variables.scss';
|
||||
|
||||
.input_editor_core {
|
||||
@at-root #{&}__format_bar {
|
||||
width: auto;
|
||||
position: absolute;
|
||||
height: 2rem;
|
||||
top: 0.25rem;
|
||||
right: 0;
|
||||
background-color: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 0.125rem;
|
||||
margin-right: 2.4rem;
|
||||
}
|
||||
@at-root #{&}__format_button {
|
||||
height: 1.5rem;
|
||||
width: 1.5rem;
|
||||
border-radius: 0.25rem;
|
||||
text-align: center;
|
||||
line-height: 1.4rem;
|
||||
cursor: pointer;
|
||||
@include user-select(none);
|
||||
@include text($font--role-default, 1rem, 600);
|
||||
color: $color__darkgray--primary;
|
||||
border: thin solid $color__gray--primary;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: $color__gray--darker;
|
||||
}
|
||||
&:active {
|
||||
background-color: $color__gray--darkest;
|
||||
}
|
||||
}
|
||||
|
||||
@at-root #{&}__spacer {
|
||||
width: 0.6rem;
|
||||
}
|
||||
|
||||
@at-root #{&}__input {
|
||||
width: 100%;
|
||||
height: 8rem;
|
||||
border: 0;
|
||||
padding: 0.5rem;
|
||||
@include text;
|
||||
outline: none;
|
||||
resize: none;
|
||||
|
||||
@include placeholder {
|
||||
@include text($font--role-emphasis, 1rem);
|
||||
display: flex;
|
||||
align-content: center;
|
||||
@include user-select(none);
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@at-root #{&}__error {
|
||||
position: absolute;
|
||||
background-color: #ffeff1;
|
||||
border: 0.125rem solid #D32F2F;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.1rem 0.25rem;
|
||||
top: 0.2125rem;
|
||||
left: calc(100% + 0.25rem);
|
||||
white-space: nowrap;
|
||||
|
||||
|
||||
&:first-letter{ text-transform: capitalize; }
|
||||
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
margin-top: -1rem;
|
||||
transition: opacity 0.2s, margin-top 0.2s;
|
||||
|
||||
@at-root #{&}--show {
|
||||
opacity: 1;
|
||||
pointer-events: all;
|
||||
margin-top: 0;
|
||||
transition: opacity 0.2s, margin-top 0.2s;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: relative;
|
||||
width: 0;
|
||||
height: 0;
|
||||
display: inline-block;
|
||||
right: calc(100% + 0.3rem);
|
||||
border-top: 0.3rem solid transparent;
|
||||
border-bottom: 0.3rem solid transparent;
|
||||
border-right: 0.3rem solid #D32F2F;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.input_editor_core__format_button--emoji {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,91 @@
|
|||
<template>
|
||||
<div class='input_editor_preview__markdownHTML'>
|
||||
<!--
|
||||
A hack to call the getHTML() function, not sure why
|
||||
the value watcher function is not working, as it does
|
||||
in the ThreadNew page
|
||||
!-->
|
||||
<div style='display: none;'>{{valueWatch}}</div>
|
||||
|
||||
<div v-html='HTML' style='margin-top: -0.5rem;'></div>
|
||||
<div v-if='!value.trim().length' class='input_editor_preview__markdownHTML--empty'>
|
||||
Nothing to preview
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Marked from 'marked'
|
||||
|
||||
Marked.setOptions({
|
||||
highlight: function (code) {
|
||||
return require('highlight.js').highlightAuto(code).value;
|
||||
}
|
||||
});
|
||||
|
||||
const renderer = new Marked.Renderer();
|
||||
renderer.link = function (href, title, text) {
|
||||
if(!href.match(/[a-z]+:\/\/.+/i)) {
|
||||
href = 'http://' + href;
|
||||
}
|
||||
|
||||
return `
|
||||
<a href='${href}' ${title ? "title='" + title + "'" : "" } target='_blank' rel='noopener'>
|
||||
${text}
|
||||
</a>
|
||||
`;
|
||||
};
|
||||
|
||||
export default {
|
||||
name: 'InputEditorPreview',
|
||||
props: ['value', 'mentions'],
|
||||
data () {
|
||||
return {
|
||||
HTML: ''
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
valueWatch () {
|
||||
this.getHTML();
|
||||
return '';
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getHTML () {
|
||||
let replacedMd = this.value;
|
||||
|
||||
(this.mentions || []).forEach(mention => {
|
||||
let regexp = new RegExp('(^|\\s)@' + mention + '($|\\s)')
|
||||
replacedMd = replacedMd.replace(regexp, `$1[@${mention}](/user/${mention})$2`)
|
||||
})
|
||||
|
||||
let HTML = Marked(replacedMd, { renderer });
|
||||
|
||||
this.HTML = HTML;
|
||||
this.$linkExpander(HTML, v => this.HTML = v);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../assets/scss/variables.scss';
|
||||
|
||||
.input_editor_preview {
|
||||
@at-root #{&}__markdownHTML {
|
||||
height: 8.125rem;
|
||||
overflow: auto;
|
||||
padding: 0.5rem;
|
||||
|
||||
@at-root #{&}--empty {
|
||||
@include text($font--role-emphasis, 1rem);
|
||||
display: flex;
|
||||
margin-top: 0.5rem;
|
||||
align-content: center;
|
||||
@include user-select(none);
|
||||
cursor: default;
|
||||
color: $color__darkgray--primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,72 @@
|
|||
<template>
|
||||
<button
|
||||
class='button loading_button'
|
||||
:class='{"loading_button--loading": loading}'
|
||||
@click='event("click")'
|
||||
@keydown='event("keydown")'
|
||||
>
|
||||
<loading-icon :dark='dark' class='loading_button__icon'></loading-icon>
|
||||
<div class='loading_button__slot'>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import LoadingIcon from './LoadingIcon'
|
||||
|
||||
export default {
|
||||
name: 'LoadingButton',
|
||||
props: ['loading', 'dark'],
|
||||
components: { LoadingIcon },
|
||||
methods: {
|
||||
event (name) {
|
||||
if(this.loading) {
|
||||
return;
|
||||
} else {
|
||||
this.$emit(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss'>
|
||||
@import '../assets/scss/variables.scss';
|
||||
|
||||
.loading_button {
|
||||
position: relative;
|
||||
|
||||
@at-root #{&}__slot {
|
||||
transition: all 0.2s;
|
||||
opacity: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
|
||||
@at-root #{&}__icon {
|
||||
position: absolute;
|
||||
width: calc(100% - 1rem);
|
||||
opacity: 0;
|
||||
justify-content: center;
|
||||
height: calc(100% - 0.85rem);
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.loading_button--loading {
|
||||
cursor: not-allowed;
|
||||
|
||||
.loading_button__icon {
|
||||
opacity: 1;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.loading_button__slot {
|
||||
opacity: 0;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,64 @@
|
|||
<template>
|
||||
<div
|
||||
class='loading_icon'
|
||||
:class='{"loading_icon--dark": dark }'
|
||||
>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'LoadingIcon',
|
||||
props: ['dark']
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss'>
|
||||
@import '../assets/scss/variables.scss';
|
||||
|
||||
@keyframes loading {
|
||||
0% {
|
||||
transform: scale(0.75);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.25);
|
||||
}
|
||||
100% {
|
||||
transform: scale(0.75);
|
||||
}
|
||||
}
|
||||
|
||||
.loading_icon {
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
pointer-events: none;
|
||||
|
||||
@at-root #{&}--dark {
|
||||
span {
|
||||
background-color: $color__darkgray--primary !important;
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
height: 0.5rem;
|
||||
width: 0.5rem;
|
||||
display: inline-block;
|
||||
border-radius: 100%;
|
||||
background-color: rgba(256,256,256,0.9);
|
||||
animation-name: loading;
|
||||
animation-duration: 1s;
|
||||
animation-timing-function: linear;
|
||||
animation-iteration-count: infinite;
|
||||
margin: 0 0.25rem;
|
||||
|
||||
&:nth-child(2n) { animation-delay: 0.333s; }
|
||||
&:nth-child(3n) { animation-delay: 0.666s; }
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,17 @@
|
|||
<template>
|
||||
<div class='overlay_message'>
|
||||
<span class='overlay_message__loading'>
|
||||
<loading-icon :dark='true'></loading-icon>
|
||||
</span>
|
||||
Loading...
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import LoadingIcon from './LoadingIcon'
|
||||
|
||||
export default {
|
||||
name: 'LoadingMessage',
|
||||
components: { LoadingIcon }
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,74 @@
|
|||
<template>
|
||||
<menu-tooltip
|
||||
v-model='menuOpen'
|
||||
class='menu_button'
|
||||
top='0'
|
||||
width='10rem'
|
||||
touch-disabled='true'
|
||||
>
|
||||
<div class='menu_button__icon' @click='menuOpen = true' slot='button'>
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<template slot='menu'>
|
||||
<div
|
||||
class='menu_button__option'
|
||||
:key='"menu-button-option-" + option.value + "-" + $index'
|
||||
v-for='(option, $index) in options'
|
||||
@click='emit(option.event)'
|
||||
:style="{ 'border-bottom' : $index === options.length-1 ? 'none' : 'solid thin rgb(245, 245, 245)' }"
|
||||
>
|
||||
{{option.value}}
|
||||
</div>
|
||||
</template>
|
||||
</menu-tooltip>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MenuTooltip from './MenuTooltip';
|
||||
|
||||
export default {
|
||||
name: 'MenuButton',
|
||||
props: ['options'],
|
||||
components: {
|
||||
MenuTooltip
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
menuOpen: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
emit (option) {
|
||||
this.$emit(option)
|
||||
this.menuOpen = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../assets/scss/variables.scss';
|
||||
|
||||
.menu_button {
|
||||
@at-root #{&}__option {
|
||||
border-radius: 0.25rem;
|
||||
cursor: default;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.5rem;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover { background-color: $color__lightgray--primary; }
|
||||
&:active { background-color: $color__lightgray--darker; }
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.menu_button {
|
||||
@at-root #{&}__option {
|
||||
padding: 0.75rem;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,133 @@
|
|||
<template>
|
||||
<div
|
||||
class='menu_tooltip'
|
||||
:class='{"menu_tooltip--touch": !touchDisabled}'
|
||||
>
|
||||
<div
|
||||
class='menu_tooltip__overlay'
|
||||
:class='{ "menu_tooltip__overlay--show": value }'
|
||||
@click='$emit("input", false)'
|
||||
></div>
|
||||
|
||||
<slot name='button'></slot>
|
||||
|
||||
<div
|
||||
class='menu_tooltip__menu'
|
||||
:class='{ "menu_tooltip__menu--show": value }'
|
||||
:style='{ "top": top, "width": width }'
|
||||
>
|
||||
<div class='menu_tooltip__menu__inner'>
|
||||
<slot name='menu'></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'MenuTooltip',
|
||||
props: ['value', 'top', 'width', 'touch-disabled']
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../assets/scss/variables.scss';
|
||||
|
||||
.menu_tooltip {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
|
||||
@at-root #{&}__overlay {
|
||||
height: 100vh;
|
||||
left: calc(50% - 50vw);
|
||||
pointer-events: none;
|
||||
position: fixed;
|
||||
top: calc(50% - 50vh);
|
||||
width: 100vw;
|
||||
z-index: 2;
|
||||
|
||||
@at-root #{&}--show {
|
||||
pointer-events: all;
|
||||
}
|
||||
}
|
||||
|
||||
@at-root #{&}__menu {
|
||||
background-color: #fff;
|
||||
border: 1.5px solid $color__gray--darker;
|
||||
border-radius: 0.25rem;
|
||||
box-shadow: 0 0.25rem 1rem rgba(#000, 0.125);
|
||||
opacity: 0;
|
||||
overflow-y: hidden;
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
top: calc(100% + 0.125rem);
|
||||
transform: translateY(-0.25rem);
|
||||
transition: transform 0.2s, opacity 0.2s;
|
||||
width: 15rem;
|
||||
z-index: 3;
|
||||
|
||||
@at-root #{&}--show {
|
||||
opacity: 1;
|
||||
pointer-events: all;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
@at-root #{&}__inner {
|
||||
max-height: 10rem;
|
||||
overflow-y: auto;
|
||||
padding: 0.25rem;
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint--tablet) and (min-width: $breakpoint--phone) {
|
||||
.menu_tooltip__menu {
|
||||
width: 60%;
|
||||
left: 20%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint--phone) {
|
||||
.menu_tooltip__menu {
|
||||
width: 100%;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint--tablet) {
|
||||
.menu_tooltip--touch {
|
||||
.menu_tooltip {
|
||||
@at-root #{&}__overlay {
|
||||
transition: all 0.2s;
|
||||
|
||||
@at-root #{&}--show {
|
||||
background-color: hsla(213, 35%, 5%, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
@at-root #{&}__menu {
|
||||
background-color: rgba(255, 255, 255, 0.97);
|
||||
border-radius: 0.25rem 0.25rem 0 0;
|
||||
font-size: 1.125rem;
|
||||
opacity: 0;
|
||||
overflow-y: auto;
|
||||
position: fixed;
|
||||
top: unset;
|
||||
bottom: -100vh;
|
||||
transition: opacity 0.2s, bottom 0.2s;
|
||||
|
||||
@at-root #{&}__inner {
|
||||
max-height: 20rem;
|
||||
}
|
||||
|
||||
@at-root #{&}--show {
|
||||
bottom: calc(50% - 50vh);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,141 @@
|
|||
<template>
|
||||
<div class='modal_window__overlay' :class='{"modal_window--show": value}' @click.self='closeModal'>
|
||||
<div class='modal_window' :class='{"modal_window--show": value}' :style='{"width": width || "20rem"}'>
|
||||
<div
|
||||
class='modal_window__loading_overlay'
|
||||
:class='{
|
||||
"modal_window__loading_overlay--show": loading
|
||||
}'
|
||||
>
|
||||
<loading-icon></loading-icon>
|
||||
</div>
|
||||
|
||||
<span
|
||||
class='modal_window__close'
|
||||
@click='closeModal'
|
||||
v-if='closeButton'
|
||||
>
|
||||
<font-awesome-icon :icon='["fa", "times"]' />
|
||||
</span>
|
||||
<div class='modal_window__main' :class='{ "modal_window__main--no_padding": noPadding }'>
|
||||
<slot name='main'></slot>
|
||||
</div>
|
||||
<div class='modal_window__footer' v-if='!hideFooter'>
|
||||
<slot name='footer'></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import LoadingIcon from './LoadingIcon'
|
||||
|
||||
export default {
|
||||
name: 'ModalWindow',
|
||||
props: ['value', 'width', 'close-button', 'hide-footer', 'no-padding', 'loading'],
|
||||
components: { LoadingIcon },
|
||||
methods: {
|
||||
closeModal () {
|
||||
this.$emit('input', false)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../assets/scss/variables.scss';
|
||||
|
||||
.modal_window__overlay {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: fixed;
|
||||
z-index: 3;
|
||||
top: 0;
|
||||
left: 0;
|
||||
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s;
|
||||
|
||||
@at-root #{&}--show {
|
||||
opacity: 1;
|
||||
pointer-events: all;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
}
|
||||
.modal_window {
|
||||
box-shadow: 0 14px 28px rgba(0,0,0,0.15), 0 10px 10px rgba(0,0,0,0.10);
|
||||
background-color: #fff;
|
||||
opacity: 0;
|
||||
position: relative;
|
||||
border-radius: 0.25rem;
|
||||
pointer-events: none;
|
||||
transform: scale(1.1);
|
||||
|
||||
transition: margin-top 0.3s, opacity 0.3s, transform 0.3s;
|
||||
|
||||
@at-root #{&}__loading_overlay {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
z-index: 5;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
|
||||
transition: all 0.2s;
|
||||
|
||||
@at-root #{&}--show {
|
||||
pointer-events: all;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@at-root #{&}__main {
|
||||
padding: 0 1rem 1rem 1rem;
|
||||
border-radius: 0.25rem;
|
||||
|
||||
@at-root #{&}--no_padding {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
@at-root #{&}__footer {
|
||||
background-color: $color__lightgray--darkest;
|
||||
border-radius: 0 0 0.25rem 0.25rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 0.35rem 1rem;
|
||||
}
|
||||
|
||||
@at-root #{&}__close {
|
||||
position: absolute;
|
||||
right: 0.7rem;
|
||||
top: 0.5rem;
|
||||
transition: color 0.2s;
|
||||
cursor: pointer;
|
||||
color: $color__gray--darkest;
|
||||
|
||||
&:hover {
|
||||
color: $color__darkgray--primary;
|
||||
}
|
||||
}
|
||||
|
||||
@at-root #{&}--show {
|
||||
margin-top: 0;
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
pointer-events: all;
|
||||
|
||||
transition: all 0.3s;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,39 @@
|
|||
<template>
|
||||
<div class='moderation_header'>
|
||||
<h1 class='moderation_header__h1'>Moderation</h1>
|
||||
<div class='moderation_header__tabs'>
|
||||
<div @click='$router.push("reports")' class='tab_button' :class='{
|
||||
"tab_button--selected": selectedTab === "reports"
|
||||
}'>
|
||||
Reports
|
||||
</div>
|
||||
<div @click='$router.push("bans")' class='tab_button' :class='{
|
||||
"tab_button--selected": selectedTab === "bans"
|
||||
}'>
|
||||
Banned users
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'ModerationHeader',
|
||||
props: ['selected-tab']
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../assets/scss/variables.scss';
|
||||
|
||||
.moderation_header {
|
||||
@at-root #{&}__h1 {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
@at-root #{&}__tabs {
|
||||
margin-bottom: 1rem;
|
||||
border-bottom: 0.2rem solid $color__gray--darker;
|
||||
width: 15rem;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,119 @@
|
|||
<template>
|
||||
<div class='more_threads'>
|
||||
<div class='more_threads__header'>More threads in category '{{category.name}}'</div>
|
||||
|
||||
<div class='more_threads__empty overlay_message' v-if='empty'>
|
||||
No more threads to show
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class='more_threads__thread more_threads__thread--header'>
|
||||
<div class='more_threads__name'>Thread</div>
|
||||
<div class='more_threads__date_created'>Date created</div>
|
||||
</div>
|
||||
|
||||
<div class='more_threads__thread' :key='"thread-id-" + thread.id' v-for='thread in threads' @click='goToThread(thread)'>
|
||||
<div class='more_threads__name' >{{thread.name}}</div>
|
||||
<div class='more_threads__date_created'>{{ thread.createdAt | formatDate }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import AjaxErrorHandler from '../assets/js/errorHandler'
|
||||
|
||||
export default {
|
||||
name: 'MoreThreads',
|
||||
props: ['category', 'threadId'],
|
||||
data () {
|
||||
return { threads: [], empty: false }
|
||||
},
|
||||
methods: {
|
||||
goToThread (thread) {
|
||||
this.$router.push(`/thread/${thread.slug}/${thread.id}`)
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.axios
|
||||
.get('/api/v1/category/' + this.category.value)
|
||||
.then(res => {
|
||||
let filtered = res.data.Threads.filter(thread => {
|
||||
return thread.id !== this.threadId
|
||||
})
|
||||
|
||||
this.threads = filtered.slice(0, 4)
|
||||
this.empty = !filtered.length
|
||||
})
|
||||
.catch(AjaxErrorHandler(this.$store))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../assets/scss/variables.scss';
|
||||
|
||||
.more_threads {
|
||||
background-color: #fff;
|
||||
border-radius: 0.25rem;
|
||||
width: 80%;
|
||||
padding: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
border: thin solid $color__gray--darker;
|
||||
|
||||
@at-root #{&}__header {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 500;
|
||||
padding-left: 0.25rem;
|
||||
}
|
||||
|
||||
@at-root #{&}__thread {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
padding: 0.6rem;
|
||||
align-items: center;
|
||||
border-bottom: thin solid $color__gray--darker;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: $color__lightgray--primary;
|
||||
}
|
||||
&:active {
|
||||
background-color: $color__lightgray--darker;
|
||||
}
|
||||
|
||||
@at-root #{&}--header {
|
||||
cursor: default;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
border-bottom: 0.125rem solid $color__gray--darker;
|
||||
|
||||
&:hover { background-color: #fff; }
|
||||
}
|
||||
}
|
||||
@at-root #{&}__name {
|
||||
word-break: break-all;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
@at-root #{&}__date_created {
|
||||
color: $color__text--secondary;
|
||||
font-size: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@at-root #{&}__empty {
|
||||
padding: 1rem;
|
||||
font-size: 1.5rem;
|
||||
color: $color__text--primary;
|
||||
}
|
||||
}
|
||||
|
||||
@include thread_mobile_breakpoint ('.more_threads');
|
||||
@media (max-width: $breakpoint--tablet) {
|
||||
.more_threads__empty {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,495 @@
|
|||
<template>
|
||||
<div class='notification_button'>
|
||||
<div
|
||||
class='notification_button__overlay'
|
||||
:class='{ "notification_button__overlay--show" : showMenu}'
|
||||
@click='setShowMenu(false)'
|
||||
></div>
|
||||
<button
|
||||
class='button notification_button__button'
|
||||
:class='{ "notification_button__button--shake": shake }'
|
||||
@click='setShowMenu(!showMenu)'
|
||||
>
|
||||
<font-awesome-icon class='notification_button__button__icon' :icon='["far", "bell"]' />
|
||||
<span
|
||||
class='notification_button__button__count'
|
||||
:class='{
|
||||
"notification_button__button__count--none": !unreadCount,
|
||||
"notification_button__button__count--two_figure": unreadCount > 9,
|
||||
"notification_button__button__count--three_figure": unreadCount > 99
|
||||
}'
|
||||
>{{unreadCountText}}</span>
|
||||
</button>
|
||||
<div
|
||||
class='notification_button__menu_group'
|
||||
:class='{ "notification_button__menu_group--show" : showMenu}'
|
||||
>
|
||||
<div class='notification_button__triangle'></div>
|
||||
<div class='notification_button__menu'>
|
||||
<div
|
||||
:key='"notification-" + index'
|
||||
v-for='(notification, index) in postNotifications'
|
||||
class='notification_button__menu__item'
|
||||
:class='{
|
||||
"notification_button__menu__item--uninteracted": !notification.interacted,
|
||||
"notification_button__menu__item--no_border": index > 2
|
||||
}'
|
||||
|
||||
@click='click(notification)'
|
||||
>
|
||||
|
||||
<div class='notification_button__menu__item__header'>
|
||||
<span v-if='notification.type === "mention"'>New mention</span>
|
||||
<span v-else-if='notification.type === "reply"'>Reply to your post</span>
|
||||
<span>
|
||||
<span class='notification_button__menu__item__header__date'>{{notification.createdAt | formatDate }}</span>
|
||||
<span
|
||||
class='notification_button__menu__item__header__close'
|
||||
@click.stop='deleteNotification(notification.id)'
|
||||
>×</span>
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span v-if='isYouOrDeleted(notification.PostNotification.User)'>
|
||||
{{ notification.PostNotification.User ? 'You' : '[deleted]' }}
|
||||
</span>
|
||||
<span class='notification_button__menu__item__link' v-else>
|
||||
{{notification.PostNotification.User.username}}
|
||||
</span>
|
||||
|
||||
wrote
|
||||
"{{notification.PostNotification.Post.content | stripTags | truncate(50)}}"
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class='notification_button__menu__empty' v-if='!notifications.length'>
|
||||
<span>{{emojis[emojiIndex % 6]}}</span>
|
||||
No notifications
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import AjaxErrorHandler from '../assets/js/errorHandler'
|
||||
|
||||
export default {
|
||||
name: 'NotificationButton',
|
||||
data () {
|
||||
return {
|
||||
unreadCount: 0,
|
||||
notifications: [],
|
||||
|
||||
showMenu: false,
|
||||
shake: false,
|
||||
emojis: ['😢', '🤷', '😘', '😒', '😔', '💩'],
|
||||
emojiIndex: Math.round(Math.random()*5)
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
unreadCountText () {
|
||||
if(this.unreadCount > 99) {
|
||||
return '99+'
|
||||
} else {
|
||||
return this.unreadCount
|
||||
}
|
||||
},
|
||||
postNotifications () {
|
||||
return this.notifications.filter(n => n.PostNotification.Post);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
isYouOrDeleted (user) {
|
||||
return !user || user.username === this.$store.state.username
|
||||
},
|
||||
setShowMenu (val) {
|
||||
this.showMenu = val
|
||||
|
||||
if(val) {
|
||||
this.resetUnreadCount()
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
this.emojiIndex++
|
||||
}, 200)
|
||||
}
|
||||
},
|
||||
getIndexById (id) {
|
||||
let index
|
||||
|
||||
this.notifications.forEach((notification, i) => {
|
||||
if(notification.id === id) {
|
||||
index = i
|
||||
}
|
||||
})
|
||||
|
||||
return index
|
||||
},
|
||||
getNotifications () {
|
||||
this.axios
|
||||
.get('/api/v1/notification')
|
||||
.then(res => {
|
||||
this.notifications = res.data.Notifications
|
||||
this.unreadCount = res.data.unreadCount
|
||||
})
|
||||
.catch(AjaxErrorHandler(this.$store))
|
||||
},
|
||||
resetUnreadCount () {
|
||||
this.axios
|
||||
.put('/api/v1/notification')
|
||||
.then(() => {
|
||||
this.unreadCount = 0
|
||||
})
|
||||
.catch(AjaxErrorHandler(this.$store))
|
||||
},
|
||||
deleteNotification (id) {
|
||||
let index = this.getIndexById(id)
|
||||
|
||||
this.axios
|
||||
.delete('/api/v1/notification/' + id)
|
||||
.then(() => {
|
||||
this.notifications.splice(index, 1)
|
||||
})
|
||||
.catch(AjaxErrorHandler(this.$store))
|
||||
},
|
||||
setInteracted (id) {
|
||||
let index = this.getIndexById(id)
|
||||
let item = this.notifications[index]
|
||||
|
||||
this.axios
|
||||
.put('/api/v1/notification/' + id)
|
||||
.then(() => {
|
||||
this.$set(
|
||||
this.notifications,
|
||||
index,
|
||||
Object.assign(item, { interacted: true })
|
||||
)
|
||||
})
|
||||
.catch(AjaxErrorHandler(this.$store))
|
||||
},
|
||||
click (notification) {
|
||||
if(!notification.interacted) {
|
||||
this.setInteracted(notification.id)
|
||||
}
|
||||
|
||||
if(notification.type === 'mention' || notification.type === 'reply') {
|
||||
this.$router.push('/p/' + notification.PostNotification.Post.id)
|
||||
} else if(notification.type === 'reply') {
|
||||
this.$router.push('/p/' + notification.PostNotification.Post.id)
|
||||
}
|
||||
|
||||
this.setShowMenu(false)
|
||||
}
|
||||
},
|
||||
created () {
|
||||
if(this.$store.state.username) this.getNotifications()
|
||||
|
||||
this.$socket.on('notification', notification => {
|
||||
this.unreadCount++
|
||||
this.notifications.unshift(notification)
|
||||
|
||||
this.shake = true
|
||||
setTimeout(() => {
|
||||
this.shake = false
|
||||
}, 1000)
|
||||
})
|
||||
},
|
||||
watch: {
|
||||
'$store.state.username': 'getNotifications'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../assets/scss/variables.scss';
|
||||
|
||||
@keyframes shake {
|
||||
0% {
|
||||
position: relative;
|
||||
left: 0;
|
||||
}
|
||||
25% {
|
||||
position: relative;
|
||||
left: -1rem;
|
||||
}
|
||||
75% {
|
||||
position: relative;
|
||||
left: 1rem;
|
||||
}
|
||||
100% {
|
||||
left: 0rem;
|
||||
}
|
||||
}
|
||||
|
||||
.notification_button {
|
||||
position: relative;
|
||||
|
||||
@at-root #{&}__overlay {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
position: fixed;
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
|
||||
@at-root #{&}--show {
|
||||
pointer-events: all;
|
||||
}
|
||||
}
|
||||
|
||||
@at-root #{&}__menu_group {
|
||||
position: relative;
|
||||
top: -3rem;
|
||||
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s, top 0.2s;
|
||||
|
||||
@at-root #{&}--show {
|
||||
pointer-events: all;
|
||||
opacity: 1;
|
||||
top: -2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@at-root #{&}__triangle {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
background-color: #fafafa;
|
||||
transform: rotate(45deg);
|
||||
position: absolute;
|
||||
top: 40px;
|
||||
border-radius: 0.125rem 0 0 0;
|
||||
border: 1.5px solid $color__gray--darkest;
|
||||
left: calc(50% - 1rem /2);
|
||||
clip-path: polygon(0 0, 100% 0%, 0 100%);
|
||||
z-index: 8;
|
||||
}
|
||||
@at-root #{&}__menu {
|
||||
left: calc(-50% - 1.25rem);
|
||||
position: absolute;
|
||||
top: 2.9rem;
|
||||
background-color: #fafafa;
|
||||
width: 20rem;
|
||||
border-radius: 0.25rem;
|
||||
border: 1.5px solid $color__gray--darkest;
|
||||
box-shadow: 0 0.25rem 1rem rgba(#000, 0.125);
|
||||
min-height: 8rem;
|
||||
max-height: 15rem;
|
||||
overflow-y: auto;
|
||||
z-index: 7;
|
||||
|
||||
@at-root #{&}__empty {
|
||||
background-color: #fafafa;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 2rem;
|
||||
height: 8rem;
|
||||
justify-content: center;
|
||||
font-size: 1rem;
|
||||
user-select: none;
|
||||
cursor: default;
|
||||
transition: none;
|
||||
color: $color__gray--darkest;
|
||||
|
||||
span {
|
||||
font-size: 2rem;
|
||||
color: $color__gray--darker;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@at-root #{&}__item {
|
||||
@at-root #{&}--no_border:last-child {
|
||||
border: none;
|
||||
}
|
||||
|
||||
padding: 0.5rem;
|
||||
border-bottom: thin solid $color__gray--primary;
|
||||
cursor: default;
|
||||
background-color: #fff;
|
||||
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: $color__lightgray--primary;
|
||||
}
|
||||
|
||||
|
||||
@at-root #{&}--uninteracted {
|
||||
background-color: rgba(13, 71, 161, 0.1);
|
||||
border-bottom-color: $color__gray--darkest;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(13, 71, 161, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@at-root #{&}__link {
|
||||
font-weight: 400;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@at-root #{&}__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.9rem;
|
||||
|
||||
@at-root #{&}__date {
|
||||
color: $color__text--secondary;
|
||||
}
|
||||
@at-root #{&}__close {
|
||||
background-color: $color__gray--darkest;
|
||||
height: 0.9rem;
|
||||
width: 0.9rem;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
border-radius: 100%;
|
||||
margin-left: 0.25rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
color: #fff;
|
||||
position: relative;
|
||||
top: 0.0625rem;
|
||||
line-height: 1;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@at-root #{&}__button {
|
||||
position: relative;
|
||||
height: 2.5rem;
|
||||
width: 2.5rem;
|
||||
transition: border 0.4s, padding 0.4s;
|
||||
|
||||
@at-root #{&}--shake {
|
||||
animation-name: shake;
|
||||
animation-iteration-count: 4;
|
||||
animation-duration: 0.25s;
|
||||
animation-timing-function: ease-in-out;
|
||||
}
|
||||
|
||||
@at-root #{&}__icon {
|
||||
font-size: 1.5rem;
|
||||
position: relative;
|
||||
top: -0.125rem;
|
||||
}
|
||||
|
||||
@at-root #{&}__count {
|
||||
position: absolute;
|
||||
background-color: $color__blue--primary;
|
||||
line-height: 1;
|
||||
margin-left: 0.25rem;
|
||||
color: #fff;
|
||||
border-radius: 100%;
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
justify-content: center;
|
||||
left: 0.8rem;
|
||||
top: -0.2rem;
|
||||
|
||||
transition: all 0.2s;
|
||||
|
||||
@at-root #{&}--none {
|
||||
opacity: 0;
|
||||
}
|
||||
@at-root #{&}--two_figure {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
@at-root #{&}--three_figure {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.notification_button__menu_group {
|
||||
left: calc(3.5rem - 100vw);
|
||||
width: calc(100vw - 0.25rem);
|
||||
}
|
||||
.notification_button__menu {
|
||||
width: 100%;
|
||||
left: unset;
|
||||
right: unset !important;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 870px) {
|
||||
//Because the notification button is
|
||||
//actually a child of the hamburger menu
|
||||
//it 'pops up' when the overlay is showing
|
||||
//so we cover it with its own overlay
|
||||
//hacky but it works...
|
||||
.notification_button__button::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
width: 100%;
|
||||
border-radius: 0.25rem;
|
||||
height: 100%;
|
||||
background-color: hsla(215, 13%, 25%, 0.5);
|
||||
transition: all 0.4s;
|
||||
}
|
||||
.header__group--show .notification_button {
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
|
||||
@at-root #{&}__button {
|
||||
border: none;
|
||||
|
||||
&::before {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.notification_button {
|
||||
position: fixed;
|
||||
right: 0.5rem;
|
||||
width: 2.4rem;
|
||||
top: 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
|
||||
@at-root #{&}__button {
|
||||
border: none;
|
||||
}
|
||||
|
||||
@at-root #{&}__menu_group {
|
||||
left: calc(3.5rem - 100vw);
|
||||
width: calc(100vw - 0.25rem);
|
||||
}
|
||||
|
||||
@at-root #{&}__menu {
|
||||
left: unset;
|
||||
right: 0.5rem;
|
||||
|
||||
@at-root #{&}__empty {
|
||||
font-weight: normal;
|
||||
}
|
||||
}
|
||||
|
||||
@at-root #{&}__triangle {
|
||||
left: unset;
|
||||
right: 1.55rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,117 @@
|
|||
<template>
|
||||
<info-tooltip class='post_reply' :class='{"post_reply--hover": hover, "post_reply--first": first}'>
|
||||
<template slot='content'>
|
||||
<div style='margin-top: -0.25rem;'>
|
||||
<div class='post_reply__username'>{{user.username}}</div>
|
||||
<div class='post_reply__date'>{{post.createdAt | formatDate('date|time', ' - ')}}</div>
|
||||
</div>
|
||||
<div class='post_reply__content'>{{post.content | stripTags | truncate(100)}}</div>
|
||||
</template>
|
||||
<div
|
||||
slot='display'
|
||||
class='post_reply__display'
|
||||
@click.stop='$emit("click")'
|
||||
>
|
||||
<div
|
||||
class='post_reply__letter'
|
||||
:style='{ "background-color": user.color }'
|
||||
>
|
||||
{{user.letter}}
|
||||
</div>
|
||||
</div>
|
||||
</info-tooltip>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import InfoTooltip from './InfoTooltip'
|
||||
|
||||
export default {
|
||||
name: 'PostReply',
|
||||
props: ['post', 'hover', 'first'],
|
||||
components: { InfoTooltip },
|
||||
computed: {
|
||||
user () {
|
||||
if(this.post.User) {
|
||||
return Object.assign({
|
||||
letter: this.post.User.username[0]
|
||||
}, this.post.User)
|
||||
} else {
|
||||
return {
|
||||
letter: ' ',
|
||||
color: null,
|
||||
username: '[deleted]'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss'>
|
||||
@import '../assets/scss/variables.scss';
|
||||
|
||||
.post_reply {
|
||||
transition: all 0.2s;
|
||||
margin-left: -0.4rem;
|
||||
|
||||
&:first-child {
|
||||
margin-left: 0rem;
|
||||
}
|
||||
|
||||
@at-root #{&}--hover {
|
||||
margin: 0 0.125rem;
|
||||
}
|
||||
|
||||
@at-root #{&}--first {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
|
||||
@at-root #{&}__date {
|
||||
display: inline-block;
|
||||
color: $color__gray--darkest;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
@at-root #{&}__username {
|
||||
display: inline-block;
|
||||
font-size: 0.9rem;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
@at-root #{&}__content {
|
||||
*:first-child, *:last-child {
|
||||
margin: 0;
|
||||
}
|
||||
p {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
@at-root #{&}__display {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
border-radius: 1rem;
|
||||
cursor: pointer;
|
||||
|
||||
@at-root #{&}--no_hover {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
@at-root #{&}__letter {
|
||||
align-items: center;
|
||||
background-color: $color__gray--darkest;
|
||||
border-radius: 100%;
|
||||
box-shadow: inset 0 0 1px 1.5px rgba(256, 256, 256, 0.75);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
height: 1.25rem;
|
||||
justify-content: center;
|
||||
padding-bottom: 0.2rem;
|
||||
width: 1.25rem;
|
||||
|
||||
@include text($font--role-emphasis, 1rem);
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,206 @@
|
|||
<template>
|
||||
<div class='post_scrubber' v-if='posts - 1'>
|
||||
<div class='post_scrubber__label post_scrubber__label--first' @click='$emit("input", 0)'>First post</div>
|
||||
<div class='post_scrubber__line' ref='line' @click='lineClick'></div>
|
||||
<div
|
||||
class='post_scrubber__dragger'
|
||||
|
||||
:class='{ "post_scrubber--no_top_transition": dragging }'
|
||||
:style='{
|
||||
"top": draggerYCoord + "px"
|
||||
}'
|
||||
|
||||
@mousedown.prevent.stop='setDragging(true)'
|
||||
@mouseup.prevent.stop='setDragging(false)'
|
||||
></div>
|
||||
<div
|
||||
class='post_scrubber__dragger_info'
|
||||
:class='{ "post_scrubber--no_top_transition": dragging }'
|
||||
|
||||
:style='{
|
||||
"top": draggerYCoord + "px"
|
||||
}'
|
||||
>
|
||||
Post <strong>{{currentPost || 0}}</strong> out of {{posts}}
|
||||
</div>
|
||||
<div
|
||||
class='post_scrubber__label post_scrubber__label--last'
|
||||
@click='$emit("input", posts-1)'
|
||||
>
|
||||
Latest post
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'PostScrubber',
|
||||
props: ['posts', 'value'],
|
||||
data () {
|
||||
return {
|
||||
clientY: 0,
|
||||
dragging: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
draggerYCoord () {
|
||||
let lineTop = this.getLineTop()
|
||||
let lineHeight = this.getLineHeight()
|
||||
|
||||
if(!this.clientY || !lineTop) return 0
|
||||
|
||||
let top = this.clientY - lineTop
|
||||
|
||||
if(top < 0) {
|
||||
return 0
|
||||
} else if(top > lineHeight) {
|
||||
return lineHeight
|
||||
} else {
|
||||
return top
|
||||
}
|
||||
},
|
||||
currentPost () {
|
||||
let postDivision = this.getLineHeight() / this.posts
|
||||
let postNumber = Math.floor(this.draggerYCoord / postDivision)
|
||||
let retPostNumber
|
||||
|
||||
if(postNumber === this.posts) {
|
||||
retPostNumber = postNumber
|
||||
} else {
|
||||
retPostNumber = postNumber + 1
|
||||
}
|
||||
|
||||
return retPostNumber
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getLineHeight () {
|
||||
let line = this.$refs.line
|
||||
if(!line) return 0
|
||||
|
||||
let lineRect = line.getBoundingClientRect()
|
||||
return lineRect.height
|
||||
},
|
||||
getLineTop () {
|
||||
let line = this.$refs.line
|
||||
if(!line) return 0
|
||||
|
||||
let lineRect = line.getBoundingClientRect()
|
||||
return lineRect.top
|
||||
},
|
||||
setDragging (val) {
|
||||
this.dragging = val
|
||||
|
||||
if(!val) {
|
||||
this.$emit('input', this.currentPost-1)
|
||||
}
|
||||
},
|
||||
lineClick (e) {
|
||||
this.clientY = e.clientY
|
||||
},
|
||||
setCurrentPost () {
|
||||
let lineTop = this.getLineTop()
|
||||
let lineHeight = this.getLineHeight()
|
||||
let postNumber = +this.value
|
||||
let postDivision = lineHeight / this.posts
|
||||
|
||||
if(postNumber+1 === this.posts) {
|
||||
this.clientY = lineTop + lineHeight
|
||||
} else {
|
||||
this.clientY = lineTop + postDivision * postNumber
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
value: 'setCurrentPost'
|
||||
},
|
||||
mounted () {
|
||||
this.setCurrentPost()
|
||||
|
||||
window.addEventListener('mousemove', e => {
|
||||
if(this.dragging) {
|
||||
this.clientY = e.clientY
|
||||
}
|
||||
})
|
||||
window.addEventListener('mouseup', () => {
|
||||
if(this.dragging) {
|
||||
this.dragging = false
|
||||
this.$emit('input', this.currentPost-1)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../assets/scss/variables.scss';
|
||||
|
||||
.post_scrubber {
|
||||
height: 10rem;
|
||||
position: relative;
|
||||
margin-top: 2rem;
|
||||
margin-left: 0.25rem;
|
||||
|
||||
@at-root #{&}--no_top_transition {
|
||||
transition: background-color 0.2s !important;
|
||||
}
|
||||
|
||||
@at-root #{&}__line {
|
||||
height: 100%;
|
||||
background-color: $color__gray--darker;
|
||||
border-radius: 1rem;
|
||||
width: 0.125rem;
|
||||
}
|
||||
|
||||
@at-root #{&}__dragger {
|
||||
background-color: $color__blue--primary;
|
||||
width: 0.5rem;
|
||||
border-radius: 1rem;
|
||||
height: 1.5rem;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: calc( (0.5rem - 0.125rem) / -2);
|
||||
margin-top: calc(-1.5rem / 2 );
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s, top 0.3s;
|
||||
|
||||
&:hover {
|
||||
background-color: $color__blue--darker;
|
||||
}
|
||||
&:active {
|
||||
background-color: $color__blue--darkest;
|
||||
}
|
||||
}
|
||||
|
||||
@at-root #{&}__label {
|
||||
position: absolute;
|
||||
color: $color__blue--primary;
|
||||
cursor: pointer;
|
||||
left: -0.25rem;
|
||||
width: 10rem;
|
||||
|
||||
@at-root #{&}--first {
|
||||
top: -2.25rem;
|
||||
}
|
||||
@at-root #{&}--last {
|
||||
bottom: -2.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
@at-root #{&}__dragger_info {
|
||||
position: absolute;
|
||||
width: 10rem;
|
||||
margin-top: calc(-1.5rem / 2 - 0.125rem);
|
||||
pointer-events: none;
|
||||
background-color: #fff;
|
||||
left: 1rem;
|
||||
font-size: 0.9rem;
|
||||
border-radius: 0.125rem;
|
||||
padding: 0.25rem;
|
||||
transition: top 0.3s;
|
||||
|
||||
@extend .shadow_border;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,95 @@
|
|||
<template>
|
||||
<info-tooltip class='replying_to' @hover='loadPost'>
|
||||
<template slot='content'>
|
||||
<div style='margin-top: -0.25rem;'>
|
||||
<div class='replying_to__username' v-if='post'>{{postUsername}}</div>
|
||||
<div class='replying_to__date' v-if='post'>{{post.createdAt | formatDate('date|time', ' - ')}}</div>
|
||||
</div>
|
||||
<div class='replying_to__content' v-if='post'>{{post.content | stripTags | truncate(100)}}</div>
|
||||
<template v-else>Loading...</template>
|
||||
</template>
|
||||
<div
|
||||
slot='display'
|
||||
class='replying_to__display'
|
||||
@click.stop='$emit("click")'
|
||||
>
|
||||
<font-awesome-icon :icon='["fa", "reply"]' class='replying_to__icon' />
|
||||
{{username || '[deleted]'}}
|
||||
</div>
|
||||
</info-tooltip>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import InfoTooltip from './InfoTooltip'
|
||||
import AjaxErrorHandler from '../assets/js/errorHandler'
|
||||
|
||||
export default {
|
||||
name: 'ReplyingTo',
|
||||
props: ['replyId', 'username'],
|
||||
components: { InfoTooltip },
|
||||
data () {
|
||||
return {
|
||||
post: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
postUsername () {
|
||||
if(this.post.User) {
|
||||
return this.post.User.username
|
||||
} else {
|
||||
return '[deleted]'
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
loadPost () {
|
||||
if(this.post) return
|
||||
|
||||
this.axios
|
||||
.get('/api/v1/post/' + this.replyId)
|
||||
.then((res) => {
|
||||
this.post = res.data
|
||||
})
|
||||
.catch(AjaxErrorHandler(this.$store))
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss'>
|
||||
@import '../assets/scss/variables.scss';
|
||||
|
||||
.replying_to {
|
||||
@at-root #{&}__icon {
|
||||
font-size: 0.7rem;
|
||||
margin-right: 0.25rem;
|
||||
color: rgba(0, 0, 0, 0.87);
|
||||
}
|
||||
|
||||
@at-root #{&}__date {
|
||||
display: inline-block;
|
||||
color: $color__gray--darkest;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
@at-root #{&}__username {
|
||||
display: inline-block;
|
||||
font-size: 0.9rem;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
@at-root #{&}__content {
|
||||
*:first-child, *:last-child {
|
||||
margin: 0;
|
||||
}
|
||||
p {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
@at-root #{&}__display {
|
||||
display: inline-flex;
|
||||
cursor: pointer;
|
||||
align-items: baseline;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,98 @@
|
|||
<template>
|
||||
<div class='report_post_modal'>
|
||||
<modal-window v-model='showModal' :loading='loading'>
|
||||
<div class='report_post_modal__modal' slot='main'>
|
||||
<h3>Report this post</h3>
|
||||
<div class='report_post_modal--margin'>Select a reason for reporting this post below:</div>
|
||||
<select-button :options='reportOptions' v-model='selectedOption' class='report_post_modal--margin' :touch-disabled='true'></select-button>
|
||||
</div>
|
||||
<div slot='footer'>
|
||||
<button class='button button--modal' @click.stop='submitReport'>Submit</button>
|
||||
<button class='button button--modal' @click.stop='setShowModal(false)'>Cancel</button>
|
||||
</div>
|
||||
</modal-window>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ModalWindow from './ModalWindow'
|
||||
import SelectButton from './SelectButton'
|
||||
|
||||
import AjaxErrorHandler from '../assets/js/errorHandler'
|
||||
|
||||
export default {
|
||||
name: 'ReportPostModal',
|
||||
props: ['value', 'post-id'],
|
||||
components: {
|
||||
ModalWindow,
|
||||
SelectButton
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
showModal: false,
|
||||
loading: false,
|
||||
|
||||
selectedOption: 0,
|
||||
reportOptions: [
|
||||
{ name: 'Reason for reporting', disabled: true },
|
||||
{ name: 'Spam', value: 'spam' },
|
||||
{ name: 'Inappropriate content', value: 'inappropriate' },
|
||||
{ name: 'Harassment', value: 'harassment' }
|
||||
]
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setShowModal (val) {
|
||||
this.showModal = val
|
||||
},
|
||||
submitReport () {
|
||||
if(this.selectedOption) {
|
||||
this.loading = true
|
||||
|
||||
this.axios
|
||||
.post('/api/v1/report', {
|
||||
postId: this.postId,
|
||||
reason: this.selectedOption
|
||||
})
|
||||
.then(() => {
|
||||
this.setShowModal(false)
|
||||
this.selectedOption = 0
|
||||
this.loading = false
|
||||
})
|
||||
.catch(e => {
|
||||
this.loading = false
|
||||
AjaxErrorHandler(this.$store)(e)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
value (val) {
|
||||
this.showModal = val
|
||||
this.$emit('input', val)
|
||||
},
|
||||
showModal (val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../assets/scss/variables.scss';
|
||||
|
||||
.report_post_modal {
|
||||
@at-root #{&}--margin {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
@at-root #{&}__modal {
|
||||
padding-top: 1rem;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,64 @@
|
|||
<template>
|
||||
<div class='scroll_load'>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import throttle from 'lodash.throttle'
|
||||
|
||||
export default {
|
||||
name: 'ScrollLoad',
|
||||
props: ['loading', 'query-selector', 'padding-bottom', 'padding-top'],
|
||||
computed: {
|
||||
element () {
|
||||
if(this.querySelector){
|
||||
return document.querySelector(this.querySelector);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onScroll: throttle(function () {
|
||||
let paddingBottom = this.paddingBottom || 300;
|
||||
let paddingTop = this.paddingTop || 150;
|
||||
|
||||
let scrollBottom, scrollTop;
|
||||
|
||||
//If already loading then do not fire
|
||||
if(this.loading) return;
|
||||
|
||||
if(this.element) {
|
||||
scrollBottom = Math.floor(
|
||||
this.element.scrollTop +
|
||||
this.element.getBoundingClientRect().height +
|
||||
paddingBottom -
|
||||
this.element.scrollHeight
|
||||
);
|
||||
|
||||
scrollTop = paddingTop - this.element.scrollTop;
|
||||
} else {
|
||||
scrollBottom =
|
||||
window.innerHeight + window.pageYOffset +
|
||||
paddingBottom -
|
||||
document.body.scrollHeight;
|
||||
|
||||
scrollTop = paddingTop - document.body.scrollTop;
|
||||
}
|
||||
|
||||
if(scrollBottom > 0) {
|
||||
this.$emit('loadNext');
|
||||
} else if(scrollTop > 0) {
|
||||
this.$emit('loadPrevious');
|
||||
}
|
||||
})
|
||||
},
|
||||
mounted () {
|
||||
(this.element || window).addEventListener('scroll', this.onScroll);
|
||||
},
|
||||
destroyed () {
|
||||
(this.element || window).removeEventListener('scroll', this.onScroll);
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,478 @@
|
|||
<template>
|
||||
<div
|
||||
class='search_box'
|
||||
ref='root'
|
||||
>
|
||||
<div
|
||||
class='search_box__input'
|
||||
tabindex='0'
|
||||
@keydown.enter='goToSearch'
|
||||
>
|
||||
<input
|
||||
class='search_box__input__field'
|
||||
:class='{ "search_box__input__field--header": headerBar }'
|
||||
:placeholder='placeholder || "Search this forum"'
|
||||
v-model='searchField'
|
||||
|
||||
ref='input'
|
||||
|
||||
@focus='setShowResults'
|
||||
@input='setShowResults'
|
||||
@keydown='setKeyHighlight'
|
||||
>
|
||||
<button
|
||||
class='search_box__input__button'
|
||||
@click='goToSearch'
|
||||
>
|
||||
<font-awesome-icon :icon='["fa", "search"]' />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class='search_box__results'
|
||||
:class='{ "search_box__results--show": showResults }'
|
||||
v-if='headerBar'
|
||||
>
|
||||
|
||||
<div class='search_box__results__container' ref='results'>
|
||||
|
||||
<template v-if='threads.length'>
|
||||
<div class='search_box__results__header'>Threads</div>
|
||||
<div
|
||||
class='search_box__results__search_all'
|
||||
:class='{
|
||||
"search_box__results--highlight": highlightIndex === getHighlightIndex("threads header")
|
||||
}'
|
||||
ref='threads header'
|
||||
@mouseover='highlightIndex = getHighlightIndex("threads header")'
|
||||
@click='goToSearch'
|
||||
>
|
||||
<div class='search_box__results__icon'><font-awesome-icon :icon='["fa", "search"]' fixed-width /></div>
|
||||
<div>
|
||||
Search all threads for '<strong>{{searchField}}</strong>'
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class='search_box__results__thread'
|
||||
:class='{
|
||||
"search_box__results--highlight": highlightIndex === getHighlightIndex("threads", index)
|
||||
}'
|
||||
v-for='(thread, index) in threads'
|
||||
:key='"thread-result-" + index'
|
||||
ref='threads'
|
||||
@mouseover='highlightIndex = getHighlightIndex("threads", index)'
|
||||
@click='goToSearch'
|
||||
>
|
||||
<div class='search_box__results__title'>{{thread.name | truncate(50)}}</div>
|
||||
<div class='search_box__results__content'>{{thread.Posts[0].content | stripTags | truncate(75) }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if='users.length'>
|
||||
<div class='search_box__results__header search_box__results__header--divider'>Users</div>
|
||||
<div
|
||||
class='search_box__results__search_all'
|
||||
:class='{
|
||||
"search_box__results--highlight": highlightIndex === getHighlightIndex("users header")
|
||||
}'
|
||||
ref='users header'
|
||||
@mouseover='highlightIndex = getHighlightIndex("users header")'
|
||||
@click='goToSearch'
|
||||
>
|
||||
<div class='search_box__results__icon'><font-awesome-icon :icon='["fa", "search"]' /></div>
|
||||
<div>
|
||||
Search all users containing '<strong>{{searchField}}</strong>'
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class='search_box__results__user'
|
||||
:class='{
|
||||
"search_box__results--highlight": highlightIndex === getHighlightIndex("users", index)
|
||||
}'
|
||||
v-for='(user, index) in users'
|
||||
:key='"user-result-" + index'
|
||||
ref='users'
|
||||
@mouseover='highlightIndex = getHighlightIndex("users", index)'
|
||||
@click='goToSearch'
|
||||
>
|
||||
<avatar-icon size='tiny' :user='user'></avatar-icon>
|
||||
<div class='search_box__results__title'>{{user.username}}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class='search_box__results__message' v-if='!threads.length && !users.length && !loading'>
|
||||
No users or threads found for '<strong>{{searchField}}</strong>'
|
||||
</div>
|
||||
<div class='search_box__results__message' v-if='loading'>
|
||||
Loading...
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import AvatarIcon from './AvatarIcon';
|
||||
|
||||
import AjaxErrorHandler from '../assets/js/errorHandler'
|
||||
|
||||
export default {
|
||||
name: 'SearchBox',
|
||||
props: ['placeholder', 'header-bar'],
|
||||
components: { AvatarIcon },
|
||||
data () {
|
||||
return {
|
||||
searchField: '',
|
||||
showResults: false,
|
||||
loading: false,
|
||||
|
||||
highlightIndex: null,
|
||||
|
||||
MinQueryLength: 2,
|
||||
|
||||
threads: [],
|
||||
users: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
totalHighlightOptions () {
|
||||
let totalHighlightOptions = 0;
|
||||
|
||||
//Add one to include the 'search all option'
|
||||
if(this.threads.length) totalHighlightOptions += this.threads.length + 1;
|
||||
if(this.users.length) totalHighlightOptions += this.users.length + 1;
|
||||
|
||||
return totalHighlightOptions;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setShowResults () {
|
||||
//Return if results should not show
|
||||
if(!this.headerBar) return;
|
||||
|
||||
this.showResults = this.searchField.trim().length > (this.$store.state.MinQueryLength-1);
|
||||
if(this.showResults) {
|
||||
this.getResults();
|
||||
} else {
|
||||
this.resetResultsBox();
|
||||
}
|
||||
},
|
||||
resetResultsBox () {
|
||||
//Return if results should not show
|
||||
if(!this.headerBar) return;
|
||||
|
||||
this.showResults = false;
|
||||
|
||||
//These changes alter ui within the box
|
||||
//therefore wait until transition completed
|
||||
setTimeout(() => {
|
||||
this.highlightIndex = null;
|
||||
this.$refs.results.scrollTop = 0;
|
||||
this.threads = [];
|
||||
this.users = [];
|
||||
}, 200);
|
||||
},
|
||||
//Produces a 'global' highlight index from the
|
||||
//relative index of each array group, dependent on
|
||||
//whether or not other array groups are empty or not
|
||||
getHighlightIndex (group, index) {
|
||||
if (group === 'threads header') {
|
||||
return 0;
|
||||
} else if(group === 'threads') {
|
||||
return 1 + index;
|
||||
} else if (group === 'users' || group === 'users header') {
|
||||
let ret = 0;
|
||||
|
||||
if(this.threads.length) {
|
||||
ret += 1 + this.threads.length;
|
||||
}
|
||||
|
||||
if(group === 'users') {
|
||||
ret += 1 + index;
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
},
|
||||
//Produces relative group and index
|
||||
//from overall highlight index
|
||||
getGroupFromIndex (index) {
|
||||
if(this.threads.length && index <= this.threads.length) {
|
||||
if(index === 0) {
|
||||
return { group: 'threads header', index: null };
|
||||
} else {
|
||||
return { group: 'threads', index: index-1 };
|
||||
}
|
||||
} else if (this.threads.length && index > this.threads.length) {
|
||||
if(index === this.threads.length + 1) {
|
||||
return { group: 'users header', index: null };
|
||||
} else {
|
||||
return { group: 'users', index: index-1-this.threads.length-1 };
|
||||
}
|
||||
} else if(this.users.length) {
|
||||
if(index === 0) {
|
||||
return { group: 'users header', index: null };
|
||||
} else {
|
||||
return { group: 'users', index: index-1 };
|
||||
}
|
||||
}
|
||||
},
|
||||
setKeyHighlight (e) {
|
||||
//Return if results should not show
|
||||
if(!this.headerBar) return;
|
||||
|
||||
//Return if not up or down arrow
|
||||
if(![38, 40].includes(e.keyCode)) return;
|
||||
|
||||
//Increment or decrement
|
||||
let sign = e.keyCode === 40 ? 1 : -1;
|
||||
|
||||
if(this.highlightIndex === null) {
|
||||
//First highlight item
|
||||
if(sign === 1) {
|
||||
this.highlightIndex = 0;
|
||||
//Last highlight item
|
||||
} else {
|
||||
this.highlightIndex = this.totalHighlightOptions - 1;
|
||||
}
|
||||
} else {
|
||||
let updatedIndex = this.highlightIndex + sign;
|
||||
//Do not highlight anything, return 'focus' to input box
|
||||
if(
|
||||
updatedIndex === this.totalHighlightOptions ||
|
||||
updatedIndex < 0
|
||||
) {
|
||||
this.highlightIndex = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this.highlightIndex = updatedIndex;
|
||||
}
|
||||
|
||||
//Get the element for highlighted item
|
||||
//and scroll into view if not visible
|
||||
let { group, index } = this.getGroupFromIndex(this.highlightIndex);
|
||||
let el = index === null ? this.$refs[group] : this.$refs[group][index];
|
||||
if(
|
||||
//Below fold
|
||||
el.offsetHeight + el.offsetTop > this.$refs.results.offsetHeight ||
|
||||
//Above fold
|
||||
el.offsetTop < this.$refs.results.scrollTop
|
||||
) {
|
||||
el.scrollIntoView();
|
||||
}
|
||||
|
||||
},
|
||||
goToSearch () {
|
||||
let searchEncoded = encodeURIComponent(this.searchField.trim());
|
||||
|
||||
if(this.highlightIndex === null && this.searchField.trim().length) {
|
||||
this.showResults = false;
|
||||
this.$router.push("/search/" + searchEncoded);
|
||||
} else {
|
||||
let { group, index } = this.getGroupFromIndex(this.highlightIndex);
|
||||
if(group === 'users') {
|
||||
this.$router.push('/user/' + this.users[index].username);
|
||||
} else if (group === 'threads') {
|
||||
let thread = this.threads[index];
|
||||
this.$router.push('/thread/' + thread.slug + '/' + thread.id);
|
||||
} else if (group === 'users header') {
|
||||
this.$router.push('/search/users/' + searchEncoded);
|
||||
} else {
|
||||
this.$router.push('/search/threads/' + searchEncoded);
|
||||
}
|
||||
|
||||
this.resetResultsBox();
|
||||
}
|
||||
|
||||
this.$refs.input.blur();
|
||||
},
|
||||
getResults () {
|
||||
let q = this.searchField.trim();
|
||||
if(q.length < this.$store.state.MinQueryLength) return;
|
||||
|
||||
this.loading = true;
|
||||
this.threads = [];
|
||||
this.users = [];
|
||||
|
||||
this.axios
|
||||
.get('/api/v1/search/thread?q=' + q)
|
||||
.then(res => {
|
||||
this.threads = res.data.threads.slice(0, 3);
|
||||
this.loading = false;
|
||||
})
|
||||
.catch(AjaxErrorHandler(this.$store));
|
||||
|
||||
this.axios
|
||||
.get('/api/v1/search/user?q=' + q)
|
||||
.then(res => {
|
||||
this.users = res.data.users.slice(0, 5);
|
||||
this.loading = false;
|
||||
})
|
||||
.catch(AjaxErrorHandler(this.$store));
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
document.body.addEventListener('click', e => {
|
||||
//If results box is showing, the root element is loaded and the click target
|
||||
//is not part of the search box, then hide the results box
|
||||
if(this.showResults && this.$refs.root && !this.$refs.root.contains(e.target)) {
|
||||
this.resetResultsBox();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../assets/scss/variables.scss';
|
||||
@import '../assets/scss/elementStyles.scss';
|
||||
|
||||
.search_box {
|
||||
position: relative;
|
||||
|
||||
@at-root #{&}__input {
|
||||
border: 1.5px solid $color__gray--darkest;
|
||||
border-right: 0;
|
||||
border-radius: 0.25rem;
|
||||
outline: none;
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
|
||||
@at-root #{&}__field {
|
||||
outline: none;
|
||||
height: 100%;
|
||||
padding: 0 0.5rem;
|
||||
border: 0;
|
||||
transition: width 0.2s;
|
||||
|
||||
@include text;
|
||||
color: $color__text--primary;
|
||||
|
||||
@include placeholder {
|
||||
@include text;
|
||||
color: $color__darkgray--primary;
|
||||
}
|
||||
}
|
||||
@at-root #{&}__button {
|
||||
@extend .button;
|
||||
|
||||
border: 0;
|
||||
border-right: 1.5px solid $color__gray--darkest;
|
||||
border-radius: 0 0.2rem 0.2rem 0;
|
||||
|
||||
&:hover, &:active {
|
||||
border-color: $color__gray--darkest;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@at-root #{&}__results {
|
||||
background-color: #fff;
|
||||
border: 1.5px solid $color__gray--darkest;
|
||||
border-radius: 0.25rem;
|
||||
box-shadow: 0 0.25rem 1rem rgba(#000, 0.125);
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
transform: translateY(-0.25rem);
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
width: 100%;
|
||||
|
||||
@at-root #{&}__container {
|
||||
max-height: 20rem;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
@at-root #{&}--show {
|
||||
opacity: 1;
|
||||
pointer-events: all;
|
||||
transform: translateY(0rem);
|
||||
}
|
||||
@at-root #{&}--highlight {
|
||||
background-color: $color__lightgray--darker;
|
||||
}
|
||||
|
||||
@at-root #{&}__icon {
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
@at-root #{&}__header {
|
||||
cursor: default;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.5rem 1rem;
|
||||
position: sticky;
|
||||
|
||||
@at-root #{&}--divider {
|
||||
border-top: thin solid $color__gray--darker;
|
||||
}
|
||||
}
|
||||
|
||||
@at-root #{&}__search_all {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
span {
|
||||
padding-top: 0.15rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@at-root #{&}__thread, #{&}__user, #{&}__search_all {
|
||||
cursor: pointer;
|
||||
padding: 0.5rem 1rem;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: $color__lightgray--darker;
|
||||
}
|
||||
&:focus {
|
||||
outline: none;
|
||||
background-color: $color__lightgray--darker;
|
||||
}
|
||||
&:last-of-type {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
@at-root #{&}__user {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding: 0.25rem 1rem;
|
||||
|
||||
.avatar_icon {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@at-root #{&}__title, #{&}__search_all {
|
||||
font-weight: 400;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
@at-root #{&}__content {
|
||||
color: $color__text--secondary;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
@at-root #{&}__message {
|
||||
cursor: default;
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 950px) and (min-width: $breakpoint--tablet) {
|
||||
.search_box__field--header {
|
||||
width: 4rem;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,145 @@
|
|||
<template>
|
||||
<menu-tooltip
|
||||
v-model='menuOpen'
|
||||
class='select_button'
|
||||
:class='{"select_button--touch": !touchDisabled}'
|
||||
>
|
||||
<template slot='button'>
|
||||
<div
|
||||
class='button button--thin_text'
|
||||
:class='{ "select_button__button--selected": menuOpen }'
|
||||
@click='menuOpen = true'
|
||||
v-if='options.length'
|
||||
>
|
||||
{{options[selectedIndex].name}}
|
||||
<font-awesome-icon
|
||||
:icon='["fa", "chevron-down"]'
|
||||
fixed-width
|
||||
class='button__icon select_button__icon'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class='button' v-else>
|
||||
No options
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template slot='menu'>
|
||||
<div
|
||||
v-for='(option, index) in options'
|
||||
:key='"select-button-option-" + option.name + index'
|
||||
@click='select(index, option.disabled)'
|
||||
class='select_button__option'
|
||||
:class='{
|
||||
"select_button__option--disabled": option.disabled,
|
||||
"select_button__option--selected": index === selectedIndex && !option.disabled
|
||||
}'
|
||||
>
|
||||
{{option.name}}
|
||||
</div>
|
||||
</template>
|
||||
</menu-tooltip>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MenuTooltip from './MenuTooltip';
|
||||
|
||||
export default {
|
||||
name: 'SelectButton',
|
||||
props: ['options', 'value', 'name', 'touch-disabled'],
|
||||
components: {
|
||||
MenuTooltip
|
||||
},
|
||||
methods: {
|
||||
select (index, disabled) {
|
||||
if(disabled) return;
|
||||
|
||||
this.selectedIndex = index;
|
||||
this.menuOpen = false;
|
||||
|
||||
this.$emit('input', this.options[index].value);
|
||||
},
|
||||
getIndexFromValue () {
|
||||
var index = 0;
|
||||
var self = this;
|
||||
|
||||
if(this.value !== null) {
|
||||
this.options.forEach((option, i) => {
|
||||
if(option.value === self.value) {
|
||||
index = i;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
selectedIndex: this.getIndexFromValue(),
|
||||
menuOpen: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
value () {
|
||||
this.selectedIndex = this.getIndexFromValue();
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../assets/scss/variables.scss';
|
||||
.select_button {
|
||||
@at-root #{&}__icon {
|
||||
font-size: 0.8rem;
|
||||
position: relative;
|
||||
top: -0.1rem;
|
||||
}
|
||||
|
||||
@at-root #{&}__button--selected {
|
||||
color: $color__blue--darker !important;
|
||||
}
|
||||
|
||||
@at-root #{&}__option {
|
||||
background-color: #fff;
|
||||
border-radius: 0.25rem;
|
||||
cursor: default;
|
||||
font-size: 0.9rem;
|
||||
margin: 0.25rem 0;
|
||||
padding: 0.25rem 0.25rem;
|
||||
user-select: none;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
@at-root #{&}--selected {
|
||||
background-color: $color__lightgray--darker;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: $color__lightgray--darker;
|
||||
}
|
||||
|
||||
@at-root #{&}--disabled {
|
||||
color: $color__gray--darkest;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint--tablet) {
|
||||
.select_button__option {
|
||||
font-size: 1.125rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.select_button--touch {
|
||||
.select_button {
|
||||
@at-root #{&}__option {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
<template>
|
||||
<menu-tooltip v-model='menuOpen' class='select_filter'>
|
||||
<button
|
||||
slot='button'
|
||||
class='button select_filter__button'
|
||||
:class='{ "select_filter__button--selected": menuOpen }'
|
||||
@click='menuOpen = true'
|
||||
>
|
||||
{{name}}
|
||||
<font-awesome-icon :icon='["fa", "chevron-down"]' />
|
||||
</button>
|
||||
|
||||
<template slot='menu'>
|
||||
<div
|
||||
class='select_filter__item select_filter__item--select_all'
|
||||
v-if='selectAll'
|
||||
@click='toggleSelectAll'
|
||||
>
|
||||
<div
|
||||
class='select_filter__checkbox'
|
||||
:class='{ "select_filter__checkbox--selected": value.length === options.length }'
|
||||
></div>
|
||||
<span>Select all</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class='select_filter__item'
|
||||
v-for='(item, index) in options'
|
||||
:key='"select-filter-item-" + item.name + index'
|
||||
@click='toggledSelectItem(item.value)'
|
||||
>
|
||||
<div
|
||||
class='select_filter__checkbox'
|
||||
:class='{ "select_filter__checkbox--selected": value.includes(item.value) }'
|
||||
></div>
|
||||
<span>{{item.name}}</span>
|
||||
</div>
|
||||
</template>
|
||||
</menu-tooltip>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MenuTooltip from './MenuTooltip';
|
||||
|
||||
export default {
|
||||
name: 'SelectFilter',
|
||||
props: ['name', 'options', 'value', 'selectAll'],
|
||||
components: { MenuTooltip },
|
||||
data () {
|
||||
return {
|
||||
menuOpen: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggleSelectAll () {
|
||||
//If everything is selected
|
||||
if(this.value.length === this.options.length) {
|
||||
this.$emit('input', []);
|
||||
} else {
|
||||
this.$emit('input', this.options.map(i => i.value));
|
||||
}
|
||||
},
|
||||
toggledSelectItem (item) {
|
||||
if(this.value.includes(item)) {
|
||||
//If no select all, then do not allow to unselect all items
|
||||
if(this.selectAll || (!this.selectAll && this.value.length > 1)) {
|
||||
this.$emit('input', this.value.filter(i => i !== item));
|
||||
}
|
||||
} else {
|
||||
this.$emit('input', [...this.value, item]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../assets/scss/variables.scss';
|
||||
|
||||
.select_filter {
|
||||
@at-root #{&}__button {
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
font-weight: normal;
|
||||
position: relative;
|
||||
transition: color 0.2s, border-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: $color__blue--darker;
|
||||
}
|
||||
|
||||
span.fa {
|
||||
font-size: 0.7rem;
|
||||
transform: rotate(0deg) translateY(-0.1rem);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
@at-root #{&}--selected {
|
||||
color: $color__blue--darker !important;
|
||||
}
|
||||
}
|
||||
|
||||
@at-root #{&}__search {
|
||||
}
|
||||
|
||||
@at-root #{&}__item {
|
||||
align-items: center;
|
||||
background-color: #fff;
|
||||
border-radius: 0.25rem;
|
||||
cursor: default;
|
||||
display: grid;
|
||||
font-size: 0.9rem;
|
||||
font-weight: normal;
|
||||
grid-column-gap: 0.5rem;
|
||||
grid-template-columns: 1rem auto;
|
||||
justify-items: start;
|
||||
padding: 0.125rem 0.25rem;
|
||||
user-select: none;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: $color__lightgray--darker;
|
||||
}
|
||||
|
||||
@at-root #{&}--select_all {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
@at-root #{&}__checkbox {
|
||||
background-color: #fff;
|
||||
border: thin solid $color__gray--darkest;
|
||||
border-radius: 0.25rem;
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
transition: all 0.2s;
|
||||
|
||||
@at-root #{&}--selected {
|
||||
background-color: $color__blue--darker;
|
||||
border: thin solid $color__blue--primary;
|
||||
box-shadow: 0 0 0 1.5px $color__blue--primary inset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: $breakpoint--tablet) {
|
||||
.select_filter__item {
|
||||
font-size: 1.125rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,39 @@
|
|||
<template>
|
||||
<div class='select_options'>
|
||||
<button
|
||||
:key='"select-option-" + index'
|
||||
v-for='(option, index) in options'
|
||||
class='button button--thin_text'
|
||||
:class='{"button--lightblue": option.value === value}'
|
||||
@click='select(option.value)'
|
||||
>
|
||||
{{option.name}}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'SelectOptions',
|
||||
props: ['value', 'options'],
|
||||
methods: {
|
||||
select (index) {
|
||||
this.$emit('input', index)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
.select_options {
|
||||
display: inline-block;
|
||||
|
||||
button {
|
||||
margin-right: 0.25rem;
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,103 @@
|
|||
<template>
|
||||
<menu-tooltip v-model='menuOpen' width='10rem'>
|
||||
<div
|
||||
slot='button'
|
||||
class='sort_menu__button'
|
||||
:class='{ "sort_menu__button--selected": menuOpen }'
|
||||
@click='menuOpen = true'
|
||||
>
|
||||
{{display}}
|
||||
<font-awesome-icon :icon='["fa", iconName]' fixed-width />
|
||||
</div>
|
||||
|
||||
<template slot='menu'>
|
||||
<div
|
||||
:key='sort'
|
||||
v-for='sort in ["asc", "desc"]'
|
||||
|
||||
class='sort_menu__item'
|
||||
:class='{
|
||||
"sort_menu__item--selected": sort == value.sort && value.column == column
|
||||
}'
|
||||
@click='setSelected(sort)'
|
||||
>
|
||||
{{sort === 'asc' ? 'Ascending' : 'Descending'}}
|
||||
</div>
|
||||
</template>
|
||||
</menu-tooltip>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MenuTooltip from './MenuTooltip';
|
||||
|
||||
export default {
|
||||
name: 'SortMenu',
|
||||
props: ['value', 'column', 'display'],
|
||||
components: { MenuTooltip },
|
||||
data () {
|
||||
return {
|
||||
menuOpen: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
iconName () {
|
||||
if(this.value.column !== this.column) {
|
||||
return 'chevron-down';
|
||||
} else if(this.value.sort === 'asc') {
|
||||
return 'sort-amount-up';
|
||||
} else { // if this.value.sort === 'desc'
|
||||
return 'sort-amount-down';
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setSelected (val) {
|
||||
this.$emit('input', {
|
||||
column: this.column,
|
||||
sort: val
|
||||
})
|
||||
|
||||
this.menuOpen = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../assets/scss/variables.scss';
|
||||
|
||||
.sort_menu {
|
||||
@at-root #{&}__button {
|
||||
cursor: pointer;
|
||||
text-transform: capitalize;
|
||||
|
||||
.fa {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
@at-root #{&}--selected {
|
||||
color: $color__blue--darker;
|
||||
}
|
||||
}
|
||||
|
||||
@at-root #{&}__item {
|
||||
background-color: #fff;
|
||||
border-radius: 0.25rem;
|
||||
cursor: default;
|
||||
font-size: 0.9rem;
|
||||
font-weight: normal;
|
||||
margin: 0.25rem 0;
|
||||
padding: 0.25rem 0.25rem;
|
||||
user-select: none;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
@at-root #{&}--selected {
|
||||
background-color: $color__lightgray--darker;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: $color__lightgray--darker;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,202 @@
|
|||
<template>
|
||||
<div class='tab_view'>
|
||||
<div
|
||||
class='tab_view__tabs'
|
||||
:class='{
|
||||
"tab_view__tabs--small_tabs": smallTabs,
|
||||
"tab_view__tabs--transparent": transparent
|
||||
}'
|
||||
>
|
||||
<div
|
||||
class='tab_view__tab'
|
||||
:key='"tab-" + index'
|
||||
v-for='(tab, index) in tabs'
|
||||
:class='{
|
||||
"tab_view__tab--selected": tabIndex === index,
|
||||
"tab_view__tab--selected_small_tabs": tabIndex === index && smallTabs,
|
||||
"tab_view__tab--selected_transparent": tabIndex === index && transparent,
|
||||
"tab_view__tab--small_tabs": smallTabs,
|
||||
"tab_view__tab--transparent": transparent
|
||||
}'
|
||||
@click='changeTab(index)'
|
||||
>
|
||||
{{tab}}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class='tab_view__content'
|
||||
:class='{
|
||||
"tab_view__content--padding": padding,
|
||||
"tab_view__content--transparent": transparent
|
||||
}'
|
||||
>
|
||||
<slot :name='currentTab'></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'TabView',
|
||||
props: ['tabs', 'value', 'padding', 'small-tabs', 'transparent'],
|
||||
methods: {
|
||||
changeTab (index) {
|
||||
this.$emit('input', index)
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
tabIndex () {
|
||||
return this.value
|
||||
},
|
||||
currentTab () {
|
||||
return this.tabs[this.tabIndex]
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../assets/scss/variables.scss';
|
||||
|
||||
.tab_view {
|
||||
border-radius: 0.25rem;
|
||||
overflow: hidden;
|
||||
|
||||
@at-root #{&}__tabs {
|
||||
display: flex;
|
||||
|
||||
@at-root #{&}--small_tabs {
|
||||
background-color: $color__gray--primary;
|
||||
border-bottom: thin solid $color__gray--darker;
|
||||
}
|
||||
|
||||
@at-root #{&}--transparent {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
@at-root #{&}__tab {
|
||||
flex-grow: 1;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
font-weight: 300;
|
||||
padding: 0.5rem 0;
|
||||
background-color: $color__gray--primary;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: $color__gray--darker;
|
||||
}
|
||||
&:active {
|
||||
background-color: rgba(210, 210, 210, 1);
|
||||
}
|
||||
|
||||
@at-root #{&}--small_tabs {
|
||||
border-radius: 0.25rem 0.25rem 0 0;
|
||||
flex-grow: 0;
|
||||
border-bottom: 0;
|
||||
margin: 0 0.25rem;
|
||||
padding: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
background-color: #fff;
|
||||
width: 100%;
|
||||
bottom: -2px;
|
||||
left: 0;
|
||||
height: 2px;
|
||||
opacity: 0;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
}
|
||||
|
||||
@at-root #{&}--transparent {
|
||||
background-color: transparent;
|
||||
flex-grow: 0;
|
||||
margin: 0 0.25rem;
|
||||
padding: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 1.25rem;
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
background-color: $color__gray--darkest;
|
||||
width: 100%;
|
||||
bottom: -3px;
|
||||
left: 0;
|
||||
height: 3px;
|
||||
border-radius: 1rem;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s, bottom 0.2s, background-color 0.2s;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: transparent;
|
||||
|
||||
&:after {
|
||||
background-color: $color__gray--darker;
|
||||
opacity: 1;
|
||||
bottom: 0px;
|
||||
}
|
||||
}
|
||||
&:active {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@at-root #{&}--selected {
|
||||
background-color: #fff;
|
||||
|
||||
&:hover {
|
||||
background-color: #fff;
|
||||
}
|
||||
&:active {
|
||||
background-color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
@at-root #{&}--selected_small_tabs {
|
||||
border: thin solid $color__gray--darker;
|
||||
|
||||
&::after {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@at-root #{&}--selected_transparent {
|
||||
border: thin solid $color__gray--darker;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
|
||||
&:hover, &:active {
|
||||
background-color: transparent;
|
||||
|
||||
&:after {
|
||||
background-color: $color__gray--darkest;
|
||||
}
|
||||
}
|
||||
|
||||
&::after {
|
||||
background-color: $color__gray--darkest;
|
||||
bottom: 0px;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@at-root #{&}__content {
|
||||
background-color: #fff;
|
||||
|
||||
@at-root #{&}--padding {
|
||||
padding: 1rem;
|
||||
}
|
||||
@at-root #{&}--transparent {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,188 @@
|
|||
<template>
|
||||
<div class='thread_display'>
|
||||
<avatar-icon
|
||||
ref='avatar'
|
||||
:user='thread.User'
|
||||
size='small'
|
||||
class='thread_display__icon'
|
||||
|
||||
@click='goToUser'
|
||||
></avatar-icon>
|
||||
<div style='width: calc(100% - 3rem);' @click='goToThread'>
|
||||
<div class='thread_display__header'>
|
||||
<span class='thread_display__name'>
|
||||
{{thread.name}}
|
||||
</span>
|
||||
<div class='thread_display__meta_bar'>
|
||||
<div>
|
||||
By
|
||||
<span class='thread_display__username' ref='username'>{{threadUsername | truncateMid(25)}}</span>
|
||||
in
|
||||
<span class='thread_display__category' ref='category'>{{thread.Category.name}}</span>
|
||||
·
|
||||
<span class='thread_display__date'>{{thread.createdAt | formatDate}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='thread_display__replies_bar'>
|
||||
<div
|
||||
class='thread_display__latest_reply'
|
||||
v-if='thread.Posts.length === 2'
|
||||
>
|
||||
<font-awesome-icon :icon='["fa", "reply"]' fixed-width />
|
||||
<span class='thread_display__latest_reply__text'>Latest reply by </span>
|
||||
<span class='thread_display__username'>{{replyUsername}}</span>
|
||||
·
|
||||
<span class='thread_display__date'>{{thread.Posts[1].createdAt | formatDate}}</span>
|
||||
</div>
|
||||
<span style='cursor: default;' v-else>No replies</span>
|
||||
<div class='thread_display__replies' title='Replies to thread'>
|
||||
<font-awesome-icon :icon='["far", "comment"]' fixed-width />
|
||||
{{thread.postsCount - 1}}
|
||||
</div>
|
||||
</div>
|
||||
<div class='thread_display__content'>
|
||||
{{thread.Posts[0].content | stripTags | truncate(150)}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import AvatarIcon from './AvatarIcon'
|
||||
|
||||
export default {
|
||||
name: 'ThreadDisplay',
|
||||
props: ['thread'],
|
||||
components: {
|
||||
AvatarIcon
|
||||
},
|
||||
computed: {
|
||||
threadUsername () {
|
||||
if(this.thread.User) {
|
||||
return this.thread.User.username
|
||||
} else {
|
||||
return '[deleted]'
|
||||
}
|
||||
},
|
||||
replyUsername () {
|
||||
if(this.thread.Posts[1].User) {
|
||||
return this.thread.Posts[1].User.username
|
||||
} else {
|
||||
return '[deleted]'
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
goToUser () {
|
||||
this.$router.push('/user/' + this.thread.User.username)
|
||||
},
|
||||
goToThread () {
|
||||
this.$router.push('/thread/' + this.thread.slug + '/' + this.thread.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../assets/scss/variables.scss';
|
||||
|
||||
.thread_display {
|
||||
background-color: #fff;
|
||||
border: thin solid $color__gray--darker;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.75rem;
|
||||
position: relative;
|
||||
transition: background-color 0.2s, box-shadow 0.2s;
|
||||
|
||||
&:hover {
|
||||
@extend .shadow_border--hover;
|
||||
}
|
||||
|
||||
@at-root #{&}__icon {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
@at-root #{&}__username,
|
||||
#{&}__category,
|
||||
#{&}__date {
|
||||
color: $color--text__primary;
|
||||
}
|
||||
|
||||
@at-root #{&}__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
@at-root #{&}__name {
|
||||
font-weight: 500;
|
||||
font-size: 1.25rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
@at-root #{&}__meta_bar {
|
||||
color: $color--gray__darkest;
|
||||
flex-shrink: 0;
|
||||
line-height: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
@at-root #{&}__replies_bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
@at-root #{&}__latest_reply {
|
||||
color: $color--text__secondary;
|
||||
|
||||
.fa {
|
||||
color: $color--text__primary;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
@at-root #{&}__replies {
|
||||
width: 4rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@at-root #{&}__content {
|
||||
margin-top: 0.5rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.thread_display {
|
||||
@at-root #{&}__header {
|
||||
flex-direction: column;
|
||||
}
|
||||
@at-root #{&}__meta_bar {
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
@at-root #{&}__content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@at-root #{&}__replies_bar {
|
||||
position: relative;
|
||||
left: -3.25rem;
|
||||
width: calc(100% + 3.25rem);
|
||||
}
|
||||
|
||||
@at-root #{&}__latest_reply {
|
||||
.fa {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
@at-root #{&}__text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,82 @@
|
|||
<template>
|
||||
<div class='thread_display_placeholder'>
|
||||
<div class='thread_display_placeholder__icon'></div>
|
||||
<div style='width: 100%;'>
|
||||
<div class='thread_display_placeholder__header'>
|
||||
<div class='thread_display_placeholder__bar thread_display_placeholder__bar--15'></div>
|
||||
<div class='thread_display_placeholder__bar thread_display_placeholder__bar--33'></div>
|
||||
</div>
|
||||
<div class='thread_display_placeholder__replies_bar'>
|
||||
<div class='thread_display_placeholder__bar thread_display_placeholder__bar--20'></div>
|
||||
<div class='thread_display_placeholder__bar thread_display_placeholder__bar--5'></div>
|
||||
</div>
|
||||
<div class='thread_display_placeholder__content'>
|
||||
<div class='thread_display_placeholder__bar'></div>
|
||||
<div class='thread_display_placeholder__bar thread_display_placeholder__bar--58'></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default { name: 'ThreadDisplayPlaceholder' }
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../assets/scss/variables.scss';
|
||||
|
||||
.thread_display_placeholder {
|
||||
display: flex;
|
||||
padding: 0.75rem;
|
||||
background-color: #fff;
|
||||
border-radius: 0.25rem;
|
||||
margin-bottom: 1rem;
|
||||
transition: background-color 0.2s;
|
||||
position: relative;
|
||||
border: thin solid $color__gray--darker;
|
||||
|
||||
@at-root #{&}__bar {
|
||||
@include flash;
|
||||
|
||||
background-color: $color__gray--primary;
|
||||
height: 0.85rem;
|
||||
width: 75%;
|
||||
margin-bottom: 0.35rem;
|
||||
|
||||
@at-root #{&}--5 { width: 5%; }
|
||||
@at-root #{&}--15 { width: 15%; }
|
||||
@at-root #{&}--20 { width: 20%; }
|
||||
@at-root #{&}--33 { width: 33%; }
|
||||
@at-root #{&}--58 { width: 58%; }
|
||||
}
|
||||
|
||||
@at-root #{&}__icon {
|
||||
@include flash;
|
||||
|
||||
margin-right: 0.75rem;
|
||||
margin-left: 0.25rem;
|
||||
border-radius: 100%;
|
||||
background-color: $color__gray--darkest;
|
||||
height: 2.5rem;
|
||||
width: 2.5rem;
|
||||
font-size: 2rem;
|
||||
line-height: 2.25rem;
|
||||
}
|
||||
|
||||
|
||||
@at-root #{&}__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
@at-root #{&}__replies_bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
@at-root #{&}__content {
|
||||
margin-top: 0.5rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,225 @@
|
|||
<template>
|
||||
<div class='poll'>
|
||||
<transition name='slide' mode='out-in'>
|
||||
<div class='poll__loading' key='loading' v-if='!poll'>
|
||||
<loading-icon :dark='true'></loading-icon>
|
||||
</div>
|
||||
<div key='poll' v-else>
|
||||
<div class='poll__question'>{{poll.question}}</div>
|
||||
|
||||
<div class='poll__voting_view' v-if='view === "voting" && !poll.hasVoted'>
|
||||
<div class='poll__answers'>
|
||||
<div
|
||||
class='poll__answer'
|
||||
:class='{ "poll__answer--selected" : answer === selected }'
|
||||
:key='"poll-answer-" + $index'
|
||||
v-for='(answer, $index) in poll.PollAnswers'
|
||||
@click='selected = answer'
|
||||
>
|
||||
{{answer.answer}}
|
||||
</div>
|
||||
</div>
|
||||
<div class='poll__buttons'>
|
||||
<loading-button
|
||||
class='button--blue'
|
||||
:class='{ "button--disabled": !selected }'
|
||||
:loading='loading'
|
||||
@click='vote'
|
||||
>Vote now</loading-button>
|
||||
<button
|
||||
class='button button--borderless button--thin_text'
|
||||
@click='view = "results"'
|
||||
>View results</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='poll__results_view' v-else>
|
||||
<div class='poll__total_votes'>
|
||||
{{poll.totalVotes}}
|
||||
{{poll.totalVotes | pluralize('total vote')}}
|
||||
</div>
|
||||
|
||||
<div class='poll__results'>
|
||||
<div class='poll__result' :key='"poll-result-" + $index' v-for='(result, $index) in poll.PollAnswers'>
|
||||
<div>
|
||||
{{result.answer}}
|
||||
<span class='poll__result__info'>
|
||||
·
|
||||
{{result.PollVotes.length}} {{result.PollVotes.length | pluralize('vote')}}
|
||||
({{result.percent || 0}}%)
|
||||
</span>
|
||||
</div>
|
||||
<div class='poll__result__bar_outer'></div>
|
||||
<div class='poll__result__bar' :style='{ "width": (result.percent || 0) + "%" }'></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='poll__buttons' v-if='!poll.hasVoted'>
|
||||
<button class='button button--thin_text' @click='view = "voting"'>Back</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import LoadingIcon from './LoadingIcon'
|
||||
import LoadingButton from './LoadingButton'
|
||||
|
||||
import AjaxErrorHandler from '../assets/js/errorHandler'
|
||||
|
||||
export default {
|
||||
name: 'ThreadPoll',
|
||||
props: ['id'],
|
||||
components: { LoadingIcon, LoadingButton },
|
||||
data () {
|
||||
return {
|
||||
poll: null,
|
||||
|
||||
view: 'voting',
|
||||
selected: null,
|
||||
loading: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
vote () {
|
||||
if(!this.$store.state.username) {
|
||||
this.$store.commit('setAccountTabs', 0)
|
||||
this.$store.commit('setAccountModalState', true)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
this.loading = true
|
||||
|
||||
this.axios
|
||||
.post('/api/v1/poll/' + this.id, { answer: this.selected.answer })
|
||||
.then(res => {
|
||||
this.poll = res.data
|
||||
this.loading = false
|
||||
this.hasVoted = true
|
||||
this.view = 'results'
|
||||
})
|
||||
.catch(e => {
|
||||
this.loading = false
|
||||
AjaxErrorHandler(this.$store)(e)
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.axios
|
||||
.get('/api/v1/poll/' + this.id)
|
||||
.then(res => this.poll = res.data)
|
||||
.catch(AjaxErrorHandler(this.$store))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../assets/scss/variables.scss';
|
||||
|
||||
.poll {
|
||||
padding: 1rem;
|
||||
width: 80%;
|
||||
background-color: #fff;
|
||||
position: relative;
|
||||
margin-bottom: 2rem;
|
||||
border-radius: 0.25rem;
|
||||
border: thin solid $color__gray--darker;
|
||||
|
||||
@at-root #{&}__loading {
|
||||
@include loading-overlay();
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@at-root #{&}__question {
|
||||
font-weight: bold;
|
||||
font-size: 1.125rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@at-root #{&}__answers, #{&}__results {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
@at-root #{&}__answer {
|
||||
padding: 0.5rem 0.625rem;
|
||||
margin: 0.5rem 0;
|
||||
border: thin solid $color__gray--primary;
|
||||
border-radius: 0.125rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
background-color: $color__blue--primary;
|
||||
width: 0rem;
|
||||
opacity: 0;
|
||||
border-radius: 0.125rem 0 0 0.125rem;
|
||||
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 0.1rem 0.25rem 0 rgba(214, 214, 214, 0.5);
|
||||
}
|
||||
|
||||
@at-root #{&}--selected {
|
||||
font-weight: bold;
|
||||
|
||||
&::after {
|
||||
opacity: 1;
|
||||
width: 0.3rem;
|
||||
}
|
||||
&:hover {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@at-root #{&}__buttons {
|
||||
> * {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@at-root #{&}__total_votes {
|
||||
color: $color__text--secondary;
|
||||
font-style: italic;
|
||||
}
|
||||
@at-root #{&}__result {
|
||||
margin: 0.5rem 0;
|
||||
word-break: break-all;
|
||||
|
||||
@at-root #{&}__info {
|
||||
color: $color__text--secondary;
|
||||
}
|
||||
|
||||
@at-root #{&}__bar_outer {
|
||||
width: 100%;
|
||||
border-radius: 1rem;
|
||||
height: 1rem;
|
||||
margin-top: 0.25rem;
|
||||
border: thin solid $color__blue--primary;
|
||||
|
||||
}
|
||||
@at-root #{&}__bar {
|
||||
background-color: lighten($color__blue--primary, 15%);
|
||||
height: 1rem;
|
||||
border-radius: 1rem;
|
||||
position: relative;
|
||||
top: -1rem;
|
||||
margin-bottom: -1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include thread_mobile_breakpoint ('.poll');
|
||||
</style>
|
|
@ -0,0 +1,449 @@
|
|||
<template>
|
||||
<div
|
||||
class='post'
|
||||
:class='{
|
||||
"post--highlighted": highlight,
|
||||
"post--selected": selected
|
||||
}'
|
||||
@mouseenter='hover = true'
|
||||
@mouseleave='hover = false'
|
||||
|
||||
@click='goToPost'
|
||||
>
|
||||
<div
|
||||
class='post__quote'
|
||||
:class='{ "post__quote--show": showQuote && allowQuote && showReply && $store.state.username }'
|
||||
:style='{
|
||||
"left": quoteX + "px",
|
||||
"top": quoteY + "px"
|
||||
}'
|
||||
|
||||
@mousedown='emitReply'
|
||||
>
|
||||
<font-awesome-icon :icon='["fa", "quote-right"]' class='post__quote__icon' />
|
||||
Quote post
|
||||
</div>
|
||||
|
||||
<font-awesome-icon
|
||||
:icon='["fa", "check"]'
|
||||
class='post__remove_icon'
|
||||
:class='{"post__remove_icon--show": showSelect && !post.removed}'
|
||||
@click.stop='toggleSelected'
|
||||
/>
|
||||
<modal-window v-model='showShareModal' @click.stop='() => {}'>
|
||||
<div slot='main'>
|
||||
<p>Copy this URL to share the post</p>
|
||||
<fancy-input placeholder='Post URL' :value='postURL' width='100%'></fancy-input>
|
||||
</div>
|
||||
<button slot='footer' class='button button--modal' @click.stop='setShareModalState(false)'>OK</button>
|
||||
</modal-window>
|
||||
|
||||
<report-post-modal v-model='showReportPostModal' :post-id='post.id'></report-post-modal>
|
||||
|
||||
<div class='post__meta_data'>
|
||||
<div style='display: inline-flex;'>
|
||||
<avatar-icon :user='post.User' class='post__avatar'></avatar-icon>
|
||||
<div class='post__thread' v-if='showThread' @click.stop='goToThread'>
|
||||
In thread <span class='post__thread__name'>{{post.Thread.name | truncateMid(50)}}</span>
|
||||
·
|
||||
</div>
|
||||
<div class='post__user' v-else>
|
||||
{{username}}
|
||||
|
||||
<span class='admin_badge' v-if='post.User && post.User.admin'>admin</span>
|
||||
</div>
|
||||
|
||||
<replying-to
|
||||
style='margin-right: 0.5rem;'
|
||||
v-if='post.replyingToUsername'
|
||||
:replyId='post.replyId'
|
||||
:username='post.replyingToUsername'
|
||||
@click='$emit("goToPost", post.replyId, true)'
|
||||
></replying-to>
|
||||
</div>
|
||||
<div class='post__date'>{{post.createdAt | formatDate('time|date', ', ')}}</div>
|
||||
</div>
|
||||
<div class='post__date post__date--mobile'>{{post.createdAt | formatDate('time|date', ', ')}}</div>
|
||||
<div
|
||||
tabindex='-1'
|
||||
class='post__content'
|
||||
v-html='postContentHTML'
|
||||
@mouseup='setShowQuote'
|
||||
@blur='showQuote = false'
|
||||
></div>
|
||||
<div class='post__footer'>
|
||||
<div
|
||||
class='post__footer_group'
|
||||
>
|
||||
<div class='post__footer_sub_group'>
|
||||
<heart-button :post='post' v-if='showReply'></heart-button>
|
||||
</div>
|
||||
<div class='post__footer_sub_group' v-if='post.Replies.length'>
|
||||
<span class='post__footer_sub_group__text post__footer_sub_group__text--replies'>replies</span>
|
||||
<post-reply
|
||||
v-for='(reply, index) in post.Replies'
|
||||
:key='"post-reply-" + reply.postNumber'
|
||||
|
||||
:post='reply'
|
||||
:hover='hover'
|
||||
:first='index === 0'
|
||||
@click='$emit("goToPost", reply.postNumber)'
|
||||
></post-reply>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div
|
||||
class='post__footer_group post__actions'
|
||||
:class='{ "post__actions--show": showActions }'
|
||||
v-if='!post.removed'
|
||||
>
|
||||
<div class='post__action post__share' @click.stop='setShareModalState(true)'>share</div>
|
||||
<div
|
||||
class='post__action'
|
||||
@click.stop='setShowReportPostModal(true)'
|
||||
v-if='$store.state.username'
|
||||
>
|
||||
report
|
||||
</div>
|
||||
<div
|
||||
class='post__action post__reply'
|
||||
v-if='$store.state.username && showReply'
|
||||
@click.stop='$emit("reply", post.id, username)'
|
||||
>
|
||||
reply
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='post__replies'>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import PostReply from './PostReply'
|
||||
import HeartButton from './HeartButton'
|
||||
import ModalWindow from './ModalWindow'
|
||||
import FancyInput from './FancyInput'
|
||||
import ReplyingTo from './ReplyingTo'
|
||||
import AvatarIcon from './AvatarIcon'
|
||||
import ReportPostModal from './ReportPostModal'
|
||||
|
||||
export default {
|
||||
name: 'ThreadPost',
|
||||
props: [
|
||||
'post',
|
||||
'highlight',
|
||||
'showReply',
|
||||
'showThread',
|
||||
'showSelect',
|
||||
'clickForPost',
|
||||
'allowQuote'
|
||||
],
|
||||
components: {
|
||||
PostReply,
|
||||
ModalWindow,
|
||||
FancyInput,
|
||||
ReplyingTo,
|
||||
AvatarIcon,
|
||||
HeartButton,
|
||||
ReportPostModal
|
||||
},
|
||||
data () {
|
||||
let post = this.post
|
||||
|
||||
return {
|
||||
hover: false,
|
||||
showShareModal: false,
|
||||
showReportPostModal: false,
|
||||
postURL: `${location.origin}/p/${post.id}`,
|
||||
selected: false,
|
||||
|
||||
showQuote: false,
|
||||
quoteX: 0,
|
||||
quoteY: 0,
|
||||
quoteSelection: '',
|
||||
|
||||
postContentHTML: post.content
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
username () {
|
||||
if(this.post.User) {
|
||||
return this.post.User.username
|
||||
} else {
|
||||
return '[deleted]'
|
||||
}
|
||||
},
|
||||
showActions () {
|
||||
return this.hover || this.showShareModal || this.showReportPostModal
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
emitReply () {
|
||||
this.showQuote = false;
|
||||
this.$emit('reply', this.post.id, this.username, this.quoteSelection);
|
||||
},
|
||||
setShowQuote () {
|
||||
let rootCoords = this.$el.getBoundingClientRect();
|
||||
|
||||
let selection = window.getSelection();
|
||||
let coords = selection.getRangeAt(0).getBoundingClientRect();
|
||||
let text = selection.toString();
|
||||
|
||||
if(text.length) {
|
||||
this.quoteY = coords.top - rootCoords.top - 30;
|
||||
this.quoteX = coords.left - rootCoords.left;
|
||||
this.quoteSelection = '> ' + text.replace(/\n/g, '\n> ') + '\n\n';
|
||||
this.showQuote = true;
|
||||
} else {
|
||||
this.showQuote = false;
|
||||
}
|
||||
},
|
||||
setShareModalState (val) {
|
||||
this.showShareModal = val
|
||||
},
|
||||
setShowReportPostModal (val) {
|
||||
this.showReportPostModal = val
|
||||
},
|
||||
goToThread () {
|
||||
this.$router.push(`/thread/${this.post.Thread.slug}/${this.post.Thread.id}`)
|
||||
},
|
||||
goToPost () {
|
||||
if(this.clickForPost) {
|
||||
this.$router.push(
|
||||
'/thread/' +
|
||||
this.post.Thread.slug + '/' +
|
||||
this.post.Thread.id + '/' +
|
||||
this.post.postNumber
|
||||
)
|
||||
}
|
||||
},
|
||||
toggleSelected () {
|
||||
this.selected = !this.selected
|
||||
|
||||
this.$emit('selected', this.post.id)
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
showSelect () {
|
||||
if(this.selected) {
|
||||
this.$emit('selected', this.post.id)
|
||||
}
|
||||
|
||||
this.selected = false
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.$linkExpander(this.post.content, v => this.postContentHTML = v);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../assets/scss/variables.scss';
|
||||
|
||||
@keyframes shake {
|
||||
0% {
|
||||
left: 0rem;
|
||||
}
|
||||
25% {
|
||||
left: -0.5rem;
|
||||
}
|
||||
75% {
|
||||
left: 0.5rem;
|
||||
}
|
||||
100% {
|
||||
left: 0rem;
|
||||
}
|
||||
}
|
||||
|
||||
.post {
|
||||
position: relative;
|
||||
border-bottom: thin solid $color__gray--darker;
|
||||
transition: background-color 0.5s;
|
||||
margin: 0.5rem -0.5rem;
|
||||
padding: 0 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
|
||||
@at-root #{&}--highlighted {
|
||||
background-color: $color__lightgray--darkest;
|
||||
animation-name: shake;
|
||||
animation-iteration-count: 5;
|
||||
animation-timing-function: linear;
|
||||
animation-duration: 0.25s;
|
||||
}
|
||||
|
||||
@at-root #{&}--last {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@at-root #{&}__quote {
|
||||
background: #464646;
|
||||
border-radius: 0.25rem;
|
||||
box-shadow: 0px 2px 0.25rem $color__gray--darkest;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
left: 70px;
|
||||
opacity: 0;
|
||||
padding: 0.25rem 0.4rem;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: 19px;
|
||||
transition: opacity 0.1s;
|
||||
z-index: 3;
|
||||
|
||||
@at-root #{&}--show {
|
||||
opacity: 1;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
@at-root #{&}__icon {
|
||||
font-size: 0.8rem;
|
||||
padding: 0 0.125rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@at-root #{&}__remove_icon {
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
display: inline-block;
|
||||
top: 1rem;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
background-color: gray;
|
||||
z-index: 1;
|
||||
border-radius: 100%;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
padding: 0.25rem;
|
||||
|
||||
transition: all 0.2s;
|
||||
|
||||
@at-root #{&}--show {
|
||||
opacity: 1;
|
||||
pointer-events: all;
|
||||
}
|
||||
}
|
||||
@at-root #{&}--selected {
|
||||
transform: scale(0.95);
|
||||
padding: 1rem;
|
||||
background-color: $color__lightgray--primary;
|
||||
}
|
||||
|
||||
@at-root #{&}__meta_data {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding-top: 0.75rem;
|
||||
position: relative;
|
||||
margin-left: 4rem;
|
||||
}
|
||||
@at-root #{&}__avatar {
|
||||
position: absolute;
|
||||
left: -4rem;
|
||||
}
|
||||
@at-root #{&}__user {
|
||||
@include text($font--role-default, 1rem, 600);
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
@at-root #{&}__thread {
|
||||
color: $color__text--secondary;
|
||||
|
||||
@at-root #{&}__name {
|
||||
cursor: pointer;
|
||||
@include text($font--role-default, 1rem, 600);
|
||||
|
||||
&:hover {
|
||||
color: $color__darkgray--primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
@at-root #{&}__date {
|
||||
|
||||
@at-root #{&}--mobile {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@at-root #{&}__content {
|
||||
padding: 0 0.5rem 0 4rem;
|
||||
outline: none;
|
||||
word-wrap: anywhere;
|
||||
}
|
||||
@at-root #{&}__footer {
|
||||
padding: 0.5rem 0 0.75rem 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
@at-root #{&}_sub_group {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
margin-right: 1rem;
|
||||
|
||||
@at-root #{&}__text {
|
||||
font-variant: small-caps;
|
||||
margin: 0 0.25rem;
|
||||
margin-left: 0;
|
||||
font-size: 0.9rem;
|
||||
position: relative;
|
||||
bottom: 0.1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@at-root #{&}_group {
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
@at-root #{&}__action {
|
||||
color: $color__darkgray--primary;
|
||||
cursor: pointer;
|
||||
margin-right: 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
font-variant: small-caps;
|
||||
position: relative;
|
||||
bottom: 0.1rem;
|
||||
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: $color__darkgray--darkest;
|
||||
}
|
||||
}
|
||||
@at-root #{&}__actions {
|
||||
opacity: 0;
|
||||
|
||||
@at-root #{&}--show {
|
||||
opacity: 1;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.post {
|
||||
@at-root #{&}__actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@at-root #{&}__content {
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
@at-root #{&}__date {
|
||||
display: none;
|
||||
|
||||
@at-root #{&}--mobile {
|
||||
display: block;
|
||||
padding-left: 4rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,90 @@
|
|||
<template>
|
||||
<transition name='slide-fade'>
|
||||
<div class='thread_post_notification' @click='$emit("goToPost")'>
|
||||
<span class='thread_post_notification__close' @click.stop='$emit("close")'></span>
|
||||
<div class='thread_post_notification__header_bar'>
|
||||
<span class='thread_post_notification__username'>{{post.username}}</span>
|
||||
replied · click to view
|
||||
</div>
|
||||
<div class='thread_post_notification__content'>{{post.content | stripTags | truncate(150)}}</div>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../assets/scss/variables.scss';
|
||||
|
||||
.slide-fade-enter-active, .slide-fade-leave-active {
|
||||
transition: all 0.4s cubic-bezier(0.18, 0.89, 0.32, 1.28) !important;
|
||||
}
|
||||
.slide-fade-enter, .slide-fade-leave-to {
|
||||
transform: translateX(25rem);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
|
||||
.thread_post_notification {
|
||||
position: fixed;
|
||||
bottom: 2.5rem;
|
||||
cursor: default;
|
||||
overflow: hidden;
|
||||
right: 2.5rem;
|
||||
width: 20rem;
|
||||
height: 5rem;
|
||||
background-color: #fff;
|
||||
z-index: 3;
|
||||
transition: background-color 0.2s;
|
||||
border-radius: 0.25rem;
|
||||
box-shadow: 0 0 0.5rem 1px rgba(175, 175, 175, 0.3), 0 0.2rem 0.3rem 0px rgba(175, 175, 175, 0.15);
|
||||
|
||||
&:hover {
|
||||
background-color: lighten($color__lightgray--primary, 2.75%);
|
||||
}
|
||||
|
||||
@at-root #{&}__header_bar {
|
||||
width: 100%;
|
||||
font-size: 0.9rem;
|
||||
margin-top: 0.5rem;
|
||||
margin-left: 0.75rem;
|
||||
color: $color__text--secondary;
|
||||
}
|
||||
@at-root #{&}__username, #{&}__date {
|
||||
color: $color__text--primary;
|
||||
}
|
||||
@at-root #{&}__content {
|
||||
padding: 0.75rem;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
@at-root #{&}__close {
|
||||
position: absolute;
|
||||
right: 0.5rem;
|
||||
top: 0.5rem;
|
||||
cursor: pointer;
|
||||
border-radius: 100%;
|
||||
background-color: $color__lightgray--primary;
|
||||
transition: background-color 0.2s;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
|
||||
@include user-select(none);
|
||||
|
||||
&:hover {
|
||||
background-color: $color__lightgray--darker;
|
||||
}
|
||||
&::after {
|
||||
content: '\d7';
|
||||
position: relative;
|
||||
left: 0.2rem;
|
||||
top: -0.15rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'ThreadPostNotification',
|
||||
props: ['post']
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,72 @@
|
|||
<template>
|
||||
<div
|
||||
class='post_placeholder'
|
||||
>
|
||||
<div class='post_placeholder__meta_data'>
|
||||
<div class='post_placeholder__avatar_icon'></div>
|
||||
<div class='post_placeholder__bar post_placeholder__bar--thin post_placeholder__bar--33'></div>
|
||||
</div>
|
||||
<div class='post_placeholder__content'>
|
||||
<div class='post_placeholder__bar post_placeholder__bar--58'></div>
|
||||
<div class='post_placeholder__bar post_placeholder__bar--66'></div>
|
||||
<div class='post_placeholder__bar post_placeholder__bar--50'></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default { name: 'ThreadPostPlaceholder' }
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../assets/scss/variables.scss';
|
||||
|
||||
.post_placeholder {
|
||||
position: relative;
|
||||
border-bottom: thin solid $color__gray--primary;
|
||||
border-radius: 0.25rem;
|
||||
transition: background-color 0.5s;
|
||||
margin: 0.5rem 0;
|
||||
|
||||
@at-root #{&}--last {
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: thin solid $color__gray--primary;
|
||||
}
|
||||
|
||||
@at-root #{&}__meta_data {
|
||||
display: flex;
|
||||
padding-top: 0.75rem;
|
||||
position: relative;
|
||||
margin-left: 4rem;
|
||||
}
|
||||
@at-root #{&}__avatar_icon {
|
||||
position: absolute;
|
||||
left: -4rem;
|
||||
height: 3rem;
|
||||
width: 3rem;
|
||||
border-radius: 100%;
|
||||
background-color: $color__gray--primary;
|
||||
|
||||
@include flash;
|
||||
}
|
||||
@at-root #{&}__bar {
|
||||
background-color: $color__gray--primary;
|
||||
height: 1.5rem;
|
||||
width: 75%;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
@include flash;
|
||||
|
||||
@at-root #{&}--thin {
|
||||
height: 1rem;
|
||||
}
|
||||
@at-root #{&}--33 { width: 33%; }
|
||||
@at-root #{&}--58 { width: 58%; }
|
||||
@at-root #{&}--66 { width: 66%; }
|
||||
@at-root #{&}--50 { width: 50%; }
|
||||
}
|
||||
@at-root #{&}__content {
|
||||
padding: 0.5rem 0 0.5rem 4rem;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,101 @@
|
|||
<template>
|
||||
<label class='toggle_switch'>
|
||||
<input type='checkbox' v-model='proxyValue' />
|
||||
<span></span>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'ToggleSwitch',
|
||||
props: ['value'],
|
||||
data () {
|
||||
return {
|
||||
proxyValue: this.value
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
value () {
|
||||
this.proxyValue = this.value
|
||||
},
|
||||
proxyValue () {
|
||||
this.$emit('input', this.proxyValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../assets/scss/variables.scss';
|
||||
|
||||
.toggle_switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 4rem;
|
||||
height: 2rem;
|
||||
cursor: pointer;
|
||||
|
||||
&:active {
|
||||
span::before {
|
||||
width: 1.5rem;
|
||||
}
|
||||
|
||||
input:checked + span::before {
|
||||
left: calc(100% - 1.85rem);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
span {
|
||||
background-color: $color__lightgray--primary;
|
||||
}
|
||||
|
||||
input:checked + span {
|
||||
background-color: darken(rgba(109, 210, 91, 0.9), 7.5%);
|
||||
border-color: darken(rgba(25, 165, 35, 0.2), 5%);
|
||||
|
||||
&::before {
|
||||
opacity: 0.95;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
span {
|
||||
background-color: #fff;
|
||||
border-radius: 5rem;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0.2rem solid $color__gray--darker;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0.4rem;
|
||||
top: 0.35rem;
|
||||
height: 1.3rem;
|
||||
width: 1.3rem;
|
||||
background-color: $color__gray--darkest;
|
||||
border-radius: 100%;
|
||||
box-shadow: 0px 0px 2px 0 #cacacacc;
|
||||
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
}
|
||||
|
||||
input:checked + span {
|
||||
background-color: rgba(109, 210, 91, 0.9);
|
||||
border-color: rgba(25, 165, 35, 0.2);
|
||||
|
||||
&::before {
|
||||
left: calc(100% - 1.65rem);
|
||||
background-color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,48 @@
|
|||
<template>
|
||||
<div
|
||||
class='user_display'
|
||||
@click='$router.push("/user/" + user.username)'
|
||||
>
|
||||
<avatar-icon :user='user' size='small'></avatar-icon>
|
||||
<div class='user_display__username'>
|
||||
{{user.username}}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import AvatarIcon from './AvatarIcon';
|
||||
|
||||
export default {
|
||||
name: 'UserDisplay',
|
||||
props: ['user'],
|
||||
components: { AvatarIcon }
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../assets/scss/variables.scss';
|
||||
|
||||
.user_display {
|
||||
align-items: center;
|
||||
background: #fff;
|
||||
border: thin solid $color__gray--darker;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
transition: box-shadow 0.2s;
|
||||
|
||||
&:hover {
|
||||
@extend .shadow_border--hover;
|
||||
}
|
||||
|
||||
@at-root #{&}__username {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 400;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,42 @@
|
|||
<template>
|
||||
<div class='user_placeholder'>
|
||||
<div class='user_placeholder__avatar'></div>
|
||||
<div class='user_placeholder__username'></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'UserPlaceholder'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../assets/scss/variables.scss';
|
||||
|
||||
.user_placeholder {
|
||||
align-items: center;
|
||||
background: #fff;
|
||||
border: thin solid $color__gray--darker;
|
||||
border-radius: 0.25rem;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding: 0.25rem 0.5rem;
|
||||
|
||||
@at-root #{&}__avatar {
|
||||
border-radius: 1.5rem;
|
||||
height: 2.5rem;
|
||||
width: 2.5rem;
|
||||
@include flash;
|
||||
}
|
||||
|
||||
@at-root #{&}__username {
|
||||
border-radius: 0.25rem;
|
||||
height: 1.5rem;
|
||||
margin-left: 1rem;
|
||||
width: calc(100% - 3.5rem);
|
||||
|
||||
@include flash;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,152 @@
|
|||
<template>
|
||||
<div class='admin'>
|
||||
<div class='admin__menu'>
|
||||
<div
|
||||
class='admin__menu__item'
|
||||
:key='route.path'
|
||||
v-for='route in routes'
|
||||
:class='{ "admin__menu__item--selected" : route.route.includes(selected) }'
|
||||
@click='$router.push("/admin/" + route.route)'
|
||||
>
|
||||
<div>
|
||||
<font-awesome-icon :icon='["fa", route.icon]' class='admin__menu__item__icon' />
|
||||
</div>
|
||||
<div>
|
||||
<div class='admin__menu__item__title'>
|
||||
{{route.title}}
|
||||
</div>
|
||||
<div class='admin__menu__item__description'>
|
||||
{{route.description}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<router-view class='admin__router_view'></router-view>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Admin',
|
||||
data () {
|
||||
return {
|
||||
selected: null,
|
||||
routes: [
|
||||
{
|
||||
title: 'Dashboard',
|
||||
route: 'dashboard',
|
||||
description: 'Quick links and stats about your forum',
|
||||
icon: 'home'
|
||||
},
|
||||
{
|
||||
title: 'General',
|
||||
route: 'general',
|
||||
description: 'Admin accounts, categories and settings',
|
||||
icon: 'th'
|
||||
},
|
||||
{
|
||||
title: 'Moderation',
|
||||
route: 'moderation/reports',
|
||||
description: 'View and respond to user reports',
|
||||
icon: 'exclamation-circle'
|
||||
},
|
||||
{
|
||||
title: 'Users',
|
||||
route: 'users',
|
||||
description: 'View current user accounts',
|
||||
icon: 'user-circle'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
$route (to) {
|
||||
this.selected = to.path.split('/')[2]
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.selected = this.$route.path.split('/')[2]
|
||||
|
||||
this.$nextTick(() => {
|
||||
if(!this.$store.state.admin) {
|
||||
this.$router.push('/')
|
||||
this.$store.commit('setAccountTabs', 1)
|
||||
this.$store.commit('setAccountModalState', true)
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../../assets/scss/variables.scss';
|
||||
|
||||
.admin {
|
||||
height: calc(100% + 1rem);
|
||||
margin-top: -1rem;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
@at-root #{&}__menu {
|
||||
width: 15rem;
|
||||
height: calc(100%);
|
||||
background-color: #fff;
|
||||
cursor: default;
|
||||
overflow-y: auto;
|
||||
border-right: thin solid $color__lightgray--darker;
|
||||
|
||||
@at-root #{&}__item {
|
||||
transition: background-color 0.2s;
|
||||
padding: 1rem;
|
||||
border-bottom: thin solid $color__lightgray--darker;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
background-color: $color__lightgray--primary;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -0.25rem;
|
||||
top: 0;
|
||||
width: 0.25rem;
|
||||
height: 100%;
|
||||
background-color: $color__gray--darkest;
|
||||
transition: left 0.2s;
|
||||
}
|
||||
|
||||
@at-root #{&}--selected {
|
||||
background-color: $color__lightgray--primary;
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@at-root #{&}__icon {
|
||||
margin-right: 0.5rem;
|
||||
margin-top: 0.1875rem;
|
||||
}
|
||||
|
||||
@at-root #{&}__title {
|
||||
font-weight: 600;
|
||||
}
|
||||
@at-root #{&}__description {
|
||||
font-size: 0.9rem;
|
||||
color: $color__text--secondary;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@at-root #{&}__router_view {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,97 @@
|
|||
<template>
|
||||
<div class='admin_dashboard'>
|
||||
<h1 style='margin: 0.5rem 1rem;'>Dashboard</h1>
|
||||
<div class='admin_dashboard__row'>
|
||||
<div class='admin_dashboard__card admin_dashboard__card--3'>
|
||||
<page-views-chart></page-views-chart>
|
||||
<div class='admin_dashboard__card__title'>Page views over the past week</div>
|
||||
</div>
|
||||
<div class='admin_dashboard__card admin_dashboard__card--2'>
|
||||
<new-posts></new-posts>
|
||||
<div class='admin_dashboard__card__title'>New threads in the last 24 hours</div>
|
||||
</div>
|
||||
<div class='admin_dashboard__card admin_dashboard__card--2'>
|
||||
<categories-chart></categories-chart>
|
||||
<div class='admin_dashboard__card__title'>Number of threads by category</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='admin_dashboard__row'>
|
||||
<div class='admin_dashboard__card admin_dashboard__card--2'>
|
||||
<top-posts></top-posts>
|
||||
<div class='admin_dashboard__card__title'>Top threads by page views today</div>
|
||||
</div>
|
||||
<div class='admin_dashboard__card admin_dashboard__card--3'>
|
||||
<new-users-chart></new-users-chart>
|
||||
<div class='admin_dashboard__card__title'>New users over the past week</div>
|
||||
</div>
|
||||
<div class='admin_dashboard__card admin_dashboard__card--2 admin_dashboard__card--hidden'></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import NewPosts from '../widgets/NewPosts'
|
||||
import PageViewsChart from '../widgets/PageViewsChart'
|
||||
import NewUsersChart from '../widgets/NewUsersChart'
|
||||
import CategoriesChart from '../widgets/CategoriesChart'
|
||||
import TopPosts from '../widgets/TopPosts'
|
||||
|
||||
export default {
|
||||
name: 'AdminDashboard',
|
||||
components: {
|
||||
NewPosts,
|
||||
PageViewsChart,
|
||||
NewUsersChart,
|
||||
CategoriesChart,
|
||||
TopPosts
|
||||
},
|
||||
mounted () {
|
||||
this.$store.dispatch('setTitle', 'admin | dashboard')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../../assets/scss/variables.scss';
|
||||
|
||||
.admin_dashboard {
|
||||
padding: 1rem;
|
||||
|
||||
@at-root #{&}__row {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@at-root #{&}__card {
|
||||
margin: 1rem;
|
||||
height: 12rem;
|
||||
background-color: #fff;
|
||||
border-radius: 0.25rem;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@extend .shadow_border;
|
||||
|
||||
@for $i from 1 through 5 {
|
||||
@at-root #{&}--#{$i} {
|
||||
flex: $i;
|
||||
}
|
||||
}
|
||||
|
||||
@at-root #{&}--hidden {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
@at-root #{&}__title {
|
||||
background-color: $color__gray--primary;
|
||||
width: 100%;
|
||||
padding: 0.25rem 0.35rem;
|
||||
box-shadow: 0 0.1rem 0.075rem rgba(175, 175, 175, 0.25);
|
||||
border-radius: 0 0 0.25rem 0.25rem;
|
||||
cursor: default;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,35 @@
|
|||
<template>
|
||||
<div class='admin_general'>
|
||||
<h1 class='admin_general__header'>General</h1>
|
||||
<admin-forum-info></admin-forum-info>
|
||||
<admin-new-admin></admin-new-admin>
|
||||
<admin-categories></admin-categories>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import AdminCategories from '../AdminCategories'
|
||||
import AdminNewAdmin from '../AdminNewAdmin'
|
||||
import AdminForumInfo from '../AdminForumInfo'
|
||||
|
||||
export default {
|
||||
name: 'AdminGeneral',
|
||||
components: {
|
||||
AdminCategories,
|
||||
AdminNewAdmin,
|
||||
AdminForumInfo
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../../assets/scss/variables.scss';
|
||||
|
||||
.admin_general {
|
||||
padding: 1rem 2rem;
|
||||
|
||||
@at-root #{&}__header {
|
||||
margin: 0.5rem 0 1rem 0;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,272 @@
|
|||
<template>
|
||||
<div class='admin_moderation'>
|
||||
<div class='admin_moderation__header'>
|
||||
<moderation-header selected-tab='bans'></moderation-header>
|
||||
<button class='button button--blue' @click='toggleShowAddNewBanModal'>Add new ban</button>
|
||||
</div>
|
||||
|
||||
<transition name='fade' mode='out-in'>
|
||||
<loading-message v-if='!bans' key='loading'></loading-message>
|
||||
|
||||
<table class='admin_moderation__table' v-else-if='bans.length' key='bans'>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Ban type</th>
|
||||
<th>Date banned</th>
|
||||
<th>Message</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
<tr :key='"ban-row-" + $index' v-for='(ban, $index) in bans'>
|
||||
<td>{{ban.User.username}}</td>
|
||||
<td>{{ban.type}}</td>
|
||||
<td>{{ban.createdAt | formatDate}}</td>
|
||||
<td>
|
||||
<template v-if='ban.message'>{{ban.message}}</template>
|
||||
<i v-else>No message given</i>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
class='button button--red'
|
||||
@click='deleteBan(ban, $index)'
|
||||
>
|
||||
Delete ban
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div class='overlay_message' v-else key='no bans'>
|
||||
<font-awesome-icon :icon='["fa", "thumbs-up"]' />
|
||||
No banned users
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<modal-window
|
||||
v-model='$store.state.moderation.showAddNewBanModal'
|
||||
width='30rem'
|
||||
:no-padding='true'
|
||||
>
|
||||
<div class='admin_moderation__add_new_ban_modal' slot='main'>
|
||||
<div
|
||||
class='admin_moderation__add_new_ban_modal__overlay'
|
||||
:class='{ "admin_moderation__add_new_ban_modal__overlay--show": loading }'
|
||||
>
|
||||
<loading-icon></loading-icon>
|
||||
</div>
|
||||
|
||||
<h2>Ban or block a user</h2>
|
||||
<p>Search for the user to ban, then select the relevant ban type for the user</p>
|
||||
|
||||
<form @submit.prevent='addBan'>
|
||||
<div>
|
||||
<fancy-input
|
||||
placeholder='Username to ban'
|
||||
v-model='$store.state.moderation.username'
|
||||
width='15rem'
|
||||
:large='true'
|
||||
></fancy-input>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<fancy-input
|
||||
placeholder='Message to user (optional)'
|
||||
v-model='$store.state.moderation.message'
|
||||
width='15rem'
|
||||
:large='true'
|
||||
></fancy-input>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<select-button
|
||||
:options='$store.state.moderation.options'
|
||||
name='test'
|
||||
v-model='$store.state.moderation.selectedOption'
|
||||
>
|
||||
</select-button>
|
||||
</div>
|
||||
|
||||
<input type='submit' style='display: none;' />
|
||||
</form>
|
||||
</div>
|
||||
<div slot='footer'>
|
||||
<button
|
||||
class='button button--modal button--green'
|
||||
@click='addBan'
|
||||
>
|
||||
Add ban
|
||||
</button>
|
||||
<button
|
||||
class='button button--modal'
|
||||
@click='toggleShowAddNewBanModal'
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</modal-window>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ModalWindow from '../ModalWindow'
|
||||
import FancyInput from '../FancyInput'
|
||||
import SelectButton from '../SelectButton'
|
||||
import ModerationHeader from '../ModerationHeader'
|
||||
import LoadingIcon from '../LoadingIcon'
|
||||
import LoadingMessage from '../LoadingMessage'
|
||||
|
||||
import AjaxErrorHandler from '../../assets/js/errorHandler'
|
||||
|
||||
export default {
|
||||
name: 'AdminDashboard',
|
||||
components: {
|
||||
ModalWindow,
|
||||
FancyInput,
|
||||
SelectButton,
|
||||
ModerationHeader,
|
||||
LoadingIcon,
|
||||
LoadingMessage
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
loading: false,
|
||||
bans_: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
bans () {
|
||||
if(!this.bans_) return null
|
||||
|
||||
return this.bans_.map(ban => {
|
||||
if(ban.ipBanned) {
|
||||
ban.type = 'IP banned'
|
||||
} else if (ban.canCreateThreads && !ban.canCreatePosts) {
|
||||
ban.type = 'Posting replies'
|
||||
} else if(ban.canCreatePosts && !ban.canCreateThreads) {
|
||||
ban.type = 'Creating threads'
|
||||
} else {
|
||||
ban.type = 'Posting replies and creating threads'
|
||||
}
|
||||
|
||||
return ban
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggleShowAddNewBanModal () {
|
||||
this.$store.commit(
|
||||
'moderation/setModal',
|
||||
!this.$store.state.moderation.showAddNewBanModal
|
||||
)
|
||||
this.$store.dispatch('moderation/clearModal')
|
||||
},
|
||||
addBan () {
|
||||
let store = this.$store.state.moderation
|
||||
|
||||
let obj = { username: store.username }
|
||||
if(store.message.trim().length) {
|
||||
obj.message = store.message
|
||||
}
|
||||
if(store.selectedOption === 'both') {
|
||||
obj.canCreatePosts = false
|
||||
obj.canCreateThreads = false
|
||||
} else if(store.selectedOption === 'thread') {
|
||||
obj.canCreateThreads = false
|
||||
} else if(store.selectedOption === 'post') {
|
||||
obj.canCreatePosts = false
|
||||
} else {
|
||||
obj.ipBanned = true
|
||||
}
|
||||
|
||||
this.loading = true
|
||||
|
||||
this.axios
|
||||
.post('/api/v1/ban', obj)
|
||||
.then(res => {
|
||||
this.loading = false
|
||||
this.bans_.push(res.data)
|
||||
this.toggleShowAddNewBanModal()
|
||||
this.$store.dispatch('moderation/clearModal')
|
||||
})
|
||||
.catch(e => {
|
||||
this.loading = false
|
||||
AjaxErrorHandler(this.$store)(e)
|
||||
})
|
||||
},
|
||||
deleteBan (ban, index) {
|
||||
this.axios
|
||||
.delete('/api/v1/ban/' + ban.id)
|
||||
.then(() => {
|
||||
this.bans_.splice(index, 1)
|
||||
})
|
||||
.catch(AjaxErrorHandler(this.$store))
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.$store.dispatch('setTitle', 'admin | moderation')
|
||||
this.axios
|
||||
.get('/api/v1/ban')
|
||||
.then(res => {
|
||||
this.bans_ = res.data
|
||||
})
|
||||
.catch(AjaxErrorHandler(this.$store))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../../assets/scss/variables.scss';
|
||||
|
||||
.admin_moderation {
|
||||
padding: 2rem;
|
||||
padding-top: 1rem;
|
||||
|
||||
|
||||
@at-root #{&}__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
|
||||
button {
|
||||
margin-bottom: 1.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@at-root #{&}__add_new_ban_modal {
|
||||
padding: 1rem;
|
||||
|
||||
@at-root #{&}__overlay {
|
||||
margin-left: -1rem;
|
||||
@include loading-overlay(rgba(0, 0, 0, 0.3), 0.125rem);
|
||||
}
|
||||
|
||||
h2 {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
margin-bottom: -0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@at-root #{&}__table {
|
||||
width: calc(100%);
|
||||
overflow: hidden;
|
||||
margin-top: 1rem;
|
||||
padding: 0.5rem;
|
||||
background-color: #fff;
|
||||
border-radius: 0.25rem;
|
||||
border-collapse: collapse;
|
||||
|
||||
border: thin solid $color__gray--darker;
|
||||
|
||||
td, th {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
th {
|
||||
text-align: left;
|
||||
}
|
||||
tr:nth-child(even) {
|
||||
background-color: $color__lightgray--darker;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,305 @@
|
|||
<template>
|
||||
<div class='admin_moderation'>
|
||||
<moderation-header selected-tab='reports'></moderation-header>
|
||||
|
||||
<confirm-modal v-model='removePostObj.showConfirmModal' @confirm='removePost' text='Remove' color='red'>
|
||||
Are you sure you want to remove this post?
|
||||
</confirm-modal>
|
||||
|
||||
<confirm-modal v-model='removePostObj.showThreadDeleteModal' @confirm='deleteThread' text='Delete' color='red'>
|
||||
Are you sure you want to delete the thread containing this post?
|
||||
</confirm-modal>
|
||||
|
||||
<transition name='fade' mode='out-in'>
|
||||
<loading-message v-if='!reports' key='loading'></loading-message>
|
||||
|
||||
<div
|
||||
class='admin_moderation__reports'
|
||||
v-else-if='filteredReports.length'
|
||||
key='reports'
|
||||
>
|
||||
|
||||
<div class='admin_moderation__report admin_moderation__report--header'>
|
||||
<div class='admin_moderation__report__post admin_moderation__report--cell_border admin_moderation__report--cell_border-hidden'>
|
||||
User and post reported
|
||||
</div>
|
||||
<div class='admin_moderation__report__reason admin_moderation__report--cell_border admin_moderation__report--cell_border-hidden'>Report reason</div>
|
||||
<div class='admin_moderation__report__flagged_by admin_moderation__report--cell_border admin_moderation__report--cell_border-hidden'>
|
||||
Reported by user
|
||||
</div>
|
||||
<div class='admin_moderation__report__actions'>
|
||||
Actions
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='admin_moderation__report' :key='"report-" + $index' v-for='(report, $index) in filteredReports'>
|
||||
<div class='admin_moderation__report__post admin_moderation__report--cell_border'>
|
||||
<div class='admin_moderation__report__post__header'>
|
||||
<avatar-icon class='admin_moderation__report__flagged_by__avatar' size='small' :user='report.Post.User'></avatar-icon>
|
||||
<div>
|
||||
<div class='admin_moderation__report__post__user'>{{report.PostUserUsername}}</div>
|
||||
<div class='admin_moderation__report__flagged_by__date'>Posted {{report.createdAt| formatDate}}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='admin_moderation__report__post__content'>{{report.Post.content | stripTags | truncate(150)}}</div>
|
||||
</div>
|
||||
<div class='admin_moderation__report__reason admin_moderation__report--cell_border'>{{report.reason}}</div>
|
||||
<div class='admin_moderation__report__flagged_by admin_moderation__report--cell_border'>
|
||||
<avatar-icon class='admin_moderation__report__flagged_by__avatar' size='small' :user='report.FlaggedByUser'></avatar-icon>
|
||||
<div class='admin_moderation__report__flagged_by__text_info'>
|
||||
<div class='admin_moderation__report__flagged_by__user'>{{report.FlaggedByUserUsername}}</div>
|
||||
<div class='admin_moderation__report__flagged_by__date'>{{report.createdAt| formatDate}}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='admin_moderation__report__actions'>
|
||||
<button class='button button--red' @click='removePost(report, $index)'>Remove post</button>
|
||||
<menu-button
|
||||
@delete='deleteReport(report.id, $index)'
|
||||
@ban='banUser(report, $index)'
|
||||
@deleteThread='deleteThread(report, $index)'
|
||||
:options='reportMenuOptions'
|
||||
>
|
||||
<button class='button'>More options…</button>
|
||||
</menu-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='overlay_message' v-else key='no reports'>
|
||||
<font-awesome-icon :icon='["fa", "thumbs-up"]' />
|
||||
No user reports
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MenuButton from '../MenuButton'
|
||||
import LoadingMessage from '../LoadingMessage'
|
||||
import AvatarIcon from '../AvatarIcon'
|
||||
import ConfirmModal from '../ConfirmModal'
|
||||
import ModerationHeader from '../ModerationHeader'
|
||||
|
||||
import AjaxErrorHandler from '../../assets/js/errorHandler'
|
||||
|
||||
export default {
|
||||
name: 'AdminDashboard',
|
||||
components: {
|
||||
MenuButton,
|
||||
LoadingMessage,
|
||||
AvatarIcon,
|
||||
ConfirmModal,
|
||||
ModerationHeader
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
reportMenuOptions: [
|
||||
{ value: "Delete report", event: 'delete' },
|
||||
{ value: "Ban or block user", event: 'ban' },
|
||||
{ value: "Delete thread", event: 'deleteThread' }
|
||||
],
|
||||
reports: null,
|
||||
|
||||
removePostObj: {
|
||||
showConfirmModal: false,
|
||||
showThreadDeleteModal: false,
|
||||
report: null,
|
||||
index: null
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
filteredReports () {
|
||||
if (!this.reports) return null;
|
||||
|
||||
return this.reports
|
||||
//Only show reports not already deleted
|
||||
.filter(report => report.Post)
|
||||
.map(report => {
|
||||
let clone = Object.create(report);
|
||||
|
||||
//Provide username text if user deleted for post
|
||||
//or flaggedByUser
|
||||
if(!report.Post.User) {
|
||||
clone.PostUserUsername = '[Deleted]';
|
||||
} else {
|
||||
clone.PostUserUsername = report.Post.User.username;
|
||||
}
|
||||
|
||||
if(!report.FlaggedByUser) {
|
||||
clone.FlaggedByUserUsername = '[Deleted]';
|
||||
} else {
|
||||
clone.FlaggedByUserUsername = report.FlaggedByUser.username;
|
||||
}
|
||||
|
||||
return clone;
|
||||
});
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
deleteReport (id, index) {
|
||||
return this.axios
|
||||
.delete('/api/v1/report/' + id)
|
||||
.then(() => {
|
||||
this.reports.splice(index, 1)
|
||||
})
|
||||
.catch(AjaxErrorHandler(this.$store))
|
||||
},
|
||||
deleteThread (report, index) {
|
||||
if(report) {
|
||||
this.removePostObj.report = report
|
||||
this.removePostObj.index = index
|
||||
|
||||
this.removePostObj.showThreadDeleteModal = true
|
||||
} else {
|
||||
let threadId = this.removePostObj.report.Post.Thread.id
|
||||
|
||||
this.axios
|
||||
.delete('/api/v1/thread/' + threadId)
|
||||
.then(() => {
|
||||
//Get reports with id of deleted thread
|
||||
//and remove them locally
|
||||
this.reports = this.reports.filter(report => {
|
||||
return report.Post.Thread.id !== threadId
|
||||
})
|
||||
|
||||
})
|
||||
.catch(AjaxErrorHandler(this.$store))
|
||||
}
|
||||
},
|
||||
removePost (report, index) {
|
||||
if(report) {
|
||||
this.removePostObj.report = report
|
||||
this.removePostObj.index = index
|
||||
|
||||
this.removePostObj.showConfirmModal = true
|
||||
} else {
|
||||
this.axios
|
||||
.delete('/api/v1/post/' + this.removePostObj.report.Post.id)
|
||||
.then(() => {
|
||||
return this.axios.delete('/api/v1/report/' + this.removePostObj.report.id)
|
||||
})
|
||||
.then(() => {
|
||||
this.reports.splice(this.removePostObj.index, 1)
|
||||
})
|
||||
.catch(AjaxErrorHandler(this.$store))
|
||||
}
|
||||
|
||||
},
|
||||
banUser (report) {
|
||||
this.$router.push('bans')
|
||||
|
||||
setTimeout(() => {
|
||||
this.$store.commit('moderation/setModal', true)
|
||||
this.$store.commit('moderation/setUsername', report.Post.User.username)
|
||||
}, 0)
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.$store.dispatch('setTitle', 'admin | moderation')
|
||||
|
||||
this.axios
|
||||
.get('/api/v1/report')
|
||||
.then(res => {
|
||||
this.reports = res.data
|
||||
})
|
||||
.catch(AjaxErrorHandler(this.$store))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../../assets/scss/variables.scss';
|
||||
|
||||
.admin_moderation {
|
||||
padding: 2rem;
|
||||
padding-top: 1rem;
|
||||
|
||||
@at-root #{&}__reports {
|
||||
margin-top: 1rem;
|
||||
border: thin solid $color__gray--darker;
|
||||
|
||||
border-radius: 0.25rem;
|
||||
&> :first-child {
|
||||
border-radius: 0.25rem 0.25rem 0 0;
|
||||
}
|
||||
&> :last-child {
|
||||
border-radius: 0 0 0.25rem 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
@at-root #{&}__report {
|
||||
display: flex;
|
||||
background-color: #fff;
|
||||
border-bottom: thin solid $color__lightgray--darkest;
|
||||
padding: 0.5rem;
|
||||
|
||||
|
||||
@at-root #{&}--header {
|
||||
font-weight: bold;
|
||||
}
|
||||
@at-root #{&}--cell_border {
|
||||
padding-right: 0.5rem;
|
||||
margin-right: 0.5rem;
|
||||
border-right: thin solid $color__lightgray--darkest;
|
||||
|
||||
@at-root #{&}-hidden {
|
||||
border-right-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@at-root #{&}__post {
|
||||
width: 35%;
|
||||
display: flex;
|
||||
|
||||
@at-root #{&}__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
border-right: thin solid $color__lightgray--darker;
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
@at-root #{&}__thread {
|
||||
font-size: 1rem;
|
||||
text-decoration: underline;
|
||||
|
||||
}
|
||||
@at-root #{&}__content {
|
||||
padding-left: 0.25rem;
|
||||
}
|
||||
}
|
||||
@at-root #{&}__reason {
|
||||
width: 15%;
|
||||
}
|
||||
@at-root #{&}__flagged_by {
|
||||
width: 20%;
|
||||
display: flex;
|
||||
|
||||
|
||||
@at-root #{&}__text_info {
|
||||
margin-left: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@at-root #{&}__date {
|
||||
color: $color__darkgray--primary;
|
||||
}
|
||||
}
|
||||
@at-root #{&}__actions {
|
||||
width: 30%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.button--red {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@at-root #{&}__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,225 @@
|
|||
<template>
|
||||
<div class='admin_users' ref='scrollElement'>
|
||||
<h1 class='admin_users__header'>Users</h1>
|
||||
<div class='category_widget__box'>
|
||||
<div class='category_widget__text__title'>Filter users</div>
|
||||
<div class='admin_users__filters'>
|
||||
<fancy-input
|
||||
placeholder='Filter users'
|
||||
:large='true'
|
||||
v-model='search'
|
||||
></fancy-input>
|
||||
<select-filter
|
||||
name='Role'
|
||||
:options='roleOptions'
|
||||
v-model='roleSelected'
|
||||
>
|
||||
</select-filter>
|
||||
</div>
|
||||
</div>
|
||||
<scroll-load
|
||||
class='category_widget__box'
|
||||
@loadNext='fetchData'
|
||||
:loading='loading'
|
||||
query-selector='.admin_users'
|
||||
:padding-bottom='100'
|
||||
>
|
||||
<table>
|
||||
<tr>
|
||||
<th>
|
||||
<sort-menu v-model='tableSort' column='username' display='Username'></sort-menu>
|
||||
</th>
|
||||
<th>
|
||||
Role
|
||||
</th>
|
||||
<th>
|
||||
<sort-menu v-model='tableSort' column='createdAt' display='Account created at'></sort-menu>
|
||||
</th>
|
||||
<th>
|
||||
<sort-menu v-model='tableSort' column='postCount' display='Posts count'></sort-menu>
|
||||
</th>
|
||||
<th>
|
||||
<sort-menu v-model='tableSort' column='threadCount' display='Threads count'></sort-menu>
|
||||
</th>
|
||||
</tr>
|
||||
<tr v-for='user in users' :key='"user-row" + user.username'>
|
||||
<td class='admin_users__user_column'>
|
||||
<avatar-icon :user='user' size='small'></avatar-icon>
|
||||
<router-link :to='"/user/" + user.username'>{{user.username}}</router-link>
|
||||
</td>
|
||||
<td>{{user.admin ? "Admin" : "User"}}</td>
|
||||
<td>{{user.createdAt | formatDate}}</td>
|
||||
<td>{{user.postCount}}</td>
|
||||
<td>{{user.threadCount}}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<transition name='fade' mode='out-in'>
|
||||
<loading-message key='loading' v-if='loading'></loading-message>
|
||||
<div class='overlay_message' v-if='!loading && !users.length'>
|
||||
No users found
|
||||
</div>
|
||||
</transition>
|
||||
</scroll-load>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import SelectFilter from '../SelectFilter.vue';
|
||||
import SortMenu from '../SortMenu.vue';
|
||||
import FancyInput from '../FancyInput.vue';
|
||||
import LoadingMessage from '../LoadingMessage';
|
||||
import ScrollLoad from '../ScrollLoad';
|
||||
import AvatarIcon from '../AvatarIcon';
|
||||
|
||||
import throttle from 'lodash.throttle';
|
||||
import AjaxErrorHandler from '../../assets/js/errorHandler';
|
||||
|
||||
export default {
|
||||
name: 'AdminUsers',
|
||||
components: {
|
||||
FancyInput,
|
||||
SelectFilter,
|
||||
SortMenu,
|
||||
LoadingMessage,
|
||||
ScrollLoad,
|
||||
AvatarIcon
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
search: '',
|
||||
users: [],
|
||||
|
||||
loading: true,
|
||||
offset: 0,
|
||||
limit: 15,
|
||||
|
||||
roleOptions: [
|
||||
{ name: 'Admins', value: 'admin' },
|
||||
{ name: 'Users', value: 'user' }
|
||||
],
|
||||
roleSelected: ['admin', 'user'],
|
||||
|
||||
tableSort: {
|
||||
column: 'username',
|
||||
sort: 'desc'
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
fetchData () {
|
||||
if(this.offset === null) return;
|
||||
|
||||
let url = `/api/v1/user?
|
||||
sort=${this.tableSort.column}
|
||||
&order=${this.tableSort.sort}
|
||||
&offset=${this.offset}
|
||||
`;
|
||||
if(this.roleSelected.length === 1) {
|
||||
url += '&role=' + this.roleSelected[0];
|
||||
}
|
||||
if(this.search.length) {
|
||||
url += '&search=' + encodeURIComponent(this.search.trim());
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
this.axios
|
||||
.get(url)
|
||||
.then(res => {
|
||||
this.users.push(...res.data);
|
||||
this.loading = /*loading =*/ false;
|
||||
|
||||
//If returned data is less than the limit
|
||||
//then there must be no more pages to paginate
|
||||
if(res.data.length < this.limit) {
|
||||
this.offset = null;
|
||||
} else {
|
||||
this.offset+= this.limit;
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
AjaxErrorHandler(this.$store)(e);
|
||||
this.loading = /*loading =*/ false;
|
||||
});
|
||||
},
|
||||
resetFetchData () {
|
||||
this.offset = 0;
|
||||
this.users = [];
|
||||
|
||||
this.fetchData();
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.fetchData();
|
||||
},
|
||||
watch: {
|
||||
tableSort: 'resetFetchData',
|
||||
roleSelected: 'resetFetchData',
|
||||
search: throttle(function () {
|
||||
this.resetFetchData();
|
||||
}, 200)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../../assets/scss/variables.scss';
|
||||
|
||||
.admin_users {
|
||||
padding: 1rem 2rem;
|
||||
|
||||
@at-root #{&}__header {
|
||||
margin: 0.5rem 0 1rem 0;
|
||||
}
|
||||
|
||||
@at-root #{&}__filters {
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
.select_filter {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
|
||||
th {
|
||||
border-bottom: 0.125rem solid $color__gray--darker;
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
tr {
|
||||
cursor: default;
|
||||
|
||||
&:first-child {
|
||||
background-color: #fff;
|
||||
}
|
||||
&:nth-child(odd) {
|
||||
background-color: lighten($color__gray--primary, 20%);
|
||||
}
|
||||
&:nth-child(even) {
|
||||
background-color: $color__gray--primary;
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
}
|
||||
@at-root #{&}__user_column {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
a {
|
||||
margin: 0 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.overlay_message {
|
||||
padding-top: 2rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,447 @@
|
|||
<template>
|
||||
<div class='route_container'>
|
||||
<div class='forum_description' v-if='
|
||||
$store.state.meta.showDescription &&
|
||||
$store.state.meta.description
|
||||
'>
|
||||
{{$store.state.meta.description}}
|
||||
</div>
|
||||
<div class='thread_sorting'>
|
||||
<select-options
|
||||
:options='filterOptions'
|
||||
v-model='selectedFilterOption'
|
||||
class='thread_sorting__filter'
|
||||
></select-options>
|
||||
<div class='thread_sorting__add_and_categories'>
|
||||
<select-button v-model='selectedCategory' :options='categories'></select-button>
|
||||
<router-link
|
||||
class='button button--blue'
|
||||
to='/thread/new'
|
||||
>
|
||||
{{postNewThreadText}}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<div class='threads_main'>
|
||||
<div class='threads_main__side_bar'>
|
||||
<div class='threads_main__side_bar__title'>
|
||||
categories
|
||||
</div>
|
||||
<router-link
|
||||
v-for='(category, $index) in categories'
|
||||
:key='"category-link-" + $index'
|
||||
|
||||
class='threads_main__side_bar__menu_item'
|
||||
:class='{"threads_main__side_bar__menu_item--selected": category.value === selectedCategory}'
|
||||
:to='"/category/" + category.value'
|
||||
>
|
||||
<span
|
||||
class='threads_main__side_bar__menu_item__border'
|
||||
:style='{"background-color": category.color}'
|
||||
></span>
|
||||
<span
|
||||
class='threads_main__side_bar__menu_item__text'
|
||||
:style='{
|
||||
"color": category.value === selectedCategory ? category.color : undefined
|
||||
}'
|
||||
>{{category.name}}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<transition name='fade' mode='out-in'>
|
||||
<div
|
||||
class='threads_main__threads'
|
||||
v-if='!threads'
|
||||
key='loading'
|
||||
>
|
||||
<thread-display-placeholder>
|
||||
</thread-display-placeholder>
|
||||
</div>
|
||||
|
||||
<scroll-load
|
||||
key='threads'
|
||||
class='threads_main__threads'
|
||||
v-else-if='filteredThreads.length'
|
||||
:loading='loading'
|
||||
@loadNext='getThreads'
|
||||
>
|
||||
<template v-if='loadingNewer'>
|
||||
<thread-display-placeholder v-for='n in newThreads' :key='"placeholder-upper-" + n'>
|
||||
</thread-display-placeholder>
|
||||
</template>
|
||||
<div class='threads_main__load_new' v-if='newThreads' @click='getNewerThreads'>
|
||||
Load {{newThreads}} new {{newThreads | pluralize('thread')}}
|
||||
</div>
|
||||
<thread-display
|
||||
v-for='thread in filteredThreads'
|
||||
:thread='thread'
|
||||
:key='"thread-display-" + thread.id'
|
||||
></thread-display>
|
||||
<template v-if='loading'>
|
||||
<thread-display-placeholder v-for='n in nextThreadsCount' :key='"placeholder-lower-" + n'>
|
||||
</thread-display-placeholder>
|
||||
</template>
|
||||
</scroll-load>
|
||||
|
||||
<div key='no threads' v-else class='threads_main__threads overlay_message'>
|
||||
<font-awesome-icon :icon='["fa", "exclamation-circle"]' />
|
||||
No threads or posts.
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ScrollLoad from '../ScrollLoad'
|
||||
import ThreadDisplay from '../ThreadDisplay'
|
||||
import ThreadDisplayPlaceholder from '../ThreadDisplayPlaceholder'
|
||||
import SelectOptions from '../SelectOptions'
|
||||
import SelectButton from '../SelectButton'
|
||||
|
||||
import AjaxErrorHandler from '../../assets/js/errorHandler'
|
||||
import logger from '../../assets/js/logger'
|
||||
|
||||
export default {
|
||||
name: 'index',
|
||||
components: {
|
||||
ScrollLoad,
|
||||
ThreadDisplay,
|
||||
ThreadDisplayPlaceholder,
|
||||
SelectOptions,
|
||||
SelectButton
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
filterOptions: [
|
||||
{name: 'New', value: 'NEW'},
|
||||
{name: 'Most active', value: 'MOST_ACTIVE'},
|
||||
{name: 'No replies', value: 'NO_REPLIES'}
|
||||
],
|
||||
selectedFilterOption: 'NEW',
|
||||
|
||||
nextURL: '',
|
||||
nextThreadsCount: 0,
|
||||
loading: false,
|
||||
|
||||
threads: null,
|
||||
newThreads: 0,
|
||||
loadingNewer: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
filteredThreads () {
|
||||
var categories = {};
|
||||
var filter = this.selectedFilterOption
|
||||
|
||||
this.$store.state.meta.categories.forEach(category => {
|
||||
categories[category.value] = category.name;
|
||||
});
|
||||
|
||||
return this.threads.filter(thread => {
|
||||
return (thread.Category.value === this.selectedCategory) || (this.selectedCategory === 'ALL');
|
||||
}).map(thread => {
|
||||
var _thread = Object.assign({}, thread);
|
||||
_thread.category = categories[thread.Category.value];
|
||||
|
||||
return _thread;
|
||||
}).sort((a, b) => {
|
||||
if(filter === 'NEW') {
|
||||
let aDate = new Date(a.Posts[0].createdAt)
|
||||
let bDate = new Date(b.Posts[0].createdAt)
|
||||
|
||||
return bDate - aDate;
|
||||
} else if(filter === 'MOST_ACTIVE') {
|
||||
return b.postsCount - a.postsCount;
|
||||
}
|
||||
}).filter(thread => {
|
||||
if(filter === 'NO_REPLIES' && thread.postsCount-1) {
|
||||
return false
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
},
|
||||
categories () {
|
||||
return this.$store.getters.alphabetizedCategories
|
||||
},
|
||||
selectedCategory: {
|
||||
set (val) {
|
||||
let name = this.categories.find(c => c.value === val)
|
||||
|
||||
this.$store.dispatch('setTitle', name ? name.name : '')
|
||||
this.$store.commit('setSelectedCategory', val)
|
||||
},
|
||||
get () {
|
||||
return this.$store.state.category.selectedCategory
|
||||
}
|
||||
},
|
||||
postNewThreadText () {
|
||||
if(this.$store.state.username) {
|
||||
return 'Post new thread'
|
||||
} else {
|
||||
return 'Login to post'
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
navigateToThread (slug, id) {
|
||||
this.$router.push('/thread/' + slug + '/' + id);
|
||||
},
|
||||
getThreads (initial) {
|
||||
if(this.nextURL === null && !initial) return
|
||||
|
||||
let URL = '/api/v1/category/' + this.selectedCategory
|
||||
if(!initial) {
|
||||
URL = this.nextURL || URL
|
||||
}
|
||||
|
||||
this.loading = true
|
||||
|
||||
this.axios
|
||||
.get(URL)
|
||||
.then(res => {
|
||||
this.loading = false
|
||||
|
||||
if(initial) {
|
||||
this.threads = res.data.Threads
|
||||
} else {
|
||||
this.threads.push(...res.data.Threads)
|
||||
}
|
||||
|
||||
this.nextURL = res.data.meta.nextURL
|
||||
this.nextThreadsCount = res.data.meta.nextThreadsCount
|
||||
})
|
||||
.catch((e) => {
|
||||
this.loading = false
|
||||
|
||||
AjaxErrorHandler(this.$store)(e)
|
||||
})
|
||||
},
|
||||
getNewerThreads () {
|
||||
this.loadingNewer = true
|
||||
|
||||
this.axios
|
||||
.get('/api/v1/category/' + this.selectedCategory + '?limit=' + this.newThreads)
|
||||
.then(res => {
|
||||
this.loadingNewer = false
|
||||
this.newThreads = 0
|
||||
|
||||
this.threads.unshift(...res.data.Threads)
|
||||
})
|
||||
.catch((e) => {
|
||||
this.loadingNewer = false
|
||||
AjaxErrorHandler(this.$store)(e)
|
||||
})
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
selectedCategory (newValue) {
|
||||
this.$router.push('/category/' + newValue.toLowerCase());
|
||||
},
|
||||
$route () {
|
||||
this.selectedCategory = this.$route.path.split('/')[2].toUpperCase()
|
||||
this.newThreads = 0
|
||||
this.getThreads(true)
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.selectedCategory = this.$route.path.split('/')[2].toUpperCase()
|
||||
this.getThreads(true)
|
||||
|
||||
this.$socket.emit('join', 'index')
|
||||
this.$socket.on('new thread', data => {
|
||||
if(data.value === this.selectedCategory || this.selectedCategory == 'ALL') {
|
||||
this.newThreads++
|
||||
}
|
||||
})
|
||||
|
||||
if(this.$route.query.token) {
|
||||
this.$store.commit('setToken', this.$route.query.token)
|
||||
this.$store.commit('setAccountTabs', 0)
|
||||
this.$store.commit('setAccountModalState', true)
|
||||
}
|
||||
|
||||
logger('index')
|
||||
},
|
||||
destroyed () {
|
||||
this.$socket.emit('leave', 'index')
|
||||
this.$socket.off('new thread')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../../assets/scss/elementStyles.scss';
|
||||
@import '../../assets/scss/variables.scss';
|
||||
|
||||
.forum_description {
|
||||
padding: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
background-color: #fff;
|
||||
border-radius: 0.25rem;
|
||||
border: thin solid $color__gray--darker;
|
||||
}
|
||||
|
||||
.threads_main {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.thread_sorting {
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
@at-root #{&}__display {
|
||||
padding-right: 0.5rem;
|
||||
border-right: thin solid $color__gray--primary;
|
||||
margin-right: 1.25rem;
|
||||
width: 10rem;
|
||||
}
|
||||
|
||||
@at-root #{&}__add_and_categories {
|
||||
.select_button {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.threads_main__side_bar {
|
||||
width: 12rem;
|
||||
height: 0%;
|
||||
background: #fff;
|
||||
margin-right: 1rem;
|
||||
border-radius: 0.25rem;
|
||||
border: thin solid $color__gray--darker;
|
||||
padding: 0.5rem 0 1rem 1rem;
|
||||
position: sticky;
|
||||
top: 4.5rem;
|
||||
|
||||
@at-root #{&}__title {
|
||||
color: $color__darkgray--darker;
|
||||
cursor: default;
|
||||
font-weight: 500;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
@at-root #{&}__menu_item {
|
||||
cursor: pointer;
|
||||
margin-top: 0.5rem;
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 0.75rem auto;
|
||||
grid-column-gap: 1rem;
|
||||
text-decoration: none;
|
||||
background-image: none;
|
||||
font-weight: normal;
|
||||
|
||||
@at-root #{&}__border {
|
||||
align-self: center;
|
||||
display: inline-block;
|
||||
height: 0.9rem;
|
||||
width: 0.9rem;
|
||||
border-radius: 0.25rem;
|
||||
margin-top: 0.25rem;
|
||||
background-color: $color__gray--darkest;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
@at-root #{&}__text {
|
||||
filter: saturate(0.75), brightness(0.75);
|
||||
}
|
||||
|
||||
|
||||
@at-root #{&}--selected {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
.threads_main__threads {
|
||||
margin-left: 1rem;
|
||||
width: calc(100% - 11rem);
|
||||
}
|
||||
|
||||
.threads_main__load_new {
|
||||
@extend .button;
|
||||
|
||||
font-size: 1.25rem;
|
||||
margin: 0 0 1rem 0;
|
||||
background-color: $color__lightgray--primary;
|
||||
border-color: $color__gray--darker;
|
||||
width: 100%;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.thread {
|
||||
background-color: #fff;
|
||||
padding: 0.5rem 0;
|
||||
cursor: default;
|
||||
text-align: left;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: $color__lightgray--primary;
|
||||
}
|
||||
|
||||
td, th {
|
||||
padding: 0.3rem 0.5rem;
|
||||
border-bottom: solid thin $color__lightgray--primary;
|
||||
}
|
||||
|
||||
@at-root #{&}--header {
|
||||
&:hover {
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: 400;
|
||||
padding-bottom: 0.25rem;
|
||||
border-bottom: thin solid $color__lightgray--darkest;
|
||||
}
|
||||
}
|
||||
|
||||
@at-root #{&}__section {
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
@at-root #{&}__user {
|
||||
display: inline-block;
|
||||
}
|
||||
@at-root #{&}__date {
|
||||
color: $color__text--secondary;
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint--tablet) {
|
||||
.thread_sorting {
|
||||
flex-direction: column-reverse;
|
||||
align-items: left;
|
||||
|
||||
@at-root #{&}__add_and_categories {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
.select_button {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.threads_main__side_bar {
|
||||
display: none;
|
||||
}
|
||||
.threads_main__threads {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint--tablet) and (min-width: $breakpoint--phone) {
|
||||
.route_container {
|
||||
padding: 1rem 2rem;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,83 @@
|
|||
<template>
|
||||
<div class='route_container not_found'>
|
||||
<h1 class='not_found__title'>404 - page not found</h1>
|
||||
|
||||
<div class='not_found__box'>
|
||||
<p class='not_found__box--large_text'>
|
||||
The page you're looking for <strong>{{$route.fullPath}}</strong> doesn't exist.
|
||||
</p>
|
||||
<p>
|
||||
<router-link to='/' class='button'>Click to go home</router-link> <span>or</span>
|
||||
<search-box
|
||||
class='not_found__search_box'
|
||||
placeholder='Try a search'
|
||||
></search-box>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import SearchBox from '../SearchBox'
|
||||
|
||||
export default {
|
||||
name: 'NotFound',
|
||||
components: { SearchBox }
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../../assets/scss/variables.scss';
|
||||
@import '../../assets/scss/elementStyles.scss';
|
||||
|
||||
.not_found {
|
||||
@at-root #{&}__title {
|
||||
font-family: $font--role-emphasis;
|
||||
font-size: 4rem;
|
||||
margin-top: 4rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@at-root #{&}__box {
|
||||
background-color: #fff;
|
||||
border-radius: 0.25rem;
|
||||
padding: 2rem;
|
||||
padding-top: 1rem;
|
||||
|
||||
.button {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
@at-root #{&}--large_text {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@at-root #{&}__search_box {
|
||||
display: inline-flex;
|
||||
margin-left: 0.5rem
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 525px) {
|
||||
.not_found {
|
||||
@at-root #{&}__title {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
@at-root #{&}__box {
|
||||
word-wrap: break-word;
|
||||
|
||||
p:nth-child(2) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
span {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,29 @@
|
|||
<template>
|
||||
<div class='route_container'>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import AjaxErrorHandler from '../../assets/js/errorHandler'
|
||||
|
||||
export default {
|
||||
name: 'P',
|
||||
methods: {
|
||||
redirect () {
|
||||
let id = this.$route.params.id
|
||||
|
||||
this.axios
|
||||
.get('/api/v1/post/' + id)
|
||||
.then((res) => {
|
||||
this.$router.push(
|
||||
`/thread/${res.data.Thread.slug}/${res.data.Thread.id}/${res.data.postNumber}`
|
||||
)
|
||||
})
|
||||
.catch(AjaxErrorHandler(this.$store))
|
||||
}
|
||||
},
|
||||
beforeRouteEnter (to, from, next) {
|
||||
next(vm => vm.redirect())
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,183 @@
|
|||
<template>
|
||||
<div class='route_container'>
|
||||
<h1>Search results for '{{$route.params.q}}'</h1>
|
||||
<transition name='fade' mode='out-in'>
|
||||
<div class='search__results' key='results' v-if='threads && threads.length && !loadingThreads'>
|
||||
<h2>Threads</h2>
|
||||
<thread-display v-for='thread in threads.slice(0, 3)' :key='"search-thread-" + thread.id' :thread='thread'></thread-display>
|
||||
|
||||
<div
|
||||
class='search__more search__item' v-if='threads.length > ($store.state.MinQueryLength-1)'
|
||||
@click='$router.push("/search/threads/" + $route.params.q)'
|
||||
>
|
||||
<font-awesome-icon :icon='["fa", "comments"]' fixed-width />
|
||||
View all matching threads
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
key='loading'
|
||||
v-if='loadingThreads'
|
||||
>
|
||||
<h2>Threads</h2>
|
||||
<thread-display-placeholder></thread-display-placeholder>
|
||||
</div>
|
||||
</transition>
|
||||
<transition name='fade' mode='out-in'>
|
||||
<div class='search__results' key='results' v-if='users && users.length && !loadingUsers'>
|
||||
<h2>Users</h2>
|
||||
<user-display v-for='user in users.slice(0, 5)' :key='"search-user-" + user.id' :user='user'></user-display>
|
||||
|
||||
<div
|
||||
class='search__item search__more' v-if='users.length > 5'
|
||||
@click='$router.push("/search/users/" + $route.params.q)'
|
||||
>
|
||||
<font-awesome-icon :icon='["fa", "user"]' fixed-width />
|
||||
View all matching users
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
key='loading'
|
||||
v-if='loadingUsers'
|
||||
>
|
||||
<h2>Users</h2>
|
||||
<user-placeholder></user-placeholder>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class='overlay_message search__overlay_message'
|
||||
v-if='showNoResults || queryTooShort'
|
||||
key='no results'
|
||||
>
|
||||
<font-awesome-icon :icon='["fa", "exclamation-circle"]' />
|
||||
{{queryTooShort ?
|
||||
"Search term is too short" :
|
||||
"No results found"
|
||||
}}
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import UserDisplay from '../UserDisplay'
|
||||
import UserPlaceholder from '../UserPlaceholder'
|
||||
import ThreadDisplay from '../ThreadDisplay'
|
||||
import ThreadDisplayPlaceholder from '../ThreadDisplayPlaceholder'
|
||||
|
||||
import AjaxErrorHandler from '../../assets/js/errorHandler'
|
||||
import logger from '../../assets/js/logger'
|
||||
|
||||
export default {
|
||||
name: 'Search',
|
||||
components: {
|
||||
UserDisplay,
|
||||
UserPlaceholder,
|
||||
ThreadDisplay,
|
||||
ThreadDisplayPlaceholder
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
threads: [],
|
||||
loadingThreads: false,
|
||||
|
||||
users: [],
|
||||
loadingUsers: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
showNoResults () {
|
||||
return (
|
||||
!this.loadingUsers && !this.loadingThreads &&
|
||||
!this.threads.length && !this.users.length
|
||||
);
|
||||
},
|
||||
queryTooShort () {
|
||||
return this.$route.params.q.length < this.$store.state.MinQueryLength
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getUsers () {
|
||||
this.loadingUsers = true;
|
||||
|
||||
this.axios
|
||||
.get('/api/v1/search/user?q=' + this.$route.params.q)
|
||||
.then(res => {
|
||||
this.loadingUsers = false;
|
||||
this.users = res.data.users;
|
||||
})
|
||||
.catch(AjaxErrorHandler(this.$store))
|
||||
},
|
||||
getThreads () {
|
||||
this.loadingThreads = true;
|
||||
|
||||
this.axios
|
||||
.get('/api/v1/search/thread?q=' + this.$route.params.q)
|
||||
.then(res => {
|
||||
this.loadingThreads = false;
|
||||
this.threads = res.data.threads;
|
||||
})
|
||||
.catch(AjaxErrorHandler(this.$store))
|
||||
},
|
||||
getResults () {
|
||||
if(this.queryTooShort) return;
|
||||
|
||||
this.$store.dispatch('setTitle', 'Search | ' + this.$route.params.q)
|
||||
|
||||
this.getThreads();
|
||||
this.getUsers();
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'$route.params': 'getResults'
|
||||
},
|
||||
mounted () {
|
||||
this.$store.dispatch('setTitle', 'Search | ' + this.$route.params.q)
|
||||
this.getResults()
|
||||
|
||||
logger('search')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../../assets/scss/variables.scss';
|
||||
|
||||
.search {
|
||||
@at-root #{&}__item {
|
||||
background-color: #fff;
|
||||
margin-bottom: 1rem;
|
||||
border: thin solid $color__gray--darker;
|
||||
transition: box-shadow 0.2s;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
@extend .shadow_border--hover;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@at-root #{&}__more {
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem;
|
||||
|
||||
span {
|
||||
color: $color__darkgray--darker;
|
||||
font-weight: 300;
|
||||
}
|
||||
}
|
||||
|
||||
@at-root #{&}__overlay_message {
|
||||
margin-top: 5rem;
|
||||
|
||||
@at-root #{&}--loading span {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,177 @@
|
|||
<template>
|
||||
<div class='route_container'>
|
||||
<h1>Results for {{searchType}} containing '{{$route.params.q}}'</h1>
|
||||
|
||||
<transition name='fade' mode='out-in'>
|
||||
<div class='search__results' key='results' v-if='results && results.length'>
|
||||
<scroll-load
|
||||
:loading='loading'
|
||||
@loadNext='loadNextPage'
|
||||
>
|
||||
|
||||
<template v-if='searchType === "users"'>
|
||||
<user-display
|
||||
v-for='result in results'
|
||||
:key='"search-user-result-" + result.id'
|
||||
:user='result'
|
||||
>
|
||||
</user-display>
|
||||
|
||||
<template v-if='loading'>
|
||||
<user-placeholder
|
||||
v-for='n in next'
|
||||
:key='"user-placeholder-" + n'
|
||||
></user-placeholder>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-if='searchType === "threads"'>
|
||||
<thread-display
|
||||
v-for='result in results'
|
||||
:key='"search-thread-result-" + result.id'
|
||||
:thread='result'
|
||||
>
|
||||
</thread-display>
|
||||
|
||||
<template v-if='loading'>
|
||||
<thread-display-placeholder
|
||||
v-for='n in next'
|
||||
:key='"thread-placeholder-" + n'
|
||||
>
|
||||
</thread-display-placeholder>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
</scroll-load>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class='overlay_message search__overlay_message'
|
||||
v-if='showNoResults || queryTooShort'
|
||||
key='no results'
|
||||
>
|
||||
<font-awesome-icon :icon='["fa", "exclamation-circle"]' />
|
||||
{{queryTooShort ?
|
||||
"Search term is too short" :
|
||||
"No results found"
|
||||
}}
|
||||
</div>
|
||||
|
||||
<div key='loading' v-else>
|
||||
<user-placeholder v-if='searchType === "users"'>
|
||||
</user-placeholder>
|
||||
|
||||
<thread-display-placeholder v-if='searchType === "threads"'>
|
||||
</thread-display-placeholder>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ScrollLoad from '../ScrollLoad'
|
||||
import UserDisplay from '../UserDisplay'
|
||||
import UserPlaceholder from '../UserPlaceholder'
|
||||
import ThreadDisplay from '../ThreadDisplay'
|
||||
import ThreadDisplayPlaceholder from '../ThreadDisplayPlaceholder'
|
||||
|
||||
import AjaxErrorHandler from '../../assets/js/errorHandler'
|
||||
import logger from '../../assets/js/logger'
|
||||
|
||||
export default {
|
||||
name: 'Search',
|
||||
components: {
|
||||
ScrollLoad,
|
||||
UserDisplay,
|
||||
UserPlaceholder,
|
||||
ThreadDisplay,
|
||||
ThreadDisplayPlaceholder
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
results: null,
|
||||
next: 0,
|
||||
offset: 0,
|
||||
|
||||
loading: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
searchType () {
|
||||
let name = this.$route.name;
|
||||
|
||||
if(name === 'search/users') {
|
||||
return 'users';
|
||||
} else {
|
||||
return 'threads';
|
||||
}
|
||||
},
|
||||
showNoResults () {
|
||||
return (
|
||||
!this.loading && this.results && !this.results.length
|
||||
);
|
||||
},
|
||||
queryTooShort () {
|
||||
return this.$route.params.q.length < this.$store.state.MinQueryLength
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getResults () {
|
||||
if (this.queryTooShort) return;
|
||||
|
||||
this.$store.dispatch('setTitle', 'Search | ' + this.$route.params.q)
|
||||
|
||||
this.axios
|
||||
.get(`/api/v1/search/${this.searchType.slice(0, -1)}?q=${this.$route.params.q}`)
|
||||
.then(res => {
|
||||
this.results = res.data[this.searchType]
|
||||
this.next = res.data.next
|
||||
this.offset = res.data.offset
|
||||
})
|
||||
.catch(AjaxErrorHandler(this.$store))
|
||||
},
|
||||
loadNextPage () {
|
||||
if(!this.next) return
|
||||
|
||||
this.loading = true
|
||||
|
||||
this.axios
|
||||
.get(
|
||||
`/api/v1/search/${this.searchType.slice(0, -1)}?q=${this.$route.params.q}&offset=${this.offset}`
|
||||
)
|
||||
.then(res => {
|
||||
this.results.push(...res.data[this.searchType])
|
||||
this.next = res.data.next
|
||||
this.offset = res.data.offset
|
||||
|
||||
this.loading = false
|
||||
})
|
||||
.catch(e => {
|
||||
this.loading = false
|
||||
AjaxErrorHandler(this.$store)(e)
|
||||
})
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'$route.params': 'getResults'
|
||||
},
|
||||
mounted () {
|
||||
this.$store.dispatch('setTitle', 'Search | ' + this.$route.params.q)
|
||||
this.getResults()
|
||||
|
||||
logger('search')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
.search {
|
||||
@at-root #{&}__overlay_message {
|
||||
margin-top: 5rem;
|
||||
|
||||
@at-root #{&}--loading span {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,204 @@
|
|||
<template>
|
||||
<div class='route_container route_container--settings'>
|
||||
<div class='settings_menu'>
|
||||
<div class='settings_menu__title'>settings</div>
|
||||
<div class='settings_menu__items'>
|
||||
<div
|
||||
class='settings_menu__item'
|
||||
:key='"menu-item-" + index'
|
||||
v-for='(item, index) in menuItems'
|
||||
:class="{'settings_menu__item--selected': index === selected}"
|
||||
@click='$router.push("/settings/" + item.route)'
|
||||
>
|
||||
<font-awesome-icon :icon='["fa", item.icon]' />
|
||||
{{item.name}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='settings_page'>
|
||||
<router-view></router-view>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'settings',
|
||||
data () {
|
||||
return {
|
||||
menuItems: [
|
||||
{ name: 'General', route: 'general', icon: 'cog' },
|
||||
{ name: 'Account', route: 'account', icon: 'lock'},
|
||||
],
|
||||
selected: 0
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
$route (to) {
|
||||
this.selected = this.getIndexFromRoute(to.path)
|
||||
},
|
||||
'$store.state.username' (username) {
|
||||
if(!username) {
|
||||
this.$router.push('/')
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.selected = this.getIndexFromRoute(this.$route.path)
|
||||
},
|
||||
methods: {
|
||||
getIndexFromRoute (path) {
|
||||
let selectedIndex
|
||||
let route = path.split('/')[2]
|
||||
|
||||
this.menuItems.forEach((item, index) => {
|
||||
if(item.route === route) {
|
||||
selectedIndex = index
|
||||
}
|
||||
})
|
||||
|
||||
return selectedIndex
|
||||
}
|
||||
},
|
||||
beforeRouteEnter (to, from, next) {
|
||||
next(vm => {
|
||||
if(!vm.$store.state.username) {
|
||||
vm.$store.commit('setAccountModalState', true);
|
||||
next('/')
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../../assets/scss/variables.scss';
|
||||
|
||||
.route_container--settings {
|
||||
display: flex;
|
||||
align-items: flex-start
|
||||
}
|
||||
|
||||
.settings_menu {
|
||||
width: 15rem;
|
||||
border: thin solid $color__gray--darker;
|
||||
background-color: #fff;
|
||||
padding: 1rem;
|
||||
border-radius: 0.25rem;
|
||||
|
||||
@at-root #{&}__title {
|
||||
cursor: default;
|
||||
font-weight: 500;
|
||||
font-variant: small-caps;
|
||||
font-size: 1.125rem;
|
||||
padding-left: 0.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
@at-root #{&}__item {
|
||||
padding: 0.5rem 1rem;
|
||||
margin-bottom: 0.25rem;
|
||||
padding-right: 0;
|
||||
transition: background-color 0.2s;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
border-radius: 0.25rem;
|
||||
|
||||
&:first-child { margin-top: 0.5rem; }
|
||||
&:last-child { margin-bottom: 0.5rem; }
|
||||
|
||||
&:hover { background-color: $color__lightgray--primary; }
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 0.25rem;
|
||||
z-index: 1;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
border-radius: 0.25rem 0 0 0.25em;
|
||||
top: 0;
|
||||
background-color: $color__gray--darkest;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
span {
|
||||
color: $color__text--secondary;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
@at-root #{&}--selected {
|
||||
background-color: $color__lightgray--darker;
|
||||
color: $color__text--primary;
|
||||
|
||||
span {
|
||||
color: $color__text--primary;
|
||||
}
|
||||
|
||||
&:hover { background-color: $color__lightgray--darker; }
|
||||
|
||||
&::before {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.settings_page {
|
||||
width: calc(100% - 15rem);
|
||||
background-color: #fff;
|
||||
border-radius: 0.25rem;
|
||||
margin-left: 2rem;
|
||||
border: thin solid $color__gray--darker;
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint--tablet) and (min-width: $breakpoint--phone) {
|
||||
div.settings_menu, div.settings_page {
|
||||
width: calc(100% - 4rem);
|
||||
margin: 0.5rem 2rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint--tablet) {
|
||||
.route_container--settings {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.settings_menu {
|
||||
width: 100%;
|
||||
|
||||
@at-root #{&}__items {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
@at-root #{&}__item {
|
||||
width: 7rem;
|
||||
margin-right: 0.5rem;
|
||||
color: $color__text--primary;
|
||||
|
||||
&:first-child, &:last-child {
|
||||
margin-bottom: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
&::before {
|
||||
height: 0.2rem;
|
||||
width: 100%;
|
||||
left: 0;
|
||||
border-radius: 0 0 1rem 1rem;
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.settings_page {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,162 @@
|
|||
<template>
|
||||
<div class='route_container'>
|
||||
<confirm-modal
|
||||
v-model='showConfirmModal'
|
||||
@confirm='deleteAccount'
|
||||
color='red'
|
||||
text='Yes, delete it'
|
||||
>
|
||||
Are you sure you want to delete your account?
|
||||
</confirm-modal>
|
||||
|
||||
<div class='h1'>Account settings</div>
|
||||
<div>
|
||||
<div class='h3'>Change your password</div>
|
||||
<p class='p--condensed'>
|
||||
For security, enter your current password
|
||||
</p>
|
||||
<div>
|
||||
<fancy-input
|
||||
placeholder='Current password'
|
||||
v-model='password.current'
|
||||
:error='password.errors["current password"]'
|
||||
type='password'
|
||||
></fancy-input>
|
||||
</div>
|
||||
<div>
|
||||
<fancy-input
|
||||
placeholder='New password'
|
||||
v-model='password.new'
|
||||
:error='password.errors["new password"]'
|
||||
type='password'
|
||||
></fancy-input>
|
||||
</div>
|
||||
<loading-button
|
||||
class='button button--green'
|
||||
@click='savePassword'
|
||||
:loading='password.loading'
|
||||
>Change password</loading-button>
|
||||
</div>
|
||||
<div>
|
||||
<div class='h3 h3--margin_top'>Delete your account</div>
|
||||
<p class='p--condensed'>
|
||||
Once this is done, your account <strong>cannot</strong> be restored <br/>
|
||||
Your current posts however will be retained
|
||||
</p>
|
||||
<loading-button
|
||||
class='button button--red'
|
||||
:loading='deleteAccountLoading'
|
||||
@click='showConfirmModal = true'
|
||||
>Delete my account</loading-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FancyInput from '../FancyInput'
|
||||
import LoadingButton from '../LoadingButton'
|
||||
import ConfirmModal from '../ConfirmModal'
|
||||
|
||||
import AjaxErrorHandler from '../../assets/js/errorHandler'
|
||||
import logger from '../../assets/js/logger'
|
||||
|
||||
export default {
|
||||
name: 'settingsAccount',
|
||||
components: {
|
||||
FancyInput,
|
||||
LoadingButton,
|
||||
ConfirmModal
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
password: {
|
||||
loading: false,
|
||||
|
||||
current: '',
|
||||
new: '',
|
||||
|
||||
errors: {
|
||||
'new password': '',
|
||||
'current password': ''
|
||||
}
|
||||
},
|
||||
|
||||
deleteAccountLoading: false,
|
||||
showConfirmModal: false
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
methods: {
|
||||
savePassword () {
|
||||
this.password.errors['current password'] = ''
|
||||
this.password.errors['new password'] = ''
|
||||
|
||||
if(!this.password.current.length) {
|
||||
this.password.errors['current password'] = 'Cannot be blank'
|
||||
return
|
||||
}
|
||||
if(!this.password.new.length) {
|
||||
this.password.errors['new password'] = 'Cannot be blank'
|
||||
return
|
||||
}
|
||||
|
||||
this.password.loading = true
|
||||
|
||||
this.axios
|
||||
.put('/api/v1/user/' + this.$store.state.username, {
|
||||
currentPassword: this.password.current,
|
||||
newPassword: this.password.new
|
||||
})
|
||||
.then(() => {
|
||||
this.password.loading = false
|
||||
|
||||
this.password.current = ''
|
||||
this.password.new = ''
|
||||
})
|
||||
.catch(e => {
|
||||
this.password.loading = false
|
||||
|
||||
console.log(e)
|
||||
|
||||
AjaxErrorHandler(this.$store)(e, error => {
|
||||
if(error.path === 'hash') {
|
||||
this.password.errors['new password'] = error.message
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
deleteAccount () {
|
||||
this.deleteAccountLoading = true
|
||||
|
||||
this.axios
|
||||
.delete('/api/v1/user/' + this.$store.state.username)
|
||||
.then(() => {
|
||||
this.deleteAccountLoading = false
|
||||
|
||||
this.$store.commit('setUsername', null)
|
||||
this.$router.push('/')
|
||||
})
|
||||
.catch(e => {
|
||||
this.deleteAccountLoading = false
|
||||
AjaxErrorHandler(this.$store)(e)
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.$store.dispatch('setTitle', 'account settings')
|
||||
|
||||
logger('settingsAccount')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../../assets/scss/variables.scss';
|
||||
|
||||
@media (max-width: $breakpoint--tablet) {
|
||||
.h1 {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,304 @@
|
|||
<template>
|
||||
<div class='route_container'>
|
||||
<confirm-modal
|
||||
v-model='picture.showRemoveProfilePictureModal'
|
||||
@confirm='removeProfilePicture'
|
||||
color='red'
|
||||
text='Yes, remove it'
|
||||
>
|
||||
Are you sure you want to remove your profile picture?
|
||||
</confirm-modal>
|
||||
|
||||
<modal-window
|
||||
v-model='picture.showProfilePictureModal'
|
||||
:loading='picture.loading'
|
||||
width='25rem'
|
||||
@input='hideProflePictureModal'
|
||||
>
|
||||
<div
|
||||
slot='main'
|
||||
class='profile_picture_modal'
|
||||
:class='{ "profile_picture_modal--picture.dragging": picture.dragging }'
|
||||
@dragover='handleDragOver'
|
||||
@drkagend='picture.dragging = false'
|
||||
@drkgleave='picture.dragging = false'
|
||||
@drop='handleFileDrop'
|
||||
>
|
||||
<div class='h3'>Add a profile picture</div>
|
||||
<p class='p--condensed'>
|
||||
Drag and drop an image or
|
||||
<label class='button profile_picture_modal__upload_button'>
|
||||
<input type='file' accept='image/*' @change='processImage($event.target.files[0])'>
|
||||
upload a file
|
||||
</label>
|
||||
</p>
|
||||
<div class='profile_picture_modal__drag_area'>
|
||||
<span
|
||||
v-if='!picture.dataURL'
|
||||
class='profile_picture_modal__drag_area__icon'
|
||||
:class='{ "profile_picture_modal__drag_area__icon--picture.dragging": picture.dragging }'
|
||||
>
|
||||
<font-awesome-icon :icon='["fa", "cloud-upload-alt"]' />
|
||||
</span>
|
||||
<div
|
||||
class='profile_picture_modal__drag_area__image picture_circle'
|
||||
:style='{ "background-image": "url(" + picture.dataURL + ")" }'
|
||||
v-else
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='profile_picture_modal__buttons' slot='footer'>
|
||||
<button
|
||||
class='button button--modal button--green'
|
||||
:class='{ "button--disabled": !picture.dataURL }'
|
||||
@click='uploadProfilePicture'
|
||||
>
|
||||
Upload picture
|
||||
</button>
|
||||
<button class='button button--modal' @click='hideProflePictureModal'>Cancel</button>
|
||||
</div>
|
||||
</modal-window>
|
||||
|
||||
<div class='h1'>General settings</div>
|
||||
<div>
|
||||
<div class='h3'>About me</div>
|
||||
<p class='p--condensed'>
|
||||
Write something about yourself to be displayed on your user page
|
||||
</p>
|
||||
<fancy-textarea
|
||||
placeholder='About me description'
|
||||
v-model='description.value'
|
||||
:error='description.error'
|
||||
type='password'
|
||||
></fancy-textarea>
|
||||
<loading-button
|
||||
class='button button--green'
|
||||
:loading='description.loading'
|
||||
@click='saveDescription'
|
||||
>
|
||||
Save description
|
||||
</loading-button>
|
||||
</div>
|
||||
<div>
|
||||
<div class='h3'>Profile picture</div>
|
||||
<p class='p--condensed'>
|
||||
This will be displayed by your posts on the site
|
||||
</p>
|
||||
<p
|
||||
class='p--condensed profile_picture_preview picture_circle'
|
||||
:style='{ "background-image": "url(" + picture.current + ")" }'
|
||||
v-if='picture.current'
|
||||
></p>
|
||||
<button class='button' @click='picture.showProfilePictureModal = true'>
|
||||
{{picture.current ? "Change" : "Add" }} profile picture
|
||||
</button>
|
||||
<button
|
||||
v-if='picture.current'
|
||||
class='button'
|
||||
style='margin-left: 0.5rem;'
|
||||
@click='picture.showRemoveProfilePictureModal = true'
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FancyTextarea from '../FancyTextarea'
|
||||
import LoadingButton from '../LoadingButton'
|
||||
import ModalWindow from '../ModalWindow'
|
||||
import ConfirmModal from '../ConfirmModal'
|
||||
|
||||
import AjaxErrorHandler from '../../assets/js/errorHandler'
|
||||
import logger from '../../assets/js/logger'
|
||||
|
||||
export default {
|
||||
name: 'settingsGeneral',
|
||||
components: {
|
||||
FancyTextarea,
|
||||
LoadingButton,
|
||||
ModalWindow,
|
||||
ConfirmModal
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
description: {
|
||||
value: '',
|
||||
loading: false,
|
||||
error: ''
|
||||
},
|
||||
|
||||
picture: {
|
||||
current: null,
|
||||
showProfilePictureModal: false,
|
||||
showRemoveProfilePictureModal: false,
|
||||
dragging: false,
|
||||
dataURL: null,
|
||||
file: null,
|
||||
loading: false
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
methods: {
|
||||
saveDescription () {
|
||||
this.description.error = ''
|
||||
this.description.loading = true
|
||||
|
||||
this.axios
|
||||
.put('/api/v1/user/' + this.$store.state.username, {
|
||||
description: this.description.value
|
||||
})
|
||||
.then(() => {
|
||||
this.description.loading = false
|
||||
})
|
||||
.catch(e => {
|
||||
this.description.loading = false
|
||||
|
||||
AjaxErrorHandler(this.$store)(e, error => {
|
||||
this.description.error = error.message
|
||||
})
|
||||
})
|
||||
},
|
||||
uploadProfilePicture () {
|
||||
this.picture.loading = true
|
||||
|
||||
let formData = new FormData()
|
||||
formData.append('picture', this.picture.file)
|
||||
|
||||
this.axios
|
||||
.post('/api/v1/user/' + this.$store.state.username + '/picture', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
.then(res => {
|
||||
this.hideProflePictureModal()
|
||||
this.picture.current = res.data.picture
|
||||
})
|
||||
.catch(e => {
|
||||
this.picture.loading = false
|
||||
|
||||
AjaxErrorHandler(this.$store)(e)
|
||||
})
|
||||
|
||||
},
|
||||
removeProfilePicture () {
|
||||
this.axios
|
||||
.delete('/api/v1/user/' + this.$store.state.username + '/picture')
|
||||
.then(() => {
|
||||
this.picture.current = null
|
||||
})
|
||||
.catch(AjaxErrorHandler(this.$store))
|
||||
},
|
||||
hideProflePictureModal () {
|
||||
this.picture.showProfilePictureModal = false
|
||||
|
||||
//Wait for transition to complete
|
||||
setTimeout(() => {
|
||||
this.picture.dataURL = null
|
||||
this.picture.loading = false
|
||||
}, 200)
|
||||
},
|
||||
handleDragOver (e) {
|
||||
e.preventDefault()
|
||||
this.picture.dragging = true
|
||||
},
|
||||
handleFileDrop (e) {
|
||||
e.preventDefault()
|
||||
this.picture.dragging = false
|
||||
|
||||
if(e.dataTransfer && e.dataTransfer.items) {
|
||||
let file = e.dataTransfer.items[0]
|
||||
|
||||
if(file.type.match('^image/')) {
|
||||
this.processImage(file.getAsFile())
|
||||
}
|
||||
}
|
||||
},
|
||||
processImage (file) {
|
||||
let reader = new FileReader()
|
||||
reader.readAsDataURL(file)
|
||||
|
||||
this.picture.file = file
|
||||
|
||||
reader.addEventListener('load', () => {
|
||||
this.picture.dataURL = reader.result
|
||||
})
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.$store.dispatch('setTitle', 'general settings')
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.axios
|
||||
.get('/api/v1/user/' + this.$store.state.username)
|
||||
.then(res => {
|
||||
this.description.value = res.data.description || ''
|
||||
this.picture.current = res.data.picture
|
||||
})
|
||||
.catch(e => {
|
||||
AjaxErrorHandler(this.$store)(e)
|
||||
})
|
||||
})
|
||||
|
||||
logger('settingsGeneral')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../../assets/scss/variables.scss';
|
||||
|
||||
.profile_picture_preview {
|
||||
height: 5rem;
|
||||
width: 5rem;
|
||||
}
|
||||
|
||||
.profile_picture_modal {
|
||||
padding-top: 1rem;
|
||||
transition: all 0.2s;
|
||||
|
||||
@at-root #{&}--picture .dragging {
|
||||
background-color: $color__lightgray--primary;
|
||||
}
|
||||
|
||||
@at-root #{&}__overlay {
|
||||
@include loading-overlay(rgba(0, 0, 0, 0.5), 0.125rem);
|
||||
}
|
||||
|
||||
@at-root #{&}__upload_button input[type="file"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@at-root #{&}__drag_area {
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
|
||||
@at-root #{&}__image {
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
display: inline-block;
|
||||
margin-top: -1rem;
|
||||
}
|
||||
|
||||
@at-root #{&}__icon {
|
||||
font-size: 6rem;
|
||||
color: $color__gray--darker;
|
||||
transition: all 0.2s;
|
||||
|
||||
@at-root #{&}--picture.dragging {
|
||||
transform: translateY(-0.5rem) scale(1.1);
|
||||
color: $color__gray--darkest;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint--tablet) {
|
||||
.h1 {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,294 @@
|
|||
<template>
|
||||
<div class='route_container route_container--fullscreen'>
|
||||
<div class='panel' v-show='panel === 1'>
|
||||
<div class='h1'>Hi.</div>
|
||||
<p class='explanation'>
|
||||
First create your admin account for the forum.
|
||||
</p>
|
||||
<form @submit.prevent='createAccount'>
|
||||
<fancy-input
|
||||
v-model='username'
|
||||
:error='errors.username'
|
||||
width='100%'
|
||||
placeholder='Username'
|
||||
></fancy-input>
|
||||
<fancy-input
|
||||
v-model='password'
|
||||
:error='errors.hash'
|
||||
width='100%'
|
||||
placeholder='Password'
|
||||
type='password'
|
||||
></fancy-input>
|
||||
<fancy-input
|
||||
v-model='confirmPassword'
|
||||
:error='errors.confirmPassword'
|
||||
width='100%'
|
||||
placeholder='Confirm password'
|
||||
type='password'
|
||||
></fancy-input>
|
||||
<loading-button
|
||||
style='width: 100%;'
|
||||
class='button button--green'
|
||||
:loading='loading'
|
||||
>
|
||||
Create account
|
||||
</loading-button>
|
||||
</form>
|
||||
</div>
|
||||
<div class='panel' v-show='panel === 2'>
|
||||
<div class='h1'>A few settings</div>
|
||||
<p class='explanation'>
|
||||
You can change these later on the admin page
|
||||
</p>
|
||||
<form @submit.prevent='addSettings'>
|
||||
<fancy-input
|
||||
v-model='forumName'
|
||||
:error='errors.forumName'
|
||||
width='100%'
|
||||
placeholder='Forum name'
|
||||
></fancy-input>
|
||||
<p class='p--small'>What is your forum about?</p>
|
||||
<fancy-textarea
|
||||
v-model='forumDescription'
|
||||
:error='errors.forumDescription'
|
||||
width='100%'
|
||||
placeholder='Forum description'
|
||||
></fancy-textarea>
|
||||
<loading-button
|
||||
style='width: 100%;'
|
||||
class='button button--green'
|
||||
:loading='loading'
|
||||
>
|
||||
Add settings
|
||||
</loading-button>
|
||||
</form>
|
||||
</div>
|
||||
<div class='panel' v-show='panel === 3'>
|
||||
<div class='h1'>Categories</div>
|
||||
<p class='explanation'>
|
||||
People post threads in categories so that they're easier to sort through<br/>
|
||||
You can add or remove them later on the admin page
|
||||
</p>
|
||||
<form @submit.prevent='addCategory'>
|
||||
<p v-if='categories.length'>
|
||||
<b>Categories:</b>
|
||||
{{categories.join(', ')}}
|
||||
</p>
|
||||
<p v-else>No categories added</p>
|
||||
<div class='categories_form'>
|
||||
<fancy-input
|
||||
v-model='category'
|
||||
:error='errors.name'
|
||||
:large='true'
|
||||
:error-bottom='true'
|
||||
width='calc(100% - 9rem)'
|
||||
placeholder='Category name'
|
||||
></fancy-input>
|
||||
<loading-button
|
||||
class='button'
|
||||
:loading='loading'
|
||||
>
|
||||
Add category
|
||||
</loading-button>
|
||||
</div>
|
||||
</form>
|
||||
<button style='width: 100%;' class='button button--green' @click='finish'>Finish</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FancyInput from '../FancyInput'
|
||||
import FancyTextarea from '../FancyTextarea'
|
||||
import LoadingButton from '../LoadingButton'
|
||||
|
||||
import AjaxErrorHandler from '../../assets/js/errorHandler'
|
||||
|
||||
export default {
|
||||
name: 'start',
|
||||
|
||||
data () {
|
||||
return {
|
||||
username: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
forumName: '',
|
||||
forumDescription: '',
|
||||
|
||||
loading: false,
|
||||
|
||||
category: '',
|
||||
categories: [],
|
||||
|
||||
panel: 1,
|
||||
|
||||
errors: {
|
||||
username: '',
|
||||
hash: '',
|
||||
confirmPassword: '',
|
||||
forumName: '',
|
||||
forumDescription: '',
|
||||
name: ''
|
||||
},
|
||||
|
||||
modal: {
|
||||
show: false,
|
||||
errors: []
|
||||
}
|
||||
}
|
||||
},
|
||||
components: {
|
||||
FancyInput,
|
||||
FancyTextarea,
|
||||
LoadingButton
|
||||
},
|
||||
computed: {},
|
||||
methods: {
|
||||
clearErrors () {
|
||||
this.errors.username = ''
|
||||
this.errors.hash = ''
|
||||
this.errors.confirmPassword = ''
|
||||
this.errors.forumName = ''
|
||||
this.errors.forumDescription = ''
|
||||
this.errors.name = ''
|
||||
},
|
||||
errorCallback (err) {
|
||||
this.loading = false
|
||||
|
||||
AjaxErrorHandler(this.$store)(err, (error, modalErrors) => {
|
||||
if(this.errors[error.path] !== undefined) {
|
||||
this.errors[error.path] = error.message
|
||||
} else {
|
||||
modalErrors.push(error.message)
|
||||
}
|
||||
})
|
||||
},
|
||||
createAccount () {
|
||||
this.clearErrors()
|
||||
|
||||
if(this.password !== this.confirmPassword) {
|
||||
this.errors.confirmPassword = 'passwords do not match'
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let req = this.axios.post('/api/v1/user', {
|
||||
username: this.username,
|
||||
password: this.password,
|
||||
admin: true
|
||||
})
|
||||
|
||||
this.loading = true
|
||||
|
||||
req.then(res => {
|
||||
this.$store.commit('setUsername', res.data.username)
|
||||
this.panel = 2
|
||||
this.loading = false
|
||||
}).catch(this.errorCallback)
|
||||
},
|
||||
addSettings () {
|
||||
this.clearErrors()
|
||||
|
||||
if(!this.forumName.trim().length) {
|
||||
this.errors.forumName = 'Forum name can\'t be blank'
|
||||
return
|
||||
}
|
||||
|
||||
this.loading = true
|
||||
|
||||
let settingsReq = this.axios.put('/api/v1/settings', {
|
||||
forumName: this.forumName,
|
||||
forumDescription: this.forumDescription
|
||||
})
|
||||
|
||||
settingsReq.then(res => {
|
||||
this.loading = false
|
||||
this.$store.commit('setSettings', res.data)
|
||||
this.panel = 3
|
||||
}).catch(this.errorCallback)
|
||||
},
|
||||
addCategory () {
|
||||
this.clearErrors()
|
||||
|
||||
if(!this.category.length) {
|
||||
this.errors.name = 'Category name can\'t be blank'
|
||||
return
|
||||
}
|
||||
|
||||
this.loading = true
|
||||
|
||||
this.axios.post('/api/v1/category', {
|
||||
name: this.category.trim()
|
||||
}).then(res => {
|
||||
this.loading = false
|
||||
this.$store.commit('addCategories', res.data)
|
||||
this.categories.push(res.data.name)
|
||||
}).catch(this.errorCallback)
|
||||
|
||||
this.category = ''
|
||||
},
|
||||
finish () {
|
||||
if(this.categories.length) this.$router.push('/')
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.$store.dispatch('setTitle', 'start')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../../assets/scss/variables.scss';
|
||||
|
||||
.route_container--fullscreen {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
margin: 0;
|
||||
z-index: 10;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
background-color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.explanation {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.p--small {
|
||||
margin: 0.5rem 0;
|
||||
width: 25rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
width: 30rem;
|
||||
}
|
||||
|
||||
.categories_form {
|
||||
margin-bottom: 1rem;
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
|
||||
.fancy_input {
|
||||
flex-grow: 6;
|
||||
margin: 0;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
div.button {
|
||||
height: 2rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 550px) {
|
||||
.panel {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,537 @@
|
|||
<template>
|
||||
<div class='route_container' :style='posts.length ? "padding-bottom: 8.5rem;" : null'>
|
||||
<confirm-modal v-model='showConfirmModal' @confirm='deleteThread' text='Delete' color='red'>
|
||||
Are you sure you want to delete this thread?
|
||||
<br>This <b>cannot</b> be undone
|
||||
</confirm-modal>
|
||||
|
||||
<thread-post-notification
|
||||
v-if='$store.state.thread.postNotification'
|
||||
:post='$store.state.thread.postNotification'
|
||||
@close='$store.commit("thread/setPostNotification", null)'
|
||||
@goToPost='goToPostNotification'
|
||||
></thread-post-notification>
|
||||
|
||||
<header class='thread_header'>
|
||||
<div class='thread_header__thread_title' ref='title'>
|
||||
{{thread}}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class='thread_side_bar'>
|
||||
<loading-button
|
||||
class='button--thin_text'
|
||||
:class='{ "button--disabled" : !$store.state.thread.selectedPosts.length }'
|
||||
:loading='false || $store.state.thread.removePostsButtonLoading'
|
||||
:dark='true'
|
||||
@click='removePosts'
|
||||
v-if='$store.state.thread.showRemovePostsButton'
|
||||
>
|
||||
Remove posts ({{$store.state.thread.selectedPosts.length}})
|
||||
</loading-button>
|
||||
<menu-button
|
||||
v-if='$store.state.admin'
|
||||
:options='[
|
||||
{ event: "lock_thread", value: $store.state.thread.locked ? "Unlock thread" : "Lock thread" },
|
||||
{ event: "delete_thread", value: "Delete thread" },
|
||||
{ event: "remove_posts", value: "Remove posts" }
|
||||
]'
|
||||
@lock_thread='setThreadLockedState'
|
||||
@remove_posts='setThreadSelectState'
|
||||
@delete_thread='showConfirmModal = true'
|
||||
>
|
||||
<button class='button button--thin_text'>
|
||||
<font-awesome-icon :icon='["fa", "cog"]' style='margin-right: 0.25rem;' />
|
||||
Manage thread
|
||||
</button>
|
||||
</menu-button>
|
||||
<button
|
||||
class='button button--thin_text'
|
||||
@click='replyThread'
|
||||
v-if='!$store.state.thread.locked'
|
||||
>
|
||||
{{replyThreadButton}}
|
||||
</button>
|
||||
<post-scrubber
|
||||
:posts='$store.state.thread.totalPostsCount'
|
||||
:value='$route.params.post_number || 0'
|
||||
@input='goToPost'
|
||||
></post-scrubber>
|
||||
</div>
|
||||
|
||||
<input-editor
|
||||
v-model='editor'
|
||||
|
||||
:show='editorState'
|
||||
:replyUsername='replyUsername'
|
||||
:loading='$store.state.thread.editor.loading'
|
||||
|
||||
v-on:mentions='setMentions'
|
||||
v-on:close='hideEditor'
|
||||
v-on:submit='addPost'
|
||||
>
|
||||
</input-editor>
|
||||
|
||||
<div class='locked_thread' v-if='$store.state.thread.locked'>
|
||||
<h2>Thread locked</h2>
|
||||
You can't post in this thread because it has been locked by an administrator
|
||||
</div>
|
||||
|
||||
<thread-poll
|
||||
v-if='$store.state.thread.PollQuestionId'
|
||||
:id='$store.state.thread.PollQuestionId'
|
||||
></thread-poll>
|
||||
|
||||
<div class='posts'>
|
||||
<scroll-load
|
||||
@loadNext='loadNextPosts'
|
||||
@loadPrevious='loadPreviousPosts'
|
||||
>
|
||||
<template v-if='!posts.length'>
|
||||
<thread-post-placeholder
|
||||
v-for='n in 3'
|
||||
:key='"thread-post-placeholder-loading-" + n'
|
||||
:class='{"post--last": n === 2}'
|
||||
></thread-post-placeholder>
|
||||
</template>
|
||||
|
||||
<template v-if='$store.state.thread.loadingPosts === "previous"'>
|
||||
<thread-post-placeholder
|
||||
v-for='n in $store.state.thread.previousPostsCount'
|
||||
:key='"thread-post-placeholder-upper-" + n'
|
||||
>
|
||||
</thread-post-placeholder>
|
||||
</template>
|
||||
<thread-post
|
||||
v-for='(post, index) in posts'
|
||||
:key='"thread-post-" + post.id'
|
||||
|
||||
@reply='replyUser'
|
||||
@goToPost='goToPost'
|
||||
@selected='setSelectedPosts'
|
||||
|
||||
:post='post'
|
||||
:show-reply='!$store.state.thread.locked'
|
||||
:showSelect='$store.state.thread.showRemovePostsButton'
|
||||
:highlight='highlightedPostIndex === index'
|
||||
:allowQuote='true'
|
||||
|
||||
:class='{"post--last": index === posts.length-1}'
|
||||
ref='posts'
|
||||
></thread-post>
|
||||
<template v-if='$store.state.thread.loadingPosts === "next"'>
|
||||
<thread-post-placeholder
|
||||
v-for='n in $store.state.thread.nextPostsCount'
|
||||
:key='"thread-post-placeholder-lower-" + n'
|
||||
>
|
||||
</thread-post-placeholder>
|
||||
</template>
|
||||
</scroll-load>
|
||||
</div>
|
||||
|
||||
<more-threads
|
||||
:category='$store.state.thread.category'
|
||||
:threadId='$store.state.thread.threadId'
|
||||
v-if='$store.state.thread.category'
|
||||
></more-threads>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import InputEditor from '../InputEditor'
|
||||
import ScrollLoad from '../ScrollLoad'
|
||||
import ThreadPost from '../ThreadPost'
|
||||
import ThreadPostNotification from '../ThreadPostNotification'
|
||||
import ThreadPostPlaceholder from '../ThreadPostPlaceholder'
|
||||
import PostScrubber from '../PostScrubber'
|
||||
import MenuButton from '../MenuButton'
|
||||
import LoadingButton from '../LoadingButton'
|
||||
import ThreadPoll from '../ThreadPoll'
|
||||
import ConfirmModal from '../ConfirmModal'
|
||||
import MoreThreads from '../MoreThreads'
|
||||
|
||||
import logger from '../../assets/js/logger'
|
||||
|
||||
import throttle from 'lodash.throttle'
|
||||
|
||||
export default {
|
||||
name: 'Thread',
|
||||
components: {
|
||||
InputEditor,
|
||||
ScrollLoad,
|
||||
ThreadPost,
|
||||
ThreadPostNotification,
|
||||
ThreadPostPlaceholder,
|
||||
PostScrubber,
|
||||
MenuButton,
|
||||
LoadingButton,
|
||||
ThreadPoll,
|
||||
ConfirmModal,
|
||||
MoreThreads
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
highlightedPostIndex: null,
|
||||
postNotification: null,
|
||||
showConfirmModal: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
thread () {
|
||||
return this.$store.state.thread.thread;
|
||||
},
|
||||
posts () {
|
||||
return this.$store.getters.sortedPosts;
|
||||
},
|
||||
replyUsername () {
|
||||
return this.$store.state.thread.reply.username
|
||||
},
|
||||
editor: {
|
||||
get () { return this.$store.state.thread.editor.value },
|
||||
set (val) {
|
||||
this.$store.commit('setThreadEditorValue', val)
|
||||
}
|
||||
},
|
||||
editorState () { return this.$store.state.thread.editor.show },
|
||||
replyThreadButton () {
|
||||
if(this.$store.state.username) {
|
||||
return 'Reply to thread';
|
||||
} else {
|
||||
return 'Login to reply'
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
deleteThread () {
|
||||
this.$store.dispatch("deleteThread", this)
|
||||
},
|
||||
removePosts () {
|
||||
this.$store.dispatch("removePostsAsync", this)
|
||||
},
|
||||
setThreadLockedState () {
|
||||
this.$store.dispatch('setThreadLockedState', this)
|
||||
},
|
||||
setThreadSelectState () {
|
||||
this.$store.commit('setShowRemovePostsButton', !this.$store.state.thread.showRemovePostsButton)
|
||||
},
|
||||
setSelectedPosts (postId) {
|
||||
this.$store.commit('setSelectedPosts', postId)
|
||||
},
|
||||
showEditor () {
|
||||
this.$store.commit('setThreadEditorState', true);
|
||||
},
|
||||
hideEditor () {
|
||||
this.$store.commit('setThreadEditorState', false);
|
||||
this.clearReply()
|
||||
},
|
||||
setMentions (mentions) {
|
||||
this.$store.commit('setMentions', mentions)
|
||||
},
|
||||
clearReply () {
|
||||
this.$store.commit({
|
||||
type: 'setReply',
|
||||
username: '',
|
||||
id: ''
|
||||
});
|
||||
},
|
||||
replyThread () {
|
||||
if(this.$store.state.username) {
|
||||
this.clearReply();
|
||||
this.showEditor();
|
||||
} else {
|
||||
this.$store.commit('setAccountTabs', 1);
|
||||
this.$store.commit('setAccountModalState', true);
|
||||
}
|
||||
},
|
||||
replyUser (id, username, quote) {
|
||||
this.$store.commit({
|
||||
type: 'setReply',
|
||||
username,
|
||||
id,
|
||||
quote
|
||||
});
|
||||
|
||||
this.showEditor();
|
||||
},
|
||||
addPost () {
|
||||
this.$store.dispatch('addPostAsync', this);
|
||||
},
|
||||
loadNextPosts () {
|
||||
let vue = this
|
||||
this.$store.dispatch('loadPostsAsync', { vue, previous: false });
|
||||
},
|
||||
loadPreviousPosts () {
|
||||
let vue = this
|
||||
this.$store.dispatch('loadPostsAsync', { vue, previous: true });
|
||||
},
|
||||
loadInitialPosts () {
|
||||
this.$store.dispatch('loadInitialPostsAsync', this)
|
||||
},
|
||||
goToPost (number, getPostNumber) {
|
||||
let pushRoute = postNumber => {
|
||||
//If postNumber is a post in `this.posts`
|
||||
if(this.posts.find(post => post.postNumber === postNumber)) {
|
||||
this.highlightPost(postNumber)
|
||||
} else {
|
||||
this.$router.replace({ name: 'thread-post', params: { post_number: postNumber } })
|
||||
this.loadInitialPosts()
|
||||
}
|
||||
}
|
||||
|
||||
//If `number` is actualy the postId
|
||||
//Get the postNumber via api request
|
||||
if(getPostNumber) {
|
||||
this.axios
|
||||
.get('/api/v1/post/' + number)
|
||||
.then( res => pushRoute(res.data.postNumber) )
|
||||
} else {
|
||||
pushRoute(number)
|
||||
}
|
||||
},
|
||||
scrollTo (postNumber, cb) {
|
||||
this.$nextTick(() => {
|
||||
let getScrollTopPosition = i => {
|
||||
let postTop = this.$refs.posts[i].$el.getBoundingClientRect().top
|
||||
let header = this.$refs.title.getBoundingClientRect().height
|
||||
|
||||
return window.pageYOffset + postTop - header - 32
|
||||
}
|
||||
|
||||
let scroll = (i) => {
|
||||
let post = this.posts[i]
|
||||
window.scrollTo(0, getScrollTopPosition(i))
|
||||
if(cb) cb(i, post)
|
||||
}
|
||||
|
||||
for(var i = 0; i < this.posts.length; i++) {
|
||||
if(this.posts[i].postNumber === postNumber) {
|
||||
if(this.$refs.posts) {
|
||||
scroll(i)
|
||||
} else {
|
||||
this.$nextTick(() => scroll(i))
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
highlightPost (postNumber) {
|
||||
this.scrollTo(postNumber, (i) => {
|
||||
this.highlightedPostIndex = i
|
||||
this.$router.replace({ name: 'thread-post', params: { post_number: postNumber } })
|
||||
|
||||
if(this.highlightedPostIndex === i) {
|
||||
setTimeout(() => this.highlightedPostIndex = null, 3000)
|
||||
}
|
||||
})
|
||||
},
|
||||
showPostNotification (post) {
|
||||
if(post.username === this.$store.state.username) return;
|
||||
|
||||
this.$store.commit('thread/setPostNotification', null)
|
||||
this.$store.commit('thread/setPostNotification', post)
|
||||
|
||||
setTimeout(() => {
|
||||
this.$store.commit('thread/setPostNotification', null)
|
||||
}, 5000)
|
||||
},
|
||||
goToPostNotification () {
|
||||
let post = this.$store.state.thread.postNotification
|
||||
|
||||
this.goToPost(post.postNumber)
|
||||
this.$store.commit('thread/setPostNotification', null)
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'$route.params.id': 'loadInitialPosts'
|
||||
},
|
||||
mounted () {
|
||||
let self = this;
|
||||
|
||||
let postInView = function() {
|
||||
if(!self.$refs.posts) return;
|
||||
let posts = self.$refs.posts.sort((a, b) => {
|
||||
return a.post.postNumber - b.post.postNumber;
|
||||
});
|
||||
|
||||
let topPostInView = posts.find(post => {
|
||||
let rect = post.$el.getBoundingClientRect()
|
||||
|
||||
return (rect.top >= 74) && (rect.bottom <= window.innerHeight)
|
||||
})
|
||||
|
||||
let postIndex = posts.indexOf(topPostInView)
|
||||
|
||||
if(postIndex > -1) {
|
||||
let postNumber = self.posts[postIndex].postNumber
|
||||
self.$router.replace({ name: 'thread-post', params: { post_number: postNumber } })
|
||||
}
|
||||
};
|
||||
document.addEventListener('scroll', throttle(postInView, 20));
|
||||
|
||||
this.loadInitialPosts()
|
||||
|
||||
this.$socket.emit('join', 'thread/' + this.$route.params.id)
|
||||
this.$socket.on('new post', post => {
|
||||
this.showPostNotification(post)
|
||||
this.$store.dispatch('loadNewPostsSinceLoad', post)
|
||||
})
|
||||
|
||||
logger('thread', this.$route.params.id)
|
||||
},
|
||||
destroyed () {
|
||||
this.$socket.emit('leave', 'thread/' + this.$route.params.id)
|
||||
this.$socket.off('new post')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../../assets/scss/variables.scss';
|
||||
|
||||
.thread_side_bar {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 10rem;
|
||||
position: fixed;
|
||||
right: 10%;
|
||||
|
||||
.button {
|
||||
margin-bottom: 0.75rem;
|
||||
height: 3rem;
|
||||
}
|
||||
}
|
||||
|
||||
.thread_header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
@at-root #{&}__thread_title {
|
||||
@include text($font--role-default, 2rem, 400);
|
||||
width: 80%;
|
||||
margin-bottom: 1rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
.locked_thread {
|
||||
h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
background-color: #fff;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
width: 80%;
|
||||
border-radius: 0.25rem;
|
||||
border: thin solid $color__gray--darker;
|
||||
}
|
||||
|
||||
.posts {
|
||||
width: 80%;
|
||||
background-color: #fff;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.25rem;
|
||||
border: thin solid $color__gray--darker;
|
||||
}
|
||||
|
||||
|
||||
@media (min-width: 1500px) {
|
||||
.thread_side_bar {
|
||||
right: calc((100% - 1200px) / 2);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: $breakpoint--phone-thread) {
|
||||
.route_container {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.thread_side_bar {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
.posts {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0;
|
||||
border-left: 0;
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
div.thread_header__thread_title {
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint--large_screen-thread) {
|
||||
.posts {
|
||||
width: calc(80% - 5rem);
|
||||
}
|
||||
}
|
||||
@media (min-width: $breakpoint--phone-thread) and (max-width: $breakpoint--tablet-thread) {
|
||||
div.posts {
|
||||
margin-left: 2rem;
|
||||
margin-right: 2rem;
|
||||
width: calc(100% - 4rem);
|
||||
}
|
||||
|
||||
div.thread_header__thread_title {
|
||||
font-size: 2rem;
|
||||
padding-left: 2.25rem;
|
||||
width: 100%;
|
||||
}
|
||||
div.thread_side_bar {
|
||||
padding-left: 2rem;
|
||||
|
||||
&> :first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint--tablet-thread) {
|
||||
.route_container {
|
||||
padding-bottom: 2rem !important;
|
||||
}
|
||||
|
||||
.thread_side_bar {
|
||||
display: flex;
|
||||
position: initial;
|
||||
flex-direction: row;
|
||||
align-items: flex-end;
|
||||
|
||||
margin-left: 1rem;
|
||||
|
||||
& > :first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
> * {
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
|
||||
.post_scrubber {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.posts {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.locked_thread {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.thread_header__thread_title {
|
||||
font-size: 2rem;
|
||||
padding-left: 0.25rem;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,463 @@
|
|||
<template>
|
||||
<div class='route_container thread_new'>
|
||||
|
||||
<div class='h1'>Post new thread</div>
|
||||
<div class='thread_meta_info'>
|
||||
<div class='thread_meta_info__text'>Enter the thread title and the category to post it in</div>
|
||||
|
||||
<div class='thread_meta_info__form'>
|
||||
<select-button v-model='selectedCategory' :options='categories'></select-button>
|
||||
<fancy-input
|
||||
placeholder='Thread title'
|
||||
v-model='name'
|
||||
:error='errors.name'
|
||||
class='thread_meta_info__title'
|
||||
large='true'
|
||||
width='15rem'
|
||||
></fancy-input>
|
||||
|
||||
<button
|
||||
class='thread_meta_info__add_poll button button--thin_text'
|
||||
v-if='!showPoll'
|
||||
@click='togglePoll(true)'
|
||||
>Add poll</button>
|
||||
</div>
|
||||
|
||||
<transition name='slide'>
|
||||
<div class='thread_meta_info__poll' v-if='showPoll'>
|
||||
<div class='thread_meta_info__poll__top_bar'>
|
||||
<fancy-input
|
||||
class='thread_meta_info__poll__question'
|
||||
v-model='pollQuestion'
|
||||
placeholder='Poll question'
|
||||
width='20rem'
|
||||
:large='true'
|
||||
:error='errors.pollQuestion'
|
||||
></fancy-input>
|
||||
<button class='button button--thin_text button--borderless' @click='removePoll'>
|
||||
Remove poll
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
class='thread_meta_info__poll__answer'
|
||||
:key='"poll-answer-" + $index'
|
||||
v-for='(pollAnswer, $index) in pollAnswers'
|
||||
>
|
||||
<fancy-input
|
||||
v-model='pollAnswer.answer'
|
||||
width='15rem'
|
||||
:large='true'
|
||||
:placeholder='"Answer " + ($index+1)'
|
||||
></fancy-input>
|
||||
<span @click='removePollAnswer($index)' title='Remove answer'>×</span>
|
||||
</div>
|
||||
<div class='thread_meta_info__form'>
|
||||
<fancy-input
|
||||
v-model='newPollAnswer'
|
||||
placeholder='Option/answer for poll'
|
||||
style='display: inline-block; margin-right: 0.5rem;'
|
||||
width='15rem'
|
||||
:large='true'
|
||||
:error='errors.pollAnswer'
|
||||
></fancy-input>
|
||||
<button class='button button--thin_text' @click='addPollAnswer'>Add answer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class='editor'
|
||||
:class='{
|
||||
"editor--focus": focusInput,
|
||||
"editor--error": errors.content
|
||||
}'
|
||||
>
|
||||
<div class='editor__input'>
|
||||
<div class='editor__format_bar editor__format_bar--editor'>
|
||||
editor
|
||||
</div>
|
||||
<input-editor-core
|
||||
v-model='editor'
|
||||
@mentions='setMentions'
|
||||
@focus='setFocusInput(true)'
|
||||
@blur='setFocusInput(false)'
|
||||
></input-editor-core>
|
||||
</div>
|
||||
<div class='editor__preview'>
|
||||
<div class='editor__format_bar editor__format_bar--preview'>
|
||||
preview
|
||||
</div>
|
||||
<input-editor-preview :value='editor' :mentions='mentions'></input-editor-preview>
|
||||
</div>
|
||||
</div>
|
||||
<error-tooltip :error='errors.content' class='editor_error'></error-tooltip>
|
||||
|
||||
<loading-button class='button--green submit' :loading='loading' @click='postThread'>Post thread</loading-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import InputEditorCore from '../InputEditorCore'
|
||||
import InputEditorPreview from '../InputEditorPreview'
|
||||
import FancyInput from '../FancyInput'
|
||||
import SelectButton from '../SelectButton'
|
||||
import LoadingButton from '../LoadingButton'
|
||||
import ErrorTooltip from '../ErrorTooltip'
|
||||
|
||||
import AjaxErrorHandler from '../../assets/js/errorHandler'
|
||||
import logger from '../../assets/js/logger'
|
||||
|
||||
export default {
|
||||
name: 'ThreadNew',
|
||||
components: {
|
||||
InputEditorCore,
|
||||
InputEditorPreview,
|
||||
SelectButton,
|
||||
FancyInput,
|
||||
LoadingButton,
|
||||
ErrorTooltip
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
selectedCategory: this.$store.state.category.selectedCategory,
|
||||
editor: '',
|
||||
mentions: [],
|
||||
name: '',
|
||||
loading: false,
|
||||
focusInput: false,
|
||||
|
||||
errors: {
|
||||
content: '',
|
||||
name: '',
|
||||
pollQuestion: '',
|
||||
pollAnswer: ''
|
||||
},
|
||||
|
||||
showPoll: false,
|
||||
pollQuestion: '',
|
||||
newPollAnswer: '',
|
||||
pollAnswers: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
categories () {
|
||||
return this.$store.getters.categoriesWithoutAll
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
togglePoll (val) {
|
||||
if(val !== undefined) {
|
||||
this.showPoll = val
|
||||
} else {
|
||||
this.showPoll = !this.showPoll
|
||||
}
|
||||
},
|
||||
addPollAnswer () {
|
||||
if(!this.newPollAnswer.trim().length) return
|
||||
|
||||
this.pollAnswers.push({ answer: this.newPollAnswer })
|
||||
this.newPollAnswer = ''
|
||||
},
|
||||
removePollAnswer ($index) {
|
||||
this.pollAnswers.splice($index, 1)
|
||||
},
|
||||
removePoll () {
|
||||
this.pollQuestion = ''
|
||||
this.pollAnswers = []
|
||||
this.newPollAnswer = ''
|
||||
|
||||
this.togglePoll()
|
||||
},
|
||||
|
||||
setErrors (errors) {
|
||||
errors.forEach(error => {
|
||||
this.errors[error.name] = error.error
|
||||
})
|
||||
},
|
||||
clearErrors () {
|
||||
this.errors.content = ''
|
||||
this.errors.name = ''
|
||||
this.errors.pollQuestion = ''
|
||||
this.errors.pollAnswer = ''
|
||||
},
|
||||
|
||||
hasDuplicates (array, cb) {
|
||||
if(cb) array = array.map(cb)
|
||||
|
||||
return array.length !== (new Set(array)).size
|
||||
},
|
||||
|
||||
postThread () {
|
||||
let thread
|
||||
let errors = []
|
||||
|
||||
this.clearErrors()
|
||||
|
||||
if(!this.editor.trim().length) {
|
||||
errors.push({name: 'content', error: 'Post content cannot be blank'})
|
||||
} if(!this.name.trim().length) {
|
||||
errors.push({name: 'name', error: 'Cannot be blank'})
|
||||
} if(this.showPoll && !this.pollQuestion.trim().length) {
|
||||
errors.push({name: 'pollQuestion', error: 'Cannot be blank'})
|
||||
} if (this.showPoll && this.pollAnswers.length < 2) {
|
||||
errors.push({name: 'pollAnswer', error: 'You need at least 2 answers'})
|
||||
} if (this.showPoll && this.hasDuplicates(this.pollAnswers, i => i.answer)) {
|
||||
errors.push({name: 'pollAnswer', error: 'Your answers can\'t contain any duplicates'})
|
||||
} if(errors.length) {
|
||||
this.setErrors(errors)
|
||||
return
|
||||
}
|
||||
|
||||
this.loading = true
|
||||
|
||||
this.axios.post('/api/v1/thread', {
|
||||
name: this.name,
|
||||
category: this.selectedCategory
|
||||
}).then(res => {
|
||||
thread = res.data
|
||||
|
||||
let ajax = []
|
||||
ajax.push(
|
||||
this.axios.post('/api/v1/post', {
|
||||
threadId: res.data.id,
|
||||
content: this.editor,
|
||||
mentions: this.mentions
|
||||
})
|
||||
)
|
||||
|
||||
if(this.showPoll) {
|
||||
ajax.push(
|
||||
this.axios.post('/api/v1/poll', {
|
||||
question: this.pollQuestion,
|
||||
answers: this.pollAnswers.map(a => a.answer),
|
||||
threadId: res.data.id
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return Promise.all(ajax)
|
||||
}).then(() => {
|
||||
this.loading = false
|
||||
this.$router.push(`/thread/${thread.slug}/${thread.id}/0`)
|
||||
}).catch(e => {
|
||||
this.loading = false
|
||||
|
||||
AjaxErrorHandler(this.$store)(e, (error, errors) => {
|
||||
let path = error.path
|
||||
|
||||
if(this.errors[path] !== undefined) {
|
||||
this.errors[path] = error.message
|
||||
} else {
|
||||
errors.push(error.message)
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
setFocusInput (val) {
|
||||
this.focusInput = val
|
||||
},
|
||||
setMentions (mentions) {
|
||||
this.mentions = mentions
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'$store.state.username' (username) {
|
||||
if(!username) {
|
||||
this.$router.push('/')
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.$store.dispatch('setTitle', 'new thread')
|
||||
logger('threadNew')
|
||||
},
|
||||
beforeRouteEnter (to, from, next) {
|
||||
next(vm => {
|
||||
if(!vm.$store.state.username) {
|
||||
vm.$store.commit('setAccountModalState', true);
|
||||
next('/')
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss'>
|
||||
@import '../../assets/scss/variables.scss';
|
||||
|
||||
.thread_new {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.thread_meta_info {
|
||||
background-color: #fff;
|
||||
border: thin solid $color__gray--darker;
|
||||
border-radius: 0.25rem;
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
|
||||
@at-root #{&}__title {
|
||||
margin: 0 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@at-root #{&}__form {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
@at-root #{&}__add_poll {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
@at-root #{&}__text {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
@at-root #{&}__poll {
|
||||
border-top: thin solid $color__gray--primary;
|
||||
margin-top: 1rem;
|
||||
padding-top: 0.75rem;
|
||||
position: relative;
|
||||
|
||||
@at-root #{&}__top_bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
@at-root #{&}__answer {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
|
||||
& > span {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: all 0.1s;
|
||||
|
||||
font-size: 1.5rem;
|
||||
margin-left: 0.5rem;
|
||||
cursor: pointer;
|
||||
@include user-select(none);
|
||||
}
|
||||
|
||||
&:hover > span {
|
||||
opacity: 1;
|
||||
pointer-events: all;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.submit {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.editor {
|
||||
display: flex;
|
||||
background-color: #fff;
|
||||
border-radius: 0.25rem;
|
||||
border: thin solid $color__gray--darker;
|
||||
|
||||
transition: all 0.2s;
|
||||
|
||||
@at-root #{&}--focus {
|
||||
border: thin solid $color__gray--darkest;
|
||||
}
|
||||
@at-root #{&}--error {
|
||||
border: thin solid $color__red--primary;
|
||||
}
|
||||
|
||||
@at-root #{&}__format_bar {
|
||||
height: 2.5rem;
|
||||
background-color: $color__gray--primary;
|
||||
display: flex;
|
||||
padding-right: 1rem;
|
||||
padding-bottom: 0.25rem;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
font-variant: small-caps;
|
||||
|
||||
@at-root #{&}--preview {
|
||||
border-radius: 0 0.25rem 0 0;
|
||||
}
|
||||
@at-root #{&}--editor {
|
||||
border-radius: 0.25rem 0 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
@at-root #{&}__input {
|
||||
width: 50%;
|
||||
position: relative;
|
||||
|
||||
.input_editor_core__format_bar {
|
||||
left: 0rem;
|
||||
}
|
||||
.input_editor_core textarea {
|
||||
height: 14rem;
|
||||
}
|
||||
}
|
||||
|
||||
@at-root #{&}__preview {
|
||||
border-left: 1px solid $color__gray--darker;
|
||||
width: 50%;
|
||||
|
||||
div.input_editor_preview__markdownHTML {
|
||||
height: 14.1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.editor_error {
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
margin-top: 0.5rem;
|
||||
border-radius: 0.2rem;
|
||||
border: thin solid $color__red--primary;
|
||||
|
||||
&.error_tooltip--show {
|
||||
max-height: 4rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.thread_meta_info {
|
||||
@at-root #{&}__form {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@at-root #{&}__title.fancy_input {
|
||||
margin: 0;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
@at-root #{&}__poll__top_bar .button {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
@at-root #{&}__poll__question {
|
||||
width: 100%;
|
||||
|
||||
> div, input {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.editor {
|
||||
flex-direction: column;
|
||||
overflow-x: hidden;
|
||||
|
||||
@at-root #{&}__input, #{&}__preview {
|
||||
border: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,246 @@
|
|||
<template>
|
||||
<div class='route_container user_route'>
|
||||
<div class='user_header'>
|
||||
<div
|
||||
class='user_header__icon picture_circle'
|
||||
:style='{
|
||||
"background-color": userColor,
|
||||
"background-image": userPicture,
|
||||
}'
|
||||
>
|
||||
{{userPicture ? '' : username[0].toUpperCase()}}
|
||||
</div>
|
||||
<div class='user_header__info'>
|
||||
<span class='user_header__username'>
|
||||
{{username}}
|
||||
<span
|
||||
class='admin_badge admin_badge--large'
|
||||
v-if='user && user.admin'
|
||||
>
|
||||
admin
|
||||
</span>
|
||||
</span>
|
||||
<span class='user_header__date' v-if='user'>User since {{user.createdAt | formatDate('date') }}</span>
|
||||
<div class='user_description' v-if='user && user.description && user.description.length' v-html='user.description'></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='user__view_holder'>
|
||||
<div class='user__links'>
|
||||
<div
|
||||
class='user__links__menu_item'
|
||||
:key='"user-menu-item-" + index'
|
||||
v-for='(item, index) in menuItems'
|
||||
:class="{'user__links__menu_item--selected': index === selected}"
|
||||
@click='$router.push(`/user/${username}/${item.route}`)'
|
||||
>
|
||||
{{item.name}}
|
||||
</div>
|
||||
</div>
|
||||
<router-view class='user__view' :username='username'></router-view>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import AjaxErrorHandler from '../../assets/js/errorHandler'
|
||||
|
||||
export default {
|
||||
name: 'user',
|
||||
data () {
|
||||
return {
|
||||
menuItems: [
|
||||
{ name: 'Posts', route: 'posts' },
|
||||
{ name: 'Threads', route: 'threads' }
|
||||
],
|
||||
selected: 0,
|
||||
|
||||
username: this.$route.params.username,
|
||||
user: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
$route (to) {
|
||||
this.selected = this.getIndexFromRoute(to.path)
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
userColor () {
|
||||
if(this.user) {
|
||||
return this.user.color
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
},
|
||||
userPicture () {
|
||||
if(this.user && this.user.picture) {
|
||||
return 'url(' + this.user.picture + ')'
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getIndexFromRoute (path) {
|
||||
let selectedIndex
|
||||
let route = path.split('/')[3]
|
||||
|
||||
this.menuItems.forEach((item, index) => {
|
||||
if(item.route === route) {
|
||||
selectedIndex = index
|
||||
}
|
||||
})
|
||||
|
||||
return selectedIndex
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.selected = this.getIndexFromRoute(this.$route.path)
|
||||
|
||||
this.axios
|
||||
.get(`/api/v1/user/${this.$route.params.username}`)
|
||||
.then(res => this.user = res.data)
|
||||
.catch(e => {
|
||||
let invalidId = e.response.data.errors.find(error => {
|
||||
return error.name === 'accountDoesNotExist'
|
||||
})
|
||||
|
||||
if(invalidId) {
|
||||
this.$store.commit('set404Page', true)
|
||||
} else {
|
||||
AjaxErrorHandler(this.$store)(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../../assets/scss/variables.scss';
|
||||
|
||||
.user_route {
|
||||
width: 70%;
|
||||
}
|
||||
|
||||
.user_header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1.5rem;
|
||||
background-color: #fff;
|
||||
padding: 1rem;
|
||||
border-radius: 0.25rem;
|
||||
border: thin solid $color__gray--darker;
|
||||
|
||||
@at-root #{&}__icon {
|
||||
height: 6rem;
|
||||
width: 6rem;
|
||||
line-height: 5.5rem;
|
||||
@include text($font--role-emphasis, 5rem)
|
||||
text-align: center;
|
||||
background-color: $color__gray--darkest;
|
||||
color: #fff;
|
||||
}
|
||||
@at-root #{&}__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-left: 1rem;
|
||||
width: calc(100% - 6rem);
|
||||
}
|
||||
@at-root #{&}__username {
|
||||
margin-top: -0.25rem;
|
||||
font-size: 2rem;
|
||||
font-weight: bold
|
||||
}
|
||||
@at-root #{&}__date {
|
||||
color: $color__darkgray--primary;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
.user_description {
|
||||
white-space: pre-line;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.user__view_holder {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
.user__links {
|
||||
width: 8rem;
|
||||
display: table;
|
||||
|
||||
@at-root #{&}__menu_item {
|
||||
cursor: pointer;
|
||||
margin-bottom: 0.5rem;
|
||||
position: relative;
|
||||
|
||||
&:hover { color: $color__darkgray--primary; }
|
||||
|
||||
@at-root #{&}--selected {
|
||||
font-weight: bold;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 0.2rem;
|
||||
z-index: 1;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
left: -0.5rem;
|
||||
top: 0.0625rem;
|
||||
background-color: $color__gray--darkest;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.user__view {
|
||||
flex-grow: 1;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint--tablet) {
|
||||
.user_route {
|
||||
width: inherit;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.user__view_holder {
|
||||
flex-direction: column;
|
||||
}
|
||||
.user_header {
|
||||
@at-root #{&}__icon {
|
||||
height: 3rem;
|
||||
width: 3rem;
|
||||
line-height: 3rem;
|
||||
font-size: 2rem;
|
||||
}
|
||||
@at-root #{&}__username {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
@at-root #{&}__date {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
.user__links {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
@at-root #{&}__menu_item {
|
||||
margin-right: 0.5rem;
|
||||
|
||||
&:hover {
|
||||
color: $color__text--primary;
|
||||
}
|
||||
|
||||
@at-root #{&}--selected::before {
|
||||
width: 100%;
|
||||
height: 0.2rem;
|
||||
left: 0rem;
|
||||
top: auto;
|
||||
border-radius: 1rem;
|
||||
bottom: -0.375rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
.user__view {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,135 @@
|
|||
<template>
|
||||
<div class='user_posts' :class='{ "user_posts--no_border_bottom": posts && !posts.length }'>
|
||||
<div class='user_posts__title'>Posts by {{username}}</div>
|
||||
|
||||
<template v-if='!posts'>
|
||||
<thread-post-placeholder v-if='!posts'>
|
||||
</thread-post-placeholder>
|
||||
</template>
|
||||
|
||||
<scroll-load
|
||||
:loading='loadingPosts'
|
||||
@loadNext='loadNewPosts'
|
||||
v-else-if='posts.length'
|
||||
>
|
||||
<thread-post
|
||||
v-for='(post, index) in posts'
|
||||
:key='"thread-post-" + post.id'
|
||||
|
||||
:post='post'
|
||||
:show-thread='true'
|
||||
:click-for-post='true'
|
||||
:class='{"post--last": index === posts.length-1}'
|
||||
></thread-post>
|
||||
<template v-if='loadingPosts'>
|
||||
<thread-post-placeholder
|
||||
v-for='n in nextPostsCount'
|
||||
:key='"thread-post-placeholder-" + n'
|
||||
></thread-post-placeholder>
|
||||
</template>
|
||||
</scroll-load>
|
||||
<template v-else>This user hasn't posted anything yet</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ScrollLoad from '../ScrollLoad'
|
||||
import ThreadPost from '../ThreadPost'
|
||||
import ThreadPostPlaceholder from '../ThreadPostPlaceholder'
|
||||
|
||||
import AjaxErrorHandler from '../../assets/js/errorHandler'
|
||||
import logger from '../../assets/js/logger'
|
||||
|
||||
export default {
|
||||
name: 'user',
|
||||
props: ['username'],
|
||||
components: {
|
||||
ThreadPost,
|
||||
ScrollLoad,
|
||||
ThreadPostPlaceholder
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
posts: null,
|
||||
loadingPosts: false,
|
||||
nextPostsCount: 0,
|
||||
nextURL: ''
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
loadNewPosts () {
|
||||
if(this.nextURL === null) return
|
||||
|
||||
this.loadingPosts = true
|
||||
|
||||
this.axios
|
||||
.get(this.nextURL)
|
||||
.then(res => {
|
||||
this.loadingPosts = false
|
||||
|
||||
if(!this.posts) this.posts = []
|
||||
|
||||
let currentPostsIds = this.posts.map(p => p.id)
|
||||
let filteredPosts =
|
||||
res.data.Posts.filter(p => !currentPostsIds.includes(p.id))
|
||||
|
||||
this.posts.push(...filteredPosts)
|
||||
this.nextURL = res.data.meta.nextURL
|
||||
this.nextPostsCount = res.data.meta.nextPostsCount
|
||||
})
|
||||
.catch((e) => {
|
||||
this.loadingPosts = false
|
||||
|
||||
AjaxErrorHandler(this.$store)(e)
|
||||
})
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.$store.dispatch('setTitle', this.$route.params.username + ' | posts')
|
||||
|
||||
this.axios
|
||||
.get(`/api/v1/user/${this.$route.params.username}?posts=true`)
|
||||
.then(res => {
|
||||
this.posts = res.data.Posts
|
||||
this.nextURL = res.data.meta.nextURL
|
||||
this.nextPostsCount = res.data.meta.nextPostsCount
|
||||
})
|
||||
.catch(e => {
|
||||
let invalidId = e.response.data.errors.find(error => {
|
||||
return error.name === 'accountDoesNotExist'
|
||||
})
|
||||
|
||||
if(invalidId) {
|
||||
this.$store.commit('set404Page', true)
|
||||
} else {
|
||||
AjaxErrorHandler(this.$store)(e)
|
||||
}
|
||||
})
|
||||
|
||||
logger('userPosts', this.$route.params.username)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../../assets/scss/variables.scss';
|
||||
|
||||
.user_posts {
|
||||
background: #fff;
|
||||
border-radius: 0.25rem;
|
||||
padding: 1rem;
|
||||
border: thin solid $color__gray--darker;
|
||||
|
||||
@at-root #{&}__title {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint--tablet) {
|
||||
.user_posts {
|
||||
margin-top: 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,143 @@
|
|||
<template>
|
||||
<div class='user_threads'>
|
||||
<div class='user_threads__title'>Threads by {{username}}</div>
|
||||
|
||||
<template v-if='!threads'>
|
||||
<thread-display-placeholder v-for='n in 10' :key='"thread-display-placeholder-" + n'>
|
||||
</thread-display-placeholder>
|
||||
</template>
|
||||
|
||||
|
||||
<scroll-load
|
||||
:loading='loadingThreads'
|
||||
:showNext='nextURL !== null'
|
||||
@loadNext='loadNewThreads'
|
||||
message='threads'
|
||||
v-else-if='threads.length'
|
||||
>
|
||||
<thread-display v-for='thread in threads' :key='"thread-display-" + thread.id' :thread='thread'></thread-display>
|
||||
<template v-if='loadingThreads'>
|
||||
<thread-display-placeholder
|
||||
v-for='n in nextThreadsCount'
|
||||
:key='n'
|
||||
></thread-display-placeholder>
|
||||
</template>
|
||||
</scroll-load>
|
||||
|
||||
<template v-else>This user hasn't started any threads yet</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ScrollLoad from '../ScrollLoad'
|
||||
import ThreadDisplay from '../ThreadDisplay'
|
||||
import ThreadDisplayPlaceholder from '../ThreadDisplayPlaceholder'
|
||||
|
||||
import AjaxErrorHandler from '../../assets/js/errorHandler'
|
||||
import logger from '../../assets/js/logger'
|
||||
|
||||
export default {
|
||||
name: 'userThreads',
|
||||
props: ['username'],
|
||||
components: {
|
||||
ThreadDisplay,
|
||||
ThreadDisplayPlaceholder,
|
||||
ScrollLoad
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
threads: null,
|
||||
loadingThreads: false,
|
||||
nextURL: '',
|
||||
nextThreadsCount: 0
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
loadNewThreads () {
|
||||
if(this.nextURL === null) return
|
||||
|
||||
this.loadingThreads = true
|
||||
|
||||
this.axios
|
||||
.get(this.nextURL)
|
||||
.then(res => {
|
||||
this.loadingThreads = false
|
||||
|
||||
if(!this.threads) this.threads = []
|
||||
|
||||
this.threads.push(...res.data.Threads)
|
||||
this.nextURL = res.data.meta.nextURL
|
||||
this.nextThreadsCount = res.data.meta.nextThreadsCount
|
||||
})
|
||||
.catch((e) => {
|
||||
this.loadingThreads = false
|
||||
|
||||
AjaxErrorHandler(this.$store)(e)
|
||||
})
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.$store.dispatch('setTitle', this.$route.params.username + ' | threads')
|
||||
|
||||
this.axios
|
||||
.get(`/api/v1/user/${this.$route.params.username}?threads=true`)
|
||||
.then(res => {
|
||||
this.threads = res.data.Threads
|
||||
this.nextURL = res.data.meta.nextURL
|
||||
this.nextThreadsCount = res.data.meta.nextThreadsCount
|
||||
})
|
||||
.catch(e => {
|
||||
let invalidId = e.response.data.errors.find(error => {
|
||||
return error.name === 'accountDoesNotExist'
|
||||
})
|
||||
|
||||
if(invalidId) {
|
||||
this.$store.commit('set404Page', true)
|
||||
} else {
|
||||
AjaxErrorHandler(this.$store)(e)
|
||||
}
|
||||
})
|
||||
|
||||
logger('userThreads', this.$route.params.username)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../../assets/scss/variables.scss';
|
||||
|
||||
.user_threads {
|
||||
@at-root #{&}__title {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@at-root #{&}__thread {
|
||||
border-top: thin solid $color__gray--primary;
|
||||
padding: 0.75rem;
|
||||
cursor: pointer;
|
||||
background-color: #fff;
|
||||
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: $color__lightgray--primary;
|
||||
}
|
||||
|
||||
@at-root #{&}--last {
|
||||
border-bottom: thin solid $color__gray--primary;
|
||||
}
|
||||
}
|
||||
@at-root #{&}__thread_bar {
|
||||
display: flex;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
@at-root #{&}__date {
|
||||
margin-left: 0.5rem;
|
||||
color: $color__gray--darkest;
|
||||
}
|
||||
@at-root #{&}__name {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,211 @@
|
|||
<template>
|
||||
<div class='widgets__categories_chart' ref='container'>
|
||||
<div class='widgets__categories_chart__overlay' :class='{ "widgets__categories_chart__overlay--show" : loading }'>
|
||||
<loading-icon :dark='true'></loading-icon>
|
||||
</div>
|
||||
<div
|
||||
class='widgets__categories_chart__tooltip'
|
||||
:class='{ "widgets__categories_chart__tooltip--show": tooltipShow }'
|
||||
:style='{ "left": tooltipX, "top": tooltipY }'
|
||||
>
|
||||
</div>
|
||||
<div class='widgets__categories_chart__main'>
|
||||
<svg>
|
||||
<g ref='g'></g>
|
||||
<text class='widgets__categories_chart__empty' x='50%' y='53%' v-if='!anyThreadsExist'>No threads yet</text>
|
||||
</svg>
|
||||
<div class='widgets__categories_chart__main__legend'>
|
||||
<div class='widgets__categories_chart__title'>categories</div>
|
||||
<div
|
||||
:key='"category-label-" + $index'
|
||||
v-for='(category, $index) in data'
|
||||
class='widgets__categories_chart__label'
|
||||
@mouseover='toggleLabelHover($index)'
|
||||
@mouseout='toggleLabelHover($index)'
|
||||
>
|
||||
<div class='widgets__categories_chart__label__square' :style="{ 'background-color': category.color }"></div>
|
||||
{{category.label}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import LoadingIcon from '../LoadingIcon'
|
||||
import AjaxErrorHandler from '../../assets/js/errorHandler'
|
||||
import * as d3 from 'd3'
|
||||
|
||||
export default {
|
||||
name: 'CategoriesChart',
|
||||
components: { LoadingIcon },
|
||||
data () {
|
||||
return {
|
||||
loading: true,
|
||||
padding: 20,
|
||||
|
||||
tooltipX: 0,
|
||||
tooltipY: 0,
|
||||
tooltipShow: false,
|
||||
tooltipItem: 0,
|
||||
|
||||
data: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
anyThreadsExist () {
|
||||
return this.data.reduce((sum, category) => {
|
||||
return sum + category.value
|
||||
}, 0)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateFuncs () {
|
||||
if(!this.data.length) return
|
||||
|
||||
let height = this.$refs.container.getBoundingClientRect().height
|
||||
|
||||
let paddedHeight = (height - this.padding) / 2
|
||||
let translate = paddedHeight + this.padding / 2
|
||||
|
||||
let pieSegments = d3.pie()(this.data.map(d => d.value))
|
||||
let arcGenerator = d3.arc()
|
||||
.innerRadius(paddedHeight - 40)
|
||||
.outerRadius(paddedHeight)
|
||||
.padAngle(Math.PI*2 * 2/360)
|
||||
|
||||
let g = d3.select(this.$refs.g).attr('transform', `translate(${translate}, ${translate})`)
|
||||
|
||||
//Arcs
|
||||
g.selectAll('path')
|
||||
.data(pieSegments)
|
||||
.enter()
|
||||
.append('path')
|
||||
.attr('d', arcGenerator)
|
||||
.attr('data-index', (d, i) => i)
|
||||
.attr('fill', (d, i) => this.data[i].color)
|
||||
|
||||
//Labels
|
||||
g.selectAll('text')
|
||||
.data(pieSegments)
|
||||
.enter()
|
||||
.append('text')
|
||||
.text(d => d.value ? d.value : '')
|
||||
.attr('data-index', (d, i) => i)
|
||||
.attr('fill', '#fff')
|
||||
.attr('transform', d => {
|
||||
d.innerRadius = paddedHeight - 40
|
||||
d.outerRadius = paddedHeight
|
||||
|
||||
|
||||
let coords = arcGenerator.centroid(d)
|
||||
.map((val, i) => i ? val+5 : val-5)
|
||||
.join(',')
|
||||
|
||||
return `translate(${coords})`
|
||||
})
|
||||
},
|
||||
toggleLabelHover (index) {
|
||||
let g = this.$refs.g
|
||||
let path = g.querySelector('path[data-index="' + index + '"]')
|
||||
let text = g.querySelector('text[data-index="' + index + '"]')
|
||||
let textTransform = text.getAttribute('transform')
|
||||
|
||||
path.classList.toggle('widgets__categories_chart__main--large')
|
||||
|
||||
if(textTransform.includes('scale')) {
|
||||
text.setAttribute('transform', textTransform.split(' ')[0])
|
||||
} else {
|
||||
text.setAttribute('transform', textTransform + ' scale(1.15)')
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
window.addEventListener('resize', this.updateFuncs)
|
||||
|
||||
this.axios
|
||||
.get('/api/v1/log/categories')
|
||||
.then(res => {
|
||||
this.data = res.data
|
||||
this.updateFuncs()
|
||||
this.loading = false
|
||||
})
|
||||
.catch(AjaxErrorHandler(this.$store))
|
||||
},
|
||||
destroyed () {
|
||||
window.removeEventListener('resize', this.updateFuncs)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss'>
|
||||
@import '../../assets/scss/variables.scss';
|
||||
|
||||
.widgets__categories_chart {
|
||||
background-color: #fff;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 0.25rem 0.25rem 0 0;
|
||||
position: relative;
|
||||
|
||||
@at-root #{&}__overlay {
|
||||
@include loading-overlay(#fff, 0.25rem 0.25rem 0 0);
|
||||
}
|
||||
|
||||
@at-root #{&}__empty {
|
||||
font-style: italic;
|
||||
text-anchor: middle;
|
||||
font-size: 1.25rem;
|
||||
alignment-baseline: central;
|
||||
}
|
||||
|
||||
@at-root #{&}__main {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 100%;
|
||||
|
||||
svg {
|
||||
height: 100%;
|
||||
width: 11rem;
|
||||
|
||||
path, text {
|
||||
transition: all 0.2s;
|
||||
}
|
||||
}
|
||||
@at-root #{&}--large {
|
||||
transform: scale(1.075);
|
||||
}
|
||||
@at-root #{&}__legend {
|
||||
padding: 10px 0;
|
||||
}
|
||||
}
|
||||
|
||||
@at-root #{&}__title {
|
||||
font-weight: normal;
|
||||
margin-left: -0.4rem;
|
||||
}
|
||||
|
||||
@at-root #{&}__label {
|
||||
position: relative;
|
||||
cursor: default;
|
||||
margin-left: 1rem;
|
||||
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@at-root #{&}__square {
|
||||
position: absolute;
|
||||
top: 0.375rem;
|
||||
left: -1.25rem;
|
||||
height: 0.75rem;
|
||||
width: 0.75rem;
|
||||
border-radius: 0.125rem;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,205 @@
|
|||
<template>
|
||||
<div class='widgets__line_chart' ref='container' :style='{ "background-color": background }'>
|
||||
<div class='widgets__line_chart__overlay' :style='{ "background-color": background }' :class='{ "widgets__line_chart__overlay--show" : loading }'>
|
||||
<loading-icon></loading-icon>
|
||||
</div>
|
||||
<div
|
||||
class='widgets__line_chart__tooltip'
|
||||
:class='{ "widgets__line_chart__tooltip--show": tooltipShow }'
|
||||
:style='{ "left": tooltipX, "top": tooltipY }'
|
||||
|
||||
v-if='points.length'
|
||||
>
|
||||
{{points[tooltipItem].pageViews}} {{points[tooltipItem].pageViews | pluralize(tooltip) }}
|
||||
</div>
|
||||
<svg>
|
||||
<g
|
||||
ref='y_axis'
|
||||
class='widgets__line_chart__axis'
|
||||
:transform='"translate(" + 3*padding + ",0)"'
|
||||
></g>
|
||||
<g
|
||||
ref='x_axis'
|
||||
class='widgets__line_chart__axis widgets__line_chart__axis--x'
|
||||
:transform='"translate(0,150)"'
|
||||
></g>
|
||||
<path :d='linePath' fill='none' stroke-width='2' stroke='#fff'></path>
|
||||
<circle
|
||||
:key='"filled-circle-" + $index'
|
||||
v-for='(circle, $index) in circles'
|
||||
:cx='circle.x'
|
||||
:cy='circle.y'
|
||||
r='4'
|
||||
:fill='point'
|
||||
>
|
||||
</circle>
|
||||
<circle
|
||||
:key='"hover-circle-" + $index'
|
||||
v-for='(circle, $index) in circles'
|
||||
:cx='circle.x'
|
||||
:cy='circle.y'
|
||||
r='10'
|
||||
fill='rgba(0, 0, 0, 0)'
|
||||
|
||||
@mousemove='showTooltip($event, $index)'
|
||||
@mouseout='hideTooltip'
|
||||
>
|
||||
</circle>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import LoadingIcon from '../LoadingIcon'
|
||||
import * as d3 from 'd3'
|
||||
|
||||
export default {
|
||||
name: 'LineChart',
|
||||
props: ['point', 'background', 'tooltip', 'points'],
|
||||
components: { LoadingIcon },
|
||||
data () {
|
||||
let x = d3
|
||||
.scaleTime()
|
||||
.domain([0, 0])
|
||||
.range([0, 0])
|
||||
|
||||
|
||||
let y = d3
|
||||
.scaleLinear()
|
||||
.domain([0, 0])
|
||||
.range([0, 0])
|
||||
|
||||
return {
|
||||
loading: true,
|
||||
padding: 10,
|
||||
|
||||
tooltipX: 0,
|
||||
tooltipY: 0,
|
||||
tooltipShow: false,
|
||||
tooltipItem: 0,
|
||||
|
||||
x, y,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setXFunc () {
|
||||
let width = this.$refs.container.getBoundingClientRect().width - this.padding*2
|
||||
|
||||
this.x = d3
|
||||
.scaleTime()
|
||||
.domain([this.points[0].date, this.points.slice(-1)[0].date])
|
||||
.range([this.padding * 4, width])
|
||||
},
|
||||
setYFunc () {
|
||||
let height = this.$refs.container.getBoundingClientRect().height - this.padding*2
|
||||
|
||||
this.y = d3
|
||||
.scaleLinear()
|
||||
.domain([d3.max(this.points.map(d => d.pageViews)), 0])
|
||||
.range([this.padding*1.5, height - this.padding/2])
|
||||
},
|
||||
updateFuncs () {
|
||||
if(!this.points.length) return
|
||||
|
||||
this.setXFunc()
|
||||
this.setYFunc()
|
||||
|
||||
d3.select(this.$refs.y_axis).call(d3.axisLeft(this.y.nice()))
|
||||
d3.select(this.$refs.x_axis).call(d3.axisBottom(this.x).tickSize(0).ticks(this.points.length))
|
||||
},
|
||||
showTooltip (e, i) {
|
||||
this.tooltipShow = true
|
||||
this.tooltipX = e.clientX + 'px'
|
||||
this.tooltipY = e.clientY - 30 + 'px'
|
||||
this.tooltipItem = i
|
||||
},
|
||||
hideTooltip () {
|
||||
this.tooltipShow = false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
linePath () {
|
||||
let line = d3
|
||||
.line()
|
||||
.curve(d3.curveCatmullRom)
|
||||
.x(d => this.x(d.date))
|
||||
.y(d => this.y(d.pageViews))
|
||||
|
||||
return line(this.points)
|
||||
},
|
||||
circles () {
|
||||
return this.points.map(d => {
|
||||
return { x: this.x(d.date), y: this.y(d.pageViews) }
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
window.addEventListener('resize', this.updateFuncs)
|
||||
},
|
||||
destroyed () {
|
||||
window.removeEventListener('resize', this.updateFuncs)
|
||||
},
|
||||
watch: {
|
||||
points () {
|
||||
this.loading = false
|
||||
this.updateFuncs()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss'>
|
||||
@import '../../assets/scss/variables.scss';
|
||||
|
||||
.widgets__line_chart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 0.25rem 0.25rem 0 0;
|
||||
position: relative;
|
||||
|
||||
@at-root #{&}__overlay {
|
||||
@include loading-overlay(#f39c12, 0.25rem 0.25rem 0 0);
|
||||
}
|
||||
|
||||
@at-root #{&}__tooltip {
|
||||
position: fixed;
|
||||
background-color: rgba(256, 256, 256, 0.9);
|
||||
pointer-events: none;
|
||||
display: inline-block;
|
||||
opacity: 0;
|
||||
padding: 0.25rem;
|
||||
z-index: 1;
|
||||
border-radius: 0.25rem;
|
||||
transition: all 0.2s;
|
||||
|
||||
@at-root #{&}--show {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@at-root #{&}__axis {
|
||||
line {
|
||||
stroke: #fff;
|
||||
}
|
||||
path {
|
||||
stroke: #fff;
|
||||
}
|
||||
text {
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
@at-root #{&}--x {
|
||||
text {
|
||||
transform: translate(0, 2px);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
svg {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,93 @@
|
|||
<template>
|
||||
<div class='widgets__new_post'>
|
||||
<div class='widgets__new_post__overlay' :class='{ "widgets__new_post__overlay--show" : loading }'>
|
||||
<loading-icon></loading-icon>
|
||||
</div>
|
||||
<div class='widgets__new_post__main'>
|
||||
<template v-if='count'>
|
||||
{{count}} new {{count | pluralize('thread')}}
|
||||
</template>
|
||||
<template v-else>
|
||||
No new posts
|
||||
</template>
|
||||
</div>
|
||||
<div class='widgets__new_post__message'>
|
||||
<template v-if='change === 0'>
|
||||
<font-awesome-icon :icon='["fa", "minus"]' />
|
||||
No change since yesterday
|
||||
</template>
|
||||
<template v-else-if='change > 0'>
|
||||
<font-awesome-icon :icon='["fa", "caret-up"]' />
|
||||
Up {{change}} since yesterday
|
||||
</template>
|
||||
<template v-else>
|
||||
<font-awesome-icon :icon='["fa", "caret-down"]' />
|
||||
Down {{Math.abs(change)}} since yesterday
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import LoadingIcon from '../LoadingIcon'
|
||||
|
||||
import AjaxErrorHandler from '../../assets/js/errorHandler'
|
||||
|
||||
export default {
|
||||
name: 'NewPosts',
|
||||
components: { LoadingIcon },
|
||||
data () {
|
||||
return {
|
||||
loading: true,
|
||||
count: 0,
|
||||
change: 0,
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.axios
|
||||
.get('/api/v1/log/new-thread')
|
||||
.then(res => {
|
||||
this.count = res.data.count
|
||||
this.change = res.data.change
|
||||
this.loading = false
|
||||
})
|
||||
.catch(AjaxErrorHandler(this.$store))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../../assets/scss/variables.scss';
|
||||
|
||||
.widgets__new_post {
|
||||
background-color: #3498db;
|
||||
color: #fff;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 0.25rem 0.25rem 0 0;
|
||||
display: flex;
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
padding: 0.5rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@at-root #{&}__overlay {
|
||||
@include loading-overlay(#3498db, 0.25rem 0.25rem 0 0);
|
||||
}
|
||||
|
||||
@at-root #{&}__main {
|
||||
font-size: 2.3rem;
|
||||
font-family: $font--role-emphasis;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@at-root #{&}__message {
|
||||
margin-top: 0.5rem;
|
||||
|
||||
span {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,36 @@
|
|||
<template>
|
||||
<line-chart
|
||||
background='#84dec0'
|
||||
point='#1da8ce'
|
||||
tooltip='new user'
|
||||
:points='points'
|
||||
></line-chart>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import LineChart from './LineChart'
|
||||
|
||||
import AjaxErrorHandler from '../../assets/js/errorHandler'
|
||||
|
||||
export default {
|
||||
name: 'NewUsersChart',
|
||||
components: { LineChart },
|
||||
data () {
|
||||
return {
|
||||
points: []
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.axios
|
||||
.get('/api/v1/log/new-users')
|
||||
.then(res => {
|
||||
this.points = res.data.map(d => {
|
||||
d.date = new Date(d.date)
|
||||
|
||||
return d
|
||||
})
|
||||
})
|
||||
.catch(AjaxErrorHandler(this.$store))
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,36 @@
|
|||
<template>
|
||||
<line-chart
|
||||
background='#f39c12'
|
||||
point='rgb(255, 237, 127)'
|
||||
tooltip='page view'
|
||||
:points='points'
|
||||
></line-chart>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import LineChart from './LineChart'
|
||||
|
||||
import AjaxErrorHandler from '../../assets/js/errorHandler'
|
||||
|
||||
export default {
|
||||
name: 'PageViewsChart',
|
||||
components: { LineChart },
|
||||
data () {
|
||||
return {
|
||||
points: []
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.axios
|
||||
.get('/api/v1/log/page-views')
|
||||
.then(res => {
|
||||
this.points = res.data.map(d => {
|
||||
d.date = new Date(d.date)
|
||||
|
||||
return d
|
||||
})
|
||||
})
|
||||
.catch(AjaxErrorHandler(this.$store))
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,156 @@
|
|||
<template>
|
||||
<div class='widgets__top_posts'>
|
||||
|
||||
|
||||
<template v-if='data_.length'>
|
||||
<div
|
||||
class='widgets__top_posts__item'
|
||||
:class='"widgets__top_posts__item--" + $index'
|
||||
:key='"post-title-" + $index'
|
||||
v-for='(thread, $index) in data'
|
||||
@click='goToThread(thread)'
|
||||
>
|
||||
<div class='widgets__top_posts__item__number' v-if='thread.Thread'>{{$index + 1}}</div>
|
||||
<div class='widgets__top_posts__item__info'>
|
||||
<div class='widgets__top_posts__item__title'>
|
||||
<template v-if='thread.Thread'>{{thread.Thread.name}}</template>
|
||||
</div>
|
||||
<div class='widgets__top_posts__item__views' v-if='thread.Thread'>
|
||||
{{thread.pageViews}} {{thread.pageViews | pluralize('page view')}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class='widgets__top_posts__overlay widgets__top_posts__overlay--show' v-else>
|
||||
<div class='widgets__top_posts__overlay__message'>No threads today</div>
|
||||
</div>
|
||||
|
||||
<div class='widgets__top_posts__overlay' :class='{ "widgets__top_posts__overlay--show" : loading }'>
|
||||
<loading-icon></loading-icon>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import LoadingIcon from '../LoadingIcon'
|
||||
|
||||
import AjaxErrorHandler from '../../assets/js/errorHandler'
|
||||
|
||||
export default {
|
||||
name: 'TopPosts',
|
||||
components: { LoadingIcon },
|
||||
data () {
|
||||
return {
|
||||
loading: true,
|
||||
|
||||
data_: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
data () {
|
||||
let ret = []
|
||||
|
||||
for(let i = 0; i < 4; i++) {
|
||||
if(this.data_[i]) {
|
||||
ret.push(this.data_[i])
|
||||
} else {
|
||||
ret.push({})
|
||||
}
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
goToThread (thread) {
|
||||
if(thread.Thread) {
|
||||
this.$router.push(
|
||||
'/thread/' +
|
||||
thread.Thread.slug + '/' +
|
||||
thread.Thread.id
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.axios
|
||||
.get('/api/v1/log/top-threads')
|
||||
.then(res => {
|
||||
this.data_ = res.data
|
||||
this.loading = false
|
||||
})
|
||||
.catch(AjaxErrorHandler(this.$store))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '../../assets/scss/variables.scss';
|
||||
|
||||
.widgets__top_posts {
|
||||
background-color: #fff;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
border-radius: 0.25rem 0.25rem 0 0;
|
||||
position: relative;
|
||||
|
||||
|
||||
@at-root #{&}__overlay {
|
||||
@include loading-overlay($color__gray--darkest, 0.25rem 0.25rem 0 0);
|
||||
}
|
||||
|
||||
@at-root #{&}__item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding: 0.25rem 1rem;
|
||||
cursor: default;
|
||||
height: 25%;
|
||||
overflow: hidden;
|
||||
padding-top: 0.125rem;
|
||||
transition: filter 0.2s;
|
||||
|
||||
&:hover {
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
@for $i from 0 through 3 {
|
||||
@at-root #{&}--#{$i} {
|
||||
$alpha: null;
|
||||
|
||||
@if $i == 3 {
|
||||
$alpha: 0.075;
|
||||
} @else {
|
||||
$alpha: 0.8 - ($i + 1) / 5
|
||||
}
|
||||
|
||||
background-color: rgba(160, 160, 160, $alpha);
|
||||
}
|
||||
}
|
||||
|
||||
@at-root #{&}__number {
|
||||
font-size: 1.75rem;
|
||||
font-family: $font--role-emphasis;
|
||||
margin-right: 1rem;
|
||||
width: 1rem;
|
||||
@include user-select(none);
|
||||
}
|
||||
|
||||
@at-root #{&}__title {
|
||||
font-size: 1.125rem;
|
||||
text-overflow: ellipsis;
|
||||
width: 13rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@at-root #{&}__views {
|
||||
color: $color__text--secondary;
|
||||
font-size: 0.9rem;
|
||||
margin-top: -0.125rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue