mirror of
https://github.com/Michatec/MiniFaceBook.git
synced 2026-03-31 23:46:30 +02:00
Files
This commit is contained in:
21
.gitignore
vendored
Normal file
21
.gitignore
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
__pycache__
|
||||
instance
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
migrations
|
||||
*.sqlite3
|
||||
*.log
|
||||
*.db
|
||||
*.env
|
||||
*.DS_Store
|
||||
.vscode
|
||||
routes/__pycache__
|
||||
tools
|
||||
*.pot
|
||||
*.mo
|
||||
routes/oauth.py
|
||||
static/profile_pics
|
||||
static/uploads
|
||||
commands.txt
|
||||
py-to-exemfc.json
|
||||
674
LICENSE
Normal file
674
LICENSE
Normal file
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU 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 <https://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:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
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
|
||||
<https://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
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
46
README.md
Normal file
46
README.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# MiniFacebook
|
||||
|
||||
MiniFacebook is a minimalist social network built with [Flask](https://flask.palletsprojects.com/), [SQLAlchemy](https://www.sqlalchemy.org/), and [Bootstrap](https://getbootstrap.com/). It allows you to share posts, images, videos, and messages with friends—ad-free and privacy-focused.
|
||||
|
||||
## Features
|
||||
|
||||
- Share posts, images, videos, and documents
|
||||
- Friend requests and friends list
|
||||
- Activity notifications
|
||||
- Shop for premium features (e.g., gold frames, extra uploads)
|
||||
- Admin panel with user management
|
||||
- Multilingual (German/English)
|
||||
- Dark and light mode
|
||||
- Discord login and linking
|
||||
- Support ticket system
|
||||
- Password reset via email
|
||||
- Responsive design for desktop and mobile
|
||||
|
||||
## Installation
|
||||
|
||||
1. **Clone the repository**
|
||||
|
||||
```sh
|
||||
git clone https://github.com/Michatec/MiniFaceBook.git
|
||||
cd MiniFaceBook
|
||||
```
|
||||
|
||||
2. **Install dependencies**
|
||||
|
||||
```sh
|
||||
pip install -r requirments.txt
|
||||
```
|
||||
|
||||
3. **Start**
|
||||
|
||||
```sh
|
||||
python main.py
|
||||
```
|
||||
|
||||
## Help to translate
|
||||
|
||||
<https://poeditor.com/join/project/emzEV5JvvI>
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the GNU General Public License v3.0.
|
||||
224
main.py
Normal file
224
main.py
Normal file
@@ -0,0 +1,224 @@
|
||||
from flask import Flask, request, render_template, redirect, url_for, flash, abort, jsonify
|
||||
from flask_migrate import Migrate
|
||||
from flask_login import LoginManager, login_required, current_user
|
||||
from werkzeug.security import generate_password_hash
|
||||
from flask_babel import Babel, gettext as _
|
||||
from waitress import serve
|
||||
from routes.admin import admin_bp
|
||||
from routes.discord import discord_bp
|
||||
from routes.post import post_bp
|
||||
from routes.login import log_bp
|
||||
from routes.support import support_bp
|
||||
from routes.like import like_bp
|
||||
from routes.profile import profile_bp
|
||||
from routes.user import user_bp
|
||||
from routes.friends import friends_bp
|
||||
from routes.notifications import noti_bp
|
||||
from routes.credits import credits_bp
|
||||
from models import db, User, Reward, Event, UserShopItem, ShopItem, SHOPITEM_ID_PREMIUM, SHOPITEM_ID_GOLDRAHMEN, SHOPITEM_ID_EXTRA_TYPES, SHOPITEM_ID_EXTRA_UPLOAD
|
||||
from routes.oauth import oauth
|
||||
import re
|
||||
import os
|
||||
|
||||
__mapper_args__ = {"confirm_deleted_rows": False}
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config['SECRET_KEY'] = "secret_key"
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///site.db'
|
||||
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||
app.config['BABEL_DEFAULT_LOCALE'] = 'en'
|
||||
db.init_app(app)
|
||||
migrate = Migrate(app, db)
|
||||
app_login = LoginManager(app)
|
||||
app_login.login_view = 'log.login'
|
||||
app_login.login_message = _('Please log in to access this page.')
|
||||
app_login.login_message_category = 'info'
|
||||
babel = Babel(app)
|
||||
oauth.init_app(app)
|
||||
|
||||
if not os.path.exists('instance/site.db'):
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
|
||||
if not os.path.exists('static/uploads'):
|
||||
os.makedirs('static/uploads')
|
||||
if not os.path.exists('static/profile_pics'):
|
||||
os.makedirs('static/profile_pics')
|
||||
|
||||
app.register_blueprint(admin_bp)
|
||||
app.register_blueprint(discord_bp)
|
||||
app.register_blueprint(post_bp)
|
||||
app.register_blueprint(log_bp)
|
||||
app.register_blueprint(support_bp)
|
||||
app.register_blueprint(profile_bp)
|
||||
app.register_blueprint(like_bp)
|
||||
app.register_blueprint(user_bp)
|
||||
app.register_blueprint(friends_bp)
|
||||
app.register_blueprint(noti_bp)
|
||||
app.register_blueprint(credits_bp)
|
||||
|
||||
with app.app_context():
|
||||
if db.session.query(ShopItem).count() == 0:
|
||||
db.session.add(ShopItem(
|
||||
name="Premium Account",
|
||||
description="Exclusive features and content.",
|
||||
price=100,
|
||||
icon="bi-star"
|
||||
))
|
||||
db.session.add(ShopItem(
|
||||
name="Gold Profile Frame",
|
||||
description="Adds a golden profile frame to your profile.",
|
||||
price=50,
|
||||
icon="bi-person-bounding-box"
|
||||
))
|
||||
db.session.add(ShopItem(
|
||||
name="Extra Upload Slot",
|
||||
description="Become able to upload more files.",
|
||||
price=130,
|
||||
icon="bi-cloud-upload"
|
||||
))
|
||||
db.session.add(ShopItem(
|
||||
name="More Types",
|
||||
description="More types for your posts. Limit: 500 types per post.",
|
||||
price=80,
|
||||
icon="bi-megaphone"
|
||||
))
|
||||
db.session.commit()
|
||||
else:
|
||||
pass
|
||||
|
||||
def get_locale():
|
||||
lang = request.cookies.get('lang')
|
||||
if lang in ['de', 'en']:
|
||||
return lang
|
||||
|
||||
babel.init_app(app, locale_selector=get_locale)
|
||||
|
||||
def needs_admin_setup():
|
||||
return db.session.query(User).filter_by(is_admin=True).count() == 0
|
||||
|
||||
@app.context_processor
|
||||
def inject_user():
|
||||
return dict(user=current_user if current_user.is_authenticated else None)
|
||||
|
||||
@app.context_processor
|
||||
def inject_theme():
|
||||
theme = request.cookies.get('theme')
|
||||
if not theme:
|
||||
theme = 'dark'
|
||||
return dict(theme_class=f"{theme}-mode" if theme else "")
|
||||
|
||||
@app.context_processor
|
||||
def inject_locale():
|
||||
return dict(get_locale=get_locale)
|
||||
|
||||
@app.context_processor
|
||||
def inject_shopitem_ids():
|
||||
return dict(
|
||||
SHOPITEM_ID_GOLDRAHMEN=SHOPITEM_ID_GOLDRAHMEN,
|
||||
SHOPITEM_ID_PREMIUM=SHOPITEM_ID_PREMIUM,
|
||||
SHOPITEM_ID_EXTRA_UPLOAD=SHOPITEM_ID_EXTRA_UPLOAD,
|
||||
SHOPITEM_ID_EXTRA_TYPES=SHOPITEM_ID_EXTRA_TYPES
|
||||
)
|
||||
|
||||
@app_login.user_loader
|
||||
def load_user(user_id):
|
||||
return db.session.get(User, int(user_id))
|
||||
|
||||
@app.before_request
|
||||
def check_for_admin():
|
||||
allowed_routes = ['setup', 'static']
|
||||
if needs_admin_setup() and request.endpoint not in allowed_routes:
|
||||
return redirect(url_for('setup'))
|
||||
|
||||
@app.route('/', methods=['GET'])
|
||||
def index():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('post.feed'))
|
||||
return render_template('index.html')
|
||||
|
||||
|
||||
@app.route('/setup', methods=['GET', 'POST'])
|
||||
def setup():
|
||||
if not needs_admin_setup():
|
||||
return redirect(url_for('log.login'))
|
||||
if request.method == 'POST':
|
||||
username = request.form['username']
|
||||
email = request.form['email']
|
||||
password = request.form['password']
|
||||
confirm_password = request.form['confirm_password']
|
||||
if password != confirm_password:
|
||||
flash(_('Passwords do not match.'), 'danger')
|
||||
elif db.session.query(User).filter_by(username=username).first():
|
||||
flash(_('Username already exists.'), 'danger')
|
||||
elif db.session.query(User).filter_by(email=email).first():
|
||||
flash(_('E-Mail already exists.'), 'danger')
|
||||
elif len(password) < 8:
|
||||
flash(_('Password must be at least 8 characters long.'), 'danger')
|
||||
elif not re.match(r'^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$', email):
|
||||
flash(_('Invalid email address.'), 'danger')
|
||||
elif not re.match(r'^[a-zA-Z0-9_.+-]+$', username):
|
||||
flash(_('Invalid username. Only alphanumeric characters are allowed.'), 'danger')
|
||||
else:
|
||||
hashed_password = generate_password_hash(password, method='pbkdf2:sha256')
|
||||
admin_user = User(username=username, email=email, password=hashed_password, is_admin=True, is_owner=True)
|
||||
db.session.add(admin_user)
|
||||
db.session.commit()
|
||||
flash(_('Admin account created. You can now log in.'), 'success')
|
||||
return redirect(url_for('log.login'))
|
||||
return render_template('setup.html')
|
||||
|
||||
@app.route('/api/events')
|
||||
@login_required
|
||||
def api_events():
|
||||
if not current_user.is_admin:
|
||||
abort(403)
|
||||
|
||||
events = db.session.query(Event).order_by(Event.timestamp.desc()).limit(20).all()
|
||||
return jsonify([
|
||||
{"timestamp": e.timestamp.strftime('%Y-%m-%d %H:%M'), "message": e.message}
|
||||
for e in events
|
||||
])
|
||||
|
||||
@app.route('/shop', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def shop():
|
||||
items = db.session.query(ShopItem).all()
|
||||
message = None
|
||||
owned_ids = [usi.item_id for usi in current_user.shop_items]
|
||||
if request.method == 'POST':
|
||||
item_id = int(request.form['item_id'])
|
||||
item = db.session.get(ShopItem, item_id)
|
||||
if item_id in owned_ids:
|
||||
message = _("Already purchased!")
|
||||
elif item and current_user.reward_points() >= item.price:
|
||||
db.session.add(Reward(user_id=current_user.id, type=f'buy_{item.name}', points=-item.price))
|
||||
db.session.add(UserShopItem(user_id=current_user.id, item_id=item.id))
|
||||
db.session.commit()
|
||||
message = _(f"Purchased: {item.name}")
|
||||
owned_ids.append(item_id)
|
||||
else:
|
||||
message = _("Not enough points!")
|
||||
return render_template('shop.html', items=items, message=message, owned_ids=owned_ids)
|
||||
|
||||
@app.errorhandler(403)
|
||||
def forbidden(error):
|
||||
return render_template('403.html'), 403
|
||||
|
||||
@app.errorhandler(404)
|
||||
def not_found(error):
|
||||
flash(f'{error}', 'danger')
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('post.feed'))
|
||||
return render_template('index.html'), 200
|
||||
|
||||
@app.route('/secret')
|
||||
@login_required
|
||||
def secret():
|
||||
return render_template('secret.html')
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
serve(app, host="0.0.0.0", port=80, threads=12)
|
||||
except:
|
||||
app.run(debug=True, host="0.0.0.0", port=80)
|
||||
137
models.py
Normal file
137
models.py
Normal file
@@ -0,0 +1,137 @@
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_login import UserMixin
|
||||
from datetime import datetime
|
||||
|
||||
db = SQLAlchemy()
|
||||
|
||||
class User(UserMixin, db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(150), unique=True, nullable=False)
|
||||
email = db.Column(db.String(150), unique=True, nullable=False)
|
||||
password = db.Column(db.String(150), nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||
is_admin = db.Column(db.Boolean, default=False)
|
||||
is_owner = db.Column(db.Boolean, default=False)
|
||||
profile_pic = db.Column(db.String(200), default='default.png')
|
||||
discord_id = db.Column(db.String(50), unique=True)
|
||||
discord_linked = db.Column(db.Boolean, default=False)
|
||||
posts = db.relationship('Post', backref='user', cascade="all, delete", passive_deletes=True)
|
||||
comments = db.relationship('Comment', backref='user', cascade="all, delete", passive_deletes=True)
|
||||
likes = db.relationship('Like', backref='user', cascade="all, delete", passive_deletes=True)
|
||||
friendships_sent = db.relationship(
|
||||
'Friendship',
|
||||
foreign_keys='Friendship.requester_id',
|
||||
backref='requester',
|
||||
cascade="all, delete",
|
||||
passive_deletes=True
|
||||
)
|
||||
friendships_received = db.relationship(
|
||||
'Friendship',
|
||||
foreign_keys='Friendship.receiver_id',
|
||||
backref='receiver',
|
||||
cascade="all, delete",
|
||||
passive_deletes=True
|
||||
)
|
||||
uploads = db.relationship('Upload', backref='user', cascade="all, delete", passive_deletes=True)
|
||||
rewards = db.relationship('Reward', backref='user', cascade="all, delete", passive_deletes=True)
|
||||
shop_items = db.relationship('UserShopItem', backref='user', cascade="all, delete", passive_deletes=True)
|
||||
|
||||
def reward_points(self):
|
||||
return sum(r.points for r in self.rewards)
|
||||
|
||||
class Friendship(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
requester_id = db.Column(db.Integer, db.ForeignKey('user.id'))
|
||||
receiver_id = db.Column(db.Integer, db.ForeignKey('user.id'))
|
||||
status = db.Column(db.String(20), default='pending')
|
||||
|
||||
class Post(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
|
||||
content = db.Column(db.Text, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||
visibility = db.Column(db.String(20), default='public')
|
||||
likes = db.relationship('Like', backref='post', lazy='dynamic', cascade="all, delete-orphan", passive_deletes=True)
|
||||
comments = db.relationship('Comment', backref='post', cascade="all, delete-orphan", passive_deletes=True)
|
||||
uploads = db.relationship('Upload', backref='post', cascade="all, delete-orphan", passive_deletes=True)
|
||||
|
||||
class Comment(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
post_id = db.Column(db.Integer, db.ForeignKey('post.id'))
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
|
||||
content = db.Column(db.Text, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||
|
||||
class Like(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
post_id = db.Column(db.Integer, db.ForeignKey('post.id', ondelete='CASCADE'))
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
|
||||
|
||||
class PasswordResetRequest(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
|
||||
requested_at = db.Column(db.DateTime, default=datetime.now)
|
||||
status = db.Column(db.String(20), default='pending')
|
||||
user = db.relationship('User', backref='reset_requests')
|
||||
|
||||
class Upload(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
|
||||
post_id = db.Column(db.Integer, db.ForeignKey('post.id'))
|
||||
filename = db.Column(db.String(255))
|
||||
filetype = db.Column(db.String(50))
|
||||
uploaded_at = db.Column(db.DateTime, default=datetime.now)
|
||||
|
||||
class Event(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
timestamp = db.Column(db.DateTime, default=datetime.now)
|
||||
message = db.Column(db.String(255))
|
||||
|
||||
class Notification(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
|
||||
message = db.Column(db.String(255))
|
||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||
read = db.Column(db.Boolean, default=False)
|
||||
|
||||
class Reward(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
|
||||
type = db.Column(db.String(50))
|
||||
points = db.Column(db.Integer, default=0)
|
||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||
|
||||
class ShopItem(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100), nullable=False)
|
||||
description = db.Column(db.String(255))
|
||||
price = db.Column(db.Integer, nullable=False)
|
||||
icon = db.Column(db.String(50), default="bi-gift")
|
||||
|
||||
class UserShopItem(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
|
||||
item_id = db.Column(db.Integer, db.ForeignKey('shop_item.id'))
|
||||
bought_at = db.Column(db.DateTime, default=datetime.now)
|
||||
item = db.relationship('ShopItem')
|
||||
|
||||
class SupportRequest(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
|
||||
title = db.Column(db.String(100), nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||
status = db.Column(db.String(20), default='open')
|
||||
comments = db.relationship('SupportComment', backref='request', cascade="all, delete", passive_deletes=True)
|
||||
|
||||
class SupportComment(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
request_id = db.Column(db.Integer, db.ForeignKey('support_request.id'))
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
|
||||
message = db.Column(db.Text, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||
user = db.relationship('User')
|
||||
|
||||
SHOPITEM_ID_GOLDRAHMEN = 2
|
||||
SHOPITEM_ID_PREMIUM = 1
|
||||
SHOPITEM_ID_EXTRA_UPLOAD = 3
|
||||
SHOPITEM_ID_EXTRA_TYPES = 4
|
||||
8
requirments.txt
Normal file
8
requirments.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
flask
|
||||
flask_login
|
||||
flask_migrate
|
||||
werkzeug
|
||||
flask_babel
|
||||
waitress
|
||||
authlib
|
||||
sqlalchemy
|
||||
330
routes/admin.py
Normal file
330
routes/admin.py
Normal file
@@ -0,0 +1,330 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, abort, request
|
||||
from flask_login import login_required, current_user
|
||||
from models import db, User, Post, Friendship, Comment, Upload, Notification, Event, PasswordResetRequest, Like, UserShopItem, Reward, SupportComment, SupportRequest
|
||||
from werkzeug.security import generate_password_hash
|
||||
from flask_babel import gettext as _
|
||||
import os
|
||||
|
||||
__mapper_args__ = {"confirm_deleted_rows": False}
|
||||
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
|
||||
|
||||
@admin_bp.route('/reset_requests/delete_all', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def admin_delete_all_reset_requests():
|
||||
if not current_user.is_admin:
|
||||
abort(403)
|
||||
db.session.query(PasswordResetRequest).delete()
|
||||
db.session.commit()
|
||||
flash(_('All password reset requests have been deleted.'), 'success')
|
||||
return redirect(url_for('admin.reset_requests'))
|
||||
|
||||
@admin_bp.route('/')
|
||||
@login_required
|
||||
def admin():
|
||||
if not current_user.is_admin:
|
||||
abort(403)
|
||||
users = db.session.query(User).all()
|
||||
posts = db.session.query(Post).all()
|
||||
friendships = db.session.query(Friendship).all()
|
||||
comments = db.session.query(Comment).all()
|
||||
uploads = db.session.query(Upload).all()
|
||||
all_notifications = db.session.query(Notification).order_by(Notification.created_at.desc()).all()
|
||||
events = db.session.query(Event).order_by(Event.timestamp.desc()).limit(50).all()
|
||||
user_shop_items = db.session.query(UserShopItem).all()
|
||||
return render_template(
|
||||
'admin.html',
|
||||
users=users,
|
||||
posts=posts,
|
||||
friendships=friendships,
|
||||
comments=comments,
|
||||
uploads=uploads,
|
||||
all_notifications=all_notifications,
|
||||
events=events,
|
||||
user_shop_items=user_shop_items
|
||||
)
|
||||
|
||||
@admin_bp.route('/reset_requests')
|
||||
@login_required
|
||||
def reset_requests():
|
||||
if not current_user.is_admin:
|
||||
abort(403)
|
||||
requests = db.session.query(PasswordResetRequest).filter_by(status='pending').all()
|
||||
requests_done = db.session.query(PasswordResetRequest).filter_by(status='done').all()
|
||||
requests_rejected = db.session.query(PasswordResetRequest).filter_by(status='rejected').all()
|
||||
return render_template('reset_requests.html', requests=requests, requests_done=requests_done, requests_rejected=requests_rejected)
|
||||
|
||||
@admin_bp.route('/reset_requests/<int:req_id>/reject', methods=['POST'])
|
||||
@login_required
|
||||
def reject_reset_request(req_id):
|
||||
if not current_user.is_admin:
|
||||
abort(403)
|
||||
req = db.session.get(PasswordResetRequest, req_id)
|
||||
if req:
|
||||
req.status = 'rejected'
|
||||
db.session.commit()
|
||||
flash(_('Request rejected.'), 'info')
|
||||
return redirect(url_for('admin.reset_requests'))
|
||||
|
||||
@admin_bp.route('/reset_requests/<int:req_id>/reset', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def admin_reset_password(req_id):
|
||||
if not current_user.is_admin:
|
||||
abort(403)
|
||||
req = db.session.get(PasswordResetRequest, req_id)
|
||||
if req and req.status == 'pending':
|
||||
user = db.session.get(User, req.user_id)
|
||||
if request.method == 'POST':
|
||||
new_pw = request.form['new_password']
|
||||
if not new_pw or len(new_pw) < 4:
|
||||
flash(_('Password too short.'), 'danger')
|
||||
else:
|
||||
user.password = generate_password_hash(new_pw, method='pbkdf2:sha256')
|
||||
req.status = 'done'
|
||||
db.session.commit()
|
||||
flash(_(f'Password for {user.username} reset.'), 'success')
|
||||
return redirect(url_for('admin.reset_requests'))
|
||||
return render_template('admin_set_password.html', req=req, user=user)
|
||||
return redirect(url_for('admin.reset_requests'))
|
||||
|
||||
@admin_bp.route('/delete_post/<int:post_id>', methods=['POST'])
|
||||
@login_required
|
||||
def admin_delete_post(post_id):
|
||||
if not current_user.is_admin:
|
||||
abort(403)
|
||||
post = db.session.get(Post, post_id)
|
||||
if post:
|
||||
for upload in post.uploads:
|
||||
file_path = os.path.join('static/uploads', upload.filename)
|
||||
try:
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
except Exception:
|
||||
pass
|
||||
likes = db.session.query(Like).filter_by(post_id=post_id).all()
|
||||
for like in likes:
|
||||
db.session.delete(like)
|
||||
comments = db.session.query(Comment).filter_by(post_id=post_id).all()
|
||||
for comment in comments:
|
||||
db.session.delete(comment)
|
||||
db.session.delete(post)
|
||||
event = Event(message=_(f"Admin {current_user.username} has deleted post {post.id}."))
|
||||
db.session.add(event)
|
||||
notification = Notification(message=_(f"Your post {post.id} has been deleted by an admin."), user_id=post.user_id)
|
||||
db.session.add(notification)
|
||||
db.session.commit()
|
||||
flash(_('Post and associated files deleted.'), 'success')
|
||||
return redirect(url_for('admin.admin'))
|
||||
|
||||
@admin_bp.route('/delete_user/<int:user_id>', methods=['POST'])
|
||||
@login_required
|
||||
def admin_delete_user(user_id):
|
||||
user = db.session.get(User, user_id)
|
||||
if user.is_owner:
|
||||
flash(_('Cannot delete the owner account.'), 'danger')
|
||||
return redirect(url_for('admin.admin'))
|
||||
if user and not user.is_admin:
|
||||
event = Event(message=f"Admin {current_user.username} hat {user.username} gelöscht.")
|
||||
db.session.add(event)
|
||||
for post in user.posts:
|
||||
db.session.delete(post)
|
||||
for friendship in user.friendships_sent + user.friendships_received:
|
||||
db.session.delete(friendship)
|
||||
for comment in user.comments:
|
||||
db.session.delete(comment)
|
||||
for user_shop_item in user.shop_items:
|
||||
db.session.delete(user_shop_item)
|
||||
for reward in user.rewards:
|
||||
db.session.delete(reward)
|
||||
for like in user.likes:
|
||||
db.session.delete(like)
|
||||
for upload in user.uploads:
|
||||
file_path = os.path.join('static/uploads', upload.filename)
|
||||
try:
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
except Exception:
|
||||
pass
|
||||
db.session.delete(upload)
|
||||
if user.profile_pic and not user.profile_pic == 'default.png':
|
||||
try:
|
||||
os.remove(os.path.join('static/profile_pics', user.profile_pic))
|
||||
except Exception:
|
||||
pass
|
||||
notifications = db.session.query(Notification).filter_by(user_id=user.id).all()
|
||||
for notif in notifications:
|
||||
db.session.delete(notif)
|
||||
db.session.delete(user)
|
||||
db.session.commit()
|
||||
flash(_('User deleted.'), 'success')
|
||||
else:
|
||||
flash(_('Cannot delete admin or user not found.'), 'danger')
|
||||
return redirect(url_for('admin.admin'))
|
||||
|
||||
@admin_bp.route('/delete_pic/<int:user_id>', methods=['POST'])
|
||||
@login_required
|
||||
def admin_delete_pic(user_id):
|
||||
if not current_user.is_admin:
|
||||
abort(403)
|
||||
user = db.session.get(User, user_id)
|
||||
if user and user.profile_pic and user.profile_pic != 'default.png':
|
||||
try:
|
||||
os.remove(os.path.join('static/profile_pics', user.profile_pic))
|
||||
except Exception:
|
||||
pass
|
||||
user.profile_pic = "default.png"
|
||||
db.session.commit()
|
||||
flash(_(f'Profile picture of {user.username} deleted.'), 'success')
|
||||
return redirect(url_for('admin.admin'))
|
||||
|
||||
@admin_bp.route('/delete_all_notifications', methods=['POST'])
|
||||
@login_required
|
||||
def admin_delete_all_notifications():
|
||||
if not current_user.is_admin:
|
||||
abort(403)
|
||||
db.session.query(Notification).delete()
|
||||
db.session.commit()
|
||||
flash(_('All notifications have been deleted.'), 'success')
|
||||
return redirect(url_for('admin.admin'))
|
||||
|
||||
@admin_bp.route('/delete_all_events', methods=['POST'])
|
||||
@login_required
|
||||
def admin_delete_all_events():
|
||||
if not current_user.is_admin:
|
||||
abort(403)
|
||||
db.session.query(Event).delete()
|
||||
db.session.commit()
|
||||
flash(_('All events have been deleted.'), 'success')
|
||||
return redirect(url_for('admin.admin'))
|
||||
|
||||
@admin_bp.route('/delete_upload/<int:upload_id>', methods=['POST'])
|
||||
@login_required
|
||||
def admin_delete_upload(upload_id):
|
||||
if not current_user.is_admin:
|
||||
abort(403)
|
||||
upload = db.session.get(Upload, upload_id)
|
||||
if upload:
|
||||
file_path = os.path.join('static/uploads', upload.filename)
|
||||
try:
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
except Exception:
|
||||
pass
|
||||
db.session.delete(upload)
|
||||
db.session.commit()
|
||||
flash(_('Upload deleted.'), 'success')
|
||||
return redirect(url_for('admin.admin'))
|
||||
|
||||
@admin_bp.route('/delete_all_uploads', methods=['POST'])
|
||||
@login_required
|
||||
def admin_delete_all_uploads():
|
||||
if not current_user.is_admin:
|
||||
abort(403)
|
||||
db.session.query(Upload).delete()
|
||||
db.session.commit()
|
||||
upload_dir = 'static/uploads'
|
||||
for filename in os.listdir(upload_dir):
|
||||
file_path = os.path.join(upload_dir, filename)
|
||||
try:
|
||||
if os.path.isfile(file_path):
|
||||
os.remove(file_path)
|
||||
except Exception:
|
||||
pass
|
||||
flash(_('All uploads have been deleted.'), 'success')
|
||||
return redirect(url_for('admin.admin'))
|
||||
|
||||
@admin_bp.route('/admin/points/<int:user_id>', methods=['POST'])
|
||||
@login_required
|
||||
def admin_points(user_id):
|
||||
if not current_user.is_admin:
|
||||
abort(403)
|
||||
action = request.form.get('action')
|
||||
try:
|
||||
points = int(request.form['points'])
|
||||
except:
|
||||
flash(_('No Points entered!'))
|
||||
return redirect(url_for('admin.admin'))
|
||||
cuser = db.session.get(User, current_user.id)
|
||||
if not cuser.is_owner:
|
||||
abort(403)
|
||||
if action == 'add':
|
||||
db.session.add(Reward(user_id=user_id, type='admin', points=points))
|
||||
db.session.commit()
|
||||
flash(_('Points added!'), 'success')
|
||||
elif action == 'remove':
|
||||
user = db.session.get(User, user_id)
|
||||
if user.reward_points() >= points:
|
||||
db.session.add(Reward(user_id=user_id, type='admin', points=-points))
|
||||
db.session.commit()
|
||||
flash(_('Points removed!'), 'success')
|
||||
else:
|
||||
flash(_("The user has not enough points to take!"), 'danger')
|
||||
return redirect(url_for('admin.admin'))
|
||||
|
||||
@admin_bp.route('/make_admin/<int:user_id>', methods=['POST'])
|
||||
@login_required
|
||||
def make_admin(user_id):
|
||||
if not current_user.is_admin:
|
||||
abort(403)
|
||||
user = db.session.get(User, user_id)
|
||||
if user and not user.is_admin:
|
||||
user.is_admin = True
|
||||
db.session.commit()
|
||||
flash(_(f"{user.username} is now an admin."), "success")
|
||||
return redirect(url_for('admin.admin'))
|
||||
|
||||
@admin_bp.route('/remove_admin/<int:user_id>', methods=['POST'])
|
||||
@login_required
|
||||
def remove_admin(user_id):
|
||||
if not current_user.is_admin:
|
||||
abort(403)
|
||||
user = db.session.get(User, user_id)
|
||||
if user and user.is_admin and not user.is_owner:
|
||||
user.is_admin = False
|
||||
db.session.commit()
|
||||
flash(_(f"Admin rights of {user.username} removed."), "info")
|
||||
else:
|
||||
flash(_("Owner cannot be removed!"), "danger")
|
||||
return redirect(url_for('admin.admin'))
|
||||
|
||||
@admin_bp.route('/wipe_server', methods=['POST'])
|
||||
@login_required
|
||||
def wipe_server():
|
||||
if not current_user.is_admin and not current_user.is_owner:
|
||||
abort(403)
|
||||
|
||||
db.session.query(Reward).delete()
|
||||
db.session.query(UserShopItem).delete()
|
||||
db.session.query(Like).delete()
|
||||
db.session.query(Comment).delete()
|
||||
db.session.query(Friendship).delete()
|
||||
db.session.query(Post).delete()
|
||||
db.session.query(Upload).delete()
|
||||
db.session.query(Notification).delete()
|
||||
db.session.query(Event).delete()
|
||||
db.session.query(PasswordResetRequest).delete()
|
||||
db.session.query(User).delete()
|
||||
db.session.query(SupportComment).delete()
|
||||
db.session.query(SupportRequest).delete()
|
||||
db.session.commit()
|
||||
|
||||
upload_dir = 'static/uploads'
|
||||
for filename in os.listdir(upload_dir):
|
||||
file_path = os.path.join(upload_dir, filename)
|
||||
try:
|
||||
if os.path.isfile(file_path):
|
||||
os.remove(file_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
profile_dir = 'static/profile_pics'
|
||||
for filename in os.listdir(profile_dir):
|
||||
if filename != 'default.png':
|
||||
file_path = os.path.join(profile_dir, filename)
|
||||
try:
|
||||
if os.path.isfile(file_path):
|
||||
os.remove(file_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
flash(_('All Data has been deleted.'), 'success')
|
||||
return redirect(url_for('admin.admin'))
|
||||
14
routes/credits.py
Normal file
14
routes/credits.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request
|
||||
from flask_login import login_required, current_user
|
||||
from models import db
|
||||
from flask_babel import gettext as _
|
||||
|
||||
credits_bp = Blueprint('credit', __name__)
|
||||
|
||||
@credits_bp.route('/credits')
|
||||
def credits():
|
||||
return render_template('credits.html')
|
||||
|
||||
@credits_bp.route('/privacy-policy')
|
||||
def privacy_policy():
|
||||
return render_template('privacy_policy.html')
|
||||
87
routes/discord.py
Normal file
87
routes/discord.py
Normal file
@@ -0,0 +1,87 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request
|
||||
from flask_login import login_required, current_user
|
||||
from models import db, User
|
||||
from werkzeug.security import generate_password_hash
|
||||
from flask_babel import gettext as _
|
||||
from routes.oauth import discord
|
||||
from routes.login import login_user
|
||||
|
||||
discord_bp = Blueprint('discord', __name__)
|
||||
|
||||
@discord_bp.route('/login/discord')
|
||||
def login_discord():
|
||||
redirect_uri = url_for('discord.discord_login_callback', _external=True)
|
||||
return discord.authorize_redirect(redirect_uri)
|
||||
|
||||
@discord_bp.route('/login/discord/callback', methods=['GET', 'POST'])
|
||||
def discord_login_callback():
|
||||
if request.method == 'GET':
|
||||
token = discord.authorize_access_token()
|
||||
user_data = discord.get('users/@me').json()
|
||||
user = User.query.filter_by(discord_id=user_data['id']).first()
|
||||
if user:
|
||||
login_user(user)
|
||||
flash(_('Logged in with Discord.'), 'success')
|
||||
return redirect(url_for('post.feed'))
|
||||
else:
|
||||
flash(_('No account linked with this Discord. Please register.'), 'info')
|
||||
return render_template(
|
||||
'discord_register.html',
|
||||
username=user_data['username'],
|
||||
email=user_data.get('email', ''),
|
||||
discord_id=user_data['id']
|
||||
)
|
||||
else:
|
||||
username = request.form.get('username')
|
||||
email = request.form.get('email')
|
||||
discord_id = request.form.get('discord_id')
|
||||
password = request.form.get('password')
|
||||
confirm_password = request.form.get('confirm_password')
|
||||
if not password or len(password) < 8:
|
||||
flash(_('Password must be at least 8 characters long.'), 'danger')
|
||||
return render_template('discord_register.html', username=username, email=email, discord_id=discord_id)
|
||||
if password != confirm_password:
|
||||
flash(_('Passwords do not match.'), 'danger')
|
||||
return render_template('discord_register.html', username=username, email=email, discord_id=discord_id)
|
||||
if db.session.query(User).filter_by(username=username).first():
|
||||
flash(_('Username already exists. Please Report It.'), 'danger')
|
||||
return render_template('discord_register.html', username="", email=email, discord_id=discord_id)
|
||||
hashed_password = generate_password_hash(password, method='pbkdf2:sha256')
|
||||
new_user = User(
|
||||
username=username,
|
||||
email=email,
|
||||
password=hashed_password,
|
||||
discord_id=discord_id,
|
||||
discord_linked=True
|
||||
)
|
||||
db.session.add(new_user)
|
||||
db.session.commit()
|
||||
login_user(new_user)
|
||||
flash(_('Account created and logged in with Discord.'), 'success')
|
||||
return redirect(url_for('post.feed'))
|
||||
|
||||
@discord_bp.route('/link_discord')
|
||||
@login_required
|
||||
def link_discord():
|
||||
redirect_uri = url_for('discord.authorize_discord', _external=True)
|
||||
return discord.authorize_redirect(redirect_uri)
|
||||
|
||||
@discord_bp.route('/authorize/discord')
|
||||
@login_required
|
||||
def authorize_discord():
|
||||
token = discord.authorize_access_token()
|
||||
user_data = discord.get('users/@me').json()
|
||||
current_user.discord_id = user_data['id']
|
||||
current_user.discord_linked = True
|
||||
db.session.commit()
|
||||
flash(_('Discord account linked!'), 'success')
|
||||
return redirect(url_for('profil.profile'))
|
||||
|
||||
@discord_bp.route('/unlink_discord', methods=['POST'])
|
||||
@login_required
|
||||
def unlink_discord():
|
||||
current_user.discord_id = None
|
||||
current_user.discord_linked = False
|
||||
db.session.commit()
|
||||
flash(_('Discord account unlinked!'), 'success')
|
||||
return redirect(url_for('profil.profile'))
|
||||
12
routes/example oauth.py
Normal file
12
routes/example oauth.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from authlib.integrations.flask_client import OAuth
|
||||
|
||||
oauth = OAuth()
|
||||
discord = oauth.register(
|
||||
name='discord',
|
||||
client_id='YOUR_CLIENT_ID',
|
||||
client_secret='YOUR_CLIENT_SECRET',
|
||||
access_token_url='https://discord.com/api/oauth2/token',
|
||||
authorize_url='https://discord.com/api/oauth2/authorize',
|
||||
api_base_url='https://discord.com/api/',
|
||||
client_kwargs={'scope': 'identify email'}
|
||||
)
|
||||
92
routes/friends.py
Normal file
92
routes/friends.py
Normal file
@@ -0,0 +1,92 @@
|
||||
from flask import Blueprint, redirect, url_for, flash, render_template
|
||||
from flask_login import login_required, current_user
|
||||
from models import db, Notification, Event, User, Friendship, Reward
|
||||
from flask_babel import gettext as _
|
||||
|
||||
friends_bp = Blueprint('friend', __name__)
|
||||
|
||||
@friends_bp.route('/add_friend/<int:user_id>', methods=['POST'])
|
||||
@login_required
|
||||
def add_friend(user_id):
|
||||
if user_id == current_user.id:
|
||||
flash(_('You cannot add yourself as a friend.'), 'warning')
|
||||
return redirect(url_for('user.users'))
|
||||
existing = db.session.query(Friendship).filter_by(requester_id=current_user.id, receiver_id=user_id).first()
|
||||
if existing:
|
||||
flash(_('Friend request already sent.'), 'info')
|
||||
else:
|
||||
friendship = Friendship(requester_id=current_user.id, receiver_id=user_id)
|
||||
db.session.add(friendship)
|
||||
db.session.commit()
|
||||
friend = db.session.get(User, user_id)
|
||||
event = Event(message=_(f"{current_user.username} sent a friend request to {friend.username}."))
|
||||
db.session.add(event)
|
||||
db.session.commit()
|
||||
notif = Notification(
|
||||
user_id=user_id,
|
||||
message=_(f"You have received a friend request from {current_user.username}.")
|
||||
)
|
||||
db.session.add(notif)
|
||||
db.session.commit()
|
||||
flash(_('Friend request sent!'), 'success')
|
||||
return redirect(url_for('user.users'))
|
||||
|
||||
@friends_bp.route('/accept_friend/<int:friendship_id>', methods=['POST'])
|
||||
@login_required
|
||||
def accept_friend(friendship_id):
|
||||
friendship = db.session.get(Friendship, friendship_id)
|
||||
if friendship and friendship.receiver_id == current_user.id:
|
||||
friendship.status = 'accepted'
|
||||
db.session.commit()
|
||||
friend = db.session.get(User, friendship.requester_id)
|
||||
event = Event(message=_(f"{current_user.username} und {friend.username} sind jetzt Freunde."))
|
||||
db.session.add(event)
|
||||
db.session.add(Reward(user_id=current_user.id, type='friendship', points=5))
|
||||
db.session.add(Reward(user_id=friendship.requester_id, type='friendship', points=5))
|
||||
db.session.commit()
|
||||
flash(_('Friend request accepted!'), 'success')
|
||||
else:
|
||||
flash(_('Invalid friend request.'), 'danger')
|
||||
return redirect(url_for('friend.friends'))
|
||||
|
||||
@friends_bp.route('/reject_friend/<int:friendship_id>', methods=['POST'])
|
||||
@login_required
|
||||
def reject_friend(friendship_id):
|
||||
friendship = db.session.get(Friendship, friendship_id)
|
||||
if friendship and friendship.receiver_id == current_user.id:
|
||||
friendship.status = 'rejected'
|
||||
db.session.commit()
|
||||
friend = db.session.get(User, friendship.requester_id)
|
||||
event = Event(message=_(f"{current_user.username} has rejected {friend.username}'s friend request."))
|
||||
db.session.add(event)
|
||||
db.session.commit()
|
||||
flash(_('Friend request rejected.'), 'info')
|
||||
else:
|
||||
flash(_('Invalid friend request.'), 'danger')
|
||||
return redirect(url_for('friend.friends'))
|
||||
|
||||
@friends_bp.route('/friends')
|
||||
@login_required
|
||||
def friends():
|
||||
friends = db.session.query(User).join(Friendship, ((Friendship.requester_id == User.id) | (Friendship.receiver_id == User.id)))\
|
||||
.filter(
|
||||
((Friendship.requester_id == current_user.id) | (Friendship.receiver_id == current_user.id)),
|
||||
Friendship.status == 'accepted',
|
||||
User.id != current_user.id
|
||||
).all()
|
||||
requests = db.session.query(Friendship).filter_by(receiver_id=current_user.id, status='pending').all()
|
||||
return render_template('friends.html', friends=friends, requests=requests)
|
||||
|
||||
@friends_bp.route('/remove_friend/<int:user_id>', methods=['POST'])
|
||||
@login_required
|
||||
def remove_friend(user_id):
|
||||
friendship = db.session.query(Friendship).filter(
|
||||
((Friendship.requester_id == current_user.id) & (Friendship.receiver_id == user_id)) |
|
||||
((Friendship.requester_id == user_id) & (Friendship.receiver_id == current_user.id)),
|
||||
Friendship.status == 'accepted'
|
||||
).first()
|
||||
if friendship:
|
||||
db.session.delete(friendship)
|
||||
db.session.commit()
|
||||
flash(_('Friendship ended.'), 'info')
|
||||
return redirect(url_for('friend.friends'))
|
||||
41
routes/like.py
Normal file
41
routes/like.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from flask import Blueprint, redirect, url_for, flash
|
||||
from flask_login import login_required, current_user
|
||||
from models import db, Post, Notification, Like
|
||||
from flask_babel import gettext as _
|
||||
|
||||
like_bp = Blueprint('like', __name__)
|
||||
|
||||
@like_bp.route('/like/<int:post_id>', methods=['POST', 'GET'])
|
||||
@login_required
|
||||
def like_post(post_id):
|
||||
post = db.session.get(Post, post_id)
|
||||
if not post:
|
||||
flash(_('Post does not exist.'), 'danger')
|
||||
return redirect(url_for('post.feed'))
|
||||
like = db.session.query(Like).filter_by(post_id=post_id, user_id=current_user.id).first()
|
||||
if not like:
|
||||
db.session.add(Like(post_id=post_id, user_id=current_user.id))
|
||||
db.session.commit()
|
||||
if post.user_id != current_user.id:
|
||||
notif = Notification(
|
||||
user_id=post.user_id,
|
||||
message=_(f"{current_user.username} liked your post.")
|
||||
)
|
||||
db.session.add(notif)
|
||||
db.session.commit()
|
||||
flash(_('Post liked.'), 'info')
|
||||
return redirect(url_for('post.feed'))
|
||||
|
||||
@like_bp.route('/unlike/<int:post_id>', methods=['POST', 'GET'])
|
||||
@login_required
|
||||
def unlike_post(post_id):
|
||||
post = db.session.get(Post, post_id)
|
||||
if not post:
|
||||
flash(_('Post does not exist.'), 'danger')
|
||||
return redirect(url_for('post.feed'))
|
||||
like = db.session.query(Like).filter_by(post_id=post_id, user_id=current_user.id).first()
|
||||
if like:
|
||||
db.session.delete(like)
|
||||
db.session.commit()
|
||||
flash(_('Like removed.'), 'info')
|
||||
return redirect(url_for('post.feed'))
|
||||
80
routes/login.py
Normal file
80
routes/login.py
Normal file
@@ -0,0 +1,80 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request
|
||||
from models import db, User, PasswordResetRequest
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import login_user, login_required, logout_user, current_user
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
import re
|
||||
|
||||
log_bp = Blueprint('log', __name__)
|
||||
|
||||
@log_bp.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('post.feed'))
|
||||
if request.method == 'POST':
|
||||
username = request.form['username']
|
||||
password = request.form['password']
|
||||
user = db.session.query(User).filter_by(username=username).first()
|
||||
if user and check_password_hash(user.password, password):
|
||||
login_user(user)
|
||||
flash(_('Logged in successfully.'), 'success')
|
||||
next_url = request.args.get('next')
|
||||
if next_url:
|
||||
return redirect(next_url)
|
||||
return redirect(url_for('post.feed'))
|
||||
else:
|
||||
flash(_('Invalid username or password.'), 'danger')
|
||||
return render_template('login.html')
|
||||
|
||||
@log_bp.route('/logout')
|
||||
@login_required
|
||||
def logout():
|
||||
logout_user()
|
||||
flash(_('Logged out successfully.'), 'success')
|
||||
return redirect(url_for('index'))
|
||||
|
||||
@log_bp.route('/register', methods=['GET', 'POST'])
|
||||
def register():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('post.feed'))
|
||||
if request.method == 'POST':
|
||||
username = request.form['username']
|
||||
email = request.form['email']
|
||||
password = request.form['password']
|
||||
confirm_password = request.form['confirm_password']
|
||||
if password != confirm_password:
|
||||
flash(_('Passwords do not match.'), 'danger')
|
||||
elif db.session.query(User).filter_by(username=username).first():
|
||||
flash(_('Username already exists.'), 'danger')
|
||||
elif db.session.query(User).filter_by(email=email).first():
|
||||
flash(_('E-Mail already exists.'), 'danger')
|
||||
elif len(password) < 8:
|
||||
flash(_('Password must be at least 8 characters long.'), 'danger')
|
||||
elif not re.match(r'^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$', email):
|
||||
flash(_('Invalid email address.'), 'danger')
|
||||
elif not re.match(r'^[a-zA-Z0-9_.+-]+$', username):
|
||||
flash(_('Invalid username. Only alphanumeric characters are allowed.'), 'danger')
|
||||
elif len(username) < 3 or len(username) > 20:
|
||||
flash(_('Username must be between 3 and 20 characters long.'), 'danger')
|
||||
else:
|
||||
hashed_password = generate_password_hash(password, method='pbkdf2:sha256')
|
||||
new_user = User(username=username, email=email, password=hashed_password)
|
||||
db.session.add(new_user)
|
||||
db.session.commit()
|
||||
flash(_('Registered successfully. You can now log in.'), 'success')
|
||||
return redirect(url_for('log.login'))
|
||||
return render_template('register.html')
|
||||
|
||||
@log_bp.route('/reset_password', methods=['GET', 'POST'])
|
||||
def reset_password():
|
||||
if request.method == 'POST':
|
||||
username = request.form['username']
|
||||
user = db.session.query(User).filter_by(username=username).first()
|
||||
if user:
|
||||
req = PasswordResetRequest(user_id=user.id)
|
||||
db.session.add(req)
|
||||
db.session.commit()
|
||||
flash(_('Reset request sent to admins.'), 'info')
|
||||
else:
|
||||
flash(_('No user with this email.'), 'danger')
|
||||
return render_template('reset_password.html')
|
||||
33
routes/notifications.py
Normal file
33
routes/notifications.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from flask import Blueprint, redirect, url_for, flash, render_template
|
||||
from flask_login import login_required, current_user
|
||||
from models import db, Notification
|
||||
from flask_babel import gettext as _
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
noti_bp = Blueprint('notif', __name__)
|
||||
|
||||
@noti_bp.route('/delete_all_notifications', methods=['POST'])
|
||||
@login_required
|
||||
def delete_all_notifications():
|
||||
db.session.query(Notification).filter_by(user_id=current_user.id).delete()
|
||||
db.session.commit()
|
||||
flash(_('All notifications have been deleted.'), 'success')
|
||||
return redirect(url_for('notif.notifications'))
|
||||
|
||||
@noti_bp.route('/delete_notification/<int:notif_id>', methods=['POST'])
|
||||
@login_required
|
||||
def delete_notification(notif_id):
|
||||
notif = db.session.get(Notification, notif_id)
|
||||
if notif and notif.user_id == current_user.id:
|
||||
db.session.delete(notif)
|
||||
db.session.commit()
|
||||
return redirect(url_for('notif.notifications'))
|
||||
|
||||
@noti_bp.route('/notifications')
|
||||
@login_required
|
||||
def notifications():
|
||||
expire_time = datetime.now() - timedelta(days=3)
|
||||
db.session.query(Notification).filter(Notification.created_at < expire_time).delete()
|
||||
db.session.commit()
|
||||
notifications = db.session.query(Notification).filter_by(user_id=current_user.id).order_by(Notification.created_at.desc()).all()
|
||||
return render_template('notifications.html', notifications=notifications)
|
||||
225
routes/post.py
Normal file
225
routes/post.py
Normal file
@@ -0,0 +1,225 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request
|
||||
from flask_login import login_required, current_user
|
||||
from models import db, Post, Reward, Friendship, Upload, Notification, Event, SHOPITEM_ID_EXTRA_UPLOAD, SHOPITEM_ID_EXTRA_TYPES, Like, Comment
|
||||
from flask_babel import gettext as _
|
||||
from sqlalchemy import func
|
||||
from datetime import datetime, date
|
||||
import os
|
||||
|
||||
post_bp = Blueprint('post', __name__)
|
||||
|
||||
@post_bp.route('/post', methods=['POST'])
|
||||
@login_required
|
||||
def create_post():
|
||||
content = request.form['content']
|
||||
visibility = request.form.get('visibility', 'public')
|
||||
file = request.files.get('file')
|
||||
file2 = request.files.get('file2')
|
||||
post = None
|
||||
if content:
|
||||
post = Post(user_id=current_user.id, content=content, visibility=visibility)
|
||||
if not SHOPITEM_ID_EXTRA_TYPES in [usi.item_id for usi in current_user.shop_items]:
|
||||
if len(content) > 250:
|
||||
flash(_('Post content is too long. Please limit it to 250 characters.'), 'danger')
|
||||
return redirect(url_for('post.feed'))
|
||||
else:
|
||||
if len(content) > 500:
|
||||
flash(_('Post content is too long. Please limit it to 500 characters.'), 'danger')
|
||||
return redirect(url_for('post.feed'))
|
||||
db.session.add(post)
|
||||
db.session.commit()
|
||||
flash(_('Post created!'), 'success')
|
||||
if file and file.filename:
|
||||
ext = os.path.splitext(file.filename)[1]
|
||||
filename = f"user_{current_user.id}_{int(datetime.now().timestamp())}{ext}"
|
||||
filepath = os.path.join('static/uploads', filename)
|
||||
file.save(filepath)
|
||||
upload = Upload(user_id=current_user.id, post_id=post.id, filename=filename, filetype=file.content_type)
|
||||
db.session.add(upload)
|
||||
db.session.commit()
|
||||
if file2 and file2.filename and SHOPITEM_ID_EXTRA_UPLOAD in [usi.item_id for usi in current_user.shop_items]:
|
||||
ext = os.path.splitext(file2.filename)[1]
|
||||
filename = f"user_{current_user.id}_{int(datetime.now().timestamp())}_extra{ext}"
|
||||
filepath = os.path.join('static/uploads', filename)
|
||||
file2.save(filepath)
|
||||
upload2 = Upload(user_id=current_user.id, post_id=post.id, filename=filename, filetype=file2.content_type)
|
||||
db.session.add(upload2)
|
||||
db.session.commit()
|
||||
event = Event(message=_(f"{current_user.username} has created a new post."))
|
||||
db.session.add(event)
|
||||
db.session.commit()
|
||||
notif = Notification(user_id=current_user.id, message=_("You have created a new post."))
|
||||
db.session.add(notif)
|
||||
db.session.add(Reward(user_id=current_user.id, type='post', points=5))
|
||||
db.session.commit()
|
||||
|
||||
return redirect(url_for('post.feed'))
|
||||
|
||||
@post_bp.route('/edit_post/<int:post_id>', methods=['GET'])
|
||||
@login_required
|
||||
def edit_post(post_id):
|
||||
post = db.session.get(Post, post_id)
|
||||
if not post:
|
||||
flash(_('Post does not exist.'), 'danger')
|
||||
return redirect(url_for('post.feed'))
|
||||
if post.user_id != current_user.id:
|
||||
flash(_('You do not have permission to edit this post.'), 'danger')
|
||||
return redirect(url_for('post.feed'))
|
||||
return render_template('edit_post.html', post=post)
|
||||
|
||||
@post_bp.route('/update_post/<int:post_id>', methods=['POST', 'GET'])
|
||||
@login_required
|
||||
def update_post(post_id):
|
||||
post = db.session.get(Post, post_id)
|
||||
if not post:
|
||||
flash(_('Post does not exist.'), 'danger')
|
||||
return redirect(url_for('post.feed'))
|
||||
if post.user_id != current_user.id:
|
||||
flash(_('You do not have permission to edit this post.'), 'danger')
|
||||
return redirect(url_for('post.feed'))
|
||||
content = request.form['content']
|
||||
visibility = request.form.get('visibility', 'public')
|
||||
post.content = content
|
||||
post.visibility = visibility
|
||||
file = request.files.get('upload')
|
||||
file2 = request.files.get('upload2')
|
||||
if not SHOPITEM_ID_EXTRA_TYPES in [usi.item_id for usi in current_user.shop_items]:
|
||||
if len(post.content) > 250:
|
||||
flash(_('Post content is too long. Please limit it to 250 characters.'), 'danger')
|
||||
return redirect(url_for('post.feed'))
|
||||
else:
|
||||
if len(post.content) > 500:
|
||||
flash(_('Post content is too long. Please limit it to 500 characters.'), 'danger')
|
||||
return redirect(url_for('post.feed'))
|
||||
|
||||
if file:
|
||||
for upload in post.uploads:
|
||||
file_path = os.path.join('static/uploads', upload.filename)
|
||||
try:
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
db.session.delete(upload)
|
||||
|
||||
ext = os.path.splitext(file.filename)[1]
|
||||
filename = f"user_{current_user.id}_{int(datetime.now().timestamp())}{ext}"
|
||||
filepath = os.path.join('static/uploads', filename)
|
||||
file.save(filepath)
|
||||
upload = Upload(user_id=current_user.id, post_id=post.id, filename=filename, filetype=file.content_type)
|
||||
db.session.add(upload)
|
||||
|
||||
if file2 and SHOPITEM_ID_EXTRA_UPLOAD in [usi.item_id for usi in current_user.shop_items]:
|
||||
for upload in post.uploads:
|
||||
if upload.filename.endswith('_extra' + os.path.splitext(file2.filename)[1]):
|
||||
file_path = os.path.join('static/uploads', upload.filename)
|
||||
try:
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
db.session.delete(upload)
|
||||
|
||||
ext = os.path.splitext(file2.filename)[1]
|
||||
filename = f"user_{current_user.id}_{int(datetime.now().timestamp())}_extra{ext}"
|
||||
filepath = os.path.join('static/uploads', filename)
|
||||
file2.save(filepath)
|
||||
upload2 = Upload(user_id=current_user.id, post_id=post.id, filename=filename, filetype=file2.content_type)
|
||||
db.session.add(upload2)
|
||||
|
||||
notif = Notification(user_id=current_user.id, message=_("Your post has been updated."))
|
||||
db.session.add(notif)
|
||||
event = Event(message=f"{current_user.username} has updated post {post.id}.")
|
||||
db.session.add(event)
|
||||
db.session.commit()
|
||||
flash(_('Post updated!'), 'success')
|
||||
return redirect(url_for('post.feed'))
|
||||
|
||||
@post_bp.route('/feed')
|
||||
def feed():
|
||||
if current_user.is_authenticated:
|
||||
today = date.today()
|
||||
reward_today = db.session.query(Reward).filter(
|
||||
Reward.user_id == current_user.id,
|
||||
Reward.type == 'daily',
|
||||
func.date(Reward.created_at) == today
|
||||
).first()
|
||||
|
||||
if not reward_today:
|
||||
db.session.add(Reward(user_id=current_user.id, type='daily', points=10))
|
||||
db.session.commit()
|
||||
|
||||
if current_user.is_admin:
|
||||
posts = db.session.query(Post).order_by(Post.created_at.desc()).all()
|
||||
else:
|
||||
friend_ids = [
|
||||
f.requester_id if f.requester_id != current_user.id else f.receiver_id
|
||||
for f in db.session.query(Friendship).filter(
|
||||
((Friendship.requester_id == current_user.id) | (Friendship.receiver_id == current_user.id)),
|
||||
Friendship.status == 'accepted'
|
||||
).all()
|
||||
]
|
||||
posts = db.session.query(Post).filter(
|
||||
(Post.visibility == 'public') |
|
||||
((Post.visibility == 'friends') & (Post.user_id.in_(friend_ids + [current_user.id])))
|
||||
).order_by(Post.created_at.desc()).all()
|
||||
else:
|
||||
posts = db.session.query(Post).filter_by(visibility='public').order_by(Post.created_at.desc()).all()
|
||||
events = db.session.query(Event).order_by(Event.timestamp.desc()).limit(20).all()
|
||||
return render_template('feed.html', posts=posts, events=events)
|
||||
|
||||
@post_bp.route('/delete_post/<int:post_id>', methods=['POST'])
|
||||
@login_required
|
||||
def delete_post(post_id):
|
||||
post = db.session.get(Post, post_id)
|
||||
if post and post.user_id == current_user.id:
|
||||
for upload in post.uploads:
|
||||
file_path = os.path.join('static/uploads', upload.filename)
|
||||
try:
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
except Exception:
|
||||
pass
|
||||
likes = db.session.query(Like).filter_by(post_id=post_id).all()
|
||||
for like in likes:
|
||||
db.session.delete(like)
|
||||
db.session.delete(post)
|
||||
comments = db.session.query(Comment).filter_by(post_id=post_id).all()
|
||||
for comment in comments:
|
||||
db.session.delete(comment)
|
||||
notif = Notification(user_id=current_user.id, message=_("Your post has been deleted."))
|
||||
db.session.add(notif)
|
||||
event = Event(message=_(f"{current_user.username} has deleted post {post.id}."))
|
||||
db.session.add(event)
|
||||
db.session.commit()
|
||||
flash(_('Post and all uploads deleted.'), 'success')
|
||||
else:
|
||||
flash(_('Not allowed.'), 'danger')
|
||||
return redirect(url_for('post.feed'))
|
||||
|
||||
@post_bp.route('/delete_comment/<int:comment_id>', methods=['POST'])
|
||||
@login_required
|
||||
def delete_comment(comment_id):
|
||||
comment = db.session.get(Comment, comment_id)
|
||||
if comment and comment.user_id == current_user.id:
|
||||
db.session.delete(comment)
|
||||
db.session.commit()
|
||||
flash(_('Comment deleted.'), 'success')
|
||||
else:
|
||||
flash(_('Not allowed.'), 'danger')
|
||||
return redirect(url_for('post.feed'))
|
||||
|
||||
@post_bp.route('/comment/<int:post_id>', methods=['POST'])
|
||||
@login_required
|
||||
def comment_post(post_id):
|
||||
content = request.form['comment']
|
||||
if content:
|
||||
comment = Comment(post_id=post_id, user_id=current_user.id, content=content)
|
||||
db.session.add(comment)
|
||||
notif = Notification(user_id=current_user.id, message=_("You have written a comment."))
|
||||
db.session.add(notif)
|
||||
db.session.add(Reward(user_id=current_user.id, type='comment', points=2))
|
||||
db.session.commit()
|
||||
return redirect(url_for('post.feed'))
|
||||
66
routes/profile.py
Normal file
66
routes/profile.py
Normal file
@@ -0,0 +1,66 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request
|
||||
from flask_login import login_required, current_user
|
||||
from models import db, User, Post
|
||||
from flask_babel import gettext as _
|
||||
from werkzeug.security import generate_password_hash
|
||||
import re
|
||||
|
||||
profile_bp = Blueprint('profil', __name__)
|
||||
|
||||
@profile_bp.route('/profile')
|
||||
@login_required
|
||||
def profile():
|
||||
return render_template('profile.html', user=current_user)
|
||||
|
||||
@profile_bp.route('/my_posts')
|
||||
@login_required
|
||||
def my_posts():
|
||||
posts = db.session.query(Post).filter_by(user_id=current_user.id).order_by(Post.created_at.desc()).all()
|
||||
return render_template('my_posts.html', posts=posts)
|
||||
|
||||
@profile_bp.route('/edit_profile', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def edit_profile():
|
||||
if request.method == 'POST':
|
||||
new_username = request.form['username']
|
||||
new_email = request.form['email']
|
||||
new_password = request.form['password']
|
||||
confirm_password = request.form['confirm_password']
|
||||
if not current_user.username or not current_user.email:
|
||||
flash(_('Username and email cannot be empty.'), 'danger')
|
||||
return redirect(url_for('profile.edit_profile'))
|
||||
else:
|
||||
if new_username and new_username != current_user.username:
|
||||
if db.session.query(User).filter_by(username=new_username).first():
|
||||
flash(_('Username already taken.'), 'danger')
|
||||
return redirect(url_for('profile.edit_profile'))
|
||||
elif not re.match(r'^[a-zA-Z0-9_.+-]+$', new_username):
|
||||
flash(_('Invalid username. Only alphanumeric characters are allowed.'), 'danger')
|
||||
return redirect(url_for('profile.edit_profile'))
|
||||
else:
|
||||
current_user.username = new_username
|
||||
elif new_email and new_email != current_user.email:
|
||||
if not re.match(r'^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$', new_email):
|
||||
flash(_('Invalid email address.'), 'danger')
|
||||
return redirect(url_for('profile.edit_profile'))
|
||||
elif db.session.query(User).filter_by(email=new_email).first():
|
||||
flash(_('E-Mail already taken.'), 'danger')
|
||||
return redirect(url_for('profile.edit_profile'))
|
||||
else:
|
||||
current_user.email = new_email
|
||||
elif new_password:
|
||||
if len(new_password) < 8:
|
||||
flash(_('Password must be at least 8 characters long.'), 'danger')
|
||||
return redirect(url_for('profile.edit_profile'))
|
||||
elif new_password != confirm_password:
|
||||
flash(_('Passwords do not match.'), 'danger')
|
||||
return redirect(url_for('profile.edit_profile'))
|
||||
else:
|
||||
current_user.password = generate_password_hash(new_password, method='pbkdf2:sha256')
|
||||
else:
|
||||
flash(_('No changes made.'), 'info')
|
||||
return redirect(url_for('profil.profile'))
|
||||
db.session.commit()
|
||||
flash(_('Profile updated.'), 'success')
|
||||
return redirect(url_for('profil.profile'))
|
||||
return render_template('edit_profile.html', user=current_user)
|
||||
77
routes/support.py
Normal file
77
routes/support.py
Normal file
@@ -0,0 +1,77 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, abort
|
||||
from models import db, SupportComment, SupportRequest
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import login_required, current_user
|
||||
from datetime import datetime
|
||||
|
||||
support_bp = Blueprint('support', __name__, url_prefix="/support")
|
||||
|
||||
@support_bp.route('/', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def support():
|
||||
if request.method == 'POST':
|
||||
title = request.form.get('title')
|
||||
description = request.form.get('description')
|
||||
if description and title:
|
||||
ticket = SupportRequest(
|
||||
user_id=current_user.id,
|
||||
title=title,
|
||||
status='open',
|
||||
created_at=datetime.now()
|
||||
)
|
||||
db.session.add(ticket)
|
||||
db.session.commit()
|
||||
db.session.add(SupportComment(request_id=ticket.id, user_id=current_user.id, message=description, created_at=datetime.now()))
|
||||
db.session.commit()
|
||||
flash(_('Support request created!'), 'success')
|
||||
else:
|
||||
flash(_('Title and message required!'), 'danger')
|
||||
|
||||
if current_user.is_admin:
|
||||
support_requests = db.session.query(SupportRequest).order_by(SupportRequest.created_at.desc()).all()
|
||||
else:
|
||||
support_requests = db.session.query(SupportRequest).filter_by(user_id=current_user.id).order_by(SupportRequest.created_at.desc()).all()
|
||||
return render_template('support.html', support_requests=support_requests)
|
||||
|
||||
@support_bp.route('/close/<int:request_id>', methods=['POST'])
|
||||
@login_required
|
||||
def support_close(request_id):
|
||||
ticket = db.session.get(SupportRequest, request_id)
|
||||
if not ticket or (not current_user.is_admin and ticket.user_id != current_user.id):
|
||||
abort(403)
|
||||
ticket.status = 'closed'
|
||||
db.session.commit()
|
||||
flash(_('Ticket closed.'), 'success')
|
||||
return redirect(url_for('support.support_thread', request_id=request_id))
|
||||
|
||||
@support_bp.route('/thread/<int:request_id>', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def support_thread(request_id):
|
||||
ticket = db.session.get(SupportRequest, request_id)
|
||||
if not ticket or (not current_user.is_admin and ticket.user_id != current_user.id):
|
||||
abort(403)
|
||||
if request.method == 'POST' and ticket.status == 'open':
|
||||
message = request.form.get('message')
|
||||
if message:
|
||||
db.session.add(SupportComment(request_id=request_id, user_id=current_user.id, message=message, created_at=datetime.now()))
|
||||
db.session.commit()
|
||||
flash(_('Comment added.'), 'success')
|
||||
else:
|
||||
flash(_('Message required!'), 'danger')
|
||||
comments = db.session.query(SupportComment).filter_by(request_id=request_id).order_by(SupportComment.created_at.asc()).all()
|
||||
return render_template('support_thread.html', ticket=ticket, comments=comments)
|
||||
|
||||
@support_bp.route('/delete/<int:request_id>', methods=['POST'])
|
||||
@login_required
|
||||
def support_delete(request_id):
|
||||
if not current_user.is_admin:
|
||||
abort(403)
|
||||
ticket = db.session.get(SupportRequest, request_id)
|
||||
if ticket:
|
||||
db.session.query(SupportComment).filter_by(request_id=request_id).delete()
|
||||
db.session.delete(ticket)
|
||||
db.session.commit()
|
||||
flash(_('Support ticket deleted.'), 'success')
|
||||
else:
|
||||
flash(_('Ticket not found.'), 'danger')
|
||||
return redirect(url_for('support.support'))
|
||||
104
routes/user.py
Normal file
104
routes/user.py
Normal file
@@ -0,0 +1,104 @@
|
||||
from flask import Blueprint, redirect, url_for, flash, request, render_template
|
||||
from flask_login import logout_user
|
||||
from flask_login import login_required, current_user
|
||||
from models import db, Notification, Upload, Event, User
|
||||
from flask_babel import gettext as _
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
__mapper_args__ = {"confirm_deleted_rows": False}
|
||||
user_bp = Blueprint('user', __name__)
|
||||
|
||||
@user_bp.route('/delete_pic', methods=['POST'])
|
||||
@login_required
|
||||
def delete_pic():
|
||||
if current_user.profile_pic and current_user.profile_pic != 'default.png':
|
||||
try:
|
||||
os.remove(os.path.join('static/profile_pics', current_user.profile_pic))
|
||||
except Exception:
|
||||
pass
|
||||
current_user.profile_pic = 'default.png'
|
||||
db.session.commit()
|
||||
flash(_('Profile picture deleted.'), 'success')
|
||||
return redirect(url_for('profil.profile'))
|
||||
|
||||
@user_bp.route('/upload_pic', methods=['POST'])
|
||||
@login_required
|
||||
def upload_pic():
|
||||
file = request.files['profile_pic']
|
||||
if file:
|
||||
if current_user.profile_pic and current_user.profile_pic != 'default.png':
|
||||
try:
|
||||
os.remove(os.path.join('static/profile_pics', current_user.profile_pic))
|
||||
except Exception:
|
||||
pass
|
||||
current_user.profile_pic = 'default.png'
|
||||
db.session.commit()
|
||||
|
||||
ext = os.path.splitext(file.filename)[1]
|
||||
filename = f"user_{current_user.id}_{int(datetime.now().timestamp())}{ext}"
|
||||
filepath = os.path.join('static/profile_pics', filename)
|
||||
file.save(filepath)
|
||||
current_user.profile_pic = filename
|
||||
db.session.commit()
|
||||
|
||||
notif = Notification(user_id=current_user.id, message=_("You have changed your profile picture."))
|
||||
db.session.add(notif)
|
||||
db.session.commit()
|
||||
|
||||
event = Event(message=_(f"{current_user.username} has changed their profile picture."))
|
||||
db.session.add(event)
|
||||
db.session.commit()
|
||||
flash(_('Profile picture updated.'), 'success')
|
||||
return redirect(url_for('profil.profile'))
|
||||
|
||||
@user_bp.route('/delete_account', methods=['POST'])
|
||||
@login_required
|
||||
def delete_account():
|
||||
if current_user.is_owner:
|
||||
flash(_('You cannot delete the owner account.'), 'danger')
|
||||
return redirect(url_for('profil.profile'))
|
||||
if current_user.is_admin:
|
||||
flash(_('You cannot delete an admin account.'), 'danger')
|
||||
return redirect(url_for('profil.profile'))
|
||||
event = Event(message=f"{current_user.username} hat sein Konto gelöscht.")
|
||||
db.session.add(event)
|
||||
for post in current_user.posts:
|
||||
db.session.delete(post)
|
||||
for friendship in current_user.friendships_sent + current_user.friendships_received:
|
||||
db.session.delete(friendship)
|
||||
for comment in current_user.comments:
|
||||
db.session.delete(comment)
|
||||
for like in current_user.likes:
|
||||
db.session.delete(like)
|
||||
for shop_item in current_user.shop_items:
|
||||
db.session.delete(shop_item)
|
||||
for reward in current_user.rewards:
|
||||
db.session.delete(reward)
|
||||
for upload in current_user.uploads:
|
||||
file_path = os.path.join('static/uploads', upload.filename)
|
||||
try:
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
except Exception:
|
||||
pass
|
||||
db.session.delete(upload)
|
||||
if current_user.profile_pic and not current_user.profile_pic == 'default.png':
|
||||
try:
|
||||
os.remove(os.path.join('static/profile_pics', current_user.profile_pic))
|
||||
except Exception:
|
||||
pass
|
||||
notifications = db.session.query(Notification).filter_by(user_id=current_user.id).all()
|
||||
for notif in notifications:
|
||||
db.session.delete(notif)
|
||||
db.session.delete(current_user)
|
||||
db.session.commit()
|
||||
logout_user()
|
||||
flash(_('Account and all your data deleted.'), 'success')
|
||||
return redirect(url_for('index'))
|
||||
|
||||
@user_bp.route('/users')
|
||||
@login_required
|
||||
def users():
|
||||
all_users = db.session.query(User).filter(User.id != current_user.id).all()
|
||||
return render_template('users.html', users=all_users)
|
||||
312
static/css/styles.css
Normal file
312
static/css/styles.css
Normal file
@@ -0,0 +1,312 @@
|
||||
body {
|
||||
background: linear-gradient(135deg, #e0e7ff 0%, #f0f2f5 100%);
|
||||
min-height: 100vh;
|
||||
font-family: 'Segoe UI', 'Roboto', Arial, sans-serif;
|
||||
cursor: url("/static/icons/custom-cursor.png"), auto;
|
||||
}
|
||||
|
||||
canvas {
|
||||
background: black;
|
||||
border: 1px solid white;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
margin-bottom: 30px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||
background: #fff;
|
||||
}
|
||||
.navbar-brand {
|
||||
font-weight: bold;
|
||||
color: #2563eb !important;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.profile-pic {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #2563eb;
|
||||
background: #fff;
|
||||
}
|
||||
.card {
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 2px 12px rgba(37,99,235,0.06);
|
||||
border: none;
|
||||
}
|
||||
.card-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.form-text {
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.btn-primary, .btn-success, .btn-danger, .btn-warning {
|
||||
border-radius: 20px;
|
||||
padding-left: 1.2em;
|
||||
padding-right: 1.2em;
|
||||
}
|
||||
.btn-primary {
|
||||
background: linear-gradient(90deg, #2563eb 60%, #60a5fa 100%);
|
||||
border: none;
|
||||
}
|
||||
.btn-primary:hover, .btn-primary:focus {
|
||||
background: linear-gradient(90deg, #1d4ed8 60%, #3b82f6 100%);
|
||||
}
|
||||
.btn-danger {
|
||||
background: linear-gradient(90deg, #ef4444 60%, #f87171 100%);
|
||||
border: none;
|
||||
}
|
||||
.btn-danger:hover, .btn-danger:focus {
|
||||
background: linear-gradient(90deg, #dc2626 60%, #f87171 100%);
|
||||
}
|
||||
.btn-success {
|
||||
background: linear-gradient(90deg, #22c55e 60%, #4ade80 100%);
|
||||
border: none;
|
||||
}
|
||||
.btn-success:hover, .btn-success:focus {
|
||||
background: linear-gradient(90deg, #16a34a 60%, #4ade80 100%);
|
||||
}
|
||||
.btn-warning {
|
||||
background: linear-gradient(90deg, #f59e42 60%, #fbbf24 100%);
|
||||
border: none;
|
||||
color: #fff;
|
||||
}
|
||||
.btn-warning:hover, .btn-warning:focus {
|
||||
background: linear-gradient(90deg, #d97706 60%, #fbbf24 100%);
|
||||
color: #fff;
|
||||
}
|
||||
.form-control, .form-select {
|
||||
border-radius: 12px;
|
||||
border: 1px solid #cbd5e1;
|
||||
background: #f8fafc;
|
||||
}
|
||||
.form-control:focus {
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 2px #2563eb22;
|
||||
}
|
||||
.list-group-item {
|
||||
border: none;
|
||||
border-radius: 12px !important;
|
||||
margin-bottom: 8px;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 4px rgba(37,99,235,0.04);
|
||||
}
|
||||
.alert {
|
||||
border-radius: 12px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.table {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.table th, .table td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
background: #e0e7ff;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #c7d2fe;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* Light Mode (default) */
|
||||
body, .card, .navbar, .list-group-item, .table, .form-control, .form-select {
|
||||
transition: background 0.3s, color 0.3s;
|
||||
}
|
||||
body.light-mode {
|
||||
background: linear-gradient(135deg, #0f5b86 0%, #0245aabb 100%);
|
||||
color: #222;
|
||||
}
|
||||
body.light-mode .card,
|
||||
body.light-mode .navbar,
|
||||
body.light-mode .list-group-item,
|
||||
body.light-mode .table {
|
||||
background: #fff;
|
||||
color: #222;
|
||||
}
|
||||
body.light-mode .form-control,
|
||||
body.light-mode .form-select {
|
||||
background: #f8fafc;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
body.light-mode li button {
|
||||
color: #0011ff !important;
|
||||
}
|
||||
|
||||
/* Dark Mode */
|
||||
body.dark-mode {
|
||||
background: linear-gradient(135deg, #0a0a52 0%, #000000 100%) !important;
|
||||
color: #e5e7eb !important;
|
||||
}
|
||||
|
||||
body.dark-mode li button {
|
||||
color: #0099f1 !important;
|
||||
}
|
||||
|
||||
body.light-mode p {
|
||||
color: #000000 !important;
|
||||
}
|
||||
|
||||
body.dark-mode .card,
|
||||
body.dark-mode .navbar,
|
||||
body.dark-mode .list-group-item,
|
||||
body.dark-mode .table {
|
||||
background: #23272f !important;
|
||||
color: #e5e7eb !important;
|
||||
border-color: #23272f !important;
|
||||
}
|
||||
body.dark-mode .form-control,
|
||||
body.dark-mode .form-select {
|
||||
background: #23272f !important;
|
||||
color: #e5e7eb !important;
|
||||
border-color: #444 !important;
|
||||
}
|
||||
body.dark-mode .form-control:focus,
|
||||
body.dark-mode .form-select:focus {
|
||||
border-color: #2563eb !important;
|
||||
box-shadow: 0 0 0 2px #2563eb55 !important;
|
||||
background: #23272f !important;
|
||||
color: #e5e7eb !important;
|
||||
}
|
||||
body.dark-mode .btn,
|
||||
body.dark-mode .btn-primary,
|
||||
body.dark-mode .btn-success,
|
||||
body.dark-mode .btn-danger,
|
||||
body.dark-mode .btn-warning {
|
||||
filter: none !important;
|
||||
background: #2563eb !important;
|
||||
color: #fff !important;
|
||||
border: none !important;
|
||||
}
|
||||
body.dark-mode .btn-danger {
|
||||
background: #ef4444 !important;
|
||||
}
|
||||
body.dark-mode .btn-success {
|
||||
background: #22c55e !important;
|
||||
}
|
||||
body.dark-mode .btn-warning {
|
||||
background: #f59e42 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
body.dark-mode .btn:hover,
|
||||
body.dark-mode .btn:focus {
|
||||
opacity: 0.9;
|
||||
}
|
||||
body.dark-mode .text-muted {
|
||||
color: #9ca3af !important;
|
||||
}
|
||||
body.dark-mode small,
|
||||
body.dark-mode p {
|
||||
color: #e5e7eb !important;
|
||||
}
|
||||
body.dark-mode .alert {
|
||||
background: #23272f !important;
|
||||
color: #e5e7eb !important;
|
||||
border-color: #444 !important;
|
||||
}
|
||||
body.dark-mode .navbar {
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.4) !important;
|
||||
}
|
||||
body.dark-mode .profile-pic {
|
||||
border-color: #60a5fa;
|
||||
background: #181a1b !important;
|
||||
}
|
||||
body.dark-mode .table th,
|
||||
body.dark-mode .table td {
|
||||
color: #e5e7eb !important;
|
||||
background: #23272f !important;
|
||||
}
|
||||
body.dark-mode .list-group-item {
|
||||
background: #23272f !important;
|
||||
color: #e5e7eb !important;
|
||||
box-shadow: 0 1px 4px rgba(37,99,235,0.08) !important;
|
||||
}
|
||||
body.dark-mode ::-webkit-scrollbar-thumb {
|
||||
background: #374151 !important;
|
||||
}
|
||||
body.dark-mode ::-webkit-scrollbar {
|
||||
background: #23272f !important;
|
||||
}
|
||||
|
||||
/* Links im Dark Mode */
|
||||
body.dark-mode a,
|
||||
body.dark-mode a:visited {
|
||||
color: #60a5fa !important;
|
||||
text-decoration: none;
|
||||
}
|
||||
body.dark-mode a:hover {
|
||||
color: #93c5fd !important;
|
||||
}
|
||||
|
||||
/* Placeholder (Input-Hint) im Dark Mode */
|
||||
body.dark-mode ::placeholder {
|
||||
color: #b0b8c1 !important;
|
||||
opacity: 1;
|
||||
}
|
||||
body.dark-mode :-ms-input-placeholder { /* IE 10+ */
|
||||
color: #b0b8c1 !important;
|
||||
}
|
||||
body.dark-mode ::-ms-input-placeholder { /* Edge */
|
||||
color: #b0b8c1 !important;
|
||||
}
|
||||
body.dark-mode .navbar-toggler-icon {
|
||||
background-image: url("data:image/svg+xml;charset=utf8,%3Csvg viewBox='0 0 30 30' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath stroke='rgba(255,255,255,0.9)' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* Footer Styling */
|
||||
.footer {
|
||||
background: #f8fafc;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
body.dark-mode .footer {
|
||||
background: #181a1b !important;
|
||||
border-top: 1px solid #23272f !important;
|
||||
}
|
||||
|
||||
.footer .text-muted, .footer a {
|
||||
color: #6c757d !important;
|
||||
}
|
||||
|
||||
body.dark-mode .footer .text-muted, body.dark-mode .footer a {
|
||||
color: #b0b8c1 !important;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.profile-pic {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
.card-body {
|
||||
padding: 1rem;
|
||||
}
|
||||
.navbar-brand {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.container {
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
.card {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.form-control, .form-select {
|
||||
font-size: 1rem;
|
||||
}
|
||||
.btn {
|
||||
font-size: 1rem;
|
||||
padding: 0.5em 1em;
|
||||
}
|
||||
}
|
||||
BIN
static/icons/custom-cursor.png
Normal file
BIN
static/icons/custom-cursor.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
BIN
static/icons/favicon.ico
Normal file
BIN
static/icons/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 171 KiB |
BIN
static/icons/icon-192.png
Normal file
BIN
static/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
static/icons/icon-512.png
Normal file
BIN
static/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
20
static/js/adstop.js
Normal file
20
static/js/adstop.js
Normal file
@@ -0,0 +1,20 @@
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
var hash = window.location.hash;
|
||||
if (hash) {
|
||||
var tabTrigger = document.querySelector('#adminTab button[data-bs-target="' + hash + '"]');
|
||||
if (tabTrigger) {
|
||||
var tab = new bootstrap.Tab(tabTrigger);
|
||||
tab.show();
|
||||
}
|
||||
}
|
||||
|
||||
var triggerTabList = [].slice.call(document.querySelectorAll('#adminTab button'));
|
||||
triggerTabList.forEach(function (triggerEl) {
|
||||
triggerEl.addEventListener('shown.bs.tab', function (event) {
|
||||
var target = triggerEl.getAttribute('data-bs-target');
|
||||
if (target) {
|
||||
history.replaceState(null, null, target);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
16
static/js/events.js
Normal file
16
static/js/events.js
Normal file
@@ -0,0 +1,16 @@
|
||||
function reloadEvents() {
|
||||
fetch(apiEventsUrl)
|
||||
.then(r => r.json())
|
||||
.then(events => {
|
||||
let tbody = document.getElementById('events-tbody');
|
||||
tbody.innerHTML = "";
|
||||
for (let e of events) {
|
||||
let tr = document.createElement('tr');
|
||||
tr.innerHTML = `<td>${e.timestamp}</td><td>${e.message}</td>`;
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setInterval(reloadEvents, 10000);
|
||||
window.onload = reloadEvents;
|
||||
5
static/js/feed.js
Normal file
5
static/js/feed.js
Normal file
@@ -0,0 +1,5 @@
|
||||
function reload_feed() {
|
||||
setTimeout(function() {
|
||||
window.location.reload();
|
||||
}, 120000);
|
||||
}
|
||||
19
static/js/sw.js
Normal file
19
static/js/sw.js
Normal file
@@ -0,0 +1,19 @@
|
||||
const CACHE_NAME = "minifb-v1";
|
||||
const urlsToCache = [
|
||||
"/",
|
||||
"/static/css/styles.css",
|
||||
"/static/js/theme.js",
|
||||
"/static/manifest.json"
|
||||
];
|
||||
|
||||
self.addEventListener("install", event => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then(cache => cache.addAll(urlsToCache))
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", event => {
|
||||
event.respondWith(
|
||||
caches.match(event.request).then(response => response || fetch(event.request))
|
||||
);
|
||||
});
|
||||
25
static/js/theme.js
Normal file
25
static/js/theme.js
Normal file
@@ -0,0 +1,25 @@
|
||||
function setTheme(mode, save=true) {
|
||||
document.body.classList.remove('light-mode', 'dark-mode');
|
||||
document.body.classList.add(mode + '-mode');
|
||||
document.getElementById('theme-icon').className = mode === 'dark' ? 'bi bi-moon-fill' : 'bi bi-sun-fill';
|
||||
document.getElementById('theme-label').textContent = mode === 'dark' ? 'Dark-Mode' : 'Light-Mode';
|
||||
if(save) document.cookie = "theme=" + mode + ";path=/;max-age=31536000";
|
||||
}
|
||||
function getCookie(name) {
|
||||
let v = document.cookie.match('(^|;) ?' + name + '=([^;]*)(;|$)');
|
||||
return v ? v[2] : null;
|
||||
}
|
||||
|
||||
function systemPrefersDark() {
|
||||
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
let theme = getCookie('theme');
|
||||
if(!theme) theme = systemPrefersDark() ? 'dark' : 'light';
|
||||
setTheme(theme, false);
|
||||
document.getElementById('toggle-theme').onclick = function() {
|
||||
let newTheme = document.body.classList.contains('dark-mode') ? 'light' : 'dark';
|
||||
setTheme(newTheme);
|
||||
};
|
||||
});
|
||||
9
static/js/translate.js
Normal file
9
static/js/translate.js
Normal file
@@ -0,0 +1,9 @@
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('.lang-select').forEach(function(el) {
|
||||
el.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
document.cookie = "lang=" + this.dataset.lang + ";path=/";
|
||||
location.reload();
|
||||
});
|
||||
});
|
||||
});
|
||||
21
static/manifest.json
Normal file
21
static/manifest.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "MiniFacebook",
|
||||
"short_name": "MiniFB",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#181a1b",
|
||||
"theme_color": "#2563eb",
|
||||
"description": "MiniFacebook Social App",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/static/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
20
templates/403.html
Normal file
20
templates/403.html
Normal file
@@ -0,0 +1,20 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _('Home') }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="row justify-content-center align-items-center mt-5">
|
||||
<div class="col-md-8">
|
||||
<div class="card shadow-lg p-4 mb-5" style="border-radius: 1.5rem;">
|
||||
<div class="card-body text-center">
|
||||
<h1 class="mb-3" style="font-size:2.5rem; font-weight:700; color:#2563eb;">
|
||||
<i class="bi bi-people-fill me-2"></i>MiniFacebook 403
|
||||
</h1>
|
||||
<p class="lead mb-4" style="font-size:1.2rem;">
|
||||
{{ _('This page is not accessible to you, you do not have the permissions to view it.') }}<br>
|
||||
<span class="text-muted">{{ _('You can go back from the page.') }}</span>
|
||||
</p>
|
||||
<a href="{{ url_for('post.feed') }}" class="btn btn-success m-2 px-4 py-2"><i class="bi bi-house-door me-1"></i>{{ _('Go to Feed') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
372
templates/admin.html
Normal file
372
templates/admin.html
Normal file
@@ -0,0 +1,372 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _('Admin') }}{% endblock %}
|
||||
{% block content %}
|
||||
<h2><i class="bi bi-shield-lock me-2"></i>{{ _('Admin Panel') }}</h2>
|
||||
|
||||
<ul class="nav nav-tabs mb-3" id="adminTab" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="users-tab" data-bs-toggle="tab" data-bs-target="#users" type="button" role="tab">{{ _('Users') }}</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="posts-tab" data-bs-toggle="tab" data-bs-target="#posts" type="button" role="tab">{{ _('Posts') }}</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="friendships-tab" data-bs-toggle="tab" data-bs-target="#friendships" type="button" role="tab">{{ _('Friendships') }}</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="comments-tab" data-bs-toggle="tab" data-bs-target="#comments" type="button" role="tab">{{ _('Comments') }}</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="uploads-tab" data-bs-toggle="tab" data-bs-target="#uploads" type="button" role="tab">{{ _('Uploads') }}</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="notifications-tab" data-bs-toggle="tab" data-bs-target="#notifications" type="button" role="tab">{{ _('Notifications') }}</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="events-tab" data-bs-toggle="tab" data-bs-target="#events" type="button" role="tab">{{ _('Events') }}</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="shop-items-tab" data-bs-toggle="tab" data-bs-target="#shop-items" type="button" role="tab">{{ _('Shop Orders') }}</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="shop-orders-tab" data-bs-toggle="tab" data-bs-target="#shop-orders" type="button" role="tab">{{ _('Reward Points') }}</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content" id="adminTabContent">
|
||||
<!-- Users Tab -->
|
||||
<div class="tab-pane fade show active" id="users" role="tabpanel">
|
||||
<h4><i class="bi bi-people me-2"></i>{{ _('Users') }}</h4>
|
||||
<table class="table table-sm align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ _('User') }}</th>
|
||||
<th>{{ _('Email') }}</th>
|
||||
<th>{{ _('Admin') }}</th>
|
||||
<th>{{ _('Owner') }}</th>
|
||||
<th>{{ _('Profile Pic') }}</th>
|
||||
<th>{{ _('Actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr>
|
||||
<td>{% if user.profile_pic and user.profile_pic != 'default.png' %}<img src="{{ url_for('static', filename='profile_pics/' ~ user.profile_pic) }}" width="32" class="rounded me-1">{% endif %} {{ user.username }}</td>
|
||||
<td>{{ user.email }}</td>
|
||||
<td>{% if user.is_admin %}<i class="bi bi-check-lg text-success"></i>{% endif %}</td>
|
||||
<td>{% if user.is_owner %}<i class="bi bi-star-fill text-warning"></i>{% endif %}</td>
|
||||
<td>
|
||||
{% if user.profile_pic and user.profile_pic != 'default.png' %}
|
||||
<img src="{{ url_for('static', filename='profile_pics/' ~ user.profile_pic) }}" width="32" class="rounded me-1">
|
||||
<form action="{{ url_for('admin.admin_delete_pic', user_id=user.id) }}" method="post" style="display:inline;">
|
||||
<button class="btn btn-danger btn-sm" title="{{ _('Delete Picture') }}"><i class="bi bi-image"></i></button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if not user.is_admin %}
|
||||
<form action="{{ url_for('admin.make_admin', user_id=user.id) }}" method="post" style="display:inline;">
|
||||
<button class="btn btn-success btn-sm" title="{{ _('Make Admin') }}"><i class="bi bi-person-gear"></i></button>
|
||||
</form>
|
||||
{% elif not user.is_owner %}
|
||||
<form action="{{ url_for('admin.remove_admin', user_id=user.id) }}" method="post" style="display:inline;">
|
||||
<button class="btn btn-warning btn-sm" title="{{ _('Remove Admin') }}"><i class="bi bi-person-dash"></i></button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if not user.is_owner %}
|
||||
<form action="{{ url_for('admin.admin_delete_user', user_id=user.id) }}" method="post" style="display:inline;">
|
||||
<button class="btn btn-danger btn-sm" title="{{ _('Delete User') }}"><i class="bi bi-person-x"></i></button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Posts Tab -->
|
||||
<div class="tab-pane fade" id="posts" role="tabpanel">
|
||||
<h4><i class="bi bi-file-earmark-text me-2"></i>{{ _('All Posts') }}</h4>
|
||||
<table class="table table-sm align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ _('User') }}</th>
|
||||
<th>{{ _('Content') }}</th>
|
||||
<th>{{ _('Visibility') }}</th>
|
||||
<th>{{ _('Created') }}</th>
|
||||
<th>{{ _('Actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for post in posts %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if post.user.profile_pic and post.user.profile_pic != 'default.png' %}
|
||||
<img src="{{ url_for('static', filename='profile_pics/' ~ post.user.profile_pic) }}" alt="{{ post.user.username }}" class="rounded me-1" width="32">{{ post.user.username }}
|
||||
{% else %}
|
||||
<i class="bi bi-person-circle me-1"></i>{{ post.user.username }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ post.content|truncate(50) }}</td>
|
||||
<td>
|
||||
{% if post.visibility == 'public' %}
|
||||
<span class="badge bg-info"><i class="bi bi-globe"></i> {{ _('Public') }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary"><i class="bi bi-people"></i> {{ _('Friends') }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ post.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td>
|
||||
<form action="{{ url_for('admin.admin_delete_post', post_id=post.id) }}" method="post" style="display:inline;">
|
||||
<button class="btn btn-danger btn-sm" title="{{ _('Delete Post') }}"><i class="bi bi-trash"></i></button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Friendships Tab -->
|
||||
<div class="tab-pane fade" id="friendships" role="tabpanel">
|
||||
<h4><i class="bi bi-people-arrows me-2"></i>{{ _('Friendships') }}</h4>
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ _('User 1') }}</th>
|
||||
<th>{{ _('User 2') }}</th>
|
||||
<th>{{ _('Status') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for f in friendships %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if f.requester.profile_pic and f.requester.profile_pic != 'default.png' %}
|
||||
<img src="{{ url_for('static', filename='profile_pics/' ~ f.requester.profile_pic) }}" alt="{{ f.requester.username }}" class="rounded me-1" width="32">{{ f.requester.username }}
|
||||
{% else %}
|
||||
<i class="bi bi-person-circle me-1"></i>{{ f.requester.username }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if f.receiver.profile_pic and f.receiver.profile_pic != 'default.png' %}
|
||||
<img src="{{ url_for('static', filename='profile_pics/' ~ f.receiver.profile_pic) }}" alt="{{ f.receiver.username }}" class="rounded me-1" width="32">{{ f.receiver.username }}
|
||||
{% else %}
|
||||
<i class="bi bi-person-circle me-1"></i>{{ f.receiver.username }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if f.status == 'accepted' %}
|
||||
<span class="badge bg-success">{{ _('Accepted') }}</span>
|
||||
{% elif f.status == 'pending' %}
|
||||
<span class="badge bg-warning text-dark">{{ _('Pending') }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger">{{ _('Rejected') }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Comments Tab -->
|
||||
<div class="tab-pane fade" id="comments" role="tabpanel">
|
||||
<h4><i class="bi bi-chat-left-text me-2"></i>{{ _('Comments') }}</h4>
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ _('User') }}</th>
|
||||
<th>{{ _('Post') }}</th>
|
||||
<th>{{ _('Content') }}</th>
|
||||
<th>{{ _('Created') }}</th>
|
||||
<th>{{ _('Actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for comment in comments %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if comment.user.profile_pic and comment.user.profile_pic != 'default.png' %}
|
||||
<img src="{{ url_for('static', filename='profile_pics/' ~ comment.user.profile_pic) }}" class="rounded me-2" width="32">{{ comment.user.username }}
|
||||
{% else %}
|
||||
<i class="bi bi-person-circle me-1"></i>{{ comment.user.username }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ comment.post.id }}</td>
|
||||
<td>{{ comment.content|truncate(50) }}</td>
|
||||
<td>{{ comment.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td>
|
||||
<form action="{{ url_for('post.delete_comment', comment_id=comment.id) }}" method="post" style="display:inline;">
|
||||
<button class="btn btn-danger btn-sm" title="{{ _('Delete Comment') }}"><i class="bi bi-trash"></i></button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Uploads Tab -->
|
||||
<div class="tab-pane fade" id="uploads" role="tabpanel">
|
||||
<h4><i class="bi bi-upload me-2"></i>{{ _('Uploads') }}
|
||||
<form action="{{ url_for('admin.admin_delete_all_uploads') }}" method="post" style="display:inline;">
|
||||
<button class="btn btn-danger btn-sm" title="{{ _('Delete All Uploads') }}"><i class="bi bi-trash"></i></button>
|
||||
</form>
|
||||
</h4>
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ _('User') }}</th>
|
||||
<th>{{ _('Filename') }}</th>
|
||||
<th>{{ _('Type') }}</th>
|
||||
<th>{{ _('Uploaded') }}</th>
|
||||
<th>{{ _('Actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for upload in uploads %}
|
||||
<tr>
|
||||
<td>{{ upload.user.username }}</td>
|
||||
<td>{{ upload.filename }}</td>
|
||||
<td>{{ upload.filetype }}</td>
|
||||
<td>{{ upload.uploaded_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('static', filename='uploads/' ~ upload.filename) }}" download class="btn btn-outline-primary btn-sm" title="{{ _('Download') }}"><i class="bi bi-download"></i></a>
|
||||
<form action="{{ url_for('admin.admin_delete_upload', upload_id=upload.id) }}" method="post" style="display:inline;">
|
||||
<button class="btn btn-danger btn-sm" title="{{ _('Delete Upload') }}"><i class="bi bi-trash"></i></button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Notifications Tab -->
|
||||
<div class="tab-pane fade" id="notifications" role="tabpanel">
|
||||
<h4>
|
||||
<i class="bi bi-bell me-2"></i>{{ _('Notifications') }}
|
||||
<form action="{{ url_for('admin.admin_delete_all_notifications') }}" method="post" onsubmit="return confirm('{{ _('Are you sure you want to delete all notifications?') }}');">
|
||||
<button class="btn btn-danger mb-2 float-end"><i class="bi bi-bell-slash"></i> {{ _('Delete All Notifications') }}</button>
|
||||
</form>
|
||||
</h4>
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ _('User') }}</th>
|
||||
<th>{{ _('Message') }}</th>
|
||||
<th>{{ _('Created') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for notif in all_notifications %}
|
||||
<tr>
|
||||
<td>{{ notif.user_id }}</td>
|
||||
<td>{{ notif.message }}</td>
|
||||
<td>{{ notif.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Events Tab -->
|
||||
<div class="tab-pane fade" id="events" role="tabpanel">
|
||||
<h4>
|
||||
<i class="bi bi-clock-history me-2"></i>{{ _('Recent Events') }}
|
||||
<form action="{{ url_for('admin.admin_delete_all_events') }}" method="post" onsubmit="return confirm('{{ _('Are you sure you want to delete all events?') }}');">
|
||||
<button class="btn btn-danger mb-2 float-end"><i class="bi bi-calendar-x"></i> {{ _('Delete All Events') }}</button>
|
||||
</form>
|
||||
</h4>
|
||||
<table class="table table-sm">
|
||||
<tbody id="events-tbody">
|
||||
{% for event in events %}
|
||||
<tr>
|
||||
<td>{{ event.timestamp.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td>{{ event.message }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- User Shop Items -->
|
||||
<div class="tab-pane fade" id="shop-items" role="tabpanel">
|
||||
<h4><i class="bi bi-shop me-2"></i>{{ _('Shop Orders') }}</h4>
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ _('User') }}</th>
|
||||
<th>{{ _('Item') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for usi in user_shop_items %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if usi.user.profile_pic and usi.user.profile_pic != 'default.png' %}
|
||||
<img src="{{ url_for('static', filename='profile_pics/' ~ usi.user.profile_pic) }}" class="rounded me-2" width="32">{{ usi.user.username }}
|
||||
{% else %}
|
||||
<i class="bi bi-person-circle me-1"></i>{{ usi.user.username }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ usi.item.name }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Reward Points -->
|
||||
<div class="tab-pane fade" id="shop-orders" role="tabpanel">
|
||||
<h4><i class="bi bi-cart-check me-2"></i>{{ _('Reward Points') }}</h4>
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ _('User') }}</th>
|
||||
<th>{{ _('Points') }}</th>
|
||||
<th>{{ _('Actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if user.profile_pic and user.profile_pic != 'default.png' %}
|
||||
<img src="{{ url_for('static', filename='profile_pics/' ~ user.profile_pic) }}" class="rounded me-2" width="32">{{ user.username }}
|
||||
{% else %}
|
||||
<i class="bi bi-person-circle me-1"></i>{{ user.username }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ user.reward_points() }}</td>
|
||||
<td>
|
||||
<form action="{{ url_for('admin.admin_points', user_id=user.id) }}" method="post" style="display:inline;">
|
||||
<input id="points" name="points" type="number" step="1" inputmode="decimal" required>
|
||||
<button class="btn btn-success btn-sm" name="action" value="add" title="{{ _('Add Points') }}">
|
||||
<i class="bi bi-plus-circle"></i>
|
||||
</button>
|
||||
<button class="btn btn-danger btn-sm" name="action" value="remove" title="{{ _('Remove Points') }}">
|
||||
<i class="bi bi-dash-circle"></i>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<script src="{{ url_for('static', filename='js/adstop.js') }}"></script>
|
||||
<script>
|
||||
var triggerTabList = [].slice.call(document.querySelectorAll('#adminTab button'))
|
||||
triggerTabList.forEach(function (triggerEl) {
|
||||
var tabTrigger = new bootstrap.Tab(triggerEl)
|
||||
triggerEl.addEventListener('click', function (event) {
|
||||
event.preventDefault()
|
||||
tabTrigger.show()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
||||
12
templates/admin_set_password.html
Normal file
12
templates/admin_set_password.html
Normal file
@@ -0,0 +1,12 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _('Set New Password') }}{% endblock %}
|
||||
{% block content %}
|
||||
<h2><i class="bi bi-key me-2"></i>{{ _('Set New Password for %(username)s', username=user.username) }}</h2>
|
||||
<form method="post">
|
||||
<div class="mb-3">
|
||||
<label class="form-label"><i class="bi bi-lock-fill me-1"></i>{{ _('New Password') }}</label>
|
||||
<input type="password" name="new_password" class="form-control" required>
|
||||
</div>
|
||||
<button class="btn btn-success" type="submit"><i class="bi bi-check-circle me-1"></i>{{ _('Set Password') }}</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
113
templates/base.html
Normal file
113
templates/base.html
Normal file
@@ -0,0 +1,113 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ get_locale() }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}MiniFacebook{% endblock %}</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="{{ url_for('static', filename='js/theme.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/translate.js') }}"></script>
|
||||
<link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}">
|
||||
<meta name="theme-color" content="#2563eb">
|
||||
<link rel="apple-touch-icon" href="{{ url_for('static', filename='icons/icon-192.png') }}">
|
||||
<link rel="icon" href="{{ url_for('static', filename='icons/icon-192.png') }}">
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', function() {
|
||||
navigator.serviceWorker.register('{{ url_for('static', filename='js/sw.js') }}');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body class="d-flex flex-column min-vh-100 {{ theme_class }}">
|
||||
<nav class="navbar navbar-expand-lg navbar-light bg-light">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="{{ url_for('index') }}">
|
||||
<i class="bi bi-people-fill me-2"></i>MiniFacebook
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav"
|
||||
aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav ms-auto">
|
||||
{% if current_user.is_authenticated %}
|
||||
{% if user.is_admin %}
|
||||
<li class="nav-item"><a class="nav-link" href="{{ url_for('admin.admin') }}"><i class="bi bi-shield-lock me-1"></i>{{ _('Admin Panel') }}</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{{ url_for('admin.reset_requests') }}"><i class="bi bi-arrow-clockwise me-1"></i>{{ _('Reset Requests') }}</a></li>
|
||||
{% endif %}
|
||||
<li class="nav-item"><a class="nav-link" href="{{ url_for('post.feed') }}"><i class="bi bi-house-door me-1"></i>{{ _('Feed') }}</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{{ url_for('user.users') }}"><i class="bi bi-people me-1"></i>{{ _('Users') }}</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{{ url_for('friend.friends') }}"><i class="bi bi-person-check me-1"></i>{{ _('Friends') }}</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{{ url_for('profil.my_posts') }}"><i class="bi bi-file-earmark-text me-1"></i>{{ _('My Posts') }}</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{{ url_for('notif.notifications') }}"><i class="bi bi-bell me-1"></i>{{ _('Notifications') }}</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{{ url_for('profil.profile') }}"><i class="bi bi-person-circle me-1"></i>{{ _('Profile') }}</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{{ url_for('log.logout') }}"><i class="bi bi-box-arrow-right me-1"></i>{{ _('Logout') }}</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{{ url_for('support.support') }}"><i class="bi bi-question-circle me-1"></i>{{ _('Support') }}</a></li>
|
||||
{% else %}
|
||||
<li class="nav-item"><a class="nav-link" href="{{ url_for('post.feed') }}"><i class="bi bi-house-door me-1"></i>{{ _('Feed') }}</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{{ url_for('log.login') }}"><i class="bi bi-box-arrow-in-right me-1"></i>{{ _('Login') }}</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{{ url_for('log.register') }}"><i class="bi bi-person-plus me-1"></i>{{ _('Register') }}</a></li>
|
||||
{% endif %}
|
||||
<li class="nav-item">
|
||||
<button id="toggle-theme" class="btn btn-outline-secondary btn-sm ms-2" type="button">
|
||||
<span id="theme-icon" class="bi"></span> <span id="theme-label">{{ _('Theme') }}</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item dropdown ms-2">
|
||||
<button class="btn btn-outline-secondary btn-sm dropdown-toggle" type="button" id="langDropdown" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<span id="lang-label">{% if get_locale() == 'de' %}Deutsch{% else %}English{% endif %}</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu" aria-labelledby="langDropdown">
|
||||
<li><a class="dropdown-item lang-select" href="#" data-lang="de">Deutsch</a></li>
|
||||
<li><a class="dropdown-item lang-select" href="#" data-lang="en">English</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="container flex-grow-1">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }} mt-2">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
{% if user.profile_pic and user.profile_pic != 'default.png' %}
|
||||
{% if SHOPITEM_ID_GOLDRAHMEN in user.shop_items | map(attribute='item_id') | list %}
|
||||
<img src="{{ url_for('static', filename='profile_pics/' ~ user.profile_pic) }}" class="profile-pic me-2" style="border-color: gold;">
|
||||
{% else %}
|
||||
<img src="{{ url_for('static', filename='profile_pics/' ~ user.profile_pic) }}" class="profile-pic me-2">
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if user.is_authenticated %}
|
||||
<h1>{{ _('Welcome, %(username)s!', username=user.username) }}</h1>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if user.is_admin %}
|
||||
<p class="text-success">{{ _('You are logged in as an admin.') }}</p>
|
||||
{% endif %}
|
||||
{% if SHOPITEM_ID_PREMIUM in user.shop_items | map(attribute='item_id') | list %}
|
||||
<span class="badge bg-warning text-dark"><i class="bi bi-star"></i> Premium</span>
|
||||
{% endif %}
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
<footer class="footer mt-auto py-3">
|
||||
<div class="container text-center">
|
||||
<span class="text-muted">
|
||||
© 2025 MiniFacebook ·
|
||||
<a href="https://github.com/Michatec/MiniFaceBook" target="_blank" rel="noopener" class="text-decoration-none"><i class="bi bi-github m-1"></i>GitHub</a>
|
||||
· <a href="{{ url_for('index') }}" class="text-decoration-none">{{ _('Home') }}</a>
|
||||
· <a href="{{ url_for('credit.privacy_policy') }}" class="text-decoration-none">{{ _('Privacy Policy') }}</a>
|
||||
· <a href="{{ url_for('credit.credits') }}" class="text-decoration-none">{{ _('Credits') }}</a>
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
20
templates/credits.html
Normal file
20
templates/credits.html
Normal file
@@ -0,0 +1,20 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _('Credits') }}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{{ _('Credits') }}</h1>
|
||||
<p>{{ _('This project was developed by') }} Michatec. {{ _('Special thanks to all contributors and supporters.') }}</p>
|
||||
<p>{{ _('Translators:') }}</p>
|
||||
<ul>
|
||||
<li>{{ _('German:') }} Michatec</li>
|
||||
<li>{{ _('English:') }} Michatec</li>
|
||||
</ul>
|
||||
<p>{{ _('Special thanks to the open-source community for their invaluable resources and tools.') }}</p>
|
||||
<p>{{ _('Design inspired by various open-source projects and communities.') }}</p>
|
||||
<p>{{ _('Backend powered by Flask and SQLAlchemy.') }}</p>
|
||||
<p>{{ _('Frontend built with Bootstrap and jQuery.') }}</p>
|
||||
<p>{{ _('Icons by FontAwesome and other open-source resources.') }}</p>
|
||||
<p>{{ _('Hosted on a secure and reliable platform.') }}</p>
|
||||
<p>{{ _('If you would like to contribute, please reach out to us.') }}</p>
|
||||
<a href="https://github.com/Michatec/MiniFaceBook" target="_blank">{{ _('GitHub Repository') }}</a>
|
||||
<p>{{ _('Thank you for using our application!') }}</p>
|
||||
{% endblock %}
|
||||
22
templates/discord_register.html
Normal file
22
templates/discord_register.html
Normal file
@@ -0,0 +1,22 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _('Discord Registration') }}{% endblock %}
|
||||
{% block content %}
|
||||
<h2>{{ _('Complete Registration') }}</h2>
|
||||
<h3>{{ _('Welcome,') }} <strong>{{ username }}</strong>!</h3>
|
||||
<p>{{ _('Please set a password for your account:') }}</p>
|
||||
<form method="POST">
|
||||
<input type="hidden" name="username" value="{{ username }}">
|
||||
<input type="hidden" name="email" value="{{ email }}">
|
||||
<input type="hidden" name="discord_id" value="{{ discord_id }}">
|
||||
<div class="form-group">
|
||||
<label for="password">{{ _('Password') }}</label>
|
||||
<input type="password" name="password" class="form-control" required minlength="8">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="confirm_password">{{ _('Confirm Password') }}</label>
|
||||
<input type="password" name="confirm_password" class="form-control" required minlength="8">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">{{ _('Create Account') }}</button>
|
||||
<a href="{{ url_for('log.login') }}" class="btn btn-secondary">{{ _('Cancel') }}</a>
|
||||
</form>
|
||||
{% endblock %}
|
||||
53
templates/edit_post.html
Normal file
53
templates/edit_post.html
Normal file
@@ -0,0 +1,53 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _('Edit Post') }}{% endblock %}
|
||||
{% block content %}
|
||||
<h2>{{ _('Edit Post') }}</h2>
|
||||
<form action="{{ url_for('post.update_post', post_id=post.id) }}" method="post" enctype="multipart/form-data" class="mb-3">
|
||||
<div class="mb-3">
|
||||
<label for="content" class="form-label">{{ _('Content') }}</label>
|
||||
{% if not SHOPITEM_ID_EXTRA_TYPES in current_user.shop_items | map(attribute='item_id') | list %}
|
||||
<p class="form-text" style="text-align: right;">{{ _('Limit: 250') }}</p>
|
||||
{% else %}
|
||||
<p class="form-text" style="text-align: right;">{{ _('Limit: 500') }}</p>
|
||||
{% endif %}
|
||||
<textarea name="content" id="content" class="form-control" required>{{ post.content }}</textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="visibility" class="form-label">{{ _('Visibility') }}</label>
|
||||
<select name="visibility" id="visibility" class="form-select">
|
||||
<option value="public" {% if post.visibility == 'public' %}selected{% endif %}>🌍 {{ _('Public') }}</option>
|
||||
<option value="friends" {% if post.visibility == 'friends' %}selected{% endif %}>👥 {{ _('Friends only') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="upload" class="form-label">{{ _('Upload Files') }}</label>
|
||||
<input type="file" name="upload" id="upload" class="form-control">
|
||||
{% if SHOPITEM_ID_EXTRA_UPLOAD in current_user.shop_items | map(attribute='item_id') | list %}
|
||||
<input type="file" name="upload2" id="upload2" class="form-control">
|
||||
{% endif %}
|
||||
<small class="form-text text-muted">{{ _('You can upload images, videos, audio files, or documents.') }}</small>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">{{ _('Update Post') }}</button>
|
||||
<a href="{{ url_for('post.feed') }}" class="btn btn-link">{{ _('Cancel') }}</a>
|
||||
</form>
|
||||
{% if post.uploads %}
|
||||
<div class="mb-3">
|
||||
<h4>{{ _('Current Uploads') }}</h4>
|
||||
{% for upload in post.uploads %}
|
||||
<div class="mb-2">
|
||||
{% if upload.filetype.startswith('image') %}
|
||||
<img src="{{ url_for('static', filename='uploads/' ~ upload.filename) }}" class="img-thumbnail" style="max-width: 200px;">
|
||||
{% elif upload.filetype.startswith('video') %}
|
||||
<video src="{{ url_for('static', filename='uploads/' ~ upload.filename) }}" controls width="320"></video>
|
||||
{% elif upload.filetype.startswith('audio') %}
|
||||
<audio src="{{ url_for('static', filename='uploads/' ~ upload.filename) }}" controls></audio>
|
||||
{% else %}
|
||||
<a href="{{ url_for('static', filename='uploads/' ~ upload.filename) }}" download>{{ upload.filename }}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<h2>{{ _('No uploads found for this post.') }}</h2>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
24
templates/edit_profile.html
Normal file
24
templates/edit_profile.html
Normal file
@@ -0,0 +1,24 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _('Edit Profile') }}{% endblock %}
|
||||
{% block content %}
|
||||
<h2><i class="bi bi-pencil-square me-2"></i>{{ _('Edit Profile') }}</h2>
|
||||
<form method="post">
|
||||
<div class="mb-3">
|
||||
<label class="form-label"><i class="bi bi-person-circle me-1"></i>{{ _('Username') }}</label>
|
||||
<input type="text" name="username" class="form-control" value="{{ user.username }}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label"><i class="bi bi-envelope-at me-1"></i>{{ _('Email') }}</label>
|
||||
<input type="email" name="email" class="form-control" value="{{ user.email }}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label"><i class="bi bi-lock-fill me-1"></i>{{ _('New Password (optional)') }}</label>
|
||||
<input type="password" name="password" class="form-control">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label"><i class="bi bi-lock me-1"></i>{{ _('Confirm New Password') }}</label>
|
||||
<input type="password" name="confirm_password" class="form-control">
|
||||
</div>
|
||||
<button class="btn btn-primary" type="submit"><i class="bi bi-save me-1"></i>{{ _('Save') }}</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
122
templates/feed.html
Normal file
122
templates/feed.html
Normal file
@@ -0,0 +1,122 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _('Feed') }}{% endblock %}
|
||||
{% block content %}
|
||||
{% if current_user.is_authenticated %}
|
||||
<form action="{{ url_for('post.create_post') }}" method="post" enctype="multipart/form-data" class="mb-4">
|
||||
{% if not SHOPITEM_ID_EXTRA_TYPES in current_user.shop_items | map(attribute='item_id') | list %}
|
||||
<p class="form-text" style="text-align: right;">{{ _('Limit: 250') }}</p>
|
||||
{% else %}
|
||||
<p class="form-text" style="text-align: right;">{{ _('Limit: 500') }}</p>
|
||||
{% endif %}
|
||||
<textarea name="content" class="form-control" placeholder="{{ _('What\'s on your mind?') }}" required></textarea>
|
||||
<input type="file" name="file">
|
||||
{% if SHOPITEM_ID_EXTRA_UPLOAD in current_user.shop_items | map(attribute='item_id') | list %}
|
||||
<input type="file" name="file2">
|
||||
{% endif %}
|
||||
<small class="form-text text-muted">{{ _('You can upload images, videos, audio files, or documents.') }}</small>
|
||||
<select name="visibility" class="form-select mt-2" style="max-width:200px;display:inline-block;">
|
||||
<option value="public">🌍 {{ _('Public') }}</option>
|
||||
<option value="friends">👥 {{ _('Friends only') }}</option>
|
||||
</select>
|
||||
<button class="btn btn-primary mt-2" type="submit"><i class="bi bi-send me-1"></i>{{ _('Post') }}</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="alert alert-info" role="alert">
|
||||
{{ _('Please') }} <a href="{{ url_for('log.login') }}">{{ _('log in') }}</a> {{ _('to create a post.') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% for post in posts %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
{% if post.user.profile_pic and post.user.profile_pic != 'default.png' %}
|
||||
{% if SHOPITEM_ID_GOLDRAHMEN in post.user.shop_items | map(attribute='item_id') | list %}
|
||||
<img src="{{ url_for('static', filename='profile_pics/' ~ post.user.profile_pic) }}" class="profile-pic me-2" style="border-color: gold;">
|
||||
{% else %}
|
||||
<img src="{{ url_for('static', filename='profile_pics/' ~ post.user.profile_pic) }}" class="profile-pic me-2">
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<i class="bi bi-person-circle me-2"></i>
|
||||
{% endif %}
|
||||
<strong>{{ post.user.username }}</strong>
|
||||
</div>
|
||||
<p class="card-text">{{ post.content }}</p>
|
||||
{% for upload in post.uploads %}
|
||||
{% if upload.filetype.startswith('image') %}
|
||||
<img src="{{ url_for('static', filename='uploads/' ~ upload.filename) }}"
|
||||
class="img-fluid mb-2"
|
||||
style="max-width: 200px; cursor: pointer;"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#imgModal{{ upload.id }}">
|
||||
<div class="modal fade" id="imgModal{{ upload.id }}" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||
<div class="modal-content bg-transparent border-0">
|
||||
<img src="{{ url_for('static', filename='uploads/' ~ upload.filename) }}" class="w-100 rounded shadow">
|
||||
<a href="{{ url_for('static', filename='uploads/' ~ upload.filename) }}" download class="btn btn-light position-absolute top-0 end-0 m-3"><i class="bi bi-download"></i> {{ _('Download') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% elif upload.filetype.startswith('video') %}
|
||||
<video src="{{ url_for('static', filename='uploads/' ~ upload.filename) }}" controls width="320" class="mb-2"></video>
|
||||
<a href="{{ url_for('static', filename='uploads/' ~ upload.filename) }}" download class="btn btn-sm btn-outline-secondary ms-2"><i class="bi bi-download"></i> {{ _('Download Video') }}</a>
|
||||
{% elif upload.filetype.startswith('audio') %}
|
||||
<audio src="{{ url_for('static', filename='uploads/' ~ upload.filename) }}" controls class="mb-2"></audio>
|
||||
<a href="{{ url_for('static', filename='uploads/' ~ upload.filename) }}" download class="btn btn-sm btn-outline-secondary ms-2"><i class="bi bi-download"></i> {{ _('Download Audio') }}</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('static', filename='uploads/' ~ upload.filename) }}" download><i class="bi bi-paperclip"></i> {{ upload.filename }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<form action="{{ url_for('like.like_post', post_id=post.id) }}" method="post" style="display:inline;">
|
||||
<button class="btn btn-link" type="submit"><i class="bi bi-hand-thumbs-up"></i> {{ _('Like') }} ({{ post.likes.count() }})</button>
|
||||
</form>
|
||||
<form action="{{ url_for('like.unlike_post', post_id=post.id) }}" method="post" style="display:inline;">
|
||||
<button class="btn btn-link" type="submit"><i class="bi bi-hand-thumbs-down"></i> {{ _('Unlike') }}</button>
|
||||
</form>
|
||||
{% if post.user_id == current_user.id %}
|
||||
<form action="{{ url_for('post.delete_post', post_id=post.id) }}" method="post" style="display:inline;">
|
||||
<button class="btn btn-danger btn-sm" type="submit"><i class="bi bi-trash"></i> {{ _('Delete') }}</button>
|
||||
</form>
|
||||
<form action="{{ url_for('post.edit_post', post_id=post.id) }}" method="get" style="display:inline;">
|
||||
<button class="btn btn-secondary btn-sm" type="submit"><i class="bi bi-pencil-square"></i> {{ _('Edit') }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if post.visibility == 'friends' %}
|
||||
<span class="badge bg-secondary ms">{{ _('Friends only') }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-success ms">{{ _('Public') }}</span>
|
||||
{% endif %}
|
||||
<small class="text-muted"><i class="bi bi-clock me-1"></i>{{ post.created_at.strftime('%Y-%m-%d %H:%M') }}</small>
|
||||
<div class="mt-2">
|
||||
<form action="{{ url_for('post.comment_post', post_id=post.id) }}" method="post">
|
||||
<input name="comment" class="form-control" placeholder="{{ _('Comment...') }}"
|
||||
{% if not current_user.is_authenticated %}disabled{% endif %} required>
|
||||
</form>
|
||||
{% if not current_user.is_authenticated %}
|
||||
<small class="text-muted">{{ _('Please login to comment.') }}</small>
|
||||
{% endif %}
|
||||
{% for comment in post.comments %}
|
||||
<div class="mt-1">
|
||||
{% if comment.user.profile_pic and comment.user.profile_pic != 'default.png' %}
|
||||
<img src="{{ url_for('static', filename='profile_pics/' ~ comment.user.profile_pic) }}" class="rounded me-2" width="32"><b>{{ comment.user.username }}</b>:
|
||||
{% else %}
|
||||
<i class="bi bi-person me-1"></i><b>{{ comment.user.username }}</b>:
|
||||
{% endif %}
|
||||
{{ comment.content }}
|
||||
{% if comment.user_id == current_user.id %}
|
||||
<form action="{{ url_for('post.delete_comment', comment_id=comment.id) }}" method="post" style="display:inline;">
|
||||
<button class="btn btn-link btn-sm text-danger"><i class="bi bi-trash"></i> {{ _('Delete') }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if not posts %}
|
||||
<div class="alert alert-info" role="alert">
|
||||
{{ _('No posts available.') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<script src="{{ url_for('static', filename='js/feed.js') }}"></script>
|
||||
{% endblock %}
|
||||
47
templates/friends.html
Normal file
47
templates/friends.html
Normal file
@@ -0,0 +1,47 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _('Friends') }}{% endblock %}
|
||||
{% block content %}
|
||||
<h2><i class="bi bi-people-fill me-2"></i>{{ _('Your Friends') }}</h2>
|
||||
<ul class="list-group mb-4">
|
||||
{% for friend in friends %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<span>
|
||||
{% if friend.profile_pic and friend.profile_pic != 'default.png' %}
|
||||
<img src="{{ url_for('static', filename='profile_pics/' + friend.profile_pic) }}" alt="{{ friend.username }}" class="profile-pic me-2">{{ friend.username }}
|
||||
{% else %}
|
||||
<i class="bi bi-person-circle me-1"></i>{{ friend.username }}
|
||||
{% endif %}
|
||||
</span>
|
||||
<form action="{{ url_for('friend.remove_friend', user_id=friend.id) }}" method="post" style="display:inline;">
|
||||
<button class="btn btn-warning btn-sm"><i class="bi bi-person-dash"></i> {{ _('Remove Friend') }}</button>
|
||||
</form>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="list-group-item">{{ _('No friends yet.') }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<h4><i class="bi bi-person-plus me-2"></i>{{ _('Friend Requests') }}</h4>
|
||||
<ul class="list-group mb-3">
|
||||
{% for req in requests %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<span>
|
||||
{% if req.requester.profile_pic and req.requester.profile_pic != 'default.png' %}
|
||||
<img src="{{ url_for('static', filename='profile_pics/' + req.requester.profile_pic) }}" alt="{{ req.requester.username }}" class="profile-pic me-2">{{ req.requester.username }}
|
||||
{% else %}
|
||||
<i class="bi bi-person-circle me-1"></i>{{ req.requester.username }}
|
||||
{% endif %}
|
||||
</span>
|
||||
<div>
|
||||
<form action="{{ url_for('friend.accept_friend', friendship_id=req.id) }}" method="post" style="display:inline;">
|
||||
<button class="btn btn-success btn-sm"><i class="bi bi-check"></i> {{ _('Accept') }}</button>
|
||||
</form>
|
||||
<form action="{{ url_for('friend.reject_friend', friendship_id=req.id) }}" method="post" style="display:inline;">
|
||||
<button class="btn btn-danger btn-sm"><i class="bi bi-x"></i> {{ _('Reject') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="list-group-item">{{ _('No new requests') }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endblock %}
|
||||
43
templates/index.html
Normal file
43
templates/index.html
Normal file
@@ -0,0 +1,43 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _('Home') }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="row justify-content-center align-items-center mt-5">
|
||||
<div class="col-md-8">
|
||||
<div class="card shadow-lg p-4 mb-5" style="border-radius: 1.5rem;">
|
||||
<div class="card-body text-center">
|
||||
<h1 class="mb-3" style="font-size:2.5rem; font-weight:700; color:#2563eb;">
|
||||
<i class="bi bi-people-fill me-2"></i>MiniFacebook
|
||||
</h1>
|
||||
<p class="lead mb-4" style="font-size:1.2rem;">
|
||||
{{ _('MiniFacebook is a minimalist social network for sharing posts, images, and messages with friends.') }}<br>
|
||||
<span class="text-muted">{{ _('Fast, simple, data-saving - and with') }} <i class="bi bi-moon-stars"></i> {{ _('Dark Mode!') }}</span>
|
||||
</p>
|
||||
{% if not current_user.is_authenticated %}
|
||||
<a href="{{ url_for('log.login') }}" class="btn btn-primary m-2 px-4 py-2"><i class="bi bi-box-arrow-in-right me-1"></i>{{ _('Login') }}</a>
|
||||
<a href="{{ url_for('log.register') }}" class="btn btn-success m-2 px-4 py-2"><i class="bi bi-person-plus me-1"></i>{{ _('Register') }}</a>
|
||||
<a href="{{ url_for('post.feed') }}" class="btn btn-outline-info m-2 px-4 py-2"><i class="bi bi-house-door me-1"></i>{{ _('Go to Feed') }}</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('post.feed') }}" class="btn btn-success m-2 px-4 py-2"><i class="bi bi-house-door me-1"></i>{{ _('Go to Feed') }}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card shadow-sm p-4" style="border-radius: 1.5rem; background: var(--about-bg, #f8fafc);">
|
||||
<div class="card-body">
|
||||
<h4 class="mb-3" style="color:#2563eb;">
|
||||
<i class="bi bi-info-circle"></i> {{ _('About MiniFacebook') }}
|
||||
</h4>
|
||||
<ul class="list-unstyled text-start mx-auto" style="max-width:420px;">
|
||||
<li class="mb-2"><i class="bi bi-check-circle text-success"></i> {{ _('Share posts, images & videos') }}</li>
|
||||
<li class="mb-2"><i class="bi bi-check-circle text-success"></i> {{ _('Friendships & notifications') }}</li>
|
||||
<li class="mb-2"><i class="bi bi-check-circle text-success"></i> {{ _('Modern') }} <i class="bi bi-moon-stars"></i> {{ _('Dark/Light Mode') }}</li>
|
||||
<li class="mb-2"><i class="bi bi-check-circle text-success"></i> {{ _('Open Source & privacy-friendly') }}</li>
|
||||
</ul>
|
||||
<div class="mt-4 text-muted" style="font-size:0.95rem;">
|
||||
<p><b>MiniFacebook</b> {{ _('is a private, data-saving network for you and your friends. No ads, no data sharing - just fun in sharing and communicating.') }}</p>
|
||||
<p>{{ _('Developed with love,') }} <i class="bi bi-bootstrap"></i> Bootstrap, <i class="bi bi-moon-stars"></i> {{ _('Dark Mode!') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
33
templates/login.html
Normal file
33
templates/login.html
Normal file
@@ -0,0 +1,33 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _('Login') }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-5">
|
||||
<div class="card shadow p-4 mt-4" style="border-radius:1rem;">
|
||||
<h2 class="mb-4 text-center"><i class="bi bi-box-arrow-in-right me-2"></i>{{ _('Login') }}</h2>
|
||||
<form method="post">
|
||||
<div class="mb-3">
|
||||
<label class="form-label"><i class="bi bi-person-circle me-1"></i>{{ _('Username') }}</label>
|
||||
<input type="text" name="username" class="form-control" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label"><i class="bi bi-lock-fill me-1"></i>{{ _('Password') }}</label>
|
||||
<input type="password" name="password" class="form-control" required>
|
||||
</div>
|
||||
<div class="d-grid gap-2">
|
||||
<button class="btn btn-primary" type="submit"><i class="bi bi-box-arrow-in-right me-1"></i>{{ _('Login') }}</button>
|
||||
</div>
|
||||
<div class="mt-3 text-center">
|
||||
<a href="{{ url_for('log.register') }}" class="btn btn-link"><i class="bi bi-person-plus"></i> {{ _('Register') }}</a>
|
||||
<a href="{{ url_for('log.reset_password') }}" class="btn btn-link"><i class="bi bi-key"></i> {{ _('Forgot password?') }}</a>
|
||||
</div>
|
||||
</form>
|
||||
<div class="mt-4 text-center">
|
||||
<a href="{{ url_for('discord.login_discord') }}" class="btn btn-primary">
|
||||
<i class="bi bi-discord"></i> {{ _('Login with Discord') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
91
templates/my_posts.html
Normal file
91
templates/my_posts.html
Normal file
@@ -0,0 +1,91 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _('My Posts') }}{% endblock %}
|
||||
{% block content %}
|
||||
<h2><i class="bi bi-file-earmark-text me-2"></i>{{ _('My Posts') }}</h2>
|
||||
{% for post in posts %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
{% if post.user.profile_pic and post.user.profile_pic != 'default.png' %}
|
||||
{% if SHOPITEM_ID_GOLDRAHMEN in post.user.shop_items | map(attribute='item_id') | list %}
|
||||
<img src="{{ url_for('static', filename='profile_pics/' ~ post.user.profile_pic) }}" class="profile-pic me-2" style="border-color: gold;">
|
||||
{% else %}
|
||||
<img src="{{ url_for('static', filename='profile_pics/' ~ post.user.profile_pic) }}" class="profile-pic me-2">
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<i class="bi bi-person-circle me-1"></i>
|
||||
{% endif %}
|
||||
<strong>{{ post.user.username }}</strong>
|
||||
</div>
|
||||
<p class="card-text">{{ post.content }}</p>
|
||||
{% for upload in post.uploads %}
|
||||
{% if upload.filetype.startswith('image') %}
|
||||
<img src="{{ url_for('static', filename='uploads/' ~ upload.filename) }}"
|
||||
class="img-fluid mb-2"
|
||||
style="max-width: 200px; cursor: pointer;"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#imgModal{{ upload.id }}">
|
||||
<div class="modal fade" id="imgModal{{ upload.id }}" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||
<div class="modal-content bg-transparent border-0">
|
||||
<img src="{{ url_for('static', filename='uploads/' ~ upload.filename) }}" class="w-100 rounded shadow">
|
||||
<a href="{{ url_for('static', filename='uploads/' ~ upload.filename) }}" download class="btn btn-light position-absolute top-0 end-0 m-3"><i class="bi bi-download"></i> {{ _('Download') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% elif upload.filetype.startswith('video') %}
|
||||
<video src="{{ url_for('static', filename='uploads/' ~ upload.filename) }}" controls width="320" class="mb-2"></video>
|
||||
<a href="{{ url_for('static', filename='uploads/' ~ upload.filename) }}" download class="btn btn-sm btn-outline-secondary ms-2"><i class="bi bi-download"></i> {{ _('Download Video') }}</a>
|
||||
{% elif upload.filetype.startswith('audio') %}
|
||||
<audio src="{{ url_for('static', filename='uploads/' ~ upload.filename) }}" controls class="mb-2"></audio>
|
||||
<a href="{{ url_for('static', filename='uploads/' ~ upload.filename) }}" download class="btn btn-sm btn-outline-secondary ms-2"><i class="bi bi-download"></i> {{ _('Download Audio') }}</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('static', filename='uploads/' ~ upload.filename) }}" download><i class="bi bi-paperclip"></i> {{ upload.filename }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<form action="{{ url_for('like.like_post', post_id=post.id) }}" method="post" style="display:inline;">
|
||||
<button class="btn btn-link" type="submit"><i class="bi bi-hand-thumbs-up"></i> {{ _('Like') }} ({{ post.likes.count() }})</button>
|
||||
</form>
|
||||
<form action="{{ url_for('like.unlike_post', post_id=post.id) }}" method="post" style="display:inline;">
|
||||
<button class="btn btn-link" type="submit"><i class="bi bi-hand-thumbs-down"></i> {{ _('Unlike') }}</button>
|
||||
</form>
|
||||
<form action="{{ url_for('post.delete_post', post_id=post.id) }}" method="post" style="display:inline;">
|
||||
<button class="btn btn-danger btn-sm" type="submit"><i class="bi bi-trash"></i> {{ _('Delete') }}</button>
|
||||
</form>
|
||||
<form action="{{ url_for('post.edit_post', post_id=post.id) }}" method="get" style="display:inline;">
|
||||
<button class="btn btn-secondary btn-sm" type="submit"><i class="bi bi-pencil-square"></i> {{ _('Edit') }}</button>
|
||||
</form>
|
||||
{% if post.visibility == 'friends' %}
|
||||
<span class="badge bg-secondary ms">{{ _('Friends only') }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-success ms">{{ _('Public') }}</span>
|
||||
{% endif %}
|
||||
<small class="text-muted"><i class="bi bi-clock me-1"></i>{{ post.created_at.strftime('%Y-%m-%d %H:%M') }}</small>
|
||||
<div class="mt-2">
|
||||
<form action="{{ url_for('post.comment_post', post_id=post.id) }}" method="post">
|
||||
<input name="comment" class="form-control" placeholder="{{ _('Comment...') }}" required>
|
||||
</form>
|
||||
{% for comment in post.comments %}
|
||||
<div class="mt-1">
|
||||
{% if comment.user.profile_pic and comment.user.profile_pic != 'default.png' %}
|
||||
<img src="{{ url_for('static', filename='profile_pics/' ~ comment.user.profile_pic) }}" class="rounded me-2" width="32"><b>{{ comment.user.username }}</b>:
|
||||
{% else %}
|
||||
<i class="bi bi-person me-1"></i><b>{{ comment.user.username }}</b>:
|
||||
{% endif %}
|
||||
{% if comment.user_id == current_user.id %}
|
||||
<form action="{{ url_for('post.delete_comment', comment_id=comment.id) }}" method="post" style="display:inline;">
|
||||
<button class="btn btn-link btn-sm text-danger"><i class="bi bi-trash"></i> {{ _('Delete') }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if not posts %}
|
||||
<div class="alert alert-info" role="alert">
|
||||
{{ _('No posts available.') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
32
templates/notifications.html
Normal file
32
templates/notifications.html
Normal file
@@ -0,0 +1,32 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _('Notifications') }}{% endblock %}
|
||||
{% block content %}
|
||||
<h3>
|
||||
<i class="bi bi-bell me-2"></i>
|
||||
{{ _('Notifications') }}
|
||||
<span class="badge bg-secondary">{{ notifications|length }}</span>
|
||||
{% if notifications %}
|
||||
<form action="{{ url_for('notif.delete_all_notifications') }}" method="post" style="display:inline;">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger float-end">
|
||||
<i class="bi bi-trash"></i> {{ _('Delete all') }}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</h3>
|
||||
<ul class="list-group">
|
||||
{% for notif in notifications %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<span>
|
||||
<i class="bi bi-info-circle text-info me-1"></i>
|
||||
{{ notif.message }}
|
||||
<span class="text-muted ms-2"><i class="bi bi-clock"></i> {{ notif.created_at.strftime('%Y-%m-%d %H:%M') }}</span>
|
||||
</span>
|
||||
<form action="{{ url_for('notif.delete_notification', notif_id=notif.id) }}" method="post" style="display:inline;">
|
||||
<button class="btn btn-sm btn-outline-danger"><i class="bi bi-trash"></i> {{ _('Delete') }}</button>
|
||||
</form>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="list-group-item">{{ _('No notifications') }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endblock %}
|
||||
16
templates/privacy_policy.html
Normal file
16
templates/privacy_policy.html
Normal file
@@ -0,0 +1,16 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _('Privacy Policy') }}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{{ _('Privacy Policy') }}</h1>
|
||||
<p>{{ _('Your privacy is important to us. This privacy policy explains how we collect, use, and protect your information when you use our application.') }}</p>
|
||||
<p>{{ _('We collect personal information that you provide to us, such as your name, email address, and any other information you choose to share.') }}</p>
|
||||
<p>{{ _('We use this information to provide and improve our services, communicate with you, and personalize your experience.') }}</p>
|
||||
<p>{{ _('We do not share your personal information with third parties without your consent, except as required by law or to protect our rights.') }}</p>
|
||||
<p>{{ _('We implement security measures to protect your information from unauthorized access, alteration, disclosure, or destruction.') }}</p>
|
||||
<p>{{ _('You have the right to access, correct, or delete your personal information at any time. Please contact us if you wish to exercise these rights.') }}</p>
|
||||
<p>{{ _('We may update this privacy policy from time to time. We will notify you of any changes by posting the new privacy policy on this page.') }}</p>
|
||||
<p>{{ _('By using our application, you agree to the terms of this privacy policy. If you do not agree, please do not use our application.') }}</p>
|
||||
<p>{{ _('If you have any questions or concerns about this privacy policy, please contact us.') }}</p>
|
||||
<a href="https://github.com/Michatec/MiniFaceBook" target="_blank">{{ _('GitHub Repository') }}</a>
|
||||
<p>{{ _('Thank you for using our application!') }}</p>
|
||||
{% endblock %}
|
||||
32
templates/profile.html
Normal file
32
templates/profile.html
Normal file
@@ -0,0 +1,32 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _('Profile') }}{% endblock %}
|
||||
{% block content %}
|
||||
<p><i class="bi bi-envelope-at me-1"></i>{{ _('Email') }}: {{ user.email }}</p>
|
||||
|
||||
<form action="{{ url_for('user.upload_pic') }}" method="post" enctype="multipart/form-data" class="mb-3">
|
||||
<input type="file" name="profile_pic" required>
|
||||
<button class="btn btn-secondary btn-sm" type="submit"><i class="bi bi-upload"></i> {{ _('Upload Picture') }}</button>
|
||||
</form>
|
||||
{% if user.profile_pic and user.profile_pic != 'default.png' %}
|
||||
<form action="{{ url_for('user.delete_pic') }}" method="post" style="display:inline;">
|
||||
<button class="btn btn-danger btn-sm"><i class="bi bi-trash"></i> {{ _('Delete Picture') }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('shop') }}" class="btn btn-secondary"><i class="bi bi-shop"></i> {{ _('Shop') }}</a>
|
||||
<a href="{{ url_for('profil.edit_profile') }}" class="btn btn-secondary"><i class="bi bi-pencil-square"></i> {{ _('Edit Profile') }}</a>
|
||||
<form action="{{ url_for('user.delete_account') }}" method="post" style="display:inline;">
|
||||
<button class="btn btn-danger"><i class="bi bi-person-x"></i> {{ _('Delete Account') }}</button>
|
||||
</form>
|
||||
{% if not user.discord_linked %}
|
||||
<a href="{{ url_for('discord.link_discord') }}" class="btn btn-primary">
|
||||
<i class="bi bi-discord"></i> {{ _('Link Discord Account') }}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="badge bg-success"><i class="bi bi-discord"></i> {{ _('Discord Linked') }}</span>
|
||||
<form action="{{ url_for('discord.unlink_discord') }}" method="post" style="display:inline;">
|
||||
<button class="btn btn-warning btn-sm" type="submit">
|
||||
<i class="bi bi-x-circle"></i> {{ _('Unlink Discord') }}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
41
templates/register.html
Normal file
41
templates/register.html
Normal file
@@ -0,0 +1,41 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _('Register') }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6 col-lg-5">
|
||||
<div class="card shadow p-4 mt-4" style="border-radius:1rem;">
|
||||
<h2 class="mb-4 text-center"><i class="bi bi-person-plus me-2"></i>{{ _('Register') }}</h2>
|
||||
<form method="post">
|
||||
<div class="mb-3">
|
||||
<label class="form-label"><i class="bi bi-person-circle me-1"></i>{{ _('Username') }}</label>
|
||||
<input type="text" name="username" class="form-control" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label"><i class="bi bi-envelope-at me-1"></i>{{ _('Email') }}</label>
|
||||
<input type="email" name="email" class="form-control" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label"><i class="bi bi-lock-fill me-1"></i>{{ _('Password') }}</label>
|
||||
<input type="password" name="password" class="form-control" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label"><i class="bi bi-lock me-1"></i>{{ _('Confirm Password') }}</label>
|
||||
<input type="password" name="confirm_password" class="form-control" required>
|
||||
</div>
|
||||
<div class="d-grid gap-2">
|
||||
<button class="btn btn-success" type="submit"><i class="bi bi-person-plus me-1"></i>{{ _('Register') }}</button>
|
||||
</div>
|
||||
<div class="mt-3 text-center">
|
||||
<span>{{ _('Already have an account?') }}</span>
|
||||
<a href="{{ url_for('log.login') }}" class="btn btn-link"><i class="bi bi-box-arrow-in-right"></i> {{ _('Login') }}</a>
|
||||
</div>
|
||||
<div class="mt-4 text-center">
|
||||
<a href="{{ url_for('discord.login_discord') }}" class="btn btn-primary">
|
||||
<i class="bi bi-discord"></i> {{ _('Login with Discord') }}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
23
templates/reset_password.html
Normal file
23
templates/reset_password.html
Normal file
@@ -0,0 +1,23 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _('Reset Password') }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-5">
|
||||
<div class="card shadow p-4 mt-4" style="border-radius:1rem;">
|
||||
<h2 class="mb-4 text-center"><i class="bi bi-key me-2"></i>{{ _('Reset Password') }}</h2>
|
||||
<form method="post">
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="username"><i class="bi bi-person-circle me-1"></i>{{ _('Username') }}</label>
|
||||
<input type="text" name="username" id="username" class="form-control" required>
|
||||
</div>
|
||||
<div class="d-grid gap-2">
|
||||
<button class="btn btn-primary" type="submit"><i class="bi bi-arrow-clockwise me-1"></i>{{ _('Request Reset') }}</button>
|
||||
</div>
|
||||
<div class="mt-3 text-center">
|
||||
<a href="{{ url_for('log.login') }}" class="btn btn-link"><i class="bi bi-box-arrow-in-right"></i> {{ _('Login') }}</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
90
templates/reset_requests.html
Normal file
90
templates/reset_requests.html
Normal file
@@ -0,0 +1,90 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _('Password Reset Requests') }}{% endblock %}
|
||||
{% block content %}
|
||||
<h2>
|
||||
<i class="bi bi-arrow-clockwise me-2"></i>{{ _('Password Reset Requests') }}
|
||||
<a href="{{ url_for('admin.admin_delete_all_reset_requests') }}" class="btn btn-danger btn-sm float-end"><i class="bi bi-trash"></i> {{ _('Delete All') }}</a>
|
||||
</h2>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><i class="bi bi-person-circle"></i> {{ _('User') }}</th>
|
||||
<th><i class="bi bi-clock"></i> {{ _('Requested At') }}</th>
|
||||
<th><i class="bi bi-gear"></i> {{ _('Actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for req in requests %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if req.user.profile_pic and req.user.profile_pic != 'default.png' %}
|
||||
<img src="{{ url_for('static', filename='profile_pics/' ~ req.user.profile_pic) }}" class="rounded me-2" width="32">{{ req.user.username }}
|
||||
{% else %}
|
||||
<i class="bi bi-person-circle me-1"></i>{{ req.user.username }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ req.requested_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('admin.admin_reset_password', req_id=req.id) }}" class="btn btn-success btn-sm"><i class="bi bi-key"></i> {{ _('Set Password') }}</a>
|
||||
<form action="{{ url_for('admin.reject_reset_request', req_id=req.id) }}" method="post" style="display:inline;">
|
||||
<button class="btn btn-danger btn-sm" type="submit"><i class="bi bi-x"></i> {{ _('Reject') }}</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if requests_done %}
|
||||
<h3><i class="bi bi-check-circle me-2"></i>{{ _('Completed Requests') }}</h3>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><i class="bi bi-person-circle"></i> {{ _('User') }}</th>
|
||||
<th><i class="bi bi-check-circle"></i> {{ _('Requested At') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for req in requests_done %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if req.user.profile_pic and req.user.profile_pic != 'default.png' %}
|
||||
<img src="{{ url_for('static', filename='profile_pics/' ~ req.user.profile_pic) }}" class="rounded me-2" width="32">{{ req.user.username }}
|
||||
{% else %}
|
||||
<i class="bi bi-person-circle me-1"></i>{{ req.user.username }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ req.requested_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
{% if requests_rejected %}
|
||||
<h3><i class="bi bi-x-circle me-2"></i>{{ _('Rejected Requests') }}</h3>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><i class="bi bi-person-circle"></i> {{ _('User') }}</th>
|
||||
<th><i class="bi bi-x-circle"></i> {{ _('Requested At') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for req in requests_rejected %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if req.user.profile_pic and req.user.profile_pic != 'default.png' %}
|
||||
<img src="{{ url_for('static', filename='profile_pics/' ~ req.user.profile_pic) }}" class="rounded me-2" width="32">{{ req.user.username }}
|
||||
{% else %}
|
||||
<i class="bi bi-person-circle me-1"></i>{{ req.user.username }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ req.requested_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
{% if not requests %}
|
||||
<div class="alert alert-info"><i class="bi bi-info-circle"></i> {{ _('No open reset requests.') }}</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
294
templates/secret.html
Normal file
294
templates/secret.html
Normal file
@@ -0,0 +1,294 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}{{ _('Secret') }}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Secret</h1>
|
||||
<canvas width="320" height="640" id="game"></canvas>
|
||||
<script>
|
||||
function getRandomInt(min, max) {
|
||||
min = Math.ceil(min);
|
||||
max = Math.floor(max);
|
||||
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
// generate a new tetromino sequence
|
||||
// @see https://tetris.fandom.com/wiki/Random_Generator
|
||||
function generateSequence() {
|
||||
const sequence = ['I', 'J', 'L', 'O', 'S', 'T', 'Z'];
|
||||
|
||||
while (sequence.length) {
|
||||
const rand = getRandomInt(0, sequence.length - 1);
|
||||
const name = sequence.splice(rand, 1)[0];
|
||||
tetrominoSequence.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
// get the next tetromino in the sequence
|
||||
function getNextTetromino() {
|
||||
if (tetrominoSequence.length === 0) {
|
||||
generateSequence();
|
||||
}
|
||||
|
||||
const name = tetrominoSequence.pop();
|
||||
const matrix = tetrominos[name];
|
||||
|
||||
// I and O start centered, all others start in left-middle
|
||||
const col = playfield[0].length / 2 - Math.ceil(matrix[0].length / 2);
|
||||
|
||||
// I starts on row 21 (-1), all others start on row 22 (-2)
|
||||
const row = name === 'I' ? -1 : -2;
|
||||
|
||||
return {
|
||||
name: name, // name of the piece (L, O, etc.)
|
||||
matrix: matrix, // the current rotation matrix
|
||||
row: row, // current row (starts offscreen)
|
||||
col: col // current col
|
||||
};
|
||||
}
|
||||
|
||||
// rotate an NxN matrix 90deg
|
||||
// @see https://codereview.stackexchange.com/a/186834
|
||||
function rotate(matrix) {
|
||||
const N = matrix.length - 1;
|
||||
const result = matrix.map((row, i) =>
|
||||
row.map((val, j) => matrix[N - j][i])
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// check to see if the new matrix/row/col is valid
|
||||
function isValidMove(matrix, cellRow, cellCol) {
|
||||
for (let row = 0; row < matrix.length; row++) {
|
||||
for (let col = 0; col < matrix[row].length; col++) {
|
||||
if (matrix[row][col] && (
|
||||
// outside the game bounds
|
||||
cellCol + col < 0 ||
|
||||
cellCol + col >= playfield[0].length ||
|
||||
cellRow + row >= playfield.length ||
|
||||
// collides with another piece
|
||||
playfield[cellRow + row][cellCol + col])
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// place the tetromino on the playfield
|
||||
function placeTetromino() {
|
||||
for (let row = 0; row < tetromino.matrix.length; row++) {
|
||||
for (let col = 0; col < tetromino.matrix[row].length; col++) {
|
||||
if (tetromino.matrix[row][col]) {
|
||||
|
||||
// game over if piece has any part offscreen
|
||||
if (tetromino.row + row < 0) {
|
||||
return showGameOver();
|
||||
}
|
||||
|
||||
playfield[tetromino.row + row][tetromino.col + col] = tetromino.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// check for line clears starting from the bottom and working our way up
|
||||
for (let row = playfield.length - 1; row >= 0; ) {
|
||||
if (playfield[row].every(cell => !!cell)) {
|
||||
|
||||
// drop every row above this one
|
||||
for (let r = row; r >= 0; r--) {
|
||||
for (let c = 0; c < playfield[r].length; c++) {
|
||||
playfield[r][c] = playfield[r-1][c];
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
row--;
|
||||
}
|
||||
}
|
||||
|
||||
tetromino = getNextTetromino();
|
||||
}
|
||||
|
||||
// show the game over screen
|
||||
function showGameOver() {
|
||||
cancelAnimationFrame(rAF);
|
||||
gameOver = true;
|
||||
|
||||
context.fillStyle = 'black';
|
||||
context.globalAlpha = 0.75;
|
||||
context.fillRect(0, canvas.height / 2 - 30, canvas.width, 60);
|
||||
|
||||
context.globalAlpha = 1;
|
||||
context.fillStyle = 'white';
|
||||
context.font = '36px monospace';
|
||||
context.textAlign = 'center';
|
||||
context.textBaseline = 'middle';
|
||||
context.fillText('GAME OVER!', canvas.width / 2, canvas.height / 2);
|
||||
}
|
||||
|
||||
const canvas = document.getElementById('game');
|
||||
const context = canvas.getContext('2d');
|
||||
const grid = 32;
|
||||
const tetrominoSequence = [];
|
||||
|
||||
// keep track of what is in every cell of the game using a 2d array
|
||||
// tetris playfield is 10x20, with a few rows offscreen
|
||||
const playfield = [];
|
||||
|
||||
// populate the empty state
|
||||
for (let row = -2; row < 20; row++) {
|
||||
playfield[row] = [];
|
||||
|
||||
for (let col = 0; col < 10; col++) {
|
||||
playfield[row][col] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// how to draw each tetromino
|
||||
// @see https://tetris.fandom.com/wiki/SRS
|
||||
const tetrominos = {
|
||||
'I': [
|
||||
[0,0,0,0],
|
||||
[1,1,1,1],
|
||||
[0,0,0,0],
|
||||
[0,0,0,0]
|
||||
],
|
||||
'J': [
|
||||
[1,0,0],
|
||||
[1,1,1],
|
||||
[0,0,0],
|
||||
],
|
||||
'L': [
|
||||
[0,0,1],
|
||||
[1,1,1],
|
||||
[0,0,0],
|
||||
],
|
||||
'O': [
|
||||
[1,1],
|
||||
[1,1],
|
||||
],
|
||||
'S': [
|
||||
[0,1,1],
|
||||
[1,1,0],
|
||||
[0,0,0],
|
||||
],
|
||||
'Z': [
|
||||
[1,1,0],
|
||||
[0,1,1],
|
||||
[0,0,0],
|
||||
],
|
||||
'T': [
|
||||
[0,1,0],
|
||||
[1,1,1],
|
||||
[0,0,0],
|
||||
]
|
||||
};
|
||||
|
||||
// color of each tetromino
|
||||
const colors = {
|
||||
'I': 'cyan',
|
||||
'O': 'yellow',
|
||||
'T': 'purple',
|
||||
'S': 'green',
|
||||
'Z': 'red',
|
||||
'J': 'blue',
|
||||
'L': 'orange'
|
||||
};
|
||||
|
||||
let count = 0;
|
||||
let tetromino = getNextTetromino();
|
||||
let rAF = null; // keep track of the animation frame so we can cancel it
|
||||
let gameOver = false;
|
||||
|
||||
// game loop
|
||||
function loop() {
|
||||
rAF = requestAnimationFrame(loop);
|
||||
context.clearRect(0,0,canvas.width,canvas.height);
|
||||
|
||||
// draw the playfield
|
||||
for (let row = 0; row < 20; row++) {
|
||||
for (let col = 0; col < 10; col++) {
|
||||
if (playfield[row][col]) {
|
||||
const name = playfield[row][col];
|
||||
context.fillStyle = colors[name];
|
||||
|
||||
// drawing 1 px smaller than the grid creates a grid effect
|
||||
context.fillRect(col * grid, row * grid, grid-1, grid-1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// draw the active tetromino
|
||||
if (tetromino) {
|
||||
|
||||
// tetromino falls every 35 frames
|
||||
if (++count > 35) {
|
||||
tetromino.row++;
|
||||
count = 0;
|
||||
|
||||
// place piece if it runs into anything
|
||||
if (!isValidMove(tetromino.matrix, tetromino.row, tetromino.col)) {
|
||||
tetromino.row--;
|
||||
placeTetromino();
|
||||
}
|
||||
}
|
||||
|
||||
context.fillStyle = colors[tetromino.name];
|
||||
|
||||
for (let row = 0; row < tetromino.matrix.length; row++) {
|
||||
for (let col = 0; col < tetromino.matrix[row].length; col++) {
|
||||
if (tetromino.matrix[row][col]) {
|
||||
|
||||
// drawing 1 px smaller than the grid creates a grid effect
|
||||
context.fillRect((tetromino.col + col) * grid, (tetromino.row + row) * grid, grid-1, grid-1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// listen to keyboard events to move the active tetromino
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (gameOver) return;
|
||||
|
||||
// left and right arrow keys (move)
|
||||
if (e.which === 37 || e.which === 39) {
|
||||
const col = e.which === 37
|
||||
? tetromino.col - 1
|
||||
: tetromino.col + 1;
|
||||
|
||||
if (isValidMove(tetromino.matrix, tetromino.row, col)) {
|
||||
tetromino.col = col;
|
||||
}
|
||||
}
|
||||
|
||||
// up arrow key (rotate)
|
||||
if (e.which === 38) {
|
||||
const matrix = rotate(tetromino.matrix);
|
||||
if (isValidMove(matrix, tetromino.row, tetromino.col)) {
|
||||
tetromino.matrix = matrix;
|
||||
}
|
||||
}
|
||||
|
||||
// down arrow key (drop)
|
||||
if(e.which === 40) {
|
||||
const row = tetromino.row + 1;
|
||||
|
||||
if (!isValidMove(tetromino.matrix, row, tetromino.col)) {
|
||||
tetromino.row = row - 1;
|
||||
|
||||
placeTetromino();
|
||||
return;
|
||||
}
|
||||
|
||||
tetromino.row = row;
|
||||
}
|
||||
});
|
||||
|
||||
// start the game
|
||||
rAF = requestAnimationFrame(loop);
|
||||
</script>
|
||||
{% endblock %}
|
||||
24
templates/setup.html
Normal file
24
templates/setup.html
Normal file
@@ -0,0 +1,24 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _('Admin Setup') }}{% endblock %}
|
||||
{% block content %}
|
||||
<h2><i class="bi bi-person-gear me-2"></i>{{ _('Admin Account Setup') }}</h2>
|
||||
<form method="post">
|
||||
<div class="mb-3">
|
||||
<label class="form-label"><i class="bi bi-person-circle me-1"></i>{{ _('Username') }}</label>
|
||||
<input type="text" name="username" class="form-control" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label"><i class="bi bi-envelope-at me-1"></i>{{ _('Email') }}</label>
|
||||
<input type="email" name="email" class="form-control" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label"><i class="bi bi-lock-fill me-1"></i>{{ _('Password') }}</label>
|
||||
<input type="password" name="password" class="form-control" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label"><i class="bi bi-lock me-1"></i>{{ _('Confirm Password') }}</label>
|
||||
<input type="password" name="confirm_password" class="form-control" required>
|
||||
</div>
|
||||
<button class="btn btn-primary" type="submit"><i class="bi bi-person-gear me-1"></i>{{ _('Create Admin') }}</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
33
templates/shop.html
Normal file
33
templates/shop.html
Normal file
@@ -0,0 +1,33 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _('Shop') }}{% endblock %}
|
||||
{% block content %}
|
||||
<h2><i class="bi bi-shop me-2"></i>{{ _('Shop') }}</h2>
|
||||
<p>{{ _('Deine Reward-Punkte:') }} <b>{{ current_user.reward_points() }}</b></p>
|
||||
{% if message %}
|
||||
<div class="alert alert-info">{{ message }}</div>
|
||||
{% endif %}
|
||||
<div class="row">
|
||||
{% for item in items %}
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body text-center">
|
||||
<i class="bi {{ item.icon }} display-4 mb-2"></i>
|
||||
<h5>{{ item.name }}</h5>
|
||||
<p>{{ item.description }}</p>
|
||||
<p><b>{{ item.price }}</b> {{ _('Points') }}</p>
|
||||
{% if item.id in owned_ids %}
|
||||
<button class="btn btn-secondary" disabled>{{ _('Bought') }}</button>
|
||||
{% else %}
|
||||
<form method="post">
|
||||
<input type="hidden" name="item_id" value="{{ item.id }}">
|
||||
<button class="btn btn-success" {% if current_user.reward_points() < item.price %}disabled{% endif %}>
|
||||
<i class="bi bi-cart"></i> {{ _('Buy') }}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
49
templates/support.html
Normal file
49
templates/support.html
Normal file
@@ -0,0 +1,49 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _('Support') }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<p>{{ _('If you have any questions or need assistance, please contact us at:') }}</p>
|
||||
<p><strong>{{ _('Github:') }}</strong> <a href="https://github.com/Michatec/MiniFaceBook/issues" target="_blank">https://github.com/Michatec/MiniFaceBook/issues</a></p>
|
||||
</div>
|
||||
{% if user.is_owner %}
|
||||
<form action="{{ url_for('admin.wipe_server') }}" method="post" style="display:inline;">
|
||||
<button class="btn btn-danger btn-sm" type="submit">
|
||||
<i class="bi bi-x-circle"></i> {{ _('Wipe Server') }}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<h1><i class="bi bi-question-circle me-2"></i>{{ _('Support') }}</h1>
|
||||
<form method="POST" class="mb-4">
|
||||
<input type="text" name="title" placeholder="{{ _('Title') }}" required class="form-control mb-2">
|
||||
<textarea name="description" placeholder="{{ _('Describe your issue...') }}" required class="form-control mb-2"></textarea>
|
||||
<button class="btn btn-primary">{{ _('Create Ticket') }}</button>
|
||||
</form>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ _('Title') }}</th>
|
||||
<th>{{ _('Status') }}</th>
|
||||
<th>{{ _('Created') }}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ticket in support_requests %}
|
||||
<tr>
|
||||
<td>{{ ticket.title }}</td>
|
||||
<td>
|
||||
{% if ticket.status == 'open' %}
|
||||
<span class="badge bg-success">{{ _('Open') }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">{{ _('Closed') }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ ticket.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('support.support_thread', request_id=ticket.id) }}" class="btn btn-sm btn-info">{{ _('View') }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
40
templates/support_thread.html
Normal file
40
templates/support_thread.html
Normal file
@@ -0,0 +1,40 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ ticket.title }}{% endblock %}
|
||||
{% block content %}
|
||||
<h2>{{ ticket.title }}</h2>
|
||||
<p><strong>{{ _('Status') }}:</strong>
|
||||
{% if ticket.status == 'open' %}
|
||||
<span class="badge bg-success">{{ _('Open') }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">{{ _('Closed') }}</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
<div class="mb-3">
|
||||
{% for comment in comments %}
|
||||
<div class="border rounded p-2 mb-2">
|
||||
<strong>{{ comment.user.username }}</strong>
|
||||
<span class="text-muted">{{ comment.created_at.strftime('%Y-%m-%d %H:%M') }}</span>
|
||||
<p>{{ comment.message }}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if ticket.status == 'open' %}
|
||||
<form method="POST">
|
||||
<textarea name="message" class="form-control mb-2" placeholder="{{ _('Write a message...') }}" required></textarea>
|
||||
<button class="btn btn-primary">{{ _('Send') }}</button>
|
||||
</form>
|
||||
<form method="POST" action="{{ url_for('support.support_close', request_id=ticket.id) }}">
|
||||
<button class="btn btn-warning mt-2">{{ _('Close Ticket') }}</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="alert alert-info">{{ _('This ticket is closed.') }}</div>
|
||||
{% endif %}
|
||||
{% if user.is_admin %}
|
||||
<form method="POST" action="{{ url_for('support.support_delete', request_id=ticket.id) }}" style="display:inline;">
|
||||
<button class="btn btn-danger btn-sm" onclick="return confirm('Delete this ticket?')">
|
||||
<i class="bi bi-trash"></i> {{ _('Delete') }}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('support.support') }}" class="btn btn-secondary mt-3">{{ _('Back to Support') }}</a>
|
||||
{% endblock %}
|
||||
46
templates/users.html
Normal file
46
templates/users.html
Normal file
@@ -0,0 +1,46 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _('Users') }}{% endblock %}
|
||||
{% block content %}
|
||||
<h2><i class="bi bi-people me-2"></i>{{ _('All Users') }}</h2>
|
||||
<ul class="list-group">
|
||||
{% for user_item in users %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<span>
|
||||
{% if user_item.profile_pic and user_item.profile_pic != 'default.png' %}
|
||||
<img src="{{ url_for('static', filename='profile_pics/' + user_item.profile_pic) }}" alt="{{ user_item.username }}" class="profile-pic me-2">{{ user_item.username }}
|
||||
{% else %}
|
||||
<i class="bi bi-person-circle me-1"></i>{{ user_item.username }}
|
||||
{% endif %}
|
||||
</span>
|
||||
<div>
|
||||
{% if user_item.id != user.id %}
|
||||
{% set friendship = (user.friendships_sent | selectattr('receiver_id', 'equalto', user_item.id) | list) %}
|
||||
{% set reverse_friendship = (user.friendships_received | selectattr('requester_id', 'equalto', user_item.id) | list) %}
|
||||
{% if friendship and friendship[0].status == 'pending' %}
|
||||
<span class="badge bg-warning"><i class="bi bi-hourglass-split me-1"></i>{{ _('Request sent') }}</span>
|
||||
{% elif reverse_friendship and reverse_friendship[0].status == 'pending' %}
|
||||
<span class="badge bg-info"><i class="bi bi-envelope-open me-1"></i>{{ _('Request received') }}</span>
|
||||
{% elif (friendship and friendship[0].status == 'accepted') or (reverse_friendship and reverse_friendship[0].status == 'accepted') %}
|
||||
<span class="badge bg-success"><i class="bi bi-person-check me-1"></i>{{ _('Friend') }}</span>
|
||||
{% else %}
|
||||
<form action="{{ url_for('friend.add_friend', user_id=user_item.id) }}" method="post" style="display:inline;">
|
||||
<button class="btn btn-sm btn-primary"><i class="bi bi-person-plus"></i> {{ _('Add Friend') }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if user.is_admin %}
|
||||
{% if not user_item.is_admin %}
|
||||
<form action="{{ url_for('admin.make_admin', user_id=user_item.id) }}" method="post" style="display:inline;">
|
||||
<button class="btn btn-sm btn-success"><i class="bi bi-person-gear"></i> {{ _('Make Admin') }}</button>
|
||||
</form>
|
||||
{% elif not user_item.is_owner %}
|
||||
<form action="{{ url_for('admin.remove_admin', user_id=user_item.id) }}" method="post" style="display:inline;">
|
||||
<button class="btn btn-sm btn-warning"><i class="bi bi-person-dash"></i> {{ _('Remove Admin') }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endblock %}
|
||||
1166
translations/de/LC_MESSAGES/messages.po
Normal file
1166
translations/de/LC_MESSAGES/messages.po
Normal file
File diff suppressed because it is too large
Load Diff
1166
translations/en/LC_MESSAGES/messages.po
Normal file
1166
translations/en/LC_MESSAGES/messages.po
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user