Merge pull request #799 from papra-hq/website-in-monorepo
chore(apps): import website as git subtree
25
apps/website/.gitignore
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
# build output
|
||||
dist/
|
||||
|
||||
# generated types
|
||||
.astro/
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
.env.production
|
||||
|
||||
# macOS-specific files
|
||||
.DS_Store
|
||||
|
||||
# jetbrains setting folder
|
||||
.idea/
|
||||
.eslintcache
|
||||
661
apps/website/LICENSE
Normal file
@@ -0,0 +1,661 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
49
apps/website/README.md
Normal file
@@ -0,0 +1,49 @@
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source srcset="./.github/icon-dark.png" media="(prefers-color-scheme: light)">
|
||||
<source srcset="./.github/icon-light.png" media="(prefers-color-scheme: dark)">
|
||||
<img src="./.github/icon-dark.png" alt="Header banner">
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
<h1 align="center">
|
||||
Papra - Website
|
||||
</h1>
|
||||
<p align="center">
|
||||
The source code for the <a href="https://papra.app">papra.app</a> website.
|
||||
</p>
|
||||
|
||||
> [!TIP]
|
||||
> If you are looking for the source code of the Papra app, you can find it here: [papra-hq/papra](https://github.com/papra-hq/papra).
|
||||
|
||||
## Development
|
||||
|
||||
This project is a static website built with [Astro](https://astro.build/) and [UnoCSS](https://unocss.dev/).
|
||||
|
||||
### Clone the repository
|
||||
|
||||
```bash
|
||||
git clone git@github.com:papra-hq/papra-website.git
|
||||
cd papra-website
|
||||
```
|
||||
|
||||
### Install dependencies
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### Start the development server
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the AGPL-3.0 License - see the [LICENSE](./LICENSE) file for details.
|
||||
|
||||
## Credits
|
||||
|
||||
This project is crafted with ❤️ by [Corentin Thomasset](https://corentin.tech).
|
||||
If you find this project helpful, please consider [supporting my work](https://buymeacoffee.com/cthmsst).
|
||||
54
apps/website/astro.config.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import cloudflare from '@astrojs/cloudflare';
|
||||
import mdx from '@astrojs/mdx';
|
||||
|
||||
import sitemap from '@astrojs/sitemap';
|
||||
import astroExpressiveCode from 'astro-expressive-code';
|
||||
import { defineConfig } from 'astro/config';
|
||||
import UnoCSS from 'unocss/astro';
|
||||
import { config } from './src/app.config';
|
||||
|
||||
import { DEFAULT_LOCALE, LOCALES } from './src/i18n/i18n.constants';
|
||||
import createRedirectsFile from './src/plugins/redirects';
|
||||
|
||||
export default defineConfig({
|
||||
site: 'https://papra.app',
|
||||
|
||||
integrations: [
|
||||
UnoCSS({ injectReset: true }),
|
||||
sitemap(),
|
||||
createRedirectsFile({
|
||||
redirects: {
|
||||
'/discord': { status: 302, destination: config.discordInvite },
|
||||
'/support': { status: 302, destination: config.sponsorLink },
|
||||
},
|
||||
}),
|
||||
astroExpressiveCode({
|
||||
themes: ['vitesse-dark', 'github-light'],
|
||||
styleOverrides: {
|
||||
frames: {
|
||||
shadowColor: 'transparent',
|
||||
},
|
||||
},
|
||||
defaultProps: {
|
||||
overridesByLang: {
|
||||
'bash,sh,shell': {
|
||||
frame: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
mdx(),
|
||||
],
|
||||
|
||||
output: 'static',
|
||||
adapter: cloudflare(),
|
||||
|
||||
i18n: {
|
||||
locales: LOCALES.map(locale => locale), // Because astro expects string[] and not readonly string[]
|
||||
defaultLocale: DEFAULT_LOCALE,
|
||||
routing: {
|
||||
prefixDefaultLocale: true,
|
||||
redirectToDefaultLocale: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
28
apps/website/eslint.config.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import antfu from '@antfu/eslint-config';
|
||||
|
||||
export default antfu({
|
||||
astro: true,
|
||||
|
||||
stylistic: {
|
||||
semi: true,
|
||||
},
|
||||
|
||||
ignores: [
|
||||
'src/components/PosthogAnalytics.astro', // Inlined script
|
||||
'src/components/ContactForm.astro', // Inlined script
|
||||
],
|
||||
|
||||
rules: {
|
||||
// To allow export on top of files
|
||||
'ts/no-use-before-define': ['error', { allowNamedExports: true, functions: false }],
|
||||
'curly': ['error', 'all'],
|
||||
'vitest/consistent-test-it': ['error', { fn: 'test' }],
|
||||
'ts/consistent-type-definitions': ['error', 'type'],
|
||||
'style/brace-style': ['error', '1tbs', { allowSingleLine: false }],
|
||||
'unused-imports/no-unused-vars': ['error', {
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
}],
|
||||
},
|
||||
});
|
||||
46
apps/website/package.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "@papra/website",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"description": "Website for Papra, the document management platform",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint --fix .",
|
||||
"check": "astro check",
|
||||
"typecheck": "pnpm run check",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.6",
|
||||
"@astrojs/cloudflare": "^12.6.10",
|
||||
"@astrojs/rss": "^4.0.11",
|
||||
"@astrojs/sitemap": "^3.3.0",
|
||||
"@branchlet/core": "^1.0.0",
|
||||
"@corentinth/chisels": "^2.0.2",
|
||||
"astro": "^5.16.15",
|
||||
"astro-capo": "^0.0.1",
|
||||
"astro-expressive-code": "^0.41.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"tailwind-merge": "^2.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "catalog:",
|
||||
"@astrojs/mdx": "^4.2.4",
|
||||
"@iconify-json/tabler": "^1.2.14",
|
||||
"@unocss/reset": "^65.4.2",
|
||||
"eslint": "catalog:",
|
||||
"eslint-plugin-astro": "^1.3.1",
|
||||
"sharp": "^0.33.5",
|
||||
"typescript": "catalog:",
|
||||
"unocss": "^66.6.0",
|
||||
"unocss-preset-animations": "^1.3.0",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
3
apps/website/public/_headers
Normal file
@@ -0,0 +1,3 @@
|
||||
/*
|
||||
X-Frame-Options: DENY
|
||||
X-Content-Type-Options: nosniff
|
||||
BIN
apps/website/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
apps/website/public/emails/papra-logo.png
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
BIN
apps/website/public/favicon-96x96.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
apps/website/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
11
apps/website/public/favicon.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
|
||||
<path d="M14 3v4a1 1 0 0 0 1 1h4"/><path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2zM9 9h1m-1 4h6m-6 4h6"/>
|
||||
</g>
|
||||
<style>
|
||||
path { stroke: #000; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
path { stroke: #FFF; }
|
||||
}
|
||||
</style>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 430 B |
5
apps/website/public/humans.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
/* TEAM */
|
||||
Developer: Corentin Thomasset
|
||||
Site: https://corentin.tech
|
||||
Twitter: @cthmsst
|
||||
BlueSky: @corentin.tech
|
||||
BIN
apps/website/public/og-image.png
Normal file
|
After Width: | Height: | Size: 592 KiB |
BIN
apps/website/public/og/blog.png
Normal file
|
After Width: | Height: | Size: 593 KiB |
BIN
apps/website/public/og/papra.png
Normal file
|
After Width: | Height: | Size: 592 KiB |
21
apps/website/public/site.webmanifest
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "Papra",
|
||||
"short_name": "Papra",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"theme_color": "#dbfd85",
|
||||
"background_color": "#141415",
|
||||
"display": "standalone"
|
||||
}
|
||||
BIN
apps/website/public/web-app-manifest-192x192.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
apps/website/public/web-app-manifest-512x512.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
8
apps/website/src/app.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export const config = {
|
||||
site: {
|
||||
name: 'Papra',
|
||||
},
|
||||
discordInvite: 'https://discord.gg/8UPjzsrBNF',
|
||||
sponsorLink: 'https://github.com/sponsors/CorentinTh',
|
||||
getStartedLink: 'https://dashboard.papra.app',
|
||||
} as const;
|
||||
BIN
apps/website/src/assets/corentin.jpg
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
apps/website/src/assets/papra-screenshot.png
Normal file
|
After Width: | Height: | Size: 107 KiB |
27
apps/website/src/components/BentoCard.astro
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
import { cn } from '../utils/cn';
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
description: string;
|
||||
icon?: string;
|
||||
};
|
||||
|
||||
const { title, description, icon } = Astro.props;
|
||||
---
|
||||
|
||||
|
||||
<div class="border rounded-xl bg-[linear-gradient(to_right,#80808008_1px,transparent_1px),linear-gradient(to_bottom,#80808008_1px,transparent_1px)] bg-[size:24px_24px] overflow-hidden">
|
||||
<div class="z-50">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<div class="relative z-60 bg-gradient-to-t from-background via-background to-card to-opacity-0 p-6 pt-24 mt--24">
|
||||
{icon && (<div class={cn('size-9 text-muted-foreground mb-4', icon)} />)}
|
||||
<h3 class="text-xl font-bold text-pretty">{title}</h3>
|
||||
|
||||
<p class="text-muted-foreground text-base mt-2 leading-tight">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
49
apps/website/src/components/ComparisonTable.astro
Normal file
@@ -0,0 +1,49 @@
|
||||
---
|
||||
import { cn } from '../utils/cn';
|
||||
|
||||
type ComparisonRow = {
|
||||
feature: string;
|
||||
description?: string;
|
||||
paperless: string;
|
||||
papra: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
rows: ComparisonRow[];
|
||||
class?: string;
|
||||
};
|
||||
|
||||
const { rows, class: className } = Astro.props;
|
||||
---
|
||||
|
||||
<div class={cn('not-prose my-12 mx--6 sm:mx-0 lg:mx--48 overflow-hidden', className)}>
|
||||
<table class="w-full border-collapse" style="table-layout: fixed;">
|
||||
<thead>
|
||||
<tr class="bg-card">
|
||||
<th class="text-left px-6 py-4 w-30%"></th>
|
||||
<th class="text-left px-6 py-4 text-sm font-bold text-muted-foreground uppercase tracking-wide w-35%">Paperless-NGX</th>
|
||||
<th class="text-left px-6 py-4 text-sm font-bold text-muted-foreground uppercase tracking-wide rounded-tr-xl w-35%">Papra</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, index) => (
|
||||
<tr class="bg-card transition-colors hover:bg-muted/20 border-t">
|
||||
<td class="px-6 py-5">
|
||||
<div class="font-medium text-foreground">{row.feature}</div>
|
||||
{row.description && (<div class="text-sm text-muted-foreground">{row.description}</div>)}
|
||||
</td>
|
||||
<td class={cn(
|
||||
'px-6 py-5 text-muted-foreground leading-relaxed',
|
||||
index === rows.length - 1 && 'rounded-br-xl',
|
||||
)}
|
||||
>{row.paperless}</td>
|
||||
<td class={cn(
|
||||
'px-6 py-5 text-muted-foreground leading-relaxed',
|
||||
index === rows.length - 1 && 'rounded-br-xl',
|
||||
)}
|
||||
>{row.papra}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
22
apps/website/src/components/Cta.astro
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
import { config } from '../app.config';
|
||||
import { useI18n } from '../i18n/i18n';
|
||||
|
||||
const { t } = useI18n({ locale: Astro.currentLocale });
|
||||
---
|
||||
|
||||
<div class="bg-card py-32 px-6">
|
||||
<div
|
||||
class="max-w-1200px mx-auto px-6 border rounded-xl bg-background pt-32 pb-24 bg-[linear-gradient(to_right,#80808010_1px,transparent_1px),linear-gradient(to_bottom,#80808010_1px,transparent_1px)] bg-[size:48px_48px] px-6 z-20"
|
||||
>
|
||||
|
||||
<h2 class="text-2xl sm:text-4xl font-bold text-center text-pretty max-w-800px mx-auto text-pretty" set:html={t('cta.heading')} />
|
||||
<div class="flex mt-4 items-center justify-center">
|
||||
<a href={config.getStartedLink} class="font-semibold text-background px-4 py-2 hover:bg-primary/80 rounded-lg bg-primary transition mt-8 inline-block flex items-center">
|
||||
{t('cta.get-started')}
|
||||
|
||||
<div class="i-tabler-arrow-right ml-2 size-5" aria-hidden="true"></div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
80
apps/website/src/components/FaqAccordion.astro
Normal file
@@ -0,0 +1,80 @@
|
||||
---
|
||||
import { cn } from '../utils/cn';
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
description: string;
|
||||
items: readonly {
|
||||
question: string;
|
||||
answer: string;
|
||||
}[];
|
||||
class?: string;
|
||||
};
|
||||
|
||||
const { title, description, items, class: className } = Astro.props;
|
||||
---
|
||||
|
||||
<div class={cn(`max-w-800px mx-auto px-6 py-42`, className)}>
|
||||
<h2 class="text-3xl font-semibold text-pretty mb-4 text-center">{title}</h2>
|
||||
<p class="text-muted-foreground text-lg text-center mb-24">{description}</p>
|
||||
|
||||
<div>
|
||||
{
|
||||
items.map((item, index) => (
|
||||
<div class="border-x border-t first:rounded-t-lg last:border-b last:rounded-b-lg bg-background overflow-hidden">
|
||||
<button class="w-full px-6 py-4 text-left flex items-center justify-between" data-faq-toggle data-faq-index={index} aria-expanded="false" aria-controls={`faq-content-${index}`}>
|
||||
<span class="font-semibold text-sm sm:text-base">{item.question}</span>
|
||||
<div class="i-tabler-chevron-down size-5 text-muted-foreground transition-transform duration-200" data-faq-icon aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<div class="px-6 pb-0 max-h-0 overflow-hidden transition-all duration-300 ease-in-out" data-faq-content data-faq-index={index} id={`faq-content-${index}`}>
|
||||
<div class="pb-4">
|
||||
<p class="text-muted-foreground leading-relaxed">{item.answer}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function expandItem(toggle: HTMLElement, content: HTMLElement, icon: HTMLElement) {
|
||||
toggle.setAttribute('aria-expanded', 'true');
|
||||
content.style.maxHeight = `${content.scrollHeight}px`;
|
||||
icon.style.transform = 'rotate(180deg)';
|
||||
}
|
||||
|
||||
function collapseItem(toggle: HTMLElement, content: HTMLElement, icon: HTMLElement) {
|
||||
toggle.setAttribute('aria-expanded', 'false');
|
||||
content.style.maxHeight = '0';
|
||||
icon.style.transform = 'rotate(0deg)';
|
||||
}
|
||||
|
||||
function handleToggleClick(e: Event) {
|
||||
const target = e.currentTarget as HTMLElement;
|
||||
const index = target.getAttribute('data-faq-index');
|
||||
const content = document.querySelector(`[data-faq-content][data-faq-index="${index}"]`) as HTMLElement;
|
||||
const icon = target.querySelector('[data-faq-icon]') as HTMLElement;
|
||||
const isExpanded = target.getAttribute('aria-expanded') === 'true';
|
||||
|
||||
if (isExpanded) {
|
||||
collapseItem(target, content, icon);
|
||||
} else {
|
||||
expandItem(target, content, icon);
|
||||
}
|
||||
}
|
||||
|
||||
function initFaqAccordion() {
|
||||
const toggles = document.querySelectorAll('[data-faq-toggle]');
|
||||
toggles.forEach((toggle) => {
|
||||
toggle.addEventListener('click', handleToggleClick);
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize FAQ accordion when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', initFaqAccordion);
|
||||
|
||||
// Also initialize for Astro's client-side navigation
|
||||
document.addEventListener('astro:page-load', initFaqAccordion);
|
||||
</script>
|
||||
150
apps/website/src/components/FeaturesBento.astro
Normal file
@@ -0,0 +1,150 @@
|
||||
---
|
||||
import { Code } from 'astro:components';
|
||||
import { useI18n } from '../i18n/i18n';
|
||||
import { cn } from '../utils/cn';
|
||||
import BentoCard from './BentoCard.astro';
|
||||
|
||||
const { t } = useI18n({ locale: Astro.currentLocale });
|
||||
|
||||
const filesIcons = [
|
||||
'i-tabler-file',
|
||||
'i-tabler-file-text',
|
||||
'i-tabler-file-code',
|
||||
'i-tabler-file-code-2',
|
||||
'i-tabler-file-invoice',
|
||||
'i-tabler-file-euro',
|
||||
'i-tabler-file-dollar',
|
||||
'i-tabler-file-analytics',
|
||||
'i-tabler-file-chart',
|
||||
'i-tabler-file-description',
|
||||
'i-tabler-file-music',
|
||||
'i-tabler-file-lambda',
|
||||
'i-tabler-file-3d',
|
||||
'i-tabler-file-rss',
|
||||
];
|
||||
|
||||
const getRandomIcon = () => filesIcons[Math.floor(Math.random() * filesIcons.length)];
|
||||
|
||||
const codeSnippet = `
|
||||
curl -X POST https://api.papra.ai/v1/documents \\
|
||||
-H "Authorization: Bearer $PAPRA_API_KEY" \\
|
||||
-H "Content-Type: multipart/form-data" \\
|
||||
-F "file=@/path/to/your/file.pdf"
|
||||
`.trim();
|
||||
---
|
||||
|
||||
<div class="pt-32 pb-48 bg-card p-6 relative overflow-hidden">
|
||||
<!-- <div class="z-10 bg-primary w-90% max-w-1000px h-60px rounded-full blur-64 op-15 absolute bottom--50px left-50% -translate-x-50% "></div> -->
|
||||
|
||||
<h2 class="text-3xl font-semibold text-pretty mb-2 text-center">{t('features.title')}</h2>
|
||||
<p class="text-base text-muted-foreground text-center max-w-500px mx-auto mb-24">
|
||||
{t('features.subtitle')}
|
||||
</p>
|
||||
|
||||
<div class="max-w-1000px mx-auto flex flex-col sm:flex-row gap-4 items-start">
|
||||
<div class="flex flex-col gap-4 w-full">
|
||||
<BentoCard
|
||||
title={t('features.all-in-one.title')}
|
||||
description={t('features.all-in-one.description')}
|
||||
icon="i-tabler-archive"
|
||||
>
|
||||
<div class="grid grid-cols-8 grid-rows-2 gap-4 p-6 overflow-hidden mb--150px">
|
||||
{Array.from({ length: 24 }).map(() => <div class={cn('size-8 sm:size-10 text-muted text-center', getRandomIcon())} />)}
|
||||
</div>
|
||||
</BentoCard>
|
||||
|
||||
<BentoCard
|
||||
title={t('features.organizations.title')}
|
||||
description={t('features.organizations.description')}
|
||||
icon="i-tabler-building-community"
|
||||
>
|
||||
<div class="flex items-center gap-2 p-6 pt-10 pb-4 justify-center">
|
||||
<div class="border rounded-xl p-3 flex items-center justify-center">
|
||||
<span class="i-tabler-users size-6 text-muted-foreground"></span>
|
||||
</div>
|
||||
|
||||
<div class="bg-muted-foreground h-3px w-20px rounded-full"></div>
|
||||
|
||||
<div class="border rounded-xl p-3 flex items-center justify-center border-primary border-2">
|
||||
<span class="i-tabler-user text-primary size-6"></span>
|
||||
</div>
|
||||
|
||||
<div class="bg-muted-foreground h-3px w-20px rounded-full"></div>
|
||||
|
||||
<div class="border rounded-xl p-3 flex items-center justify-center">
|
||||
<span class="i-tabler-users size-6 text-muted-foreground"></span>
|
||||
</div>
|
||||
</div>
|
||||
</BentoCard>
|
||||
|
||||
<BentoCard
|
||||
title={t('features.tagging.title')}
|
||||
description={t('features.tagging.description')}
|
||||
icon="i-tabler-tag"
|
||||
>
|
||||
<div class="mt-6"></div>
|
||||
</BentoCard>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4 w-full">
|
||||
<BentoCard
|
||||
title={t('features.search.title')}
|
||||
description={t('features.search.description')}
|
||||
icon="i-tabler-search"
|
||||
>
|
||||
<div class="mb--180px flex items-start justify-center pt-6 relative" role="img" aria-label="Search feature illustration">
|
||||
<div class="bg-background sm:min-w-64 border rounded-lg">
|
||||
<div class="px-4 py-2 flex items-center gap-2">
|
||||
<span class="i-tabler-search size-4 text-primary"></span>
|
||||
<span class="text-muted-foreground text-xs">{t('features.search.placeholder')}</span>
|
||||
</div>
|
||||
|
||||
<div class="border-t p-4 text-xs text-muted-foreground">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="i-tabler-file-text size-4"></span>
|
||||
{t('features.search.example-1')}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<span class="i-tabler-file-analytics size-4"></span>
|
||||
{t('features.search.example-2')}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<span class="i-tabler-file-code size-4"></span>
|
||||
{t('features.search.example-3')}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<span class="i-tabler-file size-4"></span>
|
||||
{t('features.search.example-4')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BentoCard>
|
||||
|
||||
<BentoCard
|
||||
title={t('features.developer-friendly.title')}
|
||||
description={t('features.developer-friendly.description')}
|
||||
icon="i-tabler-code"
|
||||
>
|
||||
<div class="flex justify-center p-6">
|
||||
<Code code={codeSnippet} lang="bash" theme="vitesse-dark" class="text-xs bg-transparent!" role="presentation" />
|
||||
</div>
|
||||
</BentoCard>
|
||||
|
||||
<BentoCard
|
||||
title={t('features.email-ingestion.title')}
|
||||
description={t('features.email-ingestion.description')}
|
||||
icon="i-tabler-mail"
|
||||
>
|
||||
<div class="flex items-center gap-4 p-6 pt-10 pb-4 justify-center">
|
||||
<span class="i-tabler-file-text size-10 text-muted-foreground"></span>
|
||||
<span class="i-tabler-arrow-right size-6 text-muted-foreground"></span>
|
||||
<span class="i-tabler-mailbox size-10 text-primary"></span>
|
||||
</div>
|
||||
</BentoCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
149
apps/website/src/components/Footer.astro
Normal file
@@ -0,0 +1,149 @@
|
||||
---
|
||||
import { buildLocalizedPath, useI18n } from '../i18n/i18n';
|
||||
import { getSocials } from '../socials';
|
||||
import { cn } from '../utils/cn';
|
||||
import LanguagePicker from './LanguagePicker.astro';
|
||||
|
||||
const { t } = useI18n({ locale: Astro.currentLocale });
|
||||
|
||||
const socials = getSocials({ t });
|
||||
|
||||
const sections: {
|
||||
title: string;
|
||||
links: { label: string; url: string; target?: string; rel?: string }[];
|
||||
}[] = [
|
||||
{
|
||||
title: t('footer.section.community'),
|
||||
links: socials,
|
||||
},
|
||||
{
|
||||
title: t('footer.section.papra'),
|
||||
links: [
|
||||
{
|
||||
label: t('footer.link.pricing'),
|
||||
url: buildLocalizedPath({ locale: Astro.currentLocale, path: '/pricing' }),
|
||||
},
|
||||
{
|
||||
label: t('footer.link.blog'),
|
||||
url: '/blog',
|
||||
},
|
||||
{
|
||||
label: t('footer.link.demo-app'),
|
||||
url: 'https://demo.papra.app',
|
||||
},
|
||||
{
|
||||
label: t('footer.link.documentation'),
|
||||
url: 'https://docs.papra.app',
|
||||
},
|
||||
{
|
||||
label: t('footer.link.self-host'),
|
||||
url: 'https://docs.papra.app/self-hosting/using-docker/',
|
||||
},
|
||||
{
|
||||
label: t('footer.link.roadmap'),
|
||||
url: 'https://github.com/orgs/papra-hq/projects/2',
|
||||
},
|
||||
{
|
||||
label: 'LLMs.txt',
|
||||
url: '/llms.txt',
|
||||
target: '_blank',
|
||||
},
|
||||
{
|
||||
label: 'Humans.txt',
|
||||
url: '/humans.txt',
|
||||
target: '_blank',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('footer.section.open-source'),
|
||||
links: [
|
||||
{
|
||||
label: t('footer.link.repository'),
|
||||
url: 'https://github.com/papra-hq/papra',
|
||||
},
|
||||
{
|
||||
label: t('footer.link.contributing'),
|
||||
url: 'https://github.com/papra-hq/papra/blob/main/CONTRIBUTING.md',
|
||||
},
|
||||
{
|
||||
label: t('footer.link.code-of-conduct'),
|
||||
url: 'https://github.com/papra-hq/papra/blob/main/CODE_OF_CONDUCT.md',
|
||||
},
|
||||
{
|
||||
label: t('footer.link.license'),
|
||||
url: 'https://github.com/papra-hq/papra/blob/main/LICENSE',
|
||||
},
|
||||
{
|
||||
label: t('footer.link.this-website'),
|
||||
url: 'https://github.com/papra-hq/papra-website',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('footer.section.legal'),
|
||||
links: [
|
||||
{
|
||||
label: t('footer.link.terms-of-service'),
|
||||
url: '/terms-of-service',
|
||||
},
|
||||
{
|
||||
label: t('footer.link.privacy-policy'),
|
||||
url: '/privacy',
|
||||
},
|
||||
{
|
||||
label: t('footer.link.contact'),
|
||||
url: buildLocalizedPath({ locale: Astro.currentLocale, path: '/contact' }),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<footer class="bg-card border-t border-border py-8 text-muted-foreground">
|
||||
<div class="max-w-1200px mx-auto p-4">
|
||||
<div class="flex justify-between flex-col md:flex-row gap-10">
|
||||
<div class="">
|
||||
<a href={buildLocalizedPath({ locale: Astro.currentLocale, path: '/' })} class="text-xl font-bold flex items-center group mb-2">
|
||||
<div class="i-tabler-file-text size-7 text-primary group-hover:(rotate-25deg) transition transform rotate-12deg"></div>
|
||||
<span class="ml-2 text-foreground group-hover:text-foreground/80 transition">Papra</span>
|
||||
</a>
|
||||
|
||||
<div class="flex gap-2">
|
||||
{
|
||||
socials.map(social => (
|
||||
<a href={social.url} class="hover:text-primary transition" target="_blank" rel="noopener noreferrer" aria-label={social.label}>
|
||||
<div class={`${social.icon} text-2xl`} aria-hidden="true" />
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<p class="mt-4 text-sm max-w-250px" set:html={t('footer.made-in-europe')} />
|
||||
|
||||
<div class="mt-6">
|
||||
<LanguagePicker />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={cn('grid gap-6 grid-cols-1', `sm:grid-cols-${sections.length}`)}>
|
||||
{
|
||||
sections.map(section => (
|
||||
<div>
|
||||
<div class="text-foreground font-semibold">{section.title}</div>
|
||||
<div class="mt-2">
|
||||
{section.links.map(link => (
|
||||
<a href={link.url} class="block hover:text-primary transition py-0.75 font-medium" target={link.target} rel={link.rel}>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 border-t border-border pt-4" set:html={t('footer.copyright', { year: new Date().getFullYear() })} />
|
||||
</div>
|
||||
</footer>
|
||||
151
apps/website/src/components/Head.astro
Normal file
@@ -0,0 +1,151 @@
|
||||
---
|
||||
import { Head as CapoHead } from 'astro-capo';
|
||||
import { getPathWithoutLocale, useI18n } from '../i18n/i18n';
|
||||
import { DEFAULT_LOCALE, LOCALES } from '../i18n/i18n.constants';
|
||||
import { formatCanonicalUrl } from '../utils/urls';
|
||||
import PosthogAnalytics from './PosthogAnalytics.astro';
|
||||
|
||||
const { t } = useI18n({ locale: Astro.currentLocale });
|
||||
|
||||
|
||||
export type Props = {
|
||||
title?: string;
|
||||
rawTitle?: string;
|
||||
description?: string;
|
||||
image?: { src: string; alt: string };
|
||||
canonicalUrl?: URL | null;
|
||||
pageType?: 'website' | 'article';
|
||||
locale?: string;
|
||||
extra?: string | string[];
|
||||
jsonLd?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
const siteInfo = {
|
||||
name: 'Papra',
|
||||
title: t('site.title'),
|
||||
rootUrl: 'https://papra.app',
|
||||
description: t('site.description'),
|
||||
image: { src: '/og/papra.png', alt: t('site.title') },
|
||||
author: 'Corentin Thomasset',
|
||||
};
|
||||
|
||||
const organizationLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
'name': siteInfo.name,
|
||||
'operatingSystem': 'Web, Android, iOS',
|
||||
'applicationCategory': 'Productivity',
|
||||
'description': siteInfo.description,
|
||||
'url': siteInfo.rootUrl,
|
||||
'publisher': {
|
||||
'@type': 'Organization',
|
||||
'name': siteInfo.name,
|
||||
'url': siteInfo.rootUrl,
|
||||
},
|
||||
};
|
||||
|
||||
const { rawTitle, description = siteInfo.description, image = siteInfo.image, canonicalUrl: rawCanonicalUrl, pageType = 'website', locale = 'en', jsonLd = organizationLd } = Astro.props;
|
||||
const canonicalUrl = formatCanonicalUrl(rawCanonicalUrl ?? new URL(Astro.request.url, Astro.site));
|
||||
|
||||
const title = rawTitle ?? [Astro.props.title, siteInfo.title].filter(Boolean).join(' - ');
|
||||
const resolvedImage = {
|
||||
src: new URL(image.src, Astro.site).toString(),
|
||||
alt: image.alt,
|
||||
};
|
||||
|
||||
const og = {
|
||||
name: siteInfo.name,
|
||||
title,
|
||||
description,
|
||||
image: resolvedImage,
|
||||
canonicalUrl,
|
||||
locale,
|
||||
type: pageType,
|
||||
};
|
||||
|
||||
const twitter = {
|
||||
name: siteInfo.name,
|
||||
title,
|
||||
description,
|
||||
image: resolvedImage,
|
||||
canonicalUrl,
|
||||
locale,
|
||||
creator: '@cthmsst',
|
||||
card: 'summary_large_image',
|
||||
};
|
||||
|
||||
const isMultiLangPage = Boolean(Astro.params.locale);
|
||||
|
||||
// Generate alternate links for multi-language pages
|
||||
const alternateLinks = isMultiLangPage
|
||||
? LOCALES.map((loc) => {
|
||||
const pathWithoutLocale = getPathWithoutLocale(Astro.url);
|
||||
const href = new URL(`/${loc}${pathWithoutLocale}`, Astro.site).toString();
|
||||
|
||||
return { hreflang: loc, href };
|
||||
})
|
||||
: [];
|
||||
---
|
||||
|
||||
<CapoHead>
|
||||
<!-- High Priority Global Metadata -->
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<title>{title}</title>
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<meta name="theme-color" content="#dbfd85" />
|
||||
|
||||
<meta name="description" content={description} />
|
||||
<meta name="author" content={siteInfo.author} />
|
||||
|
||||
<!-- Low Priority Global Metadata -->
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
|
||||
<link rel="sitemap" href="/sitemap-index.xml" />
|
||||
<link rel="author" href="humans.txt" />
|
||||
<link rel="canonical" href={canonicalUrl} />
|
||||
|
||||
{
|
||||
alternateLinks.length > 0 && (
|
||||
<>
|
||||
<link rel="alternate" hreflang="x-default" href={new URL(`/${DEFAULT_LOCALE}${getPathWithoutLocale(Astro.url)}`, Astro.site).toString()} />
|
||||
{alternateLinks.map(({ hreflang, href }) => (
|
||||
<link rel="alternate" hreflang={hreflang} href={href} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
<!-- OpenGraph Tags -->
|
||||
<meta property="og:title" content={og.title} />
|
||||
<meta property="og:type" content={og.type} />
|
||||
<meta property="og:url" content={og.canonicalUrl} />
|
||||
<meta property="og:locale" content={og.locale} />
|
||||
<meta property="og:description" content={og.description} />
|
||||
<meta property="og:site_name" content={og.name} />
|
||||
<meta property="og:image" content={og.image.src} />
|
||||
<meta property="og:image:alt" content={og.image.alt} />
|
||||
|
||||
<!-- Twitter Tags -->
|
||||
<meta name="twitter:card" content={twitter.card} />
|
||||
<meta name="twitter:title" content={twitter.title} />
|
||||
<meta name="twitter:description" content={twitter.description} />
|
||||
<meta name="twitter:image" content={twitter.image.src} />
|
||||
<meta name="twitter:image:alt" content={twitter.image.alt} />
|
||||
<meta name="twitter:site" content={twitter.creator} />
|
||||
<meta name="twitter:creator" content={twitter.creator} />
|
||||
<meta name="twitter:url" content={twitter.canonicalUrl} />
|
||||
|
||||
<PosthogAnalytics />
|
||||
|
||||
<meta name="robots" content="max-image-preview:large" />
|
||||
<link rel="alternate" type="application/rss+xml" title="Papra Blog" href={new URL('rss.xml', Astro.site)} />
|
||||
|
||||
<meta name="fediverse:creator" content="@papra@mastodon.social" />
|
||||
|
||||
{jsonLd && <script is:inline type="application/ld+json" set:html={JSON.stringify(jsonLd)} />}
|
||||
</CapoHead>
|
||||
79
apps/website/src/components/Header.astro
Normal file
@@ -0,0 +1,79 @@
|
||||
---
|
||||
import { config } from '../app.config';
|
||||
import { buildLocalizedPath, useI18n } from '../i18n/i18n';
|
||||
import { getSocials } from '../socials';
|
||||
import { cn } from '../utils/cn';
|
||||
|
||||
const { t } = useI18n({ locale: Astro.currentLocale });
|
||||
|
||||
const socials = getSocials({ t }).filter(social => social.inHeader);
|
||||
|
||||
const navigation = [
|
||||
{
|
||||
name: t('nav.demo'),
|
||||
url: 'https://demo.papra.app',
|
||||
},
|
||||
{
|
||||
name: t('nav.docs'),
|
||||
url: 'https://docs.papra.app',
|
||||
},
|
||||
{
|
||||
name: t('nav.blog'),
|
||||
url: '/blog',
|
||||
},
|
||||
{
|
||||
name: t('nav.self-host'),
|
||||
url: 'https://docs.papra.app/self-hosting/using-docker/',
|
||||
},
|
||||
{
|
||||
name: t('nav.pricing'),
|
||||
url: buildLocalizedPath({ locale: Astro.currentLocale, path: '/pricing' }),
|
||||
},
|
||||
];
|
||||
|
||||
type Props = {
|
||||
class?: string;
|
||||
};
|
||||
|
||||
const { class: className } = Astro.props;
|
||||
---
|
||||
|
||||
<nav
|
||||
class={`z-100 border-0 border-b md:max-w-4xl pl-4 pr-3 py-4 mx-auto flex items-center justify-between md:(py-2 border-1px rounded-full mt-4) fixed top-0 left-0 right-0 bg-background bg-op-40 backdrop-blur-sm shadow-lg ${className}`}
|
||||
>
|
||||
<a href={buildLocalizedPath({ locale: Astro.currentLocale, path: '/' })} class="text-lg font-bold flex items-center group">
|
||||
<div class="i-tabler-file-text size-6 group-hover:rotate-25deg text-primary transition transform rotate-12deg"></div>
|
||||
<span class="ml-2">Papra</span>
|
||||
</a>
|
||||
|
||||
<div class="items-center gap-6 hidden md:flex">
|
||||
{
|
||||
navigation.map(nav => (
|
||||
<a href={nav.url} class="text-sm font-bold hover:text-primary transition" {...(nav.url.startsWith('http') ? { target: '_blank', rel: 'noopener' } : {})}>
|
||||
{nav.name}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
{
|
||||
socials.map(social => (
|
||||
<a href={social.url} target="_blank" rel="noopener noreferrer" class=" hover:text-primary transition" aria-label={social.name}>
|
||||
<div class={cn('size-5', social.icon)} />
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<a
|
||||
class="flex gap-1.5 items-center bg-primary font-bold rounded py-1 px-3 rounded-xl text-primary-foreground text-sm transition hover:bg-primary/80"
|
||||
href={config.getStartedLink}
|
||||
target="_blank"
|
||||
>
|
||||
{t('nav.sign-in')}
|
||||
<div class="i-tabler-arrow-right size-4"></div>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
99
apps/website/src/components/LanguagePicker.astro
Normal file
@@ -0,0 +1,99 @@
|
||||
---
|
||||
import { getPathWithoutLocale, getTranslations } from '../i18n/i18n';
|
||||
import { LOCALES } from '../i18n/i18n.constants';
|
||||
import { cn } from '../utils/cn';
|
||||
|
||||
const languages = LOCALES.map((locale) => {
|
||||
const translations = getTranslations({ locale });
|
||||
return {
|
||||
code: locale,
|
||||
name: translations['language-name'],
|
||||
url: `/${locale}${getPathWithoutLocale(Astro.url)}`,
|
||||
isActive: locale === Astro.currentLocale,
|
||||
};
|
||||
});
|
||||
|
||||
const currentLanguage = languages.find(lang => lang.isActive);
|
||||
---
|
||||
|
||||
<div class="relative language-picker">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 bg-background border border-border rounded-md px-3 py-2 text-sm hover:border-primary focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary transition cursor-pointer"
|
||||
aria-haspopup="true"
|
||||
>
|
||||
<div class="i-tabler-language size-5" role="img" aria-hidden="true"></div>
|
||||
<span>{currentLanguage?.name}</span>
|
||||
<div class="i-tabler-chevron-down size-4 transition-transform language-picker-chevron" aria-hidden="true"></div>
|
||||
</button>
|
||||
|
||||
<div class="language-picker-menu absolute bottom-full left-0 mb-2 hidden bg-background border border-border rounded-md shadow-lg overflow-hidden min-w-full">
|
||||
{
|
||||
languages.map(lang => (
|
||||
<a
|
||||
href={lang.url}
|
||||
class={cn(
|
||||
'block px-3 py-2 text-sm hover:bg-muted transition outline-none focus:bg-muted focus:ring-2 focus:ring-primary focus:ring-inset',
|
||||
lang.isActive && 'bg-primary/10 text-primary font-semibold',
|
||||
)}
|
||||
aria-current={lang.isActive ? 'page' : undefined}
|
||||
>
|
||||
{lang.name}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const pickers = document.querySelectorAll('.language-picker');
|
||||
|
||||
pickers.forEach((picker) => {
|
||||
const button = picker.querySelector('button');
|
||||
const menu = picker.querySelector('.language-picker-menu');
|
||||
const chevron = picker.querySelector('.language-picker-chevron');
|
||||
|
||||
if (!button || !menu || !chevron) {
|
||||
return;
|
||||
}
|
||||
|
||||
let isOpen = false;
|
||||
|
||||
const toggle = () => {
|
||||
isOpen = !isOpen;
|
||||
menu.classList.toggle('hidden', !isOpen);
|
||||
chevron.classList.toggle('rotate-180', isOpen);
|
||||
button.setAttribute('aria-expanded', String(isOpen));
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
if (isOpen) {
|
||||
isOpen = false;
|
||||
menu.classList.add('hidden');
|
||||
chevron.classList.remove('rotate-180');
|
||||
button.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
};
|
||||
|
||||
button.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
toggle();
|
||||
});
|
||||
|
||||
// Close when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!picker.contains(e.target as Node)) {
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
// Close on escape key
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
close();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
118
apps/website/src/components/Pagination.astro
Normal file
@@ -0,0 +1,118 @@
|
||||
---
|
||||
import type { Page } from 'astro';
|
||||
import type { HTMLAttributes } from 'astro/types';
|
||||
import { useI18n } from '../i18n/i18n';
|
||||
|
||||
const { t } = useI18n({ locale: Astro.currentLocale });
|
||||
|
||||
export type Props = Omit<HTMLAttributes<'nav'>, 'slot'> & {
|
||||
page: Page<unknown>;
|
||||
allPages: string[];
|
||||
};
|
||||
|
||||
const { page, allPages, 'aria-label': ariaLabel = 'Pagination', ...attrs } = Astro.props;
|
||||
|
||||
const pages = allPages.map((href, i) => {
|
||||
return {
|
||||
pageNum: i + 1,
|
||||
text: String(i + 1),
|
||||
href,
|
||||
};
|
||||
});
|
||||
|
||||
export type PageLink = {
|
||||
pageNum: number;
|
||||
text: string;
|
||||
href: string;
|
||||
};
|
||||
|
||||
export type Ellipsis = {
|
||||
text: string;
|
||||
pageNum?: never;
|
||||
href?: never;
|
||||
};
|
||||
|
||||
export function collapseRange({ page, pages }: { page: Page<unknown>; pages: PageLink[] }): (PageLink | Ellipsis)[] {
|
||||
const total = pages.length;
|
||||
const max = 5;
|
||||
|
||||
// only need ellipsis if we have more pages than we can display
|
||||
const needEllipsis = total > max;
|
||||
|
||||
// show start ellipsis if the current page is further away than max - 3 from the first page
|
||||
const hasStartEllipsis = needEllipsis && page.currentPage > max - 2;
|
||||
// show end ellipsis if the current page is further than total - total + 2 from the last page
|
||||
const hasEndEllipsis = needEllipsis && page.currentPage < total - 2;
|
||||
|
||||
if (!needEllipsis) {
|
||||
return pages;
|
||||
}
|
||||
|
||||
if (hasStartEllipsis && !hasEndEllipsis) {
|
||||
return [pages[0], { text: '...' }, ...pages.slice(Math.min(page.currentPage - 2, total - 3))];
|
||||
}
|
||||
|
||||
if (!hasStartEllipsis && hasEndEllipsis) {
|
||||
return [...pages.slice(0, Math.max(3, page.currentPage + 1)), { text: '...' }, pages[pages.length - 1]];
|
||||
}
|
||||
|
||||
// we have both start and end ellipsis
|
||||
return [pages[0], { text: '...' }, ...pages.slice(page.currentPage - 2, page.currentPage + 1), { text: '...' }, pages[pages.length - 1]];
|
||||
}
|
||||
|
||||
const collapsedPages = collapseRange({ page, pages });
|
||||
---
|
||||
|
||||
<nav aria-label={ariaLabel} {...attrs}>
|
||||
<ul class="flex items-center gap-4">
|
||||
{
|
||||
page.url.prev && (
|
||||
<li class="rounded-full border">
|
||||
<a href={page.url.prev} data-astro-prefetch class="flex h-10 w-10 items-center justify-center">
|
||||
<span class="sr-only">
|
||||
{t('pagination.go-to-page', { page: page.currentPage - 1, total: page.lastPage })}
|
||||
</span>
|
||||
<div class="i-tabler-arrow-left size-6" aria-hidden="true" />
|
||||
</a>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
{
|
||||
collapsedPages.map(link => (
|
||||
<li class="hidden sm:inline-block">
|
||||
{!link.href
|
||||
? (
|
||||
<p>{link.text}</p>
|
||||
)
|
||||
: (
|
||||
<a
|
||||
href={link.href}
|
||||
class:list={[
|
||||
'relative flex h-10 w-10 items-center justify-center rounded-md border border-transparent transition-colors duration-150',
|
||||
link.pageNum !== page.currentPage && 'hover:border-white focus:border-white',
|
||||
]}
|
||||
aria-current={link.pageNum === page.currentPage ? 'page' : undefined}
|
||||
>
|
||||
{link.pageNum}
|
||||
</a>
|
||||
)}
|
||||
</li>
|
||||
))
|
||||
}
|
||||
<li class="sm:hidden">
|
||||
<p aria-current="page">{t('pagination.page', { current: page.currentPage, total: page.lastPage })}</p>
|
||||
</li>
|
||||
{
|
||||
page.url.next && (
|
||||
<li class="rounded-full border">
|
||||
<a href={page.url.next} data-astro-prefetch class="flex h-10 w-10 items-center justify-center">
|
||||
<span class="sr-only">
|
||||
{t('pagination.go-to-page', { page: page.currentPage + 1, total: page.lastPage })}
|
||||
</span>
|
||||
<div class="i-tabler-arrow-right size-6" aria-hidden="true" />
|
||||
</a>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
</ul>
|
||||
</nav>
|
||||
16
apps/website/src/components/PosthogAnalytics.astro
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
const apiKey: string | undefined = import.meta.env.POSTHOG_API_KEY;
|
||||
const apiHost: string = import.meta.env.POSTHOG_API_HOST ?? 'https://eu.i.posthog.com';
|
||||
const isEnabled: boolean = Boolean(apiKey);
|
||||
---
|
||||
{ isEnabled && (
|
||||
<link rel="preconnect" href={apiHost} />
|
||||
|
||||
<script is:inline define:vars={{apiKey, apiHost}}>
|
||||
!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.crossOrigin="anonymous",p.async=!0,p.src=s.api_host.replace(".i.posthog.com","-assets.i.posthog.com")+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="init capture register register_once register_for_session unregister unregister_for_session getFeatureFlag getFeatureFlagPayload isFeatureEnabled reloadFeatureFlags updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures on onFeatureFlags onSessionId getSurveys getActiveMatchingSurveys renderSurvey canRenderSurvey getNextSurveyStep identify setPersonProperties group resetGroups setPersonPropertiesForFlags resetPersonPropertiesForFlags setGroupPropertiesForFlags resetGroupPropertiesForFlags reset get_distinct_id getGroups get_session_id get_session_replay_url alias set_config startSessionRecording stopSessionRecording sessionRecordingStarted captureException loadToolbar get_property getSessionProperty createPersonProfile opt_in_capturing opt_out_capturing has_opted_in_capturing has_opted_out_capturing clear_opt_in_out_capturing debug getPageViewId".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
|
||||
posthog.init(apiKey, {
|
||||
api_host: apiHost,
|
||||
person_profiles: 'identified_only'
|
||||
})
|
||||
</script>
|
||||
)}
|
||||
62
apps/website/src/components/ValuesSection.astro
Normal file
@@ -0,0 +1,62 @@
|
||||
---
|
||||
import { useI18n } from '../i18n/i18n';
|
||||
|
||||
const { t } = useI18n({ locale: Astro.currentLocale });
|
||||
|
||||
|
||||
const values = [
|
||||
{
|
||||
icon: 'i-tabler-heart-handshake',
|
||||
title: t('values.ethical-by-design.title'),
|
||||
description: t('values.ethical-by-design.description'),
|
||||
},
|
||||
{
|
||||
icon: 'i-tabler-building-bank',
|
||||
title: t('values.bootstrapped-and-independent.title'),
|
||||
description: t('values.bootstrapped-and-independent.description'),
|
||||
},
|
||||
{
|
||||
icon: 'i-tabler-shield-lock',
|
||||
title: t('values.your-data-is-yours.title'),
|
||||
description: t('values.your-data-is-yours.description'),
|
||||
},
|
||||
{
|
||||
icon: 'i-tabler-brand-open-source',
|
||||
title: t('values.fully-open-source.title'),
|
||||
description: t('values.fully-open-source.description'),
|
||||
},
|
||||
{
|
||||
icon: 'i-tabler-trees',
|
||||
title: t('values.environmentally-conscious.title'),
|
||||
description: t('values.environmentally-conscious.description'),
|
||||
},
|
||||
{
|
||||
icon: 'i-tabler-users',
|
||||
title: t('values.community-driven.title'),
|
||||
description: t('values.community-driven.description'),
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<section class="py-32 border-t bg-background relative overflow-hidden">
|
||||
<div class="z-10 bg-primary w-80% max-w-800px h-300px rounded-full blur-96 op-10 absolute top-50% left-50% -translate-x-50% -translate-y-50%"></div>
|
||||
|
||||
<div class="max-w-1200px mx-auto px-6 relative z-20">
|
||||
<div class="text-center mb-24">
|
||||
<h2 class="text-3xl sm:text-4xl font-bold mb-4">{t('values.title')}</h2>
|
||||
<p class="text-lg text-muted-foreground max-w-2xl mx-auto" set:html={t('values.subtitle')} />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{
|
||||
values.map(value => (
|
||||
<div class="border rounded-xl p-6 bg-card group">
|
||||
<div class={`${value.icon} size-10 text-primary mb-4 group-hover:(scale-105 rotate-12) transition transform`} />
|
||||
<h3 class="text-xl font-bold mb-1">{value.title}</h3>
|
||||
<p class="text-muted-foreground">{value.description}</p>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
After Width: | Height: | Size: 631 KiB |
|
After Width: | Height: | Size: 774 KiB |
BIN
apps/website/src/content/blog/_images/papra-03/papra-03-og.png
Normal file
|
After Width: | Height: | Size: 386 KiB |
|
After Width: | Height: | Size: 720 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 630 KiB |
BIN
apps/website/src/content/blog/_images/papra-04/papra-04-og.png
Normal file
|
After Width: | Height: | Size: 420 KiB |
|
After Width: | Height: | Size: 772 KiB |
|
After Width: | Height: | Size: 587 KiB |
|
After Width: | Height: | Size: 879 KiB |
BIN
apps/website/src/content/blog/_images/papra-05/papra-05-og.png
Normal file
|
After Width: | Height: | Size: 426 KiB |
|
After Width: | Height: | Size: 772 KiB |
|
After Width: | Height: | Size: 788 KiB |
|
After Width: | Height: | Size: 562 KiB |
BIN
apps/website/src/content/blog/_images/papra-06/papra-06-og.png
Normal file
|
After Width: | Height: | Size: 425 KiB |
|
After Width: | Height: | Size: 772 KiB |
BIN
apps/website/src/content/blog/_images/papra-07/papra-07-og.png
Normal file
|
After Width: | Height: | Size: 424 KiB |
|
After Width: | Height: | Size: 772 KiB |
|
After Width: | Height: | Size: 43 KiB |
BIN
apps/website/src/content/blog/_images/papra-08/papra-08-og.png
Normal file
|
After Width: | Height: | Size: 424 KiB |
|
After Width: | Height: | Size: 771 KiB |
BIN
apps/website/src/content/blog/_images/papra-09/papra-09-og.png
Normal file
|
After Width: | Height: | Size: 424 KiB |
|
After Width: | Height: | Size: 771 KiB |
77
apps/website/src/content/blog/introducing-cadence-mq.mdx
Normal file
@@ -0,0 +1,77 @@
|
||||
---
|
||||
title: "Introducing Cadence-MQ: a backend-agnostic Node.js job queue"
|
||||
description: Introducing Cadence-MQ, a backend-agnostic Node.js job queue built for self-hosted applications and high-performance workloads.
|
||||
publishedAt: 2025-07-08
|
||||
lang: en
|
||||
ogImage: /src/content/blog/_images/introducing-cadence-mq/introducing-cadence-mq.og.png
|
||||
coverImage: /src/content/blog/_images/introducing-cadence-mq/introducing-cadence-mq.preview.png
|
||||
---
|
||||
|
||||
I recently released [Cadence-MQ](https://github.com/papra-hq/cadence-mq), a backend-agnostic Node.js job queue built with self-hosting in mind.
|
||||
|
||||
## Why another job queue?
|
||||
|
||||
When developing a full-stack application, you often need to run background jobs, like sending emails, processing files, or any other task that doesn't need to be done immediately or that are too heavy to be done while holding an API request.
|
||||
|
||||
There are a lot of quality job queue libraries for Node.js out there, like [BullMQ](https://github.com/taskforcesh/bullmq), [Bee-Queue](https://github.com/bee-queue/bee-queue), [Kue](https://github.com/Automattic/kue) or [Agenda](https://github.com/agenda/agenda), but most of them are tied to a specific server-based database, mainly Redis. It's really relevant for distributed cloud-based applications, but rather heavy for self-hosted applications as you need to run a Redis server on your own.
|
||||
|
||||
## How is Cadence-MQ different?
|
||||
|
||||
As Papra aims to be lightweight with a low footprint, I wanted to make a job queue that doesn't require an additional server-based database.
|
||||
|
||||
### Backend agnostic by design
|
||||
|
||||
Unlike many existing solutions, Cadence-MQ is built from the ground up to be backend-agnostic. It doesn't impose a specific database or server setup, allowing you to choose the backend that best fits your application's architecture and infrastructure. You can easily plug in your preferred database, whether it's SQLite, Redis (in the future), or other datastores.
|
||||
|
||||
### LibSQL first
|
||||
|
||||
The first backend I wanted to support was LibSQL, as it's the same database as Papra, and relatively versatile as it supports either in-memory, SQLite local files, or remote distributed LibSQL servers.
|
||||
|
||||
### Built for scalability
|
||||
|
||||
While SQLite/LibSQL is excellent for getting started quickly and efficiently, Cadence-MQ is designed to scale. Its modular architecture means you can easily switch or add backends as your needs evolve. For production workloads where higher throughput or distributed capabilities are required, you can seamlessly migrate or use different distributed databases such as Turso.
|
||||
|
||||
### Open source and community-driven
|
||||
|
||||
Cadence-MQ is obviously fully open source, under the MIT license, welcoming external contributions. Whether you find a bug, think of an improvement, or want to add support for another backend, your contributions are warmly welcomed. The repository is [papra-hq/cadence-mq](https://github.com/papra-hq/cadence-mq).
|
||||
|
||||
## Examples of usage
|
||||
|
||||
Here is a simple example of how to use Cadence-MQ with LibSQL.
|
||||
|
||||
```bash
|
||||
pnpm install @cadence-mq/core @cadence-mq/driver-libsql
|
||||
```
|
||||
|
||||
```typescript
|
||||
import { createQueue } from '@cadence-mq/core';
|
||||
import { createLibSqlDriver } from '@cadence-mq/driver-libsql';
|
||||
import { createClient } from '@libsql/client';
|
||||
|
||||
const client = createClient({ url: 'file:./cadence-mq.db' });
|
||||
const queue = createQueue({ driver: createLibSqlDriver({ client }) });
|
||||
|
||||
queue.registerTask({
|
||||
name: 'send-welcome-email',
|
||||
handler: async ({ data }) => {
|
||||
console.log(`Sending welcome email to ${data.email}`);
|
||||
},
|
||||
});
|
||||
|
||||
queue.startWorker({ workerId: 'worker-1' });
|
||||
|
||||
await queue.scheduleJob({
|
||||
taskName: 'send-welcome-email',
|
||||
data: { email: 'test@test.com' },
|
||||
});
|
||||
```
|
||||
|
||||
## What's next?
|
||||
|
||||
Cadence-MQ is still in early development, with many exciting features on the roadmap, including
|
||||
- Job status tracking
|
||||
- UI for monitoring and managing jobs
|
||||
- Unique key for jobs
|
||||
- Drivers for additional backends (Redis, MongoDB, ...)
|
||||
|
||||
I would love for you to try it out, provide feedback, and contribute to the development. Check out the [repository](https://github.com/papra-hq/cadence-mq) for more information, leave a star, and feel free to open an issue or a pull request.
|
||||
83
apps/website/src/content/blog/papra-03.mdx
Normal file
@@ -0,0 +1,83 @@
|
||||
---
|
||||
title: Papra v0.3 is out!
|
||||
description: Papra v0.3 is out! This release brings long awaited features like the folder ingestion, auto tagging rules, and more.
|
||||
publishedAt: 2025-04-16
|
||||
lang: en
|
||||
ogImage: /src/content/blog/_images/papra-03/papra-03-og.png
|
||||
coverImage: /src/content/blog/_images/papra-03/papra-03-preview.png
|
||||
---
|
||||
|
||||
I'm really excited to announce the release of Papra v0.3! This is the first release of major features since the initial beta release of Papra.
|
||||
|
||||
|
||||
## Auto tagging rules
|
||||
|
||||
This release introduces **auto tagging rules**. This allows you to define rules that will automatically tag your files based on their content on ingestion.
|
||||
|
||||
For example, you can define a rule that will tag add the `invoice` tag to all files that contain the word `invoice` in their name. Then when a file is added (from any source, email intake, manual upload, or folder ingestion), it will automatically be tagged with the `invoice` tag.
|
||||
|
||||

|
||||
|
||||
## Folder ingestion
|
||||
|
||||
One of the most awaited features in this release is the **folder ingestion** feature.
|
||||
This feature allows you to bind a folder to your Papra instance and automatically ingest all the files added to that folder.
|
||||
|
||||
It'll allow you to setup ingestion from your scanner or any other source that can write files to a folder.
|
||||
|
||||
Just add the following environment variable to your `.env` or `docker-compose.yml` file:
|
||||
|
||||
```.env
|
||||
INGESTION_FOLDER_IS_ENABLED=true
|
||||
```
|
||||
|
||||
and bind the `/app/ingestion` folder to one of your folders in your `docker-compose.yml` file:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- ./ingestion:/app/ingestion
|
||||
```
|
||||
|
||||
## Emptying the trash
|
||||
|
||||
You can now manually empty the trash from the UI. Either delete one file at a time or empty the whole trash.
|
||||
|
||||
> **Note:** It's not possible to restore files from the trash once they are permanently deleted. So be careful when emptying the trash.
|
||||
|
||||

|
||||
|
||||
## Upload status popup
|
||||
|
||||
An upload status popup has been added to the UI. This will show you the status of your uploads and allow you to know when the upload is complete or if there was an error.
|
||||
|
||||

|
||||
|
||||
## Other changes
|
||||
|
||||
- **Improved deduplication**: now when you upload the same file as one in the trash, the file in the trash will be restored and updated with the new metadata.
|
||||
- **Configuration**: upload file size limit can now be disabled in the configuration by setting `DOCUMENT_STORAGE_MAX_UPLOAD_SIZE` to `0`.
|
||||
- **Improved the docs**: the documentation has been improved with better examples and explanations.
|
||||
- Other minor bug fixes and improvements.
|
||||
|
||||
You can find more details in [the release changelog](https://github.com/papra-hq/papra/releases/tag/v0.3.0).
|
||||
|
||||
## Conclusion
|
||||
|
||||
Papra v0.3 is one of many releases to come!
|
||||
|
||||
Thank you for your support and precious feedbacks! If you have any suggestions, you can either open an issue on [GitHub](https://github.com/papra-hq/papra/issues) or join the [Discord server](https://papra.app/discord).
|
||||
|
||||
If you want to support the development of Papra, you can [buy me a coffee](https://buymeacoffee.com/cthmsst), or just [star the GitHub repository](https://github.com/papra-hq/papra), it'll help me a lot!
|
||||
|
||||
I'm looking forward to hearing from you!
|
||||
|
||||
|
||||
<div class="mt-14">
|
||||
Some useful links:
|
||||
- [v0.3 release](https://github.com/papra-hq/papra/releases/tag/v0.3.0)
|
||||
- [Discord server](https://papra.app/discord)
|
||||
- [GitHub repository](https://github.com/papra-hq/papra)
|
||||
- [Buy me a coffee](https://buymeacoffee.com/cthmsst)
|
||||
- [Bluesky account](https://bsky.app/profile/papra.app)
|
||||
- [Roadmap](https://github.com/orgs/papra-hq/projects/2)
|
||||
</div>
|
||||
209
apps/website/src/content/blog/papra-04.mdx
Normal file
@@ -0,0 +1,209 @@
|
||||
---
|
||||
title: Papra v0.4 - The Developer Update
|
||||
description: Papra v0.4 brings powerful developer tools including API keys, organization webhooks, a CLI, and a TypeScript/JavaScript SDK.
|
||||
publishedAt: 2025-05-16
|
||||
lang: en
|
||||
ogImage: /src/content/blog/_images/papra-04/papra-04-og.png
|
||||
coverImage: /src/content/blog/_images/papra-04/papra-04-preview.png
|
||||
---
|
||||
|
||||
I'm thrilled to announce the release of Papra v0.4! This release focuses on empowering developers with new tools and integrations to build powerful solutions with Papra.
|
||||
|
||||
## Developer Tools
|
||||
|
||||
### API Keys
|
||||
|
||||
This release introduces **API keys**, allowing you to securely interact with Papra's API. You can create and manage API keys from your user settings page. Each key can have different permissions, making it easy to control access to your documents.
|
||||
|
||||

|
||||
|
||||
|
||||
To use the API, simply include your API key in the `Authorization` header, for example, in a curl request:
|
||||
|
||||
```bash
|
||||
curl -X POST https://your-papra-instance/api/organizations/<organization-id>/documents \
|
||||
-H "Authorization: Bearer <your-api-key>" \
|
||||
-H "Content-Type: multipart/form-data" \
|
||||
-F "file=@invoice.pdf"
|
||||
```
|
||||
|
||||
|
||||
### TypeScript/JavaScript SDK
|
||||
|
||||
For JavaScript and TypeScript developers, we've created a **first-party SDK** that makes it easy to integrate Papra into your applications. The SDK provides a type-safe way to interact with Papra's API, with full TypeScript support.
|
||||
The SDK is available on npm and pnpm: [@papra/api-sdk](https://www.npmjs.com/package/@papra/api-sdk).
|
||||
|
||||
```bash
|
||||
pnpm install @papra/api-sdk
|
||||
# or
|
||||
npm install @papra/api-sdk
|
||||
# or
|
||||
yarn add @papra/api-sdk
|
||||
```
|
||||
|
||||
|
||||
```typescript
|
||||
import { createClient } from '@papra/api-sdk';
|
||||
|
||||
const client = createClient({
|
||||
// The API key can be found in your user settings (under /api-keys)
|
||||
// you may want to store this in an environment variable
|
||||
apiKey: 'ppapi_...',
|
||||
|
||||
// Optional: base URL of the API
|
||||
apiBaseUrl: 'http://papra.your-instance.tld',
|
||||
});
|
||||
|
||||
const myFile = new File(['test'], 'test.txt', { type: 'text/plain' });
|
||||
|
||||
await client.uploadDocument({
|
||||
file: myFile,
|
||||
organizationId: 'org_...', // The id of the organization you want to upload the document to
|
||||
});
|
||||
```
|
||||
|
||||
You can also scope the client to a specific organization:
|
||||
|
||||
```typescript
|
||||
const client = createClient({ apiKey, apiBaseUrl }).forOrganization('org_...');
|
||||
|
||||
await client.uploadDocument({ file });
|
||||
```
|
||||
|
||||
### Organization Webhooks
|
||||
|
||||
**Organization webhooks** allow you to receive real-time events from your Papra instance. You can now subscribe to events like document creation and deletions, more events will be added in the future.
|
||||
|
||||

|
||||
|
||||
You can create webhooks in the web interface, under the organization settings.
|
||||
|
||||
Each webhook payload includes:
|
||||
- The event type
|
||||
- A timestamp
|
||||
- The resource data (document, tag, etc.)
|
||||
|
||||
Example webhook payload:
|
||||
```json
|
||||
{
|
||||
"event": "document:created",
|
||||
"payload": {
|
||||
"documentId": "doc_if13q6qstj8yirmktt9mlnxe",
|
||||
"organizationId": "org_mnpl09j43uvqmcde4aob2lq3",
|
||||
"name": "37627603eu.pdf",
|
||||
"createdAt": "2025-05-13T18:08:47.607Z",
|
||||
"updatedAt": "2025-05-13T18:08:47.607Z"
|
||||
},
|
||||
"timestampMs": 1747159727627
|
||||
}
|
||||
```
|
||||
|
||||
Each payload is signed with a secret key, the signature is a HMAC-SHA256 hash of the payload and the secret key and can be found in the `X-Signature` header.
|
||||
For convenience, and easy integration and verification, we've published a package: [@papra/webhooks](https://www.npmjs.com/package/@papra/webhooks) that can be used to consume and verify the webhook events (and trigger dummy events for testing).
|
||||
|
||||
```bash
|
||||
pnpm install @papra/webhooks
|
||||
# or
|
||||
npm install @papra/webhooks
|
||||
# or
|
||||
yarn add @papra/webhooks
|
||||
```
|
||||
|
||||
If you want to use the webhooks package, you can do something like this:
|
||||
|
||||
```typescript
|
||||
import { createWebhooksHandler } from '@papra/webhooks';
|
||||
|
||||
const webhookHandler = createWebhooksHandler({ secret: 'secret' });
|
||||
|
||||
webhookHandler.on('document:created', (payload) => {
|
||||
console.log('Document created', payload);
|
||||
});
|
||||
|
||||
// ...
|
||||
// In your server, handle the webhook event
|
||||
|
||||
// Get the body and signature from the http request
|
||||
const bodyBuffer = request.body;
|
||||
const signature = request.headers['x-signature'];
|
||||
|
||||
webhookHandler.handle({ bodyBuffer, signature });
|
||||
```
|
||||
|
||||
### Command Line Interface
|
||||
|
||||
The new **Papra CLI** makes it easy to manage your Papra instance from the command line. You can perform common operations like uploading documents, managing tags, and configuring your instance.
|
||||
|
||||
For the moment, the CLI is only available as a npm package: [@papra/cli](https://www.npmjs.com/package/@papra/cli).
|
||||
|
||||
```bash
|
||||
# Install the CLI
|
||||
pnpm install -g @papra/cli
|
||||
# or
|
||||
npm install -g @papra/cli
|
||||
# or
|
||||
yarn add -g @papra/cli
|
||||
```
|
||||
|
||||
And you can use it to import documents:
|
||||
|
||||
```bash
|
||||
# Initialize the CLI, you'll be prompted to enter your
|
||||
# API key and instance base URL
|
||||
papra config init
|
||||
|
||||
# Import a document
|
||||
papra documents import -o <organization-id> <file-path>
|
||||
|
||||
```
|
||||
|
||||
For more information about any command, you can use the `--help` flag:
|
||||
|
||||
```bash
|
||||
papra --help
|
||||
papra config --help
|
||||
papra documents --help
|
||||
```
|
||||
|
||||
## Changes in versioning/release process
|
||||
|
||||
Papra will now use [changesets](https://github.com/changesets/changesets) to manage releases and no longer sync all monorepo packages on the same version, nor use the v prefix for the version.
|
||||
|
||||
Now, each package release will be tagged with the package name and the version, like `@papra/api-sdk@0.1.0`.
|
||||
|
||||
See the [releases page](https://github.com/papra-hq/papra/releases) for the latest releases.
|
||||
|
||||
## New file storage drivers
|
||||
|
||||
New file storage drivers have been added to Papra, you were able to use the **fs**, **s3**, and **memory** drivers, and now we've added:
|
||||
- **Backblaze B2**
|
||||
- **Azure Blob Storage**
|
||||
|
||||
## Other Improvements
|
||||
|
||||
- **Document Search**: Edit searchable content for better document discovery
|
||||
- **Tag Management**: Added tag creation button in the document page
|
||||
- **File Handling**: Improved handling of files without extensions
|
||||
- **Storage**: Properly hard delete files in storage driver
|
||||
- **Document Count**: Excluded deleted documents from document count
|
||||
- **Configuration**: Fixed ingestion config coercion
|
||||
- **And more!**
|
||||
|
||||
## Conclusion
|
||||
|
||||
Papra v0.4 marks a significant step forward in making the platform more developer-friendly. With the new API, SDK, CLI, and webhooks, you can now build powerful integrations and automate your document management workflows.
|
||||
|
||||
Thank you for your continued support and valuable feedback! If you have any suggestions, you can either open an issue on [GitHub](https://github.com/papra-hq/papra/issues) or join the [Discord server](https://papra.app/discord).
|
||||
|
||||
If you want to support the development of Papra, you can [buy me a coffee](https://buymeacoffee.com/cthmsst), or just [star the GitHub repository](https://github.com/papra-hq/papra), it'll help me a lot!
|
||||
|
||||
I'm looking forward to hearing from you!
|
||||
|
||||
<div class="mt-14">
|
||||
Some useful links:
|
||||
- [Discord server](https://papra.app/discord)
|
||||
- [GitHub repository](https://github.com/papra-hq/papra)
|
||||
- [Buy me a coffee](https://buymeacoffee.com/cthmsst)
|
||||
- [Bluesky account](https://bsky.app/profile/papra.app)
|
||||
- [Roadmap](https://github.com/orgs/papra-hq/projects/2)
|
||||
</div>
|
||||
83
apps/website/src/content/blog/papra-05.mdx
Normal file
@@ -0,0 +1,83 @@
|
||||
---
|
||||
title: Papra v0.5 - Multi-user organizations, OAuth2 providers, and more!
|
||||
description: Papra v0.5 brings powerful collaboration features including organization invitations and membership management, custom OAuth2/OIDC providers, and improved deployment tools.
|
||||
publishedAt: 2025-05-24
|
||||
lang: en
|
||||
ogImage: /src/content/blog/_images/papra-05/papra-05-og.png
|
||||
coverImage: /src/content/blog/_images/papra-05/papra-05-preview.png
|
||||
---
|
||||
|
||||
I'm excited to announce the release of Papra v0.5! This release focuses on improving collaboration and making Papra more accessible to teams and organizations.
|
||||
|
||||
## Organization Invitations
|
||||
|
||||
This release introduces a new **invitation and membership management system** that makes it easy to add users to your organization. You can now invite users by email, and they'll receive an in-app invitation to join your organization (and an email notification if configured).
|
||||
|
||||
|
||||
The invitation system includes:
|
||||
- Optional email notifications for invitations
|
||||
- Role-based invitations (admin, member)
|
||||
- In-app membership management
|
||||
|
||||

|
||||
|
||||
## Custom OAuth2/OIDC Providers
|
||||
|
||||
Papra now supports **custom OAuth2 and OIDC providers**, allowing you to integrate with your existing authentication infrastructure. This makes it easier to deploy Papra in enterprise environments where you might be using services like Azure AD, Okta, or other self-hosted identity providers.
|
||||
|
||||
You can configure custom providers by adding them to your environment variables:
|
||||
|
||||
```env
|
||||
AUTH_PROVIDERS_CUSTOMS='[
|
||||
{
|
||||
"providerId": "custom-oauth2",
|
||||
"providerName": "Custom OAuth2",
|
||||
"providerIconUrl": "https://api.iconify.design/tabler:login-2.svg",
|
||||
"clientId": "your-client-id",
|
||||
"clientSecret": "your-client-secret",
|
||||
"type": "oidc",
|
||||
"discoveryUrl": "https://your-provider.tld/.well-known/openid-configuration",
|
||||
"scopes": ["openid", "profile", "email"]
|
||||
}
|
||||
]'
|
||||
```
|
||||
|
||||
For more details on setting up custom OAuth2 providers, check out our [dedicated guide](https://docs.papra.app/guides/setup-custom-oauth2-providers/).
|
||||
|
||||
## Improved Deployment Tools
|
||||
|
||||
### Docker Compose Generator
|
||||
|
||||
We've added a new **Docker Compose Generator** to our documentation website that makes it easy to generate a custom `docker-compose.yml` file for your Papra instance. The generator allows you to:
|
||||
|
||||
- Choose your preferred image source
|
||||
- Configure service name and port
|
||||
- Set up authentication secret
|
||||
- Enable/disable features like ingestion folder
|
||||
- Configure intake emails
|
||||
- And more!
|
||||
|
||||
You can find the generator at [docs.papra.app/docker-compose-generator](https://docs.papra.app/docker-compose-generator/).
|
||||
|
||||
### Database Directory Check
|
||||
|
||||
Papra now ensures that the local database directory exists on boot, preventing potential issues with database initialization. This is especially helpful for users who are setting up Papra for the first time.
|
||||
|
||||
## Conclusion
|
||||
|
||||
Papra v0.5 makes it easier than ever to collaborate with your team and integrate Papra into your existing infrastructure. With organization invitations and custom OAuth2 providers, you can now deploy Papra in more environments and scale your document management workflows.
|
||||
|
||||
Thank you for your continued support and valuable feedback! If you have any suggestions, you can either open an issue on [GitHub](https://github.com/papra-hq/papra/issues) or join the [Discord server](https://papra.app/discord).
|
||||
|
||||
If you want to support the development of Papra, you can [buy me a coffee](https://buymeacoffee.com/cthmsst), or just [star the GitHub repository](https://github.com/papra-hq/papra), it'll help me a lot!
|
||||
|
||||
I'm looking forward to hearing from you!
|
||||
|
||||
<div class="mt-14">
|
||||
Some useful links:
|
||||
- [Discord server](https://papra.app/discord)
|
||||
- [GitHub repository](https://github.com/papra-hq/papra)
|
||||
- [Buy me a coffee](https://buymeacoffee.com/cthmsst)
|
||||
- [Bluesky account](https://bsky.app/profile/papra.app)
|
||||
- [Roadmap](https://github.com/orgs/papra-hq/projects/2)
|
||||
</div>
|
||||
103
apps/website/src/content/blog/papra-06.mdx
Normal file
@@ -0,0 +1,103 @@
|
||||
---
|
||||
title: Papra v0.6 - Document activity logging, invitation management, and more!
|
||||
description: Papra v0.6 introduces document activity logging, improved invitation management, full French language support, and a reworked email system with multiple drivers.
|
||||
publishedAt: 2025-06-03
|
||||
lang: en
|
||||
ogImage: /src/content/blog/_images/papra-06/papra-06-og.png
|
||||
coverImage: /src/content/blog/_images/papra-06/papra-06-preview.png
|
||||
---
|
||||
|
||||
I'm excited to announce the release of Papra v0.6! This release focuses on improving transparency, collaboration, and accessibility with activity tracking, enhanced invitation management, and full internationalization support.
|
||||
|
||||
## Document Activity Log
|
||||
|
||||
One of the biggest additions in v0.6 is the **document activity log**. You can now track all actions performed on your documents, giving you complete visibility into who did what and when.
|
||||
|
||||
The activity log captures:
|
||||
- Document creation
|
||||
- Tag additions and removals
|
||||
- Document deletions
|
||||
- Document updates (content, renames, etc.)
|
||||
|
||||
This feature is particularly valuable for teams that need to maintain audit trails or simply want better insight into how their documents are being used.
|
||||
|
||||

|
||||
|
||||
## Enhanced Invitation Management
|
||||
|
||||
We've significantly improved the **invitation management system** with a dedicated pending invitations page. Organization administrators can now:
|
||||
|
||||
- View all pending invitations in one place
|
||||
- Resend invitations that may have been missed
|
||||
- Cancel invitations that are no longer needed
|
||||
- Track invitation status
|
||||
|
||||
This makes it much easier to manage team onboarding and ensure that all the right people have access to your organization.
|
||||
|
||||

|
||||
|
||||
## Improved Email System
|
||||
|
||||
We've completely **reworked the email sending system** to be more flexible and modular. The new system supports multiple email drivers, allowing you to choose the best option for your deployment:
|
||||
|
||||
- **SMTP driver**: For traditional SMTP servers
|
||||
- **Logger driver**: For development and testing (logs emails instead of sending)
|
||||
- **Resend driver**: Uses the Resend API to send emails
|
||||
- ...and the possibility to easily add more drivers!
|
||||
|
||||
The configuration is now more straightforward:
|
||||
|
||||
```env
|
||||
# Use logger driver for development (default)
|
||||
EMAILS_DRIVER=logger
|
||||
```
|
||||
|
||||
```env
|
||||
# Use SMTP driver for production
|
||||
EMAILS_DRIVER=smtp
|
||||
SMTP_HOST=your-smtp-server.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=your-username
|
||||
SMTP_PASSWORD=your-password
|
||||
SMTP_SECURE=true
|
||||
|
||||
# Or for complex nodemailer configs
|
||||
# see https://nodemailer.com/smtp/ for more details
|
||||
EMAILS_DRIVER=smtp
|
||||
SMTP_JSON_CONFIG={ ... }
|
||||
```
|
||||
|
||||
```env
|
||||
# Use Resend driver for production
|
||||
EMAILS_DRIVER=resend
|
||||
RESEND_API_KEY=your-resend-api-key
|
||||
```
|
||||
|
||||
> **Note**: The `EMAILS_DRY_RUN` environment variable has been removed. Use `EMAILS_DRIVER=logger` (or any other driver) instead to log emails without sending them.
|
||||
|
||||
## Other Improvements
|
||||
|
||||
- Full French language support
|
||||
- Added document renaming
|
||||
- Improved error handling for tag creation
|
||||
- Cleaned a bug that was preventing users from accessing the password reset page
|
||||
- Updated the default value of `CLIENT_BASE_URL` to `http://localhost:1221` in Dockerfiles
|
||||
- Updated dependencies
|
||||
- ...and more!
|
||||
|
||||
## Conclusion
|
||||
|
||||
Thank you for your continued support and valuable feedback! If you have any suggestions, you can either open an issue on [GitHub](https://github.com/papra-hq/papra/issues) or join the [Discord server](https://papra.app/discord).
|
||||
|
||||
If you want to support the development of Papra, you can [buy me a coffee](https://buymeacoffee.com/cthmsst), or just [star the GitHub repository](https://github.com/papra-hq/papra), it'll help me a lot!
|
||||
|
||||
I'm looking forward to hearing from you!
|
||||
|
||||
<div class="mt-14">
|
||||
Some useful links:
|
||||
- [Discord server](https://papra.app/discord)
|
||||
- [GitHub repository](https://github.com/papra-hq/papra)
|
||||
- [Buy me a coffee](https://buymeacoffee.com/cthmsst)
|
||||
- [Bluesky account](https://bsky.app/profile/papra.app)
|
||||
- [Roadmap](https://github.com/orgs/papra-hq/projects/2)
|
||||
</div>
|
||||
139
apps/website/src/content/blog/papra-07.mdx
Normal file
@@ -0,0 +1,139 @@
|
||||
---
|
||||
title: Papra v0.7 - Enhanced file previews, SSO-only auth, more languages, and more!
|
||||
description: Papra v0.7 brings improved text file previews, SSO-only authentication support, tag color customization, API documentation, and support for 6 new languages.
|
||||
publishedAt: 2025-07-13
|
||||
lang: en
|
||||
ogImage: /src/content/blog/_images/papra-07/papra-07-og.png
|
||||
coverImage: /src/content/blog/_images/papra-07/papra-07-preview.png
|
||||
---
|
||||
|
||||
I'm excited to announce the release of Papra v0.7! This release focuses on improving the user experience with better file previews, enhanced authentication options, and significantly expanded internationalization support.
|
||||
|
||||
## Enhanced File Previews
|
||||
|
||||
We improved the file previews in v0.7, you can now preview a wider range of text files including:
|
||||
|
||||
- Configuration files (`.env`, `.yaml`, `.yml`, `.json`)
|
||||
- Script files (`.sh`, `.bash`, `.py`, `.js`, `.ts`)
|
||||
- Documentation files (`.md`, `.txt`)
|
||||
- Files without extensions that contain text content
|
||||
|
||||
This makes it much easier to review and understand your documents without having to download them first.
|
||||
|
||||
## SSO-Only Authentication
|
||||
|
||||
For setups that rely entirely on Single Sign-On (SSO), we've added the ability to **disable email-based authentication**. This is particularly useful for setups that already have a SSO provider.
|
||||
|
||||
You can configure this by setting the following environment variable:
|
||||
|
||||
```bash
|
||||
AUTH_PROVIDERS_EMAIL_IS_ENABLED=false
|
||||
```
|
||||
|
||||
When disabled, users will only be able to authenticate through your configured OAuth2/OIDC providers, ensuring consistent authentication across your setup.
|
||||
|
||||
## App Base URL for Simplified Configuration
|
||||
|
||||
We've added the `APP_BASE_URL` environment variable to simplify the configuration of Papra, when set, it'll override the `SERVER_BASE_URL` and `CLIENT_BASE_URL` environment variables.
|
||||
|
||||
You can configure this by setting the following environment variable:
|
||||
|
||||
```bash
|
||||
APP_BASE_URL=https://papra.example.com
|
||||
```
|
||||
|
||||
When set, Papra will use this URL as the base URL for all links and redirects.
|
||||
|
||||
## Tag Color Customization
|
||||
|
||||
We've introduced **tag color swatches and a color picker** to make tag management more visual and intuitive. You can now:
|
||||
|
||||
- Choose from predefined color swatches
|
||||
- Use a color picker for custom colors
|
||||
- See color previews when creating or editing tags
|
||||
|
||||

|
||||
|
||||
## API Documentation
|
||||
|
||||
For developers integrating with Papra, we've added a **comprehensive API endpoints documentation**.
|
||||
|
||||
You can access the API documentation [in the documentation website](https://docs.papra.app/resources/api-endpoints/).
|
||||
|
||||
## OCR Language Configuration
|
||||
|
||||
We've added **configurable OCR language support** to improve text extraction accuracy for documents in different languages. You can now specify which languages the OCR engine should recognize:
|
||||
|
||||
```bash
|
||||
# Set the default ocr language to French
|
||||
DOCUMENTS_OCR_LANGUAGES=fra
|
||||
|
||||
# Set the default ocr language to French, English and German
|
||||
DOCUMENTS_OCR_LANGUAGES=fra,eng,deu
|
||||
```
|
||||
|
||||
This is particularly useful for organizations working with multilingual documents, as it significantly improves the accuracy of text extraction and search functionality.
|
||||
|
||||
## Expanded Language Support
|
||||
|
||||
Papra v0.7 adds support for **6 new languages**, bringing the total to 8 supported languages:
|
||||
|
||||
- **Spanish** (es)
|
||||
- **Polish** (pl)
|
||||
- **Brazilian Portuguese** (pt-BR)
|
||||
- **European Portuguese** (pt)
|
||||
- **Romanian** (ro)
|
||||
|
||||
This makes Papra more accessible to users worldwide and supports organizations with international teams.
|
||||
|
||||
## Bug Fixes and Improvements
|
||||
|
||||
- **Fixed back to organization link** in organization settings
|
||||
- **Resolved 400 error** when submitting tags with uppercase hex color codes
|
||||
- **Fixed weird centering** in document page for long filenames
|
||||
- **Corrected invalid domain** in documentation JSON schema URLs
|
||||
- **Fixed permission issue** for non-1000:1000 rootless users
|
||||
|
||||
## What's next?
|
||||
|
||||
While working on Papra, I've been developing a new open-source project that can be useful for Node.js developers: **[CadenceMQ](https://github.com/papra-hq/cadence-mq)**. It's a backend-agnostic Node.js job queue library built with self-hosting in mind.
|
||||
|
||||
### Why CadenceMQ?
|
||||
|
||||
When building applications like Papra, you often need to run background jobs (sending emails, processing files, etc.). Most existing job queue libraries are tied to specific databases like Redis, which adds complexity for self-hosted setups. CadenceMQ solves this by being backend-agnostic - you can start with SQLite for simplicity and scale to distributed databases when needed.
|
||||
|
||||
### Key features
|
||||
- **Backend agnostic** - Works with SQLite, LibSQL, and more
|
||||
- **Self-hosted friendly** - No additional servers required
|
||||
- **Scalable** - From simple setups to production workloads
|
||||
- **TypeScript support** - Full type safety
|
||||
- **Open source** - MIT licensed
|
||||
|
||||
It's still in early development, but if you're building applications that need background job processing, check out [CadenceMQ on GitHub](https://github.com/papra-hq/cadence-mq).
|
||||
|
||||
## Supporting Papra Development
|
||||
|
||||
I'm excited to announce that Papra now has a dedicated **[GitHub Sponsors page](https://github.com/sponsors/papra-hq)**!
|
||||
|
||||
Your sponsorship helps support infrastructure costs, feature development, and community growth. While Papra will always remain free, your support ensures it stays sustainable and feature-rich.
|
||||
|
||||
Thank you for supporting independent open-source development!
|
||||
|
||||
## Conclusion
|
||||
|
||||
Papra v0.7 continues our focus on improving usability and accessibility. With enhanced file previews, SSO-only authentication, and expanded language support, Papra is now more versatile and user-friendly than ever.
|
||||
|
||||
Thank you for your continued support and valuable feedback! If you have any suggestions, you can either open an issue on [GitHub](https://github.com/papra-hq/papra/issues) or join the [Discord server](https://papra.app/discord).
|
||||
|
||||
If you want to support the development of Papra, you can [buy me a coffee](https://buymeacoffee.com/cthmsst), or just [star the GitHub repository](https://github.com/papra-hq/papra), it'll help me a lot!
|
||||
|
||||
I'm looking forward to hearing from you!
|
||||
|
||||
<div class="mt-14">
|
||||
Some useful links:
|
||||
- [Discord server](https://papra.app/discord)
|
||||
- [GitHub repository](https://github.com/papra-hq/papra)
|
||||
- [Buy me a coffee](https://buymeacoffee.com/cthmsst)
|
||||
- [Bluesky account](https://bsky.app/profile/papra.app)
|
||||
- [Roadmap](https://github.com/orgs/papra-hq/projects/2)
|
||||
</div>
|
||||
130
apps/website/src/content/blog/papra-08.mdx
Normal file
@@ -0,0 +1,130 @@
|
||||
---
|
||||
title: Papra v0.8 - Improved webhooks, async task processing, and enhanced migration system
|
||||
description: Papra v0.8 introduces standard webhook compliance, asynchronous file processing with our new task runner, a complete migration system overhaul, and many quality of life improvements.
|
||||
publishedAt: 2025-08-08
|
||||
lang: en
|
||||
ogImage: /src/content/blog/_images/papra-08/papra-08-og.png
|
||||
coverImage: /src/content/blog/_images/papra-08/papra-08-preview.png
|
||||
---
|
||||
|
||||
I'm thrilled to announce the release of Papra v0.8! This is a significant technical release that lays the foundation for exciting future features while improving the developer experience.
|
||||
|
||||
## Enhanced Webhook System
|
||||
|
||||
One of the biggest improvements in v0.8 is our completely overhauled webhook system. We've made it more powerful, reliable, and standards-compliant.
|
||||
|
||||
### Standard Webhook Compliance
|
||||
|
||||
**Breaking change**: We've updated our webhook format to comply with the [Standard Webhooks specification](https://www.standardwebhooks.com/). This brings several benefits, with the main benefit being ecosystem compatibility.
|
||||
|
||||
#### Migration Guide
|
||||
|
||||
If you're currently using webhooks, you'll need to update your webhook handlers:
|
||||
|
||||
**Before (v0.7 and earlier):**
|
||||
```http
|
||||
# Headers
|
||||
x-signature: P8HFU+6SAJSczKQugKpx7aylbGoNH/RTyvLtgS7jzjA=
|
||||
x-event: document:created
|
||||
|
||||
# Body
|
||||
{
|
||||
"event": "document:created",
|
||||
"payload": {
|
||||
"documentId": "doc_hlrwbh2jz2gv851wwtib3ler",
|
||||
"organizationId": "org_eda48ocnvekbcj8q0enxybty",
|
||||
"name": "index (1).js",
|
||||
"createdAt": "2025-08-08T18:40:15.210Z",
|
||||
"updatedAt": "2025-08-08T18:40:15.210Z"
|
||||
},
|
||||
"timestampMs": 1754678415223
|
||||
}
|
||||
```
|
||||
|
||||
**After (v0.8+):**
|
||||
```http
|
||||
# Headers
|
||||
webhook-signature: v1,hQvJ5c3gKIx6NBXLXNWfXJgpLymHE+rXjQqML0DlaIA=
|
||||
webhook-timestamp: 1754678128
|
||||
webhook-id: msg_pb568hoi1t6n3k3fkfoc4u13
|
||||
|
||||
# Body
|
||||
{
|
||||
"data": {
|
||||
"documentId": "doc_tny6ix8nort5ip05s6tl2efd",
|
||||
"organizationId": "org_eda48ocnvekbcj8q0enxybty",
|
||||
"name": "index.js",
|
||||
"createdAt": "2025-08-08T18:35:28.941Z",
|
||||
"updatedAt": "2025-08-08T18:35:28.941Z"
|
||||
},
|
||||
"type": "document:created",
|
||||
"timestamp": "2025-08-08T18:35:28.966Z"
|
||||
}
|
||||
```
|
||||
|
||||
You can now use any standard webhook-compliant library, or our official [@papra/webhooks](https://www.npmjs.com/package/@papra/webhooks) package for easy webhook validation.
|
||||
|
||||
|
||||
### New Webhook Events
|
||||
|
||||
We've expanded the webhook events you can subscribe to:
|
||||
|
||||
- **`document:updated`** - Triggered when document name or content changes
|
||||
- **`document:tag:added`** - Triggered when a tag is added to a document
|
||||
- **`document:tag:removed`** - Triggered when a tag is removed from a document
|
||||
|
||||
### Improved Performance
|
||||
|
||||
Webhook invocations are now **deferred**, meaning they no longer block API responses. This significantly improves the responsiveness of all document operations while ensuring reliable webhook delivery.
|
||||
|
||||
|
||||
## Task Processing with Cadence MQ
|
||||
|
||||
We've introduced **Cadence MQ**, our custom-built task runner designed specifically for self-hosting environments. Unlike traditional solutions like BullMQ that require Redis, Cadence MQ is built to be self-hosting friendly.
|
||||
|
||||
### What's Changed
|
||||
|
||||
- **File content extraction** (OCR, text extraction) now runs asynchronously, improving upload performance and reducing the format-specific processing time (image vs text for example)
|
||||
- **Recurring maintenance tasks** (document cleanup, token expiration) are now handled by the task runner
|
||||
|
||||
This foundation enables us to add more background processing features in future releases while keeping Papra's infrastructure requirements minimal.
|
||||
|
||||
Learn more about [Cadence MQ on GitHub](https://github.com/papra-hq/cadence-mq).
|
||||
|
||||
## Enhanced Migration System
|
||||
|
||||
We've completely rewritten our database migration mechanism to be more flexible and powerful:
|
||||
|
||||
- **JavaScript migrations** instead of SQL-only files
|
||||
- **Up and down migrations** with full business logic support
|
||||
- **Backward compatibility** with existing SQL migrations
|
||||
- **Future-proof** foundation for complex schema changes
|
||||
|
||||
This technical improvement ensures Papra can evolve smoothly while maintaining data integrity across updates.
|
||||
|
||||
## Quality of Life Improvements
|
||||
|
||||
- **Better Error Handling**
|
||||
- **Improved feedback** for invalid origin config with [some documentation](https://docs.papra.app/resources/troubleshooting/#invalid-application-origin)
|
||||
- **Added error messages** when tag deletion fails
|
||||
- **Fixed tag deletion** issue when tags were assigned to documents
|
||||
- **Enhanced Internationalization**: Added support for Italian and improved Romanian translation
|
||||
- **Simplified organization member list** with a cleaner, more intuitive design
|
||||
- **OCR for scanned pdfs**, finally!
|
||||
|
||||
## Conclusion
|
||||
|
||||
Thank you for your continued support and valuable feedback! If you have any suggestions, you can either open an issue on [GitHub](https://github.com/papra-hq/papra/issues) or join the [Discord server](https://papra.app/discord).
|
||||
|
||||
If you want to support the development of Papra, you can [buy me a coffee](https://buymeacoffee.com/cthmsst), or just [star the GitHub repository](https://github.com/papra-hq/papra), it'll help me a lot!
|
||||
|
||||
I'm looking forward to hearing from you!
|
||||
|
||||
<div class="mt-14">
|
||||
Some useful links:
|
||||
- [Discord server](https://papra.app/discord)
|
||||
- [GitHub repository](https://github.com/papra-hq/papra)
|
||||
- [Buy me a coffee](https://buymeacoffee.com/cthmsst)
|
||||
- [Bluesky account](https://bsky.app/profile/papra.app)
|
||||
- [Roadmap](https://github.com/orgs/papra-hq/projects/2)
|
||||
</div>
|
||||
133
apps/website/src/content/blog/papra-09.mdx
Normal file
@@ -0,0 +1,133 @@
|
||||
---
|
||||
title: Papra v0.9 - Document encryption, performance improvements, and streamlined storage
|
||||
description: Papra v0.9 introduces document encryption with DEK/KEK architecture, migrates Backblaze B2 to S3, adds streaming uploads, and brings significant performance and UX improvements.
|
||||
publishedAt: 2025-09-09
|
||||
lang: en
|
||||
ogImage: /src/content/blog/_images/papra-09/papra-09-og.png
|
||||
coverImage: /src/content/blog/_images/papra-09/papra-09-preview.png
|
||||
---
|
||||
|
||||
I'm excited to announce the release of Papra v0.9! This release brings a major security enhancement with document encryption, significant performance improvements, and important infrastructure updates that make Papra more secure and efficient.
|
||||
|
||||
## Document Encryption Layer
|
||||
|
||||
The biggest feature in v0.9 is the introduction of **document encryption** at the storage level. This provides an additional layer of security for your documents, regardless of which storage driver you're using.
|
||||
|
||||
### How It Works
|
||||
|
||||
Papra uses a **two-layer encryption approach**:
|
||||
|
||||
1. **Document Encryption Key (DEK)**: Papra generates a random encryption key for each document that is used to encrypt the document content
|
||||
2. **Key Encryption Key (KEK)**: The DEK is encrypted using your provided master key and stored in the database
|
||||
|
||||
This architecture ensures that even if your storage is compromised, your documents remain encrypted and unreadable without access to both the database and your encryption keys, and it allows you to simply rotate the encryption key without having to re-encrypt all the documents.
|
||||
|
||||
### Quick Setup
|
||||
|
||||
Getting started with document encryption is straightforward:
|
||||
|
||||
```bash
|
||||
# Enable document encryption
|
||||
DOCUMENT_STORAGE_ENCRYPTION_IS_ENABLED=true
|
||||
|
||||
# Provide your encryption key (must be 32 bytes in hex format)
|
||||
DOCUMENT_STORAGE_DOCUMENT_KEY_ENCRYPTION_KEYS=<your-32-byte-encryption-key-in-hex>
|
||||
```
|
||||
|
||||
### Storage Driver Agnostic
|
||||
|
||||
The encryption layer works seamlessly with all storage drivers (fs, s3, azure blob storage, etc.) by wrapping the storage driver and encrypting/decrypting the document content.
|
||||
|
||||
### Migrating Existing Documents
|
||||
|
||||
If you have existing documents and want to enable encryption, don't worry! New documents will be automatically encrypted, and you can encrypt existing documents using:
|
||||
|
||||
```bash
|
||||
docker compose exec papra pnpm maintenance:encrypt-all-documents
|
||||
```
|
||||
|
||||
This command will encrypt all previously unencrypted documents without affecting their accessibility or metadata.
|
||||
|
||||
For more technical details and advanced configuration options, check out the [document encryption guide](https://docs.papra.app/guides/document-encryption) in our documentation.
|
||||
|
||||
## Breaking Change: Backblaze B2 Migration
|
||||
|
||||
**Important**: We've migrated from the dedicated Backblaze B2 driver to using B2 through the S3-compatible API. This change allows us to drop a third-party, community-maintained B2 client that wasn't well-maintained and gives you access to the full S3 feature set.
|
||||
|
||||
### Migration Guide
|
||||
|
||||
If you're currently using Backblaze B2, you'll need to update your configuration:
|
||||
|
||||
**Before (v0.8 and earlier):**
|
||||
```bash
|
||||
DOCUMENT_STORAGE_DRIVER=b2
|
||||
DOCUMENT_STORAGE_B2_APPLICATION_KEY_ID=<key-id>
|
||||
DOCUMENT_STORAGE_B2_APPLICATION_KEY=<secret-key>
|
||||
DOCUMENT_STORAGE_B2_BUCKET_NAME=<bucket-name>
|
||||
DOCUMENT_STORAGE_B2_BUCKET_ID=<bucket-id>
|
||||
```
|
||||
|
||||
**After (v0.9+):**
|
||||
```bash
|
||||
DOCUMENT_STORAGE_DRIVER=s3
|
||||
DOCUMENT_STORAGE_S3_ENDPOINT=https://<region>.backblazeb2.com
|
||||
DOCUMENT_STORAGE_S3_REGION=<region>
|
||||
DOCUMENT_STORAGE_S3_BUCKET_NAME=<bucket-name>
|
||||
DOCUMENT_STORAGE_S3_ACCESS_KEY_ID=<key-id>
|
||||
DOCUMENT_STORAGE_S3_SECRET_ACCESS_KEY=<secret-key>
|
||||
```
|
||||
|
||||
This migration provides several benefits:
|
||||
- **Better maintenance**: Using the official AWS S3 SDK instead of a third-party library
|
||||
- **More features**: Access to the full S3 API feature set
|
||||
- **Better compatibility**: Standard S3 interface works with all S3-compatible services
|
||||
|
||||
## Performance Improvements
|
||||
|
||||
### Streaming File Uploads
|
||||
|
||||
We've completely reworked file upload handling to use **streaming instead of loading entire files into memory**. This brings several benefits:
|
||||
|
||||
- **Better performance**: Uploads start processing immediately
|
||||
- **Lower memory footprint**: No more memory spikes with large files
|
||||
- **More reliable uploads**: Better handling of large files and slow connections
|
||||
- **Reduced server load**: Memory usage stays constant regardless of file size
|
||||
|
||||
### Optimized Client Bundle
|
||||
|
||||
Improved the client bundle size by optimizing the code splitting and lazy loading some heavy components like the PDF viewer.
|
||||
|
||||
## User Experience Improvements
|
||||
|
||||
### Enhanced Document Content Editor
|
||||
|
||||
We've improved the **UX of the document content editing panel** for smoother interactions and better visual feedback.
|
||||
|
||||
### Advanced Email Address Support
|
||||
|
||||
The **intake email functionality** now supports more complex email address formats for origin addresses, fully compliant with **RFC 5322**. The main goal was to support Proton auto forwarding addresses that have some uncommon special characters in the email address, like `foo=bar.org+biz-buz-314=callback.email@forward.protonmail.ch`.
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
- **Fixed tag visibility issue**: Tags assigned only to deleted documents now properly appear in the tag list
|
||||
- **Resolved search modal infinite loading**: Prevented infinite loading states when errors occur in the search modal
|
||||
- **Fixed ingestion folder paths**: Resolved a bug where absolute paths for `INGESTION_FOLDER_POST_PROCESSING_MOVE_FOLDER_PATH` and `INGESTION_FOLDER_ERROR_FOLDER_PATH` weren't working correctly
|
||||
|
||||
## Conclusion
|
||||
|
||||
Papra v0.9 represents a significant step forward in both security and performance and is a big step for the Cloud instance of Papra.
|
||||
|
||||
Thank you for your continued support and valuable feedback! If you have any suggestions, you can either open an issue on [GitHub](https://github.com/papra-hq/papra/issues) or join the [Discord server](https://papra.app/discord).
|
||||
|
||||
If you want to support the development of Papra, you can [buy me a coffee](https://buymeacoffee.com/cthmsst), or just [star the GitHub repository](https://github.com/papra-hq/papra), it'll help me a lot!
|
||||
|
||||
I'm looking forward to hearing from you!
|
||||
|
||||
<div class="mt-14">
|
||||
Some useful links:
|
||||
- [Discord server](https://papra.app/discord)
|
||||
- [GitHub repository](https://github.com/papra-hq/papra)
|
||||
- [Buy me a coffee](https://buymeacoffee.com/cthmsst)
|
||||
- [Bluesky account](https://bsky.app/profile/papra.app)
|
||||
- [Roadmap](https://github.com/orgs/papra-hq/projects/2)
|
||||
</div>
|
||||
18
apps/website/src/content/config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineCollection, z } from 'astro:content';
|
||||
|
||||
const blog = defineCollection({
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
publishedAt: z.coerce.date(),
|
||||
homepageCallout: z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
}).optional(),
|
||||
lang: z.enum(['en']).optional(),
|
||||
ogImage: z.string().optional(),
|
||||
coverImage: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const collections = { blog };
|
||||
4
apps/website/src/i18n/i18n.constants.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const LOCALES = ['en', 'fr'] as const;
|
||||
export type Locale = (typeof LOCALES)[number];
|
||||
|
||||
export const DEFAULT_LOCALE: Locale = 'en';
|
||||
51
apps/website/src/i18n/i18n.routes.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { createRedirectionToLocalizedPage } from './i18n.routes';
|
||||
|
||||
describe('i18n routes', () => {
|
||||
describe('createRedirectionToLocalizedPage', () => {
|
||||
test('given the user preferences a supported locale, it redirects to that locale', () => {
|
||||
const handler = createRedirectionToLocalizedPage(
|
||||
({ locale }) => `/${locale}/test`,
|
||||
{
|
||||
defaultLocale: 'en',
|
||||
locales: ['en', 'fr', 'de'],
|
||||
},
|
||||
);
|
||||
|
||||
const response = handler({ preferredLocaleList: ['fr', 'en'] });
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get('Location')).toBe('/fr/test');
|
||||
expect(response.headers.get('Vary')).toBe('Accept-Language');
|
||||
});
|
||||
|
||||
test('given the user preferences an unsupported locale, it redirects to the default locale', () => {
|
||||
const handler = createRedirectionToLocalizedPage(
|
||||
({ locale }) => `/${locale}/test`,
|
||||
{
|
||||
defaultLocale: 'en',
|
||||
locales: ['en', 'fr', 'de'],
|
||||
},
|
||||
);
|
||||
|
||||
const response = handler({ preferredLocaleList: ['es', 'it'] });
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get('Location')).toBe('/en/test');
|
||||
expect(response.headers.get('Vary')).toBe('Accept-Language');
|
||||
});
|
||||
|
||||
test('given no user preferences, it redirects to the default locale', () => {
|
||||
const handler = createRedirectionToLocalizedPage(
|
||||
({ locale }) => `/${locale}/test`,
|
||||
{
|
||||
defaultLocale: 'en',
|
||||
locales: ['en', 'fr', 'de'],
|
||||
},
|
||||
);
|
||||
|
||||
const response = handler();
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get('Location')).toBe('/en/test');
|
||||
expect(response.headers.get('Vary')).toBe('Accept-Language');
|
||||
});
|
||||
});
|
||||
});
|
||||
16
apps/website/src/i18n/i18n.routes.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { DEFAULT_LOCALE, LOCALES } from './i18n.constants';
|
||||
|
||||
export function createRedirectionToLocalizedPage(buildUrl: (args: { locale: string }) => string, { defaultLocale = DEFAULT_LOCALE, locales = LOCALES }: { defaultLocale?: string; locales?: readonly string[] } = {}) {
|
||||
return ({ preferredLocaleList = [] }: { preferredLocaleList?: string[] } = {}) => {
|
||||
const locale = preferredLocaleList.find(candidateLocale => locales.includes(candidateLocale)) ?? defaultLocale;
|
||||
const url = buildUrl({ locale });
|
||||
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: url,
|
||||
Vary: 'Accept-Language',
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
68
apps/website/src/i18n/i18n.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { TranslationsDictionary } from './i18n.types';
|
||||
import { createBranchlet } from '@branchlet/core';
|
||||
import { joinUrlPaths } from '@corentinth/chisels';
|
||||
import { translations } from '../locales/index';
|
||||
import { DEFAULT_LOCALE, LOCALES } from './i18n.constants';
|
||||
|
||||
export function getLocaleFromUrl(url: URL) {
|
||||
const [, lang] = url.pathname.split('/');
|
||||
|
||||
if (lang && (LOCALES as ReadonlyArray<string>).includes(lang)) {
|
||||
return lang;
|
||||
}
|
||||
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
export function getTranslations({ locale }: { locale: string }): TranslationsDictionary {
|
||||
const defaultTranslations = translations[DEFAULT_LOCALE] as TranslationsDictionary;
|
||||
|
||||
if (locale === DEFAULT_LOCALE) {
|
||||
return defaultTranslations;
|
||||
}
|
||||
|
||||
const localeTranslations = (translations as Record<string, typeof defaultTranslations>)[locale] ?? {};
|
||||
|
||||
return {
|
||||
...defaultTranslations,
|
||||
...localeTranslations,
|
||||
};
|
||||
}
|
||||
|
||||
const { parse } = createBranchlet();
|
||||
|
||||
export function useI18n({ locale = DEFAULT_LOCALE }: { locale?: string } = {}) {
|
||||
return {
|
||||
locale,
|
||||
t: <K extends keyof TranslationsDictionary>(key: K, args?: Record<string, string | number>): TranslationsDictionary[K] => {
|
||||
const translations = getTranslations({ locale });
|
||||
const template = translations[key] ?? key;
|
||||
|
||||
if (typeof template !== 'string') {
|
||||
return template;
|
||||
}
|
||||
|
||||
return parse(template, args) as TranslationsDictionary[K];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type Translator = ReturnType<typeof useI18n>['t'];
|
||||
|
||||
export function getPathWithoutLocale(url: URL | string) {
|
||||
const pathname = typeof url === 'string' ? url : url.pathname;
|
||||
|
||||
// remove first / if exists
|
||||
const trimmedPathname = pathname.startsWith('/') ? pathname.slice(1) : pathname;
|
||||
const [firstSegment, ...rest] = trimmedPathname.split('/');
|
||||
|
||||
if (firstSegment && (LOCALES as ReadonlyArray<string>).includes(firstSegment)) {
|
||||
return `/${rest.join('/')}`;
|
||||
}
|
||||
|
||||
return pathname;
|
||||
}
|
||||
|
||||
export function buildLocalizedPath({ locale = DEFAULT_LOCALE, path }: { locale?: string; path: string }) {
|
||||
return `/${joinUrlPaths(locale, path)}`;
|
||||
}
|
||||
7
apps/website/src/i18n/i18n.types.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { translations as defaultTranslations } from '../locales/en';
|
||||
|
||||
type Widen<T> = T extends string ? string : T extends number ? number : T extends boolean ? boolean : T extends Array<infer U> ? Array<Widen<U>> : T extends object ? { [K in keyof T]: Widen<T[K]> } : T;
|
||||
|
||||
export type TranslationsDictionary = {
|
||||
[K in keyof typeof defaultTranslations]: Widen<typeof defaultTranslations[K]>;
|
||||
};
|
||||
20
apps/website/src/layouts/Layout.astro
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
import type { Props as HeaddProps } from '../components/Head.astro';
|
||||
import Head from '../components/Head.astro';
|
||||
import { DEFAULT_LOCALE } from '../i18n/i18n.constants';
|
||||
import '../styles/app.css';
|
||||
|
||||
type Props = {} & HeaddProps;
|
||||
|
||||
const { ...headProps } = Astro.props;
|
||||
|
||||
const locale = Astro.currentLocale ?? DEFAULT_LOCALE;
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang={locale}>
|
||||
<Head {...headProps} />
|
||||
<body class="bg-background font-sans antialiased min-h-screen text-foreground text-sm" data-kb-theme="dark">
|
||||
<slot />
|
||||
</body>
|
||||
</html>
|
||||
39
apps/website/src/layouts/MdPage.astro
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
import Cta from '../components/Cta.astro';
|
||||
import Footer from '../components/Footer.astro';
|
||||
import Header from '../components/Header.astro';
|
||||
import { cn } from '../utils/cn';
|
||||
import Layout from './Layout.astro';
|
||||
---
|
||||
|
||||
<Layout>
|
||||
<div class="bg-card">
|
||||
<Header />
|
||||
|
||||
<div class="w-full bg-[linear-gradient(to_right,#80808010_1px,transparent_1px),linear-gradient(to_bottom,#80808010_1px,transparent_1px)] bg-[size:48px_48px] bg-background border-b pt-32 pb-24">
|
||||
<div class="max-w-700px mx-auto p-4">
|
||||
<h1 class="text-4xl mb-2 font-bold">{Astro.props.frontmatter.title}</h1>
|
||||
<p class="text-base mb-2 text-muted-foreground lh-1.75rem">{Astro.props.frontmatter.description}</p>
|
||||
|
||||
{Astro.props.frontmatter.tags && (
|
||||
<div class="flex flex-wrap mt-2">
|
||||
{Astro.props.frontmatter.tags.map((tag: string) => (
|
||||
<span class="text-xs bg-card border rounded-lg px-2 py-1 mr-2 mb-2 font-medium">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="px-6">
|
||||
<div class={cn('prose prose-invert prose-neutral mx-auto text-base bg-card', !Astro.props.frontmatter.withCta && 'pb-32')}>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{Astro.props.frontmatter.withCta && (<Cta />)}
|
||||
|
||||
<Footer class="bg-background" />
|
||||
</div>
|
||||
</Layout>
|
||||
297
apps/website/src/locales/en.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
export const translations = {
|
||||
'language-name': 'English',
|
||||
|
||||
'site.title': 'Papra - The document archiving platform',
|
||||
'site.description': 'Papra is an open-source platform to organize, secure, and archive all your documents, effortlessly. Start digitizing your life today!',
|
||||
|
||||
// Call to Action Section
|
||||
'cta.get-started': 'Get Started',
|
||||
'cta.heading': 'Stop searching. Start finding.<br /> Organize your documents with Papra.',
|
||||
|
||||
// Values Section
|
||||
'values.title': 'Built on Principles, Not Profit',
|
||||
'values.subtitle': 'We\'re committed to building software the right way. <br /> No shortcuts, no compromises.',
|
||||
|
||||
'values.ethical-by-design.title': 'Ethical by Design',
|
||||
'values.ethical-by-design.description': 'No dark patterns, no manipulative tactics. We build software that respects you.',
|
||||
|
||||
'values.bootstrapped-and-independent.title': 'Bootstrapped & Independent',
|
||||
'values.bootstrapped-and-independent.description': 'No VC funding, no diluted financing, debt-free. We answer to our users, not investors.',
|
||||
|
||||
'values.your-data-is-yours.title': 'Your Data is Yours',
|
||||
'values.your-data-is-yours.description': 'We never sell your data. Period. Privacy is a right, not a feature.',
|
||||
|
||||
'values.fully-open-source.title': 'Fully Open Source',
|
||||
'values.fully-open-source.description': 'Complete transparency. Audit our code, contribute, or self-host anytime.',
|
||||
|
||||
'values.environmentally-conscious.title': 'Environmentally Conscious',
|
||||
'values.environmentally-conscious.description': 'We prioritize sustainability and eco-friendliness in our operations and product design.',
|
||||
|
||||
'values.community-driven.title': 'Community-Driven',
|
||||
'values.community-driven.description': 'Built with and for the community. Your feedback shapes our roadmap.',
|
||||
|
||||
// Features Section
|
||||
'features.title': 'Features',
|
||||
'features.subtitle': 'Manage your documents with ease. Papra offers a range of features to help you organize, search, and access your documents effortlessly.',
|
||||
|
||||
'features.all-in-one.title': 'All your documents in one place',
|
||||
'features.all-in-one.description': 'Say goodbye to scattered documents across different platforms. Papra helps you archive and organize all your documents in one place.',
|
||||
|
||||
'features.organizations.title': 'Organizations',
|
||||
'features.organizations.description': 'Organize your documents into organizations. Organizations help you manage your documents and users, like a team or a company.',
|
||||
|
||||
'features.tagging.title': 'Documents tagging',
|
||||
'features.tagging.description': 'Organize your documents with tags. Tags help you categorize and filter your documents easily. Define tagging rules to automatically tag your documents.',
|
||||
|
||||
'features.search.title': 'Search and find documents easily',
|
||||
'features.search.description': 'With Papra\'s powerful search functionality, you can find any document in seconds. No more endless scrolling and searching.',
|
||||
'features.search.placeholder': 'Search for documents...',
|
||||
'features.search.example-1': 'Phone invoice.pdf',
|
||||
'features.search.example-2': 'Analytics report.pdf',
|
||||
'features.search.example-3': 'Code snippets.txt',
|
||||
'features.search.example-4': 'Document.docx',
|
||||
|
||||
'features.developer-friendly.title': 'Developer friendly',
|
||||
'features.developer-friendly.description': 'Papra is built with customizability in mind. We provide a powerful API, webhooks, CLI and SDK to help you integrate Papra into your existing workflow.',
|
||||
|
||||
'features.email-ingestion.title': 'Email ingestion',
|
||||
'features.email-ingestion.description': 'Generate a unique email address and forward your emails to Papra. We\'ll automatically save your email attachments as documents.',
|
||||
|
||||
// Footer Section
|
||||
'footer.made-in-europe': 'Papra is made and hosted in Europe with <span class="i-tabler-heart-filled size-3.5 mb--0.3 text-primary inline-block"></span> by <a href="https://corentin.tech" class="text-primary border-b hover:border-b-primary transition">Corentin Thomasset</a>.',
|
||||
'footer.copyright': '© {{year}} Papra. All rights reserved.',
|
||||
|
||||
// Footer - Social links
|
||||
'footer.social.bluesky': 'Bluesky',
|
||||
'footer.social.x': 'X / Twitter',
|
||||
'footer.social.github': 'GitHub',
|
||||
'footer.social.discord': 'Discord',
|
||||
'footer.social.reddit': 'Reddit',
|
||||
'footer.social.linkedin': 'LinkedIn',
|
||||
'footer.social.mastodon': 'Mastodon',
|
||||
|
||||
// Footer - Sections
|
||||
'footer.section.community': 'Community',
|
||||
'footer.section.papra': 'Papra',
|
||||
'footer.section.open-source': 'Open Source',
|
||||
'footer.section.legal': 'Legal',
|
||||
|
||||
// Footer - Papra links
|
||||
'footer.link.blog': 'Blog',
|
||||
'footer.link.pricing': 'Pricing',
|
||||
'footer.link.demo-app': 'Demo app',
|
||||
'footer.link.documentation': 'Documentation',
|
||||
'footer.link.self-host': 'Self-host',
|
||||
'footer.link.roadmap': 'Roadmap',
|
||||
|
||||
// Footer - Open Source links
|
||||
'footer.link.repository': 'Repository',
|
||||
'footer.link.contributing': 'Contributing',
|
||||
'footer.link.code-of-conduct': 'Code of Conduct',
|
||||
'footer.link.license': 'License',
|
||||
'footer.link.this-website': 'This website',
|
||||
|
||||
// Footer - Legal links
|
||||
'footer.link.terms-of-service': 'Terms of Service',
|
||||
'footer.link.privacy-policy': 'Privacy Policy',
|
||||
'footer.link.contact': 'Contact',
|
||||
|
||||
// Home Page - Hero Section
|
||||
'home.hero.title': 'Your Solution to Document Chaos',
|
||||
'home.hero.subtitle': 'Papra is an open-source document management platform designed to help you organize, secure, and archive your files effortlessly.',
|
||||
'home.hero.live-demo': 'Live Demo',
|
||||
'home.hero.get-started': 'Get started',
|
||||
|
||||
// Home Page - Open Source Section
|
||||
'home.open-source.title': 'Papra is <span class="bg-primary text-primary-foreground px-3 py-1 rounded-md inline-block leading-tight">open-source</span>',
|
||||
'home.open-source.description': 'The whole Papra ecosystem is proudly open-source and easily self-hostable. It\'s available on <a href="https://github.com/papra-hq/papra" class="text-primary hover:underline">GitHub</a> under the <a href="https://github.com/papra-hq/papra/blob/main/LICENSE" class="text-primary hover:underline">AGPL-3.0</a> license.',
|
||||
'home.open-source.see-on-github': 'See on GitHub',
|
||||
|
||||
// Home Page - FAQ Section
|
||||
'home.faq.title': 'Frequently Asked Questions',
|
||||
'home.faq.subtitle': 'Everything you need to know about Papra',
|
||||
|
||||
'home.faq.questions': [
|
||||
{
|
||||
question: 'What is Papra?',
|
||||
answer: 'Papra is an open-source document management platform designed to help you organize, secure, and archive your files effortlessly. It provides a centralized solution for managing all your documents in one place.',
|
||||
},
|
||||
{
|
||||
question: 'Is Papra really open-source?',
|
||||
answer: 'Yes! Papra is completely open-source and available under the AGPL-3.0 license. You can view the source code on GitHub and even self-host it if you prefer.',
|
||||
},
|
||||
{
|
||||
question: 'How does document organization work?',
|
||||
answer: 'Papra uses a combination of organizations, tags, and powerful search functionality to help you organize your documents. You can create organizations for teams or companies, add tags for categorization, and find documents quickly with our search feature.',
|
||||
},
|
||||
{
|
||||
question: 'Can I self-host Papra?',
|
||||
answer: 'Absolutely! Since Papra is open-source, you can self-host it on your own infrastructure. This gives you complete control over your data and allows you to customize the platform to your specific needs.',
|
||||
},
|
||||
{
|
||||
question: 'What file types does Papra support?',
|
||||
answer: 'Papra supports a wide range of document types including PDFs, text files, code files, invoices, spreadsheets, and many more. The platform is designed to handle various file formats commonly used in business and personal document management.',
|
||||
},
|
||||
{
|
||||
question: 'Are my documents secure?',
|
||||
answer: 'Yes! Papra uses industry-standard in-transit and at-rest encryption to protect your documents.',
|
||||
},
|
||||
{
|
||||
question: 'Are my data stored in Europe?',
|
||||
answer: 'Yes! Papra is hosted in Europe and all data is stored in Europe.',
|
||||
},
|
||||
{
|
||||
question: 'How can I get started with Papra?',
|
||||
answer: 'Getting started with Papra is easy! Simply sign up for a free account, upload your documents, and start organizing them. You can also check out our documentation for more detailed instructions on how to use the platform.',
|
||||
},
|
||||
],
|
||||
|
||||
// Pricing Page
|
||||
'pricing.title': 'Simple, transparent pricing',
|
||||
'pricing.subtitle': 'Choose the plan that fits your needs. All plans include core Papra features.',
|
||||
'pricing.toggle.monthly': 'Monthly',
|
||||
'pricing.toggle.annual': 'Annual',
|
||||
'pricing.currency-note': 'Prices shown in USD. Your bank will convert to your local currency at checkout if needed.',
|
||||
'pricing.most-popular': 'Most Popular',
|
||||
'pricing.per-month': '/ month',
|
||||
'pricing.billed-annually': '{{price}} billed annually',
|
||||
'pricing.get-started': 'Get Started',
|
||||
'pricing.contact-us': 'Contact us',
|
||||
|
||||
// Pricing Page - Plans
|
||||
'pricing.plan.free.name': 'Free, forever',
|
||||
'pricing.plan.free.features': [
|
||||
'512MB storage (~4,000 standard PDF)',
|
||||
'3 members per organization',
|
||||
'Max upload size: 25MB',
|
||||
'1 intake email address',
|
||||
'Basic support',
|
||||
],
|
||||
|
||||
'pricing.plan.plus.name': 'Plus',
|
||||
'pricing.plan.plus.features': [
|
||||
'5GB storage (~20,000 standard PDF)',
|
||||
'10 members per organization',
|
||||
'Max upload size: 100MB',
|
||||
'10 intake email addresses',
|
||||
'Priority support',
|
||||
],
|
||||
|
||||
'pricing.plan.pro.name': 'Pro',
|
||||
'pricing.plan.pro.features': [
|
||||
'50GB storage (~200,000 standard PDF)',
|
||||
'50 members per organization',
|
||||
'Max upload size: 500MB',
|
||||
'100 intake email addresses',
|
||||
'Priority support',
|
||||
],
|
||||
|
||||
// Pricing Page - Enterprise & Self-hosting
|
||||
'pricing.enterprise.title': 'Enterprise solutions',
|
||||
'pricing.enterprise.description': 'Looking for a custom plan tailored to your organization\'s needs? <br /> We offer enterprise solutions with advanced features, dedicated support, and flexible pricing. Contact us to discuss your requirements and get a personalized quote.',
|
||||
'pricing.self-hosting.title': 'Self-hosting',
|
||||
'pricing.self-hosting.description': 'Papra is open-source, under the <a href="https://github.com/papra-app/papra/blob/main/LICENSE" class="underline hover:text-primary transition">AGPLv3</a> and can be self-hosted on your own infrastructure at no cost. <br />Get started with our documentation.',
|
||||
'pricing.self-hosting.cta': 'View self-hosting guide',
|
||||
|
||||
// Pricing Page - FAQ
|
||||
'pricing.faq.title': 'Frequently Asked Questions',
|
||||
'pricing.faq.subtitle': 'Everything you need to know about Papra pricing',
|
||||
|
||||
'pricing.faq.questions': [
|
||||
{
|
||||
question: 'Can I change plans at any time?',
|
||||
answer: 'Yes, you can upgrade or downgrade your plan at any time. If you upgrade, you\'ll be charged a prorated amount for the remainder of your billing period. If you downgrade, the change will take effect at the end of your current billing cycle.',
|
||||
},
|
||||
{
|
||||
question: 'What happens if I exceed my storage limit?',
|
||||
answer: 'If you reach your storage limit, you won\'t be able to upload new documents until you either upgrade your plan or delete some existing documents. We\'ll send you notifications before you reach your limit.',
|
||||
},
|
||||
{
|
||||
question: 'Is there a free trial for paid plans?',
|
||||
answer: 'You can start with our Free plan to explore Papra\'s features. When you\'re ready to upgrade, you can switch to a paid plan at any time.',
|
||||
},
|
||||
{
|
||||
question: 'What payment methods do you accept?',
|
||||
answer: 'We accept all major credit cards (Visa, MasterCard, American Express) and debit cards. All payments are processed securely through our payment provider.',
|
||||
},
|
||||
{
|
||||
question: 'Can I cancel my subscription anytime?',
|
||||
answer: 'Yes, you can cancel your subscription at any time. Your account will remain active until the end of your current billing period, after which you\'ll be moved to the Free plan.',
|
||||
},
|
||||
{
|
||||
question: 'Do you offer discounts for annual billing?',
|
||||
answer: 'Yes! Annual plans save you 20% compared to monthly billing.',
|
||||
},
|
||||
{
|
||||
question: 'How do I apply a promo code?',
|
||||
answer: 'During checkout, look for the "Add promotion code" link in the products details. Click it, enter your promo code, and the discount will be applied automatically to your order.',
|
||||
},
|
||||
{
|
||||
question: 'Can I add more team members later?',
|
||||
answer: 'Yes, each plan has a specific member limit. If you need more team members, you can upgrade to a higher plan or contact us for a custom enterprise solution.',
|
||||
},
|
||||
],
|
||||
|
||||
// Pricing Page - Final CTA
|
||||
'pricing.final-cta.title': 'Trusted by homes, startups, and enterprises of all sizes.',
|
||||
'pricing.final-cta.button': 'Get started',
|
||||
'pricing.discount-banner.title': 'Launch Special: 50% off for life - limited time offer!',
|
||||
'pricing.discount-banner.description': 'Get 50% off for life when you upgrade your organization to any paid plan during our launch period. This offer expires on 31 December 2025!',
|
||||
|
||||
// Contact Page
|
||||
'contact.title': 'Get in Touch',
|
||||
'contact.subtitle': 'Have questions or feedback? We\'d love to hear from you. Reach out through any of our channels below.',
|
||||
'contact.general-inquiries.title': 'General Inquiries',
|
||||
'contact.general-inquiries.description': 'For general questions, feedback or support, reach out to us via email.',
|
||||
'contact.partnerships.title': 'Partnerships',
|
||||
'contact.partnerships.description': 'Interested in partnering with Papra? Get in touch!',
|
||||
'contact.bug.title': 'Found a Bug?',
|
||||
'contact.bug.description': 'Report bugs or issues you encounter while using Papra.',
|
||||
'contact.bug.button-label': 'Report on GitHub',
|
||||
'contact.community.title': 'Join Our Community',
|
||||
'contact.community.description': 'Connect with us and other users on Discord, share ideas, and get support.',
|
||||
'contact.community.button-label': 'Join Discord',
|
||||
'contact.connect.title': 'Connect with Us',
|
||||
'contact.connect.description': 'Follow us on social media to stay updated with the latest news and updates.',
|
||||
|
||||
'socials.discord.name': 'Discord',
|
||||
'socials.discord.label': 'Papra Discord community',
|
||||
'socials.github.name': 'GitHub',
|
||||
'socials.github.label': 'Papra GitHub repository',
|
||||
'socials.bluesky.name': 'Bluesky',
|
||||
'socials.bluesky.label': 'Bluesky profile',
|
||||
'socials.x.name': 'X / Twitter',
|
||||
'socials.x.label': 'Papra X profile',
|
||||
'socials.reddit.name': 'Reddit',
|
||||
'socials.reddit.label': 'r/papra community',
|
||||
'socials.linkedin.name': 'LinkedIn',
|
||||
'socials.linkedin.label': 'Papra LinkedIn profile',
|
||||
'socials.mastodon.name': 'Mastodon',
|
||||
'socials.mastodon.label': 'Papra Mastodon profile',
|
||||
|
||||
// Blog Page
|
||||
'blog.title': 'Papra Blog',
|
||||
'blog.subtitle': 'What\'s new in the Papra ecosystem, stay updated with the latest news and updates from Papra.',
|
||||
'blog.posts-heading': 'Blog Posts',
|
||||
|
||||
// 404 Page
|
||||
'404.title': '404 - Not Found',
|
||||
'404.description': 'The page you are looking for does not exist.',
|
||||
'404.go-home': 'Go back home',
|
||||
|
||||
// Header/Navigation
|
||||
'nav.demo': 'Demo',
|
||||
'nav.docs': 'Docs',
|
||||
'nav.blog': 'Blog',
|
||||
'nav.self-host': 'Self-host',
|
||||
'nav.pricing': 'Pricing',
|
||||
'nav.early-access': 'Early Access',
|
||||
'nav.sign-in': 'Sign In',
|
||||
|
||||
// Launch Banner
|
||||
'launch-banner.text': 'Last Chance: {{percentage}}% off for life - Offer expires December 31st, 2025!',
|
||||
|
||||
// Pagination
|
||||
'pagination.go-to-page': 'Go to page {{page}} of {{total}}',
|
||||
'pagination.page': 'Page {{current}} of {{total}}',
|
||||
} as const;
|
||||
298
apps/website/src/locales/fr.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
import type { TranslationsDictionary } from '../i18n/i18n.types';
|
||||
|
||||
export const translations: Partial<TranslationsDictionary> = {
|
||||
'language-name': 'Français',
|
||||
|
||||
'site.title': 'Papra - La plateforme d\'archivage de documents',
|
||||
'site.description': 'Papra est une plateforme open-source pour organiser, sécuriser et archiver tous vos documents, sans effort. Commencez à digitaliser votre vie dès aujourd\'hui !',
|
||||
|
||||
// Call to Action Section
|
||||
'cta.get-started': 'Commencer',
|
||||
'cta.heading': 'Fini la recherche infructueuse.<br /> Organisez vos documents avec Papra.',
|
||||
|
||||
// Values Section
|
||||
'values.title': 'Des Valeurs Avant Tout',
|
||||
'values.subtitle': 'On développe des logiciels comme il faut. <br /> Sans compromis, sans raccourcis.',
|
||||
|
||||
'values.ethical-by-design.title': 'Éthique dès la conception',
|
||||
'values.ethical-by-design.description': 'Aucune manipulation, aucune astuce douteuse. Un logiciel qui vous respecte vraiment.',
|
||||
|
||||
'values.bootstrapped-and-independent.title': 'Autofinancé & Indépendant',
|
||||
'values.bootstrapped-and-independent.description': 'Pas de levée de fonds, pas de dilution, zéro dette. On répond à nos utilisateurs, pas à des investisseurs.',
|
||||
|
||||
'values.your-data-is-yours.title': 'Vos données restent vôtres',
|
||||
'values.your-data-is-yours.description': 'On ne vend jamais vos données. Point. La vie privée est un droit fondamental, pas une option.',
|
||||
|
||||
'values.fully-open-source.title': '100% Open Source',
|
||||
'values.fully-open-source.description': 'Transparence totale. Auditez le code, contribuez ou auto-hébergez quand vous voulez.',
|
||||
|
||||
'values.environmentally-conscious.title': 'Respectueux de l\'environnement',
|
||||
'values.environmentally-conscious.description': 'La durabilité et l\'éco-responsabilité guident nos choix techniques et notre conception.',
|
||||
|
||||
'values.community-driven.title': 'Fait par et pour la communauté',
|
||||
'values.community-driven.description': 'Vos retours façonnent notre feuille de route. C\'est vous qui décidez.',
|
||||
|
||||
// Features Section
|
||||
'features.title': 'Fonctionnalités',
|
||||
'features.subtitle': 'Gérez vos documents simplement. Papra vous aide à organiser, rechercher et accéder à vos fichiers en un clin d\'œil.',
|
||||
|
||||
'features.all-in-one.title': 'Tous vos documents réunis',
|
||||
'features.all-in-one.description': 'Fini les fichiers éparpillés partout. Avec Papra, archivez et organisez tout au même endroit.',
|
||||
|
||||
'features.organizations.title': 'Organisations',
|
||||
'features.organizations.description': 'Structurez vos documents par organisation. Idéal pour gérer les fichiers d\'une équipe ou d\'une entreprise.',
|
||||
|
||||
'features.tagging.title': 'Étiquettes intelligentes',
|
||||
'features.tagging.description': 'Catégorisez et filtrez vos documents avec des étiquettes. Créez même des règles pour les assigner automatiquement.',
|
||||
|
||||
'features.search.title': 'Recherche ultra-rapide',
|
||||
'features.search.description': 'Trouvez n\'importe quel document en quelques secondes. Fini le scroll interminable.',
|
||||
'features.search.placeholder': 'Rechercher...',
|
||||
'features.search.example-1': 'Facture mobile.pdf',
|
||||
'features.search.example-2': 'Rapport analytics.pdf',
|
||||
'features.search.example-3': 'Code snippets.txt',
|
||||
'features.search.example-4': 'Document.docx',
|
||||
|
||||
'features.developer-friendly.title': 'Pensé pour les devs',
|
||||
'features.developer-friendly.description': 'API complète, webhooks, CLI et SDK. Intégrez Papra dans votre workflow existant comme vous le voulez.',
|
||||
|
||||
'features.email-ingestion.title': 'Import par email',
|
||||
'features.email-ingestion.description': 'Créez une adresse email dédiée et transférez-y vos messages. Les pièces jointes deviennent automatiquement des documents.',
|
||||
|
||||
// Footer Section
|
||||
'footer.made-in-europe': 'Papra est créé et hébergé en Europe avec <span class="i-tabler-heart-filled size-3.5 mb--0.3 text-primary inline-block"></span> par <a href="https://corentin.tech" class="text-primary border-b hover:border-b-primary transition">Corentin Thomasset</a>.',
|
||||
'footer.copyright': '© {{year}} Papra. Tous droits réservés.',
|
||||
|
||||
// Footer - Social links
|
||||
'footer.social.bluesky': 'Bluesky',
|
||||
'footer.social.github': 'GitHub',
|
||||
'footer.social.discord': 'Discord',
|
||||
'footer.social.reddit': 'Reddit',
|
||||
'footer.social.linkedin': 'LinkedIn',
|
||||
'footer.social.mastodon': 'Mastodon',
|
||||
|
||||
// Footer - Sections
|
||||
'footer.section.community': 'Communauté',
|
||||
'footer.section.papra': 'Papra',
|
||||
'footer.section.open-source': 'Open Source',
|
||||
'footer.section.legal': 'Légal',
|
||||
|
||||
// Footer - Papra links
|
||||
'footer.link.blog': 'Blog',
|
||||
'footer.link.pricing': 'Tarifs',
|
||||
'footer.link.demo-app': 'Application démo',
|
||||
'footer.link.documentation': 'Documentation',
|
||||
'footer.link.self-host': 'Auto-hébergement',
|
||||
'footer.link.roadmap': 'Feuille de route',
|
||||
|
||||
// Footer - Open Source links
|
||||
'footer.link.repository': 'Dépôt',
|
||||
'footer.link.contributing': 'Contribuer',
|
||||
'footer.link.code-of-conduct': 'Code de conduite',
|
||||
'footer.link.license': 'Licence',
|
||||
'footer.link.this-website': 'Ce site web',
|
||||
|
||||
// Footer - Legal links
|
||||
'footer.link.terms-of-service': 'Conditions d\'utilisation',
|
||||
'footer.link.privacy-policy': 'Politique de confidentialité',
|
||||
'footer.link.contact': 'Contact',
|
||||
|
||||
// Home Page - Hero Section
|
||||
'home.hero.title': 'La plateforme open-source d\'archivage et de gestion de documents',
|
||||
'home.hero.subtitle': 'Papra est une plateforme open-source pour organiser, sécuriser et archiver tous vos fichiers, sans effort.',
|
||||
'home.hero.live-demo': 'Essayer la démo',
|
||||
'home.hero.get-started': 'Commencer',
|
||||
|
||||
// Home Page - Open Source Section
|
||||
'home.open-source.title': 'Papra est <span class="bg-primary text-primary-foreground px-3 py-1 rounded-md inline-block leading-tight">open-source</span>',
|
||||
'home.open-source.description': 'Tout l\'écosystème Papra est open-source et auto-hébergeable. Disponible sur <a href="https://github.com/papra-hq/papra" class="text-primary hover:underline">GitHub</a> sous licence <a href="https://github.com/papra-hq/papra/blob/main/LICENSE" class="text-primary hover:underline">AGPL-3.0</a>.',
|
||||
'home.open-source.see-on-github': 'Voir sur GitHub',
|
||||
|
||||
// Home Page - FAQ Section
|
||||
'home.faq.title': 'Questions fréquentes',
|
||||
'home.faq.subtitle': 'Tout ce qu\'il faut savoir sur Papra',
|
||||
|
||||
'home.faq.questions': [
|
||||
{
|
||||
question: 'C\'est quoi Papra ?',
|
||||
answer: 'Papra est une plateforme open-source de gestion documentaire. Elle centralise tous vos fichiers au même endroit pour les organiser, les sécuriser et les archiver facilement.',
|
||||
},
|
||||
{
|
||||
question: 'C\'est vraiment open-source ?',
|
||||
answer: 'Oui ! Le code est sous licence AGPL-3.0 et disponible sur GitHub. Vous pouvez l\'auto-héberger si vous le souhaitez.',
|
||||
},
|
||||
{
|
||||
question: 'Comment on organise les documents ?',
|
||||
answer: 'Papra combine organisations, étiquettes et recherche puissante. Créez des espaces pour vos équipes, ajoutez des tags pour catégoriser, et retrouvez tout en un instant.',
|
||||
},
|
||||
{
|
||||
question: 'Je peux l\'auto-héberger ?',
|
||||
answer: 'Absolument ! C\'est même l\'un des gros avantages de l\'open-source. Vos données restent chez vous, et vous personnalisez tout comme bon vous semble.',
|
||||
},
|
||||
{
|
||||
question: 'Quels fichiers sont supportés ?',
|
||||
answer: 'PDF, texte, code, factures, tableurs... Tous les formats courants de la gestion documentaire pro et perso.',
|
||||
},
|
||||
{
|
||||
question: 'Mes documents sont sécurisés ?',
|
||||
answer: 'Oui ! Chiffrement en transit et au repos, selon les standards de l\'industrie.',
|
||||
},
|
||||
{
|
||||
question: 'Mes données restent en Europe ?',
|
||||
answer: 'Oui ! Hébergement et stockage 100% européens.',
|
||||
},
|
||||
{
|
||||
question: 'Comment démarrer ?',
|
||||
answer: 'Super simple : créez un compte gratuit, uploadez vos docs et c\'est parti. Consultez la doc pour aller plus loin.',
|
||||
},
|
||||
],
|
||||
|
||||
// Pricing Page
|
||||
'pricing.title': 'Tarifs simples et transparents',
|
||||
'pricing.subtitle': 'Choisissez le plan qui vous correspond. Toutes les fonctionnalités essentielles incluses.',
|
||||
'pricing.toggle.monthly': 'Mensuel',
|
||||
'pricing.toggle.annual': 'Annuel',
|
||||
'pricing.currency-note': 'Prix affichés en USD. Votre banque fera la conversion en euros au moment du paiement.',
|
||||
'pricing.most-popular': 'Le Plus Populaire',
|
||||
'pricing.per-month': '/ mois',
|
||||
'pricing.billed-annually': '{{price}} facturés par an',
|
||||
'pricing.get-started': 'Commencer',
|
||||
'pricing.contact-us': 'Nous contacter',
|
||||
|
||||
// Pricing Page - Plans
|
||||
'pricing.plan.free.name': 'Gratuit, pour toujours',
|
||||
'pricing.plan.free.features': [
|
||||
'512 Mo de stockage (~4 000 PDF)',
|
||||
'Jusqu\'à 3 membres par organisation',
|
||||
'Fichiers jusqu\'à 25 Mo',
|
||||
'1 adresse email d\'import',
|
||||
'Support de base',
|
||||
],
|
||||
|
||||
'pricing.plan.plus.name': 'Plus',
|
||||
'pricing.plan.plus.features': [
|
||||
'5 Go de stockage (~20 000 PDF)',
|
||||
'Jusqu\'à 10 membres par organisation',
|
||||
'Fichiers jusqu\'à 100 Mo',
|
||||
'10 adresses email d\'import',
|
||||
'Support prioritaire',
|
||||
],
|
||||
|
||||
'pricing.plan.pro.name': 'Pro',
|
||||
'pricing.plan.pro.features': [
|
||||
'50 Go de stockage (~200 000 PDF)',
|
||||
'Jusqu\'à 50 membres par organisation',
|
||||
'Fichiers jusqu\'à 500 Mo',
|
||||
'100 adresses email d\'import',
|
||||
'Support prioritaire',
|
||||
],
|
||||
|
||||
// Pricing Page - Enterprise & Self-hosting
|
||||
'pricing.enterprise.title': 'Solutions entreprise',
|
||||
'pricing.enterprise.description': 'Besoin d\'un plan sur mesure pour votre organisation ? <br /> On propose des solutions entreprise avec fonctionnalités avancées, support dédié et tarifs flexibles. Contactez-nous pour en discuter.',
|
||||
'pricing.self-hosting.title': 'Auto-hébergement',
|
||||
'pricing.self-hosting.description': 'Papra est open-source, sous licence <a href="https://github.com/papra-app/papra/blob/main/LICENSE" class="underline hover:text-primary transition">AGPLv3</a>, et s\'auto-héberge gratuitement sur votre propre infrastructure. <br />Démarrez avec notre doc.',
|
||||
'pricing.self-hosting.cta': 'Guide d\'auto-hébergement',
|
||||
|
||||
// Pricing Page - FAQ
|
||||
'pricing.faq.title': 'Questions fréquentes',
|
||||
'pricing.faq.subtitle': 'Tout sur les tarifs Papra',
|
||||
|
||||
'pricing.faq.questions': [
|
||||
{
|
||||
question: 'Je peux changer de plan quand je veux ?',
|
||||
answer: 'Oui, upgrade ou downgrade quand vous voulez. Si vous passez à un plan supérieur, on facture au prorata pour le temps restant. Si vous descendez, le changement s\'applique à la fin de votre période actuelle.',
|
||||
},
|
||||
{
|
||||
question: 'Que se passe-t-il si je dépasse mon stockage ?',
|
||||
answer: 'Si vous atteignez la limite, vous ne pourrez plus uploader tant que vous n\'aurez pas upgradé votre plan ou supprimé des docs. On vous prévient avant d\'arriver à la limite.',
|
||||
},
|
||||
{
|
||||
question: 'Y a un essai gratuit pour les plans payants ?',
|
||||
answer: 'Commencez avec le plan Gratuit pour tester Papra. Quand vous êtes prêt, passez à un plan payant quand vous voulez.',
|
||||
},
|
||||
{
|
||||
question: 'Quels moyens de paiement vous acceptez ?',
|
||||
answer: 'On accepte toutes les cartes bancaires (Visa, MasterCard, American Express). Tous les paiements sont sécurisés via notre prestataire.',
|
||||
},
|
||||
{
|
||||
question: 'Je peux annuler quand je veux ?',
|
||||
answer: 'Oui, annulez quand vous voulez. Votre compte reste actif jusqu\'à la fin de votre période en cours, puis vous passez au plan Gratuit.',
|
||||
},
|
||||
{
|
||||
question: 'Y a des réducs sur les plans annuels ?',
|
||||
answer: 'Oui ! Les plans annuels font économiser 20% par rapport au mensuel.',
|
||||
},
|
||||
{
|
||||
question: 'Comment utiliser un code promo ?',
|
||||
answer: 'Au paiement, cliquez sur "Ajouter un code promotionnel" dans les détails. Entrez votre code et la réduction s\'applique automatiquement.',
|
||||
},
|
||||
{
|
||||
question: 'Je peux ajouter des membres plus tard ?',
|
||||
answer: 'Oui, chaque plan a une limite de membres. Si vous en avez besoin de plus, passez à un plan supérieur ou contactez-nous pour une solution entreprise sur mesure.',
|
||||
},
|
||||
],
|
||||
|
||||
// Pricing Page - Final CTA
|
||||
'pricing.final-cta.title': 'Ils nous font confiance : particuliers, startups et entreprises.',
|
||||
'pricing.final-cta.button': 'Commencer',
|
||||
'pricing.discount-banner.title': 'Offre de Lancement : -50% à vie',
|
||||
'pricing.discount-banner.description': 'Bénéficiez de 50% de réduction à vie en passant vos organisations à n\'importe quel plan payant pendant notre période de lancement. Cette offre expire le 31 décembre 2025 !',
|
||||
|
||||
// Contact Page
|
||||
'contact.title': 'Nous contacter',
|
||||
'contact.subtitle': 'Une question ou un retour à faire ? On serait ravis de vous lire. Contactez-nous via n\'importe lequel de ces canaux.',
|
||||
'contact.general-inquiries.title': 'Questions générales',
|
||||
'contact.general-inquiries.description': 'Pour toute question, retour ou demande d\'assistance, écrivez-nous par email.',
|
||||
'contact.partnerships.title': 'Partenariats',
|
||||
'contact.partnerships.description': 'Envie de collaborer avec Papra ? Prenons contact !',
|
||||
'contact.bug.title': 'Un bug à signaler ?',
|
||||
'contact.bug.description': 'Signalez les bugs ou problèmes que vous rencontrez en utilisant Papra.',
|
||||
'contact.bug.button-label': 'Signaler sur GitHub',
|
||||
'contact.community.title': 'Rejoignez la communauté',
|
||||
'contact.community.description': 'Échangez avec nous et d\'autres utilisateurs sur Discord, partagez vos idées et obtenez de l\'aide.',
|
||||
'contact.community.button-label': 'Rejoindre Discord',
|
||||
'contact.connect.title': 'Suivez-nous',
|
||||
'contact.connect.description': 'Restez au courant des dernières actus et mises à jour en nous suivant sur les réseaux sociaux.',
|
||||
|
||||
'socials.discord.name': 'Discord',
|
||||
'socials.discord.label': 'Communauté Discord Papra',
|
||||
'socials.github.name': 'GitHub',
|
||||
'socials.github.label': 'Dépôt GitHub Papra',
|
||||
'socials.bluesky.name': 'Bluesky',
|
||||
'socials.bluesky.label': 'Profil Bluesky',
|
||||
'socials.x.name': 'X / Twitter',
|
||||
'socials.x.label': 'Profil Papra sur X',
|
||||
'socials.reddit.name': 'Reddit',
|
||||
'socials.reddit.label': 'Communauté r/papra',
|
||||
'socials.linkedin.name': 'LinkedIn',
|
||||
'socials.linkedin.label': 'Profil LinkedIn Papra',
|
||||
'socials.mastodon.name': 'Mastodon',
|
||||
'socials.mastodon.label': 'Profil Mastodon Papra',
|
||||
|
||||
// Blog Page
|
||||
'blog.title': 'Blog Papra',
|
||||
'blog.subtitle': 'Les nouveautés de l\'écosystème Papra, toutes les actus et mises à jour.',
|
||||
'blog.posts-heading': 'Articles',
|
||||
|
||||
// 404 Page
|
||||
'404.title': '404 - Page introuvable',
|
||||
'404.description': 'Cette page n\'existe pas.',
|
||||
'404.go-home': 'Retour à l\'accueil',
|
||||
|
||||
// Header/Navigation
|
||||
'nav.demo': 'Démo',
|
||||
'nav.docs': 'Docs',
|
||||
'nav.blog': 'Blog',
|
||||
'nav.self-host': 'Auto-héberger',
|
||||
'nav.pricing': 'Tarifs',
|
||||
'nav.early-access': 'Accès anticipé',
|
||||
'nav.sign-in': 'Connexion',
|
||||
|
||||
// Launch Banner
|
||||
'launch-banner.text': 'Dernière chance : {{percentage}}% de réduction à vie - Expire le 31 décembre 2025 !',
|
||||
|
||||
// Pagination
|
||||
'pagination.go-to-page': 'Aller à la page {{page}} sur {{total}}',
|
||||
'pagination.page': 'Page {{current}} sur {{total}}',
|
||||
};
|
||||
9
apps/website/src/locales/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { Locale } from '../i18n/i18n.constants';
|
||||
import type { TranslationsDictionary } from '../i18n/i18n.types';
|
||||
import { translations as en } from './en';
|
||||
import { translations as fr } from './fr';
|
||||
|
||||
export const translations: Record<Locale, TranslationsDictionary | Partial<TranslationsDictionary>> = {
|
||||
en,
|
||||
fr,
|
||||
};
|
||||
27
apps/website/src/pages/404.astro
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
import Header from '../components/Header.astro';
|
||||
import { useI18n } from '../i18n/i18n';
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
|
||||
const { t } = useI18n({ locale: Astro.currentLocale });
|
||||
---
|
||||
|
||||
<Layout>
|
||||
<Header />
|
||||
|
||||
<div class="pt-24 md:pt-48 flex items-center justify-center gap-4 flex-col text-center">
|
||||
<div class="i-tabler-coffee size-20"></div>
|
||||
<div class="mt-4">
|
||||
<h1 class="text-lg font-medium">{t('404.title')}</h1>
|
||||
<p class="text-sm text-muted-foreground">{t('404.description')}</p>
|
||||
|
||||
<a
|
||||
href={`/${Astro.currentLocale}/`}
|
||||
class="mt-6 inline-block text-foreground border font-bold rounded py-2 px-4 rounded-lg transition inline-flex items-center gap-2 hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<div class="i-tabler-arrow-left size-5" aria-hidden="true"></div>
|
||||
{t('404.go-home')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
32
apps/website/src/pages/[locale]/404.astro
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
import Header from '../../components/Header.astro';
|
||||
import { useI18n } from '../../i18n/i18n';
|
||||
import { LOCALES } from '../../i18n/i18n.constants';
|
||||
import Layout from '../../layouts/Layout.astro';
|
||||
|
||||
const { t } = useI18n({ locale: Astro.currentLocale });
|
||||
|
||||
export async function getStaticPaths() {
|
||||
return LOCALES.map(locale => ({ params: { locale } }));
|
||||
}
|
||||
---
|
||||
|
||||
<Layout>
|
||||
<Header />
|
||||
|
||||
<div class="pt-24 md:pt-48 flex items-center justify-center gap-4 flex-col text-center">
|
||||
<div class="i-tabler-coffee size-20"></div>
|
||||
<div class="mt-4">
|
||||
<h1 class="text-lg font-medium">{t('404.title')}</h1>
|
||||
<p class="text-sm text-muted-foreground">{t('404.description')}</p>
|
||||
|
||||
<a
|
||||
href={`/${Astro.currentLocale}/`}
|
||||
class="mt-6 inline-block text-foreground border font-bold rounded py-2 px-4 rounded-lg transition inline-flex items-center gap-2 hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<div class="i-tabler-arrow-left size-5" aria-hidden="true"></div>
|
||||
{t('404.go-home')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
129
apps/website/src/pages/[locale]/contact.astro
Normal file
@@ -0,0 +1,129 @@
|
||||
---
|
||||
import Footer from '../../components/Footer.astro';
|
||||
import Header from '../../components/Header.astro';
|
||||
import { useI18n } from '../../i18n/i18n';
|
||||
import { LOCALES } from '../../i18n/i18n.constants';
|
||||
import Layout from '../../layouts/Layout.astro';
|
||||
import { DISCORD_INVITE_URL, getSocials, GITHUB_ISSUES_URL } from '../../socials';
|
||||
import { cn } from '../../utils/cn';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
return LOCALES.map(locale => ({ params: { locale } }));
|
||||
}
|
||||
|
||||
const { t } = useI18n({ locale: Astro.currentLocale });
|
||||
|
||||
const generalCards = [
|
||||
{
|
||||
title: t('contact.general-inquiries.title'),
|
||||
description: t('contact.general-inquiries.description'),
|
||||
icon: 'i-tabler-mail',
|
||||
email: 'contact@papra.app',
|
||||
},
|
||||
{
|
||||
title: t('contact.partnerships.title'),
|
||||
description: t('contact.partnerships.description'),
|
||||
icon: 'i-tabler-heart-handshake',
|
||||
email: 'partnerships@papra.app',
|
||||
},
|
||||
{
|
||||
title: t('contact.bug.title'),
|
||||
description: t('contact.bug.description'),
|
||||
icon: 'i-tabler-bug',
|
||||
button: {
|
||||
icon: 'i-tabler-brand-github',
|
||||
label: t('contact.bug.button-label'),
|
||||
url: GITHUB_ISSUES_URL,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('contact.community.title'),
|
||||
description: t('contact.community.description'),
|
||||
icon: 'i-tabler-messages',
|
||||
button: {
|
||||
icon: 'i-tabler-brand-discord',
|
||||
label: t('contact.community.button-label'),
|
||||
url: DISCORD_INVITE_URL,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const socials = getSocials({ t });
|
||||
---
|
||||
|
||||
<Layout>
|
||||
<div class="bg-card flex flex-col w-full min-h-screen">
|
||||
<Header />
|
||||
|
||||
<div class="w-full bg-[linear-gradient(to_right,#80808010_1px,transparent_1px),linear-gradient(to_bottom,#80808010_1px,transparent_1px)] bg-[size:48px_48px] bg-background border-b pt-24 md:pt-32 pb-8 md:pb-18">
|
||||
<div class="max-w-700px mx-auto p-6 md:text-center">
|
||||
<h1 class="text-3xl md:text-5xl mb-4 font-bold">{t('contact.title')}</h1>
|
||||
<p class="text-base md:text-xl leading-tight text-muted-foreground text-pretty">{t('contact.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="max-w-4xl mx-auto px-6 py-8 md:py-24">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
{
|
||||
generalCards.map(card => (
|
||||
<div class={cn('border border-border rounded-lg p-6 bg-background/50')}>
|
||||
<div class="size-14 flex items-center justify-center bg-primary/10 rounded-xl">
|
||||
<div class={`${card.icon} size-8 text-primary`} aria-hidden="true" />
|
||||
</div>
|
||||
<h2 class="text-2xl font-semibold mt-4 mb-3">{card.title}</h2>
|
||||
<p class="text-base text-muted-foreground mb-4">{card.description}</p>
|
||||
{card.email
|
||||
? (
|
||||
<a
|
||||
href={`mailto:${card.email}`}
|
||||
class="text-base font-semibold text-primary hover:underline"
|
||||
>
|
||||
{card.email}
|
||||
</a>
|
||||
)
|
||||
: card.button
|
||||
? (
|
||||
<a
|
||||
href={card.button.url}
|
||||
class="text-base font-semibold bg-primary rounded-lg py-2 px-4 text-primary-foreground transition hover:bg-primary/80 inline-flex items-center"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<div class={`${card.button.icon} size-6 inline-block mr-2`} aria-hidden="true" />
|
||||
{card.button.label}
|
||||
</a>
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col md:flex-row justify-between items-center gap-8 border rounded-lg mt-8 p-6 bg-background/50">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold mb-3">{t('contact.connect.title')}</h2>
|
||||
<p class="text-base text-muted-foreground">{t('contact.connect.description')}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4 flex-wrap md:flex-nowrap">
|
||||
{
|
||||
socials.map(social => (
|
||||
<a
|
||||
href={social.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={social.name}
|
||||
class="size-12 text-primary bg-primary/10 rounded-lg flex items-center justify-center hover:bg-primary/20 transition"
|
||||
title={social.name}
|
||||
>
|
||||
<div class={cn('size-6', social.icon)} aria-hidden="true" />
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Footer class="bg-background" />
|
||||
</div>
|
||||
</Layout>
|
||||
84
apps/website/src/pages/[locale]/index.astro
Normal file
@@ -0,0 +1,84 @@
|
||||
---
|
||||
import { Image } from 'astro:assets';
|
||||
import { config } from '../../app.config';
|
||||
import Hero from '../../assets/papra-screenshot.png';
|
||||
import Cta from '../../components/Cta.astro';
|
||||
import FaqAccordion from '../../components/FaqAccordion.astro';
|
||||
import FeaturesBento from '../../components/FeaturesBento.astro';
|
||||
import Footer from '../../components/Footer.astro';
|
||||
import Header from '../../components/Header.astro';
|
||||
import ValuesSection from '../../components/ValuesSection.astro';
|
||||
import { useI18n } from '../../i18n/i18n';
|
||||
import { LOCALES } from '../../i18n/i18n.constants';
|
||||
import Layout from '../../layouts/Layout.astro';
|
||||
|
||||
|
||||
export async function getStaticPaths() {
|
||||
return LOCALES.map(locale => ({ params: { locale } }));
|
||||
}
|
||||
const { t } = useI18n({ locale: Astro.currentLocale });
|
||||
---
|
||||
|
||||
<Layout
|
||||
rawTitle={t('site.title')}
|
||||
description={t('site.description')}
|
||||
>
|
||||
|
||||
<div class="relative overflow-hidden pb-300px bg-[linear-gradient(to_right,#80808010_1px,transparent_1px),linear-gradient(to_bottom,#80808010_1px,transparent_1px)] bg-[size:48px_48px]">
|
||||
<div class="z-9 bg-gradient-to-b from-background to-transparent w-full h-full absolute top-0 left-0"></div>
|
||||
<div class="z-10 bg-primary w-80% max-w-1000px h-400px rounded-xl blur-64 op-20 absolute bottom--100px left-50% -translate-x-50%"></div>
|
||||
|
||||
<Header />
|
||||
|
||||
<div class="max-w-3xl mx-auto pt-20 pb-32 px-4 mt-24 text-center z-50 relative">
|
||||
<h1 class="text-4xl font-bold mb-4 text-pretty">{t('home.hero.title')}</h1>
|
||||
<p class="text-lg text-muted-foreground">{t('home.hero.subtitle')}</p>
|
||||
|
||||
<div class="flex items-center justify-center gap-2 mt-8">
|
||||
<a href="https://demo.papra.app" class="border text-sm font-bold rounded py-2 px-4 rounded-xl hover:border-primary hover:text-primary transition" target="_blank">{t('home.hero.live-demo')}</a>
|
||||
|
||||
<a href={config.getStartedLink} class="text-sm flex items-center gap-1.5 bg-primary hover:bg-primary/80 font-bold rounded py-2 px-4 rounded-xl text-primary-foreground transition">
|
||||
{t('home.hero.get-started')}
|
||||
<div class="i-tabler-arrow-right size-5"></div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-card border-t">
|
||||
<Image src={Hero} alt="Papra Screenshot" class="mx-auto max-w-1000px w-full relative z-50 mt--300px" loading="eager" />
|
||||
</div>
|
||||
|
||||
<FeaturesBento />
|
||||
|
||||
<ValuesSection />
|
||||
|
||||
<div class="bg-card py-64 border-t flex items-center justify-center gap-8 sm:gap-12 flex-col sm:flex-row relative overflow-hidden">
|
||||
<div class="max-w-600px px-6 text-center sm:text-left">
|
||||
<h2 class="text-2xl sm:text-4xl font-bold max-w-650px mx-auto" set:html={t('home.open-source.title')} />
|
||||
|
||||
<p class="text-muted-foreground mx-auto mt-4 text-lg" set:html={t('home.open-source.description')} />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center gap-2 mt-8">
|
||||
<a
|
||||
href="https://github.com/papra-hq/papra"
|
||||
class="border text-sm font-bold rounded py-2 px-4 rounded-xl hover:border-primary hover:text-primary transition flex items-center gap-2 flex-shrink-0"
|
||||
target="_blank"
|
||||
>
|
||||
{t('home.open-source.see-on-github')} <div class="i-tabler-arrow-right size-5" aria-hidden="true"></div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<FaqAccordion
|
||||
title={t('home.faq.title')}
|
||||
description={t('home.faq.subtitle')}
|
||||
items={t('home.faq.questions')}
|
||||
/>
|
||||
|
||||
<Cta />
|
||||
|
||||
<Footer />
|
||||
</Layout>
|
||||
276
apps/website/src/pages/[locale]/pricing.astro
Normal file
@@ -0,0 +1,276 @@
|
||||
---
|
||||
import { config } from '../../app.config';
|
||||
import FaqAccordion from '../../components/FaqAccordion.astro';
|
||||
import Footer from '../../components/Footer.astro';
|
||||
import Header from '../../components/Header.astro';
|
||||
import { useI18n } from '../../i18n/i18n';
|
||||
import { LOCALES } from '../../i18n/i18n.constants';
|
||||
import Layout from '../../layouts/Layout.astro';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
return LOCALES.map(locale => ({ params: { locale } }));
|
||||
}
|
||||
|
||||
const { t } = useI18n({ locale: Astro.currentLocale });
|
||||
|
||||
type Plan = {
|
||||
name: string;
|
||||
monthlyPrice: number;
|
||||
annualPrice: number;
|
||||
features: readonly string[];
|
||||
cta: {
|
||||
text: string;
|
||||
url: string;
|
||||
};
|
||||
popular?: boolean;
|
||||
} & (
|
||||
| {
|
||||
discountedMonthlyPrice?: undefined;
|
||||
discountedAnnualPrice?: undefined;
|
||||
}
|
||||
| {
|
||||
discountedMonthlyPrice: number;
|
||||
discountedAnnualPrice: number;
|
||||
}
|
||||
);
|
||||
|
||||
const plans: Plan[] = [
|
||||
{
|
||||
name: t('pricing.plan.free.name'),
|
||||
monthlyPrice: 0,
|
||||
annualPrice: 0,
|
||||
features: t('pricing.plan.free.features'),
|
||||
cta: {
|
||||
text: t('pricing.get-started'),
|
||||
url: config.getStartedLink,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: t('pricing.plan.plus.name'),
|
||||
monthlyPrice: 9,
|
||||
annualPrice: 90,
|
||||
features: t('pricing.plan.plus.features'),
|
||||
cta: {
|
||||
text: t('pricing.get-started'),
|
||||
url: config.getStartedLink,
|
||||
},
|
||||
popular: true,
|
||||
},
|
||||
|
||||
{
|
||||
name: t('pricing.plan.pro.name'),
|
||||
monthlyPrice: 30,
|
||||
annualPrice: 300,
|
||||
features: t('pricing.plan.pro.features'),
|
||||
cta: {
|
||||
text: t('pricing.get-started'),
|
||||
url: config.getStartedLink,
|
||||
},
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<Layout>
|
||||
<div class="bg-card flex flex-col w-full min-h-screen relative">
|
||||
<Header />
|
||||
|
||||
<div class="w-full bg-[linear-gradient(to_right,#80808010_1px,transparent_1px),linear-gradient(to_bottom,#80808010_1px,transparent_1px)] bg-[size:48px_48px] bg-background border-b pt-32 pb-24">
|
||||
<div class="max-w-700px mx-auto p-4 text-center">
|
||||
<h1 class="text-3xl md:text-4xl mb-4 font-bold">{t('pricing.title')}</h1>
|
||||
<p class="text-base md:text-lg text-muted-foreground leading-tight">{t('pricing.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="max-w-1200px mx-auto px-6 md:px-8 flex-1 w-full">
|
||||
<div class="flex flex-col items-center justify-center my-12 gap-3">
|
||||
<div class="inline-flex rounded-lg border border-border bg-muted p-1" role="group">
|
||||
<button id="monthly-btn" type="button" class="px-4 py-2 text-sm font-medium rounded-md transition-colors">{t('pricing.toggle.monthly')}</button>
|
||||
<button id="annual-btn" type="button" class="px-4 py-2 text-sm font-medium rounded-md transition-colors bg-background text-foreground shadow-sm flex items-center">
|
||||
{t('pricing.toggle.annual')}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground text-center">
|
||||
{t('pricing.currency-note')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{
|
||||
plans.map(plan => (
|
||||
<div class={`relative border rounded-xl bg-background p-6 flex flex-col ${plan.popular ? 'border-primary' : ''}`}>
|
||||
<div class="flex gap-2 absolute top--3 right-6">
|
||||
{plan.popular && <div class="bg-primary text-primary-foreground text-xs font-bold px-3 py-1 rounded-full">{t('pricing.most-popular')}</div>}
|
||||
</div>
|
||||
|
||||
<h2 class="text-2xl font-bold mb-2">{plan.name}</h2>
|
||||
|
||||
<div class="mb-6 border-b pb-6">
|
||||
<div>
|
||||
{plan.discountedMonthlyPrice !== undefined
|
||||
? (
|
||||
<div class="flex gap-1 flex-wrap items-end">
|
||||
<div class="flex flex-col items-start gap-1">
|
||||
<span class="monthly-price text-2xl font-medium text-muted-foreground hidden lh-none relative after:(content-[''] absolute left--5px right--5px top-1/2 h-2px bg-muted-foreground/40 rounded-full -rotate-12 origin-center)">${plan.monthlyPrice}</span>
|
||||
<span class="monthly-price text-5xl font-semibold hidden lh-none text-primary">${plan.discountedMonthlyPrice}</span>
|
||||
</div>
|
||||
{plan.annualPrice > 0
|
||||
? (
|
||||
<div class="flex flex-col items-start gap-1">
|
||||
<span class="annual-price text-2xl text-muted-foreground lh-none relative after:(content-[''] absolute left--5px right--5px top-1/2 h-2px bg-muted-foreground/40 rounded-full -rotate-12 origin-center)">${Math.round((100 * plan.annualPrice) / 12) / 100}</span>
|
||||
<span class="annual-price text-5xl font-semibold lh-none text-primary">${Math.round((100 * plan.discountedAnnualPrice) / 12) / 100}</span>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<span class="annual-price text-5xl font-semibold">$0</span>
|
||||
)}
|
||||
<div>
|
||||
<span class="text-muted-foreground">{t('pricing.per-month')}</span>
|
||||
{plan.annualPrice > 0 && <div class="text-xs text-muted-foreground annual-price">{t('pricing.billed-annually', { price: `$${plan.discountedAnnualPrice}` })}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div class="flex gap-1 flex-wrap items-end">
|
||||
<span class="monthly-price text-5xl font-semibold hidden lh-none">${plan.monthlyPrice}</span>
|
||||
{plan.annualPrice > 0
|
||||
? (
|
||||
<span class="annual-price text-5xl font-semibold lh-none">${Math.round((100 * plan.annualPrice) / 12) / 100}</span>
|
||||
)
|
||||
: (
|
||||
<span class="annual-price text-5xl font-semibold">$0</span>
|
||||
)}
|
||||
<div>
|
||||
<span class="text-muted-foreground">{t('pricing.per-month')}</span>
|
||||
{plan.annualPrice > 0 && <div class="text-xs text-muted-foreground annual-price">{t('pricing.billed-annually', { price: `$${plan.annualPrice}` })}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="space-y-3 mb-8 flex-1">
|
||||
{plan.features.map(feature => (
|
||||
<li class="flex items-start gap-2">
|
||||
<div class="rounded-lg bg-muted p-0.75">
|
||||
<div class="i-tabler-check size-4 text-primary flex-shrink-0" />
|
||||
</div>
|
||||
<span class="text-sm">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<a
|
||||
href={plan.cta.url}
|
||||
class={`text-center font-semibold px-4 py-2.5 rounded-lg transition ${
|
||||
plan.popular ? 'bg-primary text-primary-foreground hover:bg-primary/80' : 'border border-border hover:border-primary hover:text-primary'
|
||||
}`}
|
||||
>
|
||||
{plan.cta.text}
|
||||
</a>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="border rounded-xl bg-background p-6 flex-col items-start">
|
||||
<h2 class="text-2xl font-bold">{t('pricing.enterprise.title')}</h2>
|
||||
<p class="text-muted-foreground mt-2 mb-4 flex-1" set:html={t('pricing.enterprise.description')} />
|
||||
<a href="/contact" class="inline-flex items-center gap-2 border font-semibold px-4 py-2 rounded-lg hover:border-primary hover:text-primary transition">
|
||||
{t('pricing.contact-us')}
|
||||
<div class="i-tabler-arrow-right size-5"></div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="border rounded-xl bg-background p-6 flex flex-col items-start">
|
||||
<h2 class="text-2xl font-bold">{t('pricing.self-hosting.title')}</h2>
|
||||
<p class="text-muted-foreground mt-2 mb-4 flex-1" set:html={t('pricing.self-hosting.description')} />
|
||||
<a
|
||||
href="https://docs.papra.app/self-hosting/using-docker/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="inline-flex items-center gap-2 border font-semibold px-4 py-2 rounded-lg hover:border-primary hover:text-primary transition"
|
||||
>
|
||||
{t('pricing.self-hosting.cta')}
|
||||
<div class="i-tabler-arrow-right size-5"></div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FaqAccordion
|
||||
title={t('pricing.faq.title')}
|
||||
description={t('pricing.faq.subtitle')}
|
||||
items={t('pricing.faq.questions')}
|
||||
/>
|
||||
|
||||
<div class="bg-card pb-32">
|
||||
<div class="max-w-1200px mx-auto px-6">
|
||||
<div
|
||||
class="border rounded-xl bg-background pt-32 pb-24 bg-[linear-gradient(to_right,#80808010_1px,transparent_1px),linear-gradient(to_bottom,#80808010_1px,transparent_1px)] bg-[size:48px_48px] px-6"
|
||||
>
|
||||
<h2 class="text-2xl sm:text-4xl font-bold text-center max-w-650px mx-auto">{t('pricing.final-cta.title')}</h2>
|
||||
|
||||
<div class="flex mt-4 items-center justify-center">
|
||||
<a href={config.getStartedLink} class="font-semibold text-background px-4 py-2 hover:bg-primary/80 rounded-lg bg-primary transition mt-8 inline-block flex items-center">
|
||||
{t('pricing.final-cta.button')}
|
||||
|
||||
<div class="i-tabler-arrow-right ml-2 size-5" aria-hidden="true"></div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const monthlyBtn = document.getElementById('monthly-btn');
|
||||
const annualBtn = document.getElementById('annual-btn');
|
||||
const monthlyPrices = document.querySelectorAll('.monthly-price');
|
||||
const annualPrices = document.querySelectorAll('.annual-price');
|
||||
const monthlyPromoPrices = document.querySelectorAll('.monthly-promo-price');
|
||||
const annualPromoPrices = document.querySelectorAll('.annual-promo-price');
|
||||
|
||||
const activeClasses = ['bg-background', 'text-foreground', 'shadow-sm'];
|
||||
const inactiveClasses = ['bg-transparent', 'text-muted-foreground'];
|
||||
|
||||
function setAnnual() {
|
||||
// Update button styles
|
||||
annualBtn?.classList.add(...activeClasses);
|
||||
annualBtn?.classList.remove(...inactiveClasses);
|
||||
monthlyBtn?.classList.remove(...activeClasses);
|
||||
monthlyBtn?.classList.add(...inactiveClasses);
|
||||
|
||||
// Toggle price visibility
|
||||
monthlyPrices.forEach(el => el.classList.add('hidden'));
|
||||
annualPrices.forEach(el => el.classList.remove('hidden'));
|
||||
monthlyPromoPrices.forEach(el => el.classList.add('hidden'));
|
||||
annualPromoPrices.forEach(el => el.classList.remove('hidden'));
|
||||
}
|
||||
|
||||
function setMonthly() {
|
||||
// Update button styles
|
||||
monthlyBtn?.classList.add(...activeClasses);
|
||||
monthlyBtn?.classList.remove(...inactiveClasses);
|
||||
annualBtn?.classList.remove(...activeClasses);
|
||||
annualBtn?.classList.add(...inactiveClasses);
|
||||
|
||||
// Toggle price visibility
|
||||
monthlyPrices.forEach(el => el.classList.remove('hidden'));
|
||||
annualPrices.forEach(el => el.classList.add('hidden'));
|
||||
monthlyPromoPrices.forEach(el => el.classList.remove('hidden'));
|
||||
annualPromoPrices.forEach(el => el.classList.add('hidden'));
|
||||
}
|
||||
|
||||
monthlyBtn?.addEventListener('click', setMonthly);
|
||||
annualBtn?.addEventListener('click', setAnnual);
|
||||
|
||||
// Start with annual selected
|
||||
setAnnual();
|
||||
});
|
||||
</script>
|
||||
54
apps/website/src/pages/blog/[...page].astro
Normal file
@@ -0,0 +1,54 @@
|
||||
---
|
||||
import type { GetStaticPathsOptions, Page } from 'astro';
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
import { getCollection } from 'astro:content';
|
||||
import Footer from '../../components/Footer.astro';
|
||||
import Header from '../../components/Header.astro';
|
||||
import { useI18n } from '../../i18n/i18n';
|
||||
// import Pagination from '../../components/Pagination.astro';
|
||||
import Layout from '../../layouts/Layout.astro';
|
||||
import BlogCard from './_components/BlogCard.astro';
|
||||
|
||||
const { t } = useI18n({ locale: Astro.currentLocale });
|
||||
|
||||
export async function getStaticPaths({ paginate }: GetStaticPathsOptions) {
|
||||
const posts = await getCollection('blog');
|
||||
|
||||
const sortedPosts = posts.sort((a, b) => b.data.publishedAt.getTime() - a.data.publishedAt.getTime());
|
||||
|
||||
return paginate(sortedPosts, { pageSize: 12 });
|
||||
}
|
||||
|
||||
|
||||
const { page } = Astro.props as { page: Page<CollectionEntry<'blog'>> };
|
||||
|
||||
// const allPages = Array.from({ length: page.lastPage }).map((_, index) => {
|
||||
// return `/blog${index === 0 ? '' : `/${String(index + 1)}`}`;
|
||||
// });
|
||||
|
||||
---
|
||||
|
||||
<Layout title="Blog" description="Stay updated with the latest news and updates from Papra." image={{ src: '/og/blog.png', alt: 'What’s new in the Papra ecosystem' }}>
|
||||
<Header />
|
||||
|
||||
<div class="flex flex-col min-h-screen">
|
||||
<div class="w-full bg-[linear-gradient(to_right,#80808010_1px,transparent_1px),linear-gradient(to_bottom,#80808010_1px,transparent_1px)] bg-[size:48px_48px] bg-background border-b pt-32 pb-24">
|
||||
<div class="max-w-450px mx-auto p-4 text-center">
|
||||
<h1 class="text-3xl mb-2 font-bold">{t('blog.title')}</h1>
|
||||
<p class="text-base mb-2 leading-tight">{t('blog.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="sr-only">{t('blog.posts-heading')}</h2>
|
||||
<div class="p-6 flex-1 py-20 flex flex-col gap-8">
|
||||
{
|
||||
page.data.map(post => (
|
||||
<BlogCard {...{ post }} />
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- <Pagination page={page} allPages={allPages} /> -->
|
||||
<Footer class="bg-background" />
|
||||
</div>
|
||||
</Layout>
|
||||
78
apps/website/src/pages/blog/[slug].astro
Normal file
@@ -0,0 +1,78 @@
|
||||
---
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
import { Image } from 'astro:assets';
|
||||
import { getCollection } from 'astro:content';
|
||||
import { format } from 'date-fns';
|
||||
import CorentinAvatar from '../../assets/corentin.jpg';
|
||||
import Footer from '../../components/Footer.astro';
|
||||
import Header from '../../components/Header.astro';
|
||||
import Layout from '../../layouts/Layout.astro';
|
||||
import { resolveImage } from '../../utils/urls';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const blog = await getCollection('blog');
|
||||
|
||||
return blog.map(post => ({
|
||||
params: { slug: post.slug },
|
||||
props: { post },
|
||||
}));
|
||||
}
|
||||
|
||||
export type Props = {
|
||||
post: CollectionEntry<'blog'>;
|
||||
};
|
||||
|
||||
const { post } = Astro.props;
|
||||
|
||||
const { data, render } = post;
|
||||
const { Content } = await render();
|
||||
|
||||
const ogImage = await resolveImage(data.ogImage);
|
||||
const ogImageUrl = ogImage ? new URL(ogImage.src, Astro.site).toString() : undefined;
|
||||
|
||||
const structuredData = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BlogPosting',
|
||||
'@id': Astro.url,
|
||||
'headline': data.title,
|
||||
'description': data.description,
|
||||
...(ogImageUrl && { thumbnailUrl: ogImageUrl, image: [ogImageUrl] }),
|
||||
'datePublished': data.publishedAt.toISOString(),
|
||||
'potentialAction': [{ '@type': 'ReadAction', 'target': [Astro.url] }],
|
||||
|
||||
};
|
||||
---
|
||||
|
||||
<Layout title={data.title} description={data.description} image={ogImage ? { src: ogImage.src, alt: data.title } : undefined} jsonLd={structuredData}>
|
||||
<Header />
|
||||
|
||||
<div class="flex flex-col min-h-screen">
|
||||
<div class="w-full bg-[linear-gradient(to_right,#80808010_1px,transparent_1px),linear-gradient(to_bottom,#80808010_1px,transparent_1px)] bg-[size:48px_48px] bg-background border-b pt-26 pb-12">
|
||||
<div class="max-w-800px mx-auto p-4 sm:text-center">
|
||||
|
||||
<h1 class="text-3xl my-4 font-bold">{data.title}</h1>
|
||||
<p class="text-base leading-tight">{data.description}</p>
|
||||
|
||||
<div class="flex sm:justify-center items-center gap-1 text-muted-foreground mt-4">
|
||||
<time class="text-muted-foreground" datetime={data.publishedAt.toISOString()}>
|
||||
{format(data.publishedAt, 'MMMM d, yyyy')}
|
||||
</time>
|
||||
<span class="text-muted-foreground"> - </span>
|
||||
By
|
||||
<a href="https://corentin.tech" class="text-muted-foreground hover:text-primary transition flex items-center gap-1" target="_blank" rel="noreferrer">
|
||||
<Image src={CorentinAvatar} alt="Corentin Thomasset" width={30} height={30} class="rounded-full border" aria-hidden="true" loading="eager" decoding="async" />
|
||||
Corentin Thomasset
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<article class="prose prose-invert text-base prose-neutral max-w-700px mx-auto mt-16 mb-40 flex-1 px-6">
|
||||
<Content />
|
||||
</article>
|
||||
|
||||
<Footer class="bg-background" />
|
||||
</div>
|
||||
|
||||
|
||||
</Layout>
|
||||
38
apps/website/src/pages/blog/_components/BlogCard.astro
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
import { Image } from 'astro:assets';
|
||||
import { format } from 'date-fns';
|
||||
import { resolveImage } from '../../../utils/urls';
|
||||
|
||||
export type Props = {
|
||||
post: {
|
||||
slug: string;
|
||||
data: {
|
||||
title: string;
|
||||
description: string;
|
||||
publishedAt: Date;
|
||||
coverImage?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const { post } = Astro.props;
|
||||
|
||||
const coverImage = await resolveImage(post.data.coverImage);
|
||||
---
|
||||
|
||||
<article class="relative border max-w-700px mx-auto bg-card rounded-xl transition hover:border-primary overflow-hidden" aria-labelledby={post.slug}>
|
||||
{coverImage && <Image src={coverImage} alt={post.data.title} height={(630 * 700) / 1500} width={700} class="border-b" />}
|
||||
|
||||
<div class="p-6">
|
||||
<h3 class="font-medium text-2xl" id={post.slug}>
|
||||
<a href={`/blog/${post.slug}/`} data-astro-prefetch class="outline-none after:absolute after:inset-0 after:content-['']">
|
||||
{post.data.title}
|
||||
</a>
|
||||
</h3>
|
||||
|
||||
<p class="text-muted-foreground my-2">{post.data.description}</p>
|
||||
<time class="text-muted-foreground block" datetime={post.data.publishedAt.toISOString()}>
|
||||
{format(post.data.publishedAt, 'MMMM d, yyyy')}
|
||||
</time>
|
||||
</div>
|
||||
</article>
|
||||
6
apps/website/src/pages/contact.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { createRedirectionToLocalizedPage } from '../i18n/i18n.routes';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const GET: APIRoute = createRedirectionToLocalizedPage(({ locale }) => `/${locale}/contact`);
|
||||
151
apps/website/src/pages/en/papra-vs-paperless-ngx.mdx
Normal file
@@ -0,0 +1,151 @@
|
||||
---
|
||||
layout: ../../layouts/MdPage.astro
|
||||
title: Papra vs Paperless-NGX Comparison
|
||||
description: Feature comparison between Papra and Paperless-NGX document management systems. Compare storage, scalability, collaboration, and deployment options.
|
||||
withCta: true
|
||||
---
|
||||
|
||||
import ComparisonTable from '../../components/ComparisonTable.astro';
|
||||
|
||||
## Why Papra Exists
|
||||
|
||||
Paperless-NGX is an excellent, mature project with a large community and extensive features. If it works perfectly for your needs, that's great! However, Papra was created to address specific areas where improvements could be made:
|
||||
|
||||
- **User Experience**: Papra is built from the ground up to be intuitive and easy to use, with a focus on modern UI/UX principles
|
||||
- **Collaboration**: Designed to make sharing documents with family members and teams easier with organizations and flexible permissions
|
||||
- **Modern Architecture**: Built with horizontal scalability and cloud-native deployments in mind from day one
|
||||
- **Developer Experience**: Provides modern tooling like a JavaScript SDK and comprehensive API documentation
|
||||
|
||||
If you're looking for something that prioritizes these aspects, Papra might be worth exploring.
|
||||
|
||||
## Feature Comparison
|
||||
|
||||
Here's a detailed comparison between Papra and Paperless-NGX across various dimensions:
|
||||
|
||||
<ComparisonTable
|
||||
rows={[
|
||||
{
|
||||
feature: 'File Storage',
|
||||
paperless: 'Local filesystem only',
|
||||
papra: 'Local filesystem, any S3-compatible storage, Azure Blob Storage, in-memory'
|
||||
},
|
||||
{
|
||||
feature: 'Container Size',
|
||||
paperless: '2 containers (App: 1.42GB + Redis: 137MB)',
|
||||
papra: '1 container (963MB)'
|
||||
},
|
||||
{
|
||||
feature: 'Scalability',
|
||||
paperless: 'Primarily single-server, tied to local filesystem',
|
||||
papra: 'Horizontally scalable from the start, cloud-native architecture'
|
||||
},
|
||||
{
|
||||
feature: 'Deployment Options',
|
||||
paperless: 'Self-hosted only (unofficial third-party hosting exists)',
|
||||
papra: 'Self-hosted + official managed cloud service'
|
||||
},
|
||||
{
|
||||
feature: 'Document Ingestion Methods',
|
||||
paperless: 'Web UI, watch folder, CLI, API, email inbox lookup',
|
||||
papra: 'Web UI, watch folder, CLI, API, email ingestion (send-to address)'
|
||||
},
|
||||
{
|
||||
feature: 'Mobile Apps',
|
||||
paperless: 'Multiple third-party mobile applications',
|
||||
papra: 'Official mobile app (coming soon)'
|
||||
},
|
||||
{
|
||||
feature: 'Desktop Apps',
|
||||
paperless: 'Some unmaintained third-party desktop app',
|
||||
papra: 'Official desktop app (coming soon)'
|
||||
},
|
||||
{
|
||||
feature: 'Developer Tools',
|
||||
paperless: 'API + Webhooks',
|
||||
papra: 'API + JS/TS SDK + Webhooks'
|
||||
},
|
||||
{
|
||||
feature: 'Multi-User Support',
|
||||
paperless: 'Yes',
|
||||
papra: 'Yes'
|
||||
},
|
||||
{
|
||||
feature: 'Organizations',
|
||||
paperless: 'No',
|
||||
papra: 'Yes, with flexible organization management'
|
||||
},
|
||||
{
|
||||
feature: 'Sharing & Collaboration',
|
||||
paperless: 'Multi-user but limited shared collection support',
|
||||
papra: 'Built-in organization structure makes sharing with family and teams easier'
|
||||
},
|
||||
{
|
||||
feature: 'License',
|
||||
paperless: 'GPL-3.0',
|
||||
papra: 'AGPL-3.0'
|
||||
},
|
||||
{
|
||||
feature: 'Maturity',
|
||||
paperless: 'Very mature, established project',
|
||||
papra: 'Newer, actively developed'
|
||||
},
|
||||
{
|
||||
feature: 'Community',
|
||||
paperless: 'Large, well-established ecosystem',
|
||||
papra: 'Growing community'
|
||||
},
|
||||
{
|
||||
feature: 'Target Audience',
|
||||
paperless: 'Tech-savvy users comfortable with self-hosting',
|
||||
papra: 'Users looking for modern, intuitive experience'
|
||||
},
|
||||
{
|
||||
feature: 'Documentation',
|
||||
paperless: 'Extensive and mature, many community resources and tutorials',
|
||||
papra: 'Growing and improving'
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
## Which Should You Choose?
|
||||
|
||||
Both Papra and Paperless-NGX are excellent open-source solutions for document management. Your choice depends on your specific needs:
|
||||
|
||||
### Choose Paperless-NGX if:
|
||||
- You want a mature, battle-tested solution with years of development
|
||||
- You're part of the existing Paperless ecosystem and community
|
||||
- You prefer a single-server setup with local filesystem storage
|
||||
- You're satisfied with the current feature set and user experience
|
||||
- You want access to multiple third-party mobile applications
|
||||
|
||||
### Choose Papra if:
|
||||
- You prioritize modern, intuitive user experience
|
||||
- You need flexible storage options (S3, Azure Blob, etc.)
|
||||
- You want built-in organization features for easier family/team sharing
|
||||
- You're planning for horizontal scalability from the start
|
||||
- You prefer an official managed cloud option instead of self-hosting
|
||||
- You want modern developer tooling like a JavaScript SDK
|
||||
- You value a more streamlined, lightweight container setup
|
||||
|
||||
### Try Both!
|
||||
|
||||
Since both projects are open-source and self-hostable, you can try both to see which fits your workflow better. Many users have successfully migrated between different document management solutions.
|
||||
|
||||
- **Paperless-NGX**:
|
||||
- Demo: [demo.paperless-ngx.com](https://demo.paperless-ngx.com/)
|
||||
- GitHub: [github.com/paperless-ngx/paperless-ngx](https://github.com/paperless-ngx/paperless-ngx)
|
||||
- Documentation: [docs.paperless-ngx.com](https://docs.paperless-ngx.com/)
|
||||
- **Papra**:
|
||||
- Demo: [demo.papra.app](https://demo.papra.app)
|
||||
- GitHub: [github.com/papra-app/papra](https://github.com/papra-app/papra)
|
||||
- Documentation: [docs.papra.app](https://docs.papra.app/)
|
||||
|
||||
## Final Thoughts
|
||||
|
||||
Both projects share the same goal: helping people manage their documents effectively. Your choice depends on your specific needs, technical comfort level, and preferences.
|
||||
|
||||
We encourage you to try both and see what works best for your workflow. The document management space benefits from having multiple quality open-source options, and we're grateful for the foundation the Paperless-NGX team has built.
|
||||
|
||||
---
|
||||
|
||||
*Have questions about Papra or want to discuss features? Join our [Discord community](https://discord.gg/8UPjzsrBNF) or reach out via our [contact page](/en/contact).*
|
||||
14
apps/website/src/pages/index.astro
Normal file
@@ -0,0 +1,14 @@
|
||||
---
|
||||
import { DEFAULT_LOCALE, LOCALES } from '../i18n/i18n.constants';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
const preferredLocales = Astro.preferredLocaleList ?? [];
|
||||
|
||||
const locale = preferredLocales.find(locale => (LOCALES as readonly string[]).includes(locale)) ?? DEFAULT_LOCALE;
|
||||
|
||||
Astro.response.headers.set('Vary', 'Accept-Language');
|
||||
|
||||
return Astro.redirect(`/${locale}/`, 302);
|
||||
---
|
||||
|
||||
43
apps/website/src/pages/llms.txt.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { getCollection } from 'astro:content';
|
||||
|
||||
const getDocsSections = (): Promise<{ label: string; items: { label: string; url: string; description: string }[] }[]> => fetch('https://docs.papra.app/docs-navigation.json').then(res => res.json());
|
||||
|
||||
export const GET: APIRoute = async ({ site }) => {
|
||||
const posts = await getCollection('blog');
|
||||
const getBlogPostUrl = (slug: string) => new URL(`blog/${slug}`, site).href;
|
||||
|
||||
const docsSections = await getDocsSections();
|
||||
|
||||
const llmTxt = `
|
||||
# Papra
|
||||
|
||||
> Papra is a minimalistic document management and archiving platform. It is designed to be simple to use and accessible to everyone. Papra is a platform for long-term document storage and management, like a digital archive for your documents.
|
||||
|
||||
## Blog Posts
|
||||
|
||||
${posts.map(post => `- [${post.data.title}](${getBlogPostUrl(post.slug)}): ${post.data.description}`).join('\n')}
|
||||
|
||||
## Assets
|
||||
|
||||
- [Papra Documentation](https://docs.papra.app): The self-hosting documentation for Papra.
|
||||
- [Papra GitHub](https://github.com/papra-hq/papra): The source code for Papra.
|
||||
- [Papra Discord](https://papra.app/discord): The official Discord server for Papra.
|
||||
- [Bluesky account](https://bsky.app/profile/papra.app): The official Bluesky account for Papra @papra.app.
|
||||
- [Mastodon account](https://mastodon.social/@papra): The official Mastodon account for Papra @papra@mastodon.social.
|
||||
- [X / Twitter account](https://x.com/papra_app): The official X account for Papra @papra_app.
|
||||
- [Support the project](https://buymeacoffee.com/cthmsst): Support the project by sponsoring the developer.
|
||||
- [Contact](https://papra.app/contact): Contact the development team.
|
||||
|
||||
## Legal
|
||||
|
||||
- [Privacy Policy](https://papra.app/privacy): The privacy policy for Papra.
|
||||
- [Terms of Service](https://papra.app/terms-of-service): The terms of service for Papra.
|
||||
|
||||
## Docs
|
||||
|
||||
${docsSections.map(section => `### ${section.label}\n\n${section.items.map(item => `- [${item.label}](${item.url}): ${item.description ?? item.label}`).join('\n')}`).join('\n\n')}
|
||||
`.trim();
|
||||
|
||||
return new Response(llmTxt);
|
||||
};
|
||||
6
apps/website/src/pages/pricing.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { createRedirectionToLocalizedPage } from '../i18n/i18n.routes';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const GET: APIRoute = createRedirectionToLocalizedPage(({ locale }) => `/${locale}/pricing`);
|
||||
128
apps/website/src/pages/privacy.md
Normal file
@@ -0,0 +1,128 @@
|
||||
---
|
||||
layout: ../layouts/MdPage.astro
|
||||
title: Privacy Policy
|
||||
description: Privacy Policy for Papra, the document management platform.
|
||||
---
|
||||
|
||||
## Privacy Policy
|
||||
|
||||
- **Effective Date:** October 16, 2025
|
||||
- **Last Updated:** October 16, 2025
|
||||
|
||||
**Important**: This Privacy Policy applies exclusively to the managed cloud service hosted at **dashboard.papra.app** (the "Hosted Service"). This Privacy Policy does not apply to self-hosted instances of the Papra open-source software. Self-hosters act as independent data controllers and are solely responsible for their own privacy practices and GDPR compliance.
|
||||
|
||||
### 1. Information Collection
|
||||
Papra collects the following personal data:
|
||||
- Email addresses, names, and authentication details (email/password or Single Sign-On providers such as Google or GitHub).
|
||||
- Usage and analytics data collected via PostHog (including IP addresses, pages visited, interactions, and usage patterns).
|
||||
- Documents uploaded by users, including metadata (titles, descriptions, tags) and document content.
|
||||
|
||||
### 2. Purpose of Data Collection
|
||||
Papra collects data to:
|
||||
- Provide, operate, and maintain the services offered by Papra.
|
||||
- Offer user support and address technical issues.
|
||||
- Analyze usage to continuously improve our services.
|
||||
- Perform marketing analysis and related activities (via PostHog).
|
||||
|
||||
### 3. Cookies and Tracking Technologies
|
||||
Papra employs the following types of cookies and tracking technologies:
|
||||
- **Strictly Necessary Cookies**: Required for platform functionality, user authentication, and session management. These cannot be disabled.
|
||||
- **Analytics Cookies**: PostHog analytics to understand user behavior, track feature usage, and improve service quality. These cookies collect anonymized usage data.
|
||||
- **Performance Cookies**: To monitor platform performance and identify technical issues.
|
||||
|
||||
You can manage your cookie preferences through your browser settings. However, disabling certain cookies may affect platform functionality. For more information about managing cookies, visit your browser's help documentation.
|
||||
|
||||
### 4. Data Storage and Security
|
||||
All data collected by Papra is securely stored within Europe to ensure GDPR compliance. Papra implements industry-standard security measures including:
|
||||
- Encryption of data in transit (TLS/SSL) and at rest
|
||||
- Regular security audits and updates
|
||||
- Access controls and authentication mechanisms
|
||||
- Secure backup procedures
|
||||
|
||||
However, no method of transmission over the internet or electronic storage is 100% secure. While we strive to protect your personal data, we cannot guarantee absolute security.
|
||||
|
||||
### 5. Third-Party Providers
|
||||
Papra uses the following trusted third-party services to operate and improve the platform:
|
||||
- **Render**: Hosting infrastructure and application deployment
|
||||
- **Cloudflare**: Document Storage, CDN, security, and DDoS protection services
|
||||
- **Turso**: Database hosting and management (based on libSQL/SQLite)
|
||||
- **PostHog**: Analytics, product insights, and feature usage tracking
|
||||
- **Stripe**: Payment processing for subscriptions (does not store card details on Papra servers)
|
||||
|
||||
All third-party providers are carefully selected based on their security practices and GDPR compliance. These providers only receive the minimum data necessary to perform their services.
|
||||
|
||||
### 6. Data Sharing and Transfer
|
||||
Papra does not sell, rent, or trade user data to third parties for marketing purposes. Data sharing is limited to the following circumstances:
|
||||
- **Service Providers**: Sharing with third-party providers (listed in Section 5) necessary to operate the platform
|
||||
- **Legal Compliance**: When required by law, regulation, legal process, or governmental request
|
||||
- **Protection of Rights**: To protect the rights, property, or safety of Papra, our users, or the public
|
||||
- **Business Transfers**: In connection with a merger, acquisition, or sale of assets, with advance notice to affected users
|
||||
|
||||
**International Data Transfers**: While all primary data storage occurs within the European Union, some third-party providers may process data outside the EU. In such cases, we ensure appropriate safeguards are in place to protect your data in accordance with GDPR requirements.
|
||||
|
||||
### 7. User Rights (GDPR Compliance)
|
||||
Under the General Data Protection Regulation (GDPR), users have the following rights:
|
||||
- **Right of Access**: Request a copy of the personal data we hold about you
|
||||
- **Right to Rectification**: Correct any inaccurate or incomplete personal data
|
||||
- **Right to Erasure** ("Right to be Forgotten"): Request deletion of your personal data under certain conditions
|
||||
- **Right to Restriction**: Request that we limit the processing of your personal data in specific circumstances
|
||||
- **Right to Data Portability**: Receive your personal data in a structured, commonly used, machine-readable format
|
||||
- **Right to Object**: Object to the processing of your personal data for specific purposes, including direct marketing
|
||||
- **Right to Withdraw Consent**: Withdraw your consent at any time where we rely on consent to process your personal data
|
||||
- **Right to Lodge a Complaint**: File a complaint with your local data protection authority (in France: CNIL - Commission Nationale de l'Informatique et des Libertés)
|
||||
|
||||
To exercise any of these rights, please contact us at privacy@papra.app. We will respond to your request within 30 days. You may also access, update, or delete certain information directly through your account settings.
|
||||
|
||||
### 8. Data Retention
|
||||
Papra retains personal data only for as long as necessary to fulfill the purposes outlined in this Privacy Policy:
|
||||
- **Active Accounts**: Data is retained while your account remains active
|
||||
- **Deleted Accounts**: Upon account deletion, user data is permanently deleted within 30 days
|
||||
- **Backups**: Backup copies may be retained for up to 90 days after deletion for disaster recovery purposes
|
||||
- **Legal Obligations**: Certain data may be retained longer if required by law, regulation, or to resolve disputes
|
||||
- **Anonymous Analytics**: Aggregated, anonymized usage data may be retained indefinitely for statistical purposes
|
||||
|
||||
### 9. Changes to Privacy Policy
|
||||
Papra reserves the right to update this Privacy Policy periodically to reflect changes in our practices, technology, legal requirements, or business operations. When we make material changes:
|
||||
- We will update the "Last Updated" date at the top of this policy
|
||||
- We will notify users via email to the address associated with their account
|
||||
- We may display a notification within the platform
|
||||
- Continued use of the service after changes indicates acceptance of the updated policy
|
||||
|
||||
We encourage you to review this Privacy Policy periodically to stay informed about how we protect your data.
|
||||
|
||||
### 10. Legal Basis for Processing (GDPR)
|
||||
Under GDPR, we process personal data based on the following legal grounds:
|
||||
- **Contract Performance**: Processing necessary to provide services you've requested or to enter into a contract with you
|
||||
- **Legitimate Interests**: Processing necessary for our legitimate business interests, such as improving our services, ensuring security, and preventing fraud
|
||||
- **Consent**: Where you've provided explicit consent for specific processing activities (e.g., marketing communications)
|
||||
- **Legal Obligations**: Processing required to comply with legal obligations under EU or member state law
|
||||
|
||||
### 11. Automated Decision-Making and Profiling
|
||||
Papra does not use automated decision-making or profiling that produces legal effects or similarly significantly affects users. Any analytics performed on user data is for aggregate statistical purposes only and does not result in automated decisions about individual users.
|
||||
|
||||
### 12. Children's Privacy
|
||||
Papra does not knowingly collect personal data from individuals under the age of 13 (or the applicable age of digital consent in your jurisdiction). The platform is intended for users aged 13 and above. If we become aware that we have inadvertently collected personal data from a child under this age, we will take steps to delete such information promptly. If you believe we have collected data from a child, please contact us at privacy@papra.app.
|
||||
|
||||
### 13. California Privacy Rights (CCPA)
|
||||
If you are a California resident, you have additional rights under the California Consumer Privacy Act (CCPA):
|
||||
- **Right to Know**: Request information about the categories and specific pieces of personal data we've collected
|
||||
- **Right to Delete**: Request deletion of your personal data, subject to certain exceptions
|
||||
- **Right to Opt-Out**: Opt-out of the sale of personal data (Note: Papra does not sell personal data)
|
||||
- **Right to Non-Discrimination**: You will not receive discriminatory treatment for exercising your privacy rights
|
||||
|
||||
To exercise these rights, contact us at privacy@papra.app. We will verify your identity before processing requests.
|
||||
|
||||
### 14. Data Protection Officer
|
||||
As a micro-entrepreneur based in France, Papra is not required to appoint a formal Data Protection Officer (DPO). However, all privacy-related matters are handled directly by Corentin Thomasset, who can be reached at privacy@papra.app.
|
||||
|
||||
### 15. Supervisory Authority
|
||||
If you are located in the European Union and believe we have not adequately addressed your privacy concerns, you have the right to lodge a complaint with your local supervisory authority:
|
||||
- **France**: CNIL (Commission Nationale de l'Informatique et des Libertés) - https://www.cnil.fr/
|
||||
- You may also contact the supervisory authority in your country of residence
|
||||
|
||||
### 16. Contact Information
|
||||
For privacy-related inquiries, to exercise your rights, or to report privacy concerns, please contact:
|
||||
- **Email**: privacy@papra.app
|
||||
- **General Inquiries**: contact@papra.app
|
||||
|
||||
We aim to respond to all inquiries within 30 days.
|
||||
15
apps/website/src/pages/robots.txt.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
|
||||
function getRobotsTxt(sitemapURL: URL) {
|
||||
return `
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: ${sitemapURL.href}
|
||||
`.trim();
|
||||
}
|
||||
|
||||
export const GET: APIRoute = ({ site }) => {
|
||||
const sitemapURL = new URL('sitemap-index.xml', site);
|
||||
return new Response(getRobotsTxt(sitemapURL));
|
||||
};
|
||||
29
apps/website/src/pages/rss.xml.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import rss from '@astrojs/rss';
|
||||
import { getCollection } from 'astro:content';
|
||||
|
||||
function sortPosts<T extends { data: { publishedAt: Date } }>(posts: T[]) {
|
||||
return posts.sort((a, b) => b.data.publishedAt.getTime() - a.data.publishedAt.getTime());
|
||||
}
|
||||
|
||||
function formatDate(date: Date) {
|
||||
date.setUTCHours(0);
|
||||
return date;
|
||||
}
|
||||
|
||||
export const GET: APIRoute = async (context) => {
|
||||
const unsortedPosts = await getCollection('blog');
|
||||
const posts = sortPosts(unsortedPosts);
|
||||
|
||||
return rss({
|
||||
title: 'Papra Blog',
|
||||
description: 'News and updates about Papra.',
|
||||
site: context.site!.href,
|
||||
items: posts.map(item => ({
|
||||
title: item.data.title,
|
||||
description: item.data.description,
|
||||
link: `/blog/${item.slug}/`,
|
||||
pubDate: formatDate(item.data.publishedAt),
|
||||
})),
|
||||
});
|
||||
};
|
||||
105
apps/website/src/pages/terms-of-service.md
Normal file
@@ -0,0 +1,105 @@
|
||||
---
|
||||
layout: ../layouts/MdPage.astro
|
||||
title: Terms of Service
|
||||
description: Terms of Service for Papra, the document management platform.
|
||||
---
|
||||
|
||||
## Terms of Use
|
||||
|
||||
- **Effective Date:** October 16, 2025
|
||||
- **Last Updated:** October 16, 2025
|
||||
|
||||
### 1. Platform Information
|
||||
Papra is an open-source document management platform managed by Corentin Thomasset. Contact email: contact@papra.app.
|
||||
|
||||
**Important**: These Terms of Service apply exclusively to the managed cloud service hosted at **dashboard.papra.app** (the "Hosted Service"). These Terms do not apply to self-hosted instances of the Papra open-source software.
|
||||
|
||||
### 2. Acceptance and Modification of Terms
|
||||
- By accessing or using the Papra Hosted Service (dashboard.papra.app), users agree to these Terms.
|
||||
- Papra reserves the right to update these Terms periodically. Significant changes will be communicated via email or through notifications on the dashboard.
|
||||
- Continued use of the Hosted Service after updates implies acceptance of the revised Terms.
|
||||
|
||||
### 3. User Accounts and Responsibilities
|
||||
- Users must create an account via email/password or Single Sign-On providers (Google, GitHub).
|
||||
- Accounts must be secured by strong passwords.
|
||||
- Users are fully responsible for activities conducted through their accounts.
|
||||
|
||||
### 4. Usage Policies
|
||||
- Users agree not to utilize Papra for illegal activities, unauthorized distribution of protected materials, or hosting malicious content.
|
||||
- Papra may terminate or suspend accounts suspected of violating these conditions without prior notice.
|
||||
|
||||
### 5. Intellectual Property and Open Source
|
||||
- **Open Source Software**: The Papra software is licensed under AGPLv3 and is freely available at https://github.com/papra-hq/papra. Anyone may download, modify, and self-host Papra subject to the AGPLv3 license terms.
|
||||
- **Self-Hosted Instances**: Users who self-host Papra must comply with AGPLv3 license conditions. These Terms of Service, the Privacy Policy, and any service-level commitments apply ONLY to the Hosted Service (dashboard.papra.app), not to self-hosted instances.
|
||||
- **No Liability for Self-Hosted Use**: Corentin Thomasset and Papra are not responsible for any activities, data processing, security breaches, or legal compliance issues occurring within self-hosted instances. Self-hosters are solely responsible as independent data controllers.
|
||||
- **Hosted Service Infrastructure**: The infrastructure, configurations, managed services, and any proprietary features specific to the Hosted Service (dashboard.papra.app) remain the intellectual property of Corentin Thomasset.
|
||||
- **Trademarks**: "Papra" and associated logos are trademarks. Self-hosters may use the name to accurately identify the software but may not imply endorsement or official affiliation with the Hosted Service.
|
||||
|
||||
### 6. Data Ownership and Privacy
|
||||
- Users retain full ownership of their uploaded data.
|
||||
- Papra never sells user data to third parties and uses trusted providers (Render, Cloudflare, Turso, PostHog) to store and process data securely, exclusively within Europe, ensuring GDPR compliance.
|
||||
|
||||
### 7. Data Retention and Deletion
|
||||
- Upon account deletion, user data will be permanently removed within 30 days.
|
||||
- Backups might be retained for up to 90 days post-deletion.
|
||||
|
||||
### 8. Payments and Refunds
|
||||
- Papra offers free and paid service tiers with quotas based on document storage volumes (e.g., 500MB for free tier, up to 10GB for paid tiers).
|
||||
- Payment processing is handled by Stripe, a third-party payment processor. By subscribing to paid services, you agree to Stripe's terms and conditions.
|
||||
- All fees are stated in United States Dollars (USD) and are non-refundable except as required by law or at Papra's sole discretion.
|
||||
- Users may cancel subscriptions anytime through their account settings, with cancellations taking effect at the end of the billing cycle.
|
||||
- Refunds are granted at Papra's discretion, typically due to technical issues or billing errors. Refund requests must be submitted within 14 days of the charge.
|
||||
- Papra reserves the right to change pricing with 30 days' notice to active subscribers.
|
||||
|
||||
### 9. Liability and Indemnification
|
||||
- Papra is provided "as is" without warranties of any kind, either express or implied, including but not limited to warranties of merchantability, fitness for a particular purpose, or non-infringement.
|
||||
- Papra does not guarantee that the service will be uninterrupted, secure, or error-free.
|
||||
- To the maximum extent permitted by applicable law, Papra's total liability for any claims shall not exceed the amount paid by the user in the 12 months preceding the claim, or €100, whichever is greater.
|
||||
- Papra is not liable for any indirect, incidental, special, consequential, or punitive damages, including loss of profits, data, use, or goodwill.
|
||||
- Users agree to indemnify, defend, and hold harmless Papra, its officers, directors, employees, and agents from any claims, damages, losses, liabilities, and expenses (including legal fees) arising from: (a) user's violation of these Terms, (b) user's violation of any rights of another party, or (c) user's use or misuse of the service.
|
||||
|
||||
### 10. Availability and Support
|
||||
- Papra aims for high availability but provides no uptime guarantees.
|
||||
- Support is available via email and potentially through community platforms such as Discord.
|
||||
|
||||
### 11. Dispute Resolution
|
||||
- These Terms are governed by French law.
|
||||
- Disputes will initially aim for amicable resolution or mediation, with jurisdiction given to courts near the company location.
|
||||
|
||||
### 12. Additional Services and Integrations
|
||||
- Future features (automatic tagging, SDK/CLI tools, etc.) may introduce additional usage conditions and privacy considerations, clearly communicated upon feature launch.
|
||||
|
||||
### 13. API Usage and SDK/CLI
|
||||
- Users may access Papra's API, SDK, or CLI tools subject to rate limits and usage quotas associated with their subscription tier.
|
||||
- API keys must be kept confidential and secure. Users are responsible for all activities conducted using their API keys.
|
||||
- Automated or excessive use that degrades service performance may result in temporary suspension or permanent termination of API access.
|
||||
- API terms may be updated separately, with changes communicated through developer documentation.
|
||||
|
||||
### 14. Account Termination
|
||||
- Users may terminate their accounts at any time through the account settings page.
|
||||
- Papra reserves the right to suspend or terminate accounts immediately and without notice if: (a) the user violates these Terms, (b) the account is involved in fraudulent or illegal activities, (c) the user's actions pose security or legal risks to Papra or other users.
|
||||
- Upon termination, users will lose access to all data and services. It is the user's responsibility to export data before termination.
|
||||
- Papra may terminate accounts that remain inactive for more than 12 months after providing 30 days' notice to the registered email address.
|
||||
|
||||
### 15. Force Majeure
|
||||
- Papra is not liable for any failure or delay in performance due to circumstances beyond its reasonable control, including but not limited to natural disasters, war, terrorism, riots, embargoes, acts of civil or military authorities, fire, floods, accidents, pandemics, strikes, or shortages of transportation, facilities, fuel, energy, labor, or materials.
|
||||
|
||||
### 16. Severability
|
||||
- If any provision of these Terms is found to be invalid, illegal, or unenforceable by a court of competent jurisdiction, the remaining provisions shall continue in full force and effect.
|
||||
- The invalid provision shall be modified to the minimum extent necessary to make it valid and enforceable while preserving the original intent.
|
||||
|
||||
### 17. Entire Agreement
|
||||
- These Terms, together with the Privacy Policy and any additional terms applicable to specific features, constitute the entire agreement between the user and Papra.
|
||||
- These Terms supersede all prior or contemporaneous agreements, communications, and proposals, whether oral or written, between the user and Papra.
|
||||
|
||||
### 18. Assignment
|
||||
- Users may not transfer or assign their rights or obligations under these Terms without Papra's prior written consent.
|
||||
- Papra may transfer or assign its rights and obligations under these Terms without restriction, including in connection with a merger, acquisition, reorganization, or sale of assets.
|
||||
|
||||
### 19. Export Control
|
||||
- Users agree to comply with all applicable export control laws and regulations, including those of the European Union and France.
|
||||
- Users may not use Papra in violation of any export or trade embargo, or to export or transfer documents or data to prohibited countries or individuals.
|
||||
|
||||
### 20. Contact Information
|
||||
For any questions or concerns regarding these Terms, please contact:
|
||||
- Email: contact@papra.app
|
||||
52
apps/website/src/plugins/redirects.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { AstroConfig, AstroIntegration } from 'astro';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
type Redirects = AstroConfig['redirects'];
|
||||
|
||||
function buildRedirects({ redirects }: { redirects?: Redirects }) {
|
||||
if (!redirects) {
|
||||
return;
|
||||
}
|
||||
|
||||
return Object
|
||||
.entries(redirects)
|
||||
.map(([from, to]) => {
|
||||
const toPath = typeof to === 'string' ? to : to.destination;
|
||||
const status = typeof to === 'string' ? '' : to.status;
|
||||
|
||||
return `${from} ${toPath} ${status}`.trim();
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export default function createRedirectsFile({
|
||||
fileName = '_redirects',
|
||||
redirects = {},
|
||||
}: {
|
||||
fileName?: string;
|
||||
redirects?: Redirects;
|
||||
} = {}): AstroIntegration {
|
||||
let config: AstroConfig;
|
||||
|
||||
return {
|
||||
name: 'create-redirects-file',
|
||||
hooks: {
|
||||
'astro:config:done': async (args) => {
|
||||
config = args.config;
|
||||
},
|
||||
'astro:build:done': async ({ dir, logger }) => {
|
||||
const filePath = fileURLToPath(new URL(fileName, dir));
|
||||
const fileContent = buildRedirects({ redirects: redirects ?? config?.redirects });
|
||||
|
||||
if (!fileContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
await writeFile(filePath, fileContent);
|
||||
|
||||
logger.info(`${fileName} file created`);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
71
apps/website/src/socials.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { Translator } from './i18n/i18n';
|
||||
import { useI18n } from './i18n/i18n';
|
||||
|
||||
export const GITHUB_REPO_URL = 'https://github.com/papra-hq/papra';
|
||||
export const GITHUB_ISSUES_URL = `${GITHUB_REPO_URL}/issues`;
|
||||
export const DISCORD_INVITE_URL = 'https://papra.app/discord';
|
||||
|
||||
export function getSocials({
|
||||
t = useI18n().t,
|
||||
}: {
|
||||
t?: Translator;
|
||||
} = {}) {
|
||||
return [
|
||||
{
|
||||
id: 'github',
|
||||
name: t('socials.github.name'),
|
||||
label: t('socials.github.label'),
|
||||
url: GITHUB_REPO_URL,
|
||||
icon: 'i-tabler-brand-github',
|
||||
inHeader: true,
|
||||
},
|
||||
{
|
||||
id: 'discord',
|
||||
name: t('socials.discord.name'),
|
||||
label: t('socials.discord.label'),
|
||||
url: DISCORD_INVITE_URL,
|
||||
icon: 'i-tabler-brand-discord',
|
||||
inHeader: true,
|
||||
},
|
||||
{
|
||||
id: 'bluesky',
|
||||
name: t('socials.bluesky.name'),
|
||||
label: t('socials.bluesky.label'),
|
||||
url: 'https://bsky.app/profile/papra.app',
|
||||
icon: 'i-tabler-brand-bluesky',
|
||||
inHeader: true,
|
||||
},
|
||||
{
|
||||
id: 'mastodon',
|
||||
name: t('socials.mastodon.name'),
|
||||
label: t('socials.mastodon.label'),
|
||||
url: 'https://mastodon.social/@papra',
|
||||
icon: 'i-tabler-brand-mastodon',
|
||||
inHeader: true,
|
||||
},
|
||||
{
|
||||
id: 'x',
|
||||
name: t('socials.x.name'),
|
||||
label: t('socials.x.label'),
|
||||
url: 'https://x.com/papra_app',
|
||||
icon: 'i-tabler-brand-x',
|
||||
inHeader: true,
|
||||
},
|
||||
{
|
||||
id: 'reddit',
|
||||
name: t('socials.reddit.name'),
|
||||
label: t('socials.reddit.label'),
|
||||
url: 'https://www.reddit.com/r/Papra/',
|
||||
icon: 'i-tabler-brand-reddit',
|
||||
inHeader: false,
|
||||
},
|
||||
{
|
||||
id: 'linkedin',
|
||||
name: t('socials.linkedin.name'),
|
||||
label: t('socials.linkedin.label'),
|
||||
url: 'https://www.linkedin.com/company/papra-hq',
|
||||
icon: 'i-tabler-brand-linkedin',
|
||||
inHeader: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
169
apps/website/src/styles/app.css
Normal file
@@ -0,0 +1,169 @@
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 3.9%;
|
||||
|
||||
--card: 0 0% 98%;
|
||||
--card-foreground: 0 0% 3.9%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 3.9%;
|
||||
|
||||
--primary: 16 99% 65%;
|
||||
--primary-foreground: 0 0% 3.9%;
|
||||
|
||||
--secondary: 0 0% 96.1%;
|
||||
--secondary-foreground: 0 0% 9%;
|
||||
|
||||
--muted: 0 0% 96.1%;
|
||||
--muted-foreground: 0 0% 45.1%;
|
||||
|
||||
--accent: 0 0% 96.1%;
|
||||
--accent-foreground: 0 0% 9%;
|
||||
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
|
||||
--warning: 31 98% 50%;
|
||||
--warning-foreground: 0 0% 98%;
|
||||
|
||||
--border: 0 0% 89.8%;
|
||||
--input: 0 0% 89.8%;
|
||||
--ring: 0 0% 3.9%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
[data-kb-theme="dark"] {
|
||||
--background: 240 4% 10%;
|
||||
--foreground: 0 0% 98%;
|
||||
|
||||
--card: 240 4% 8%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
|
||||
--popover: 240 4% 8%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
|
||||
--primary: 77 100% 74%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
|
||||
--accent: 0 0% 14.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
|
||||
--warning: 31 98% 50%;
|
||||
--warning-foreground: 0 0% 98%;
|
||||
|
||||
--border: 0 0% 14.9%;
|
||||
--input: 0 0% 14.9%;
|
||||
--ring: 0 0% 83.1%;
|
||||
}
|
||||
|
||||
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
|
||||
|
||||
/* inspired by Astro blog https://github.com/withastro/astro.build/blob/cc7df140766a6c60ec54284593ea8ea940604dd5/src/styles/prose.css */
|
||||
@layer components {
|
||||
.prose {
|
||||
@apply mx-auto w-full tracking-wide lg:text-lg;
|
||||
line-height: 1.75em;
|
||||
}
|
||||
|
||||
.prose > p,
|
||||
.prose > blockquote > p {
|
||||
@apply my-4;
|
||||
}
|
||||
.prose > blockquote {
|
||||
padding-left: 2rem;
|
||||
border-left: 1px solid;
|
||||
}
|
||||
|
||||
.prose > hr {
|
||||
@apply my-8;
|
||||
}
|
||||
|
||||
.prose > img {
|
||||
@apply my-4;
|
||||
}
|
||||
|
||||
.prose strong {
|
||||
@apply font-medium text-white;
|
||||
}
|
||||
|
||||
.prose ul {
|
||||
@apply my-4 pl-8;
|
||||
}
|
||||
|
||||
.prose ol {
|
||||
@apply my-4 list-inside list-decimal pl-4;
|
||||
}
|
||||
.prose ol ::marker {
|
||||
@apply font-mono inline-block font-bold text-muted-foreground;
|
||||
}
|
||||
|
||||
/* Workaround for when li first node is wrapped in a p */
|
||||
.prose li > p:first-child {
|
||||
@apply inline-block;
|
||||
}
|
||||
|
||||
.prose li {
|
||||
@apply my-0.5;
|
||||
}
|
||||
|
||||
.prose :where(a) {
|
||||
@apply text-primary! underline underline-offset-3 hover:text-foreground! transition-colors;
|
||||
}
|
||||
|
||||
.prose :where(code):not(:where(pre, h1, h2, h3, h4, h5, h6) code) {
|
||||
@apply mx-0.5! inline-block rounded-md! bg-muted! px-2 align-baseline text-sm! leading-6!;
|
||||
}
|
||||
|
||||
.prose :where(:not(pre) > code):not(:where(.not-prose,.not-prose *))::before,
|
||||
.prose :where(:not(pre) > code):not(:where(.not-prose,.not-prose *))::after {
|
||||
@apply content-['']!;
|
||||
}
|
||||
|
||||
.prose > p a > code {
|
||||
@apply text-inherit;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
|
||||
.prose .expressive-code {
|
||||
@apply my-6;
|
||||
}
|
||||
|
||||
.prose table {
|
||||
@apply overflow-auto border-spacing-0 text-sm sm:text-base w-full;
|
||||
}
|
||||
.prose tr {
|
||||
@apply w-full;
|
||||
}
|
||||
.prose :is(th, td) {
|
||||
@apply border-b py-2 px-4 align-baseline;
|
||||
}
|
||||
.prose :is(th, td):first-child {
|
||||
@apply pl-0;
|
||||
}
|
||||
.prose :is(th, td):last-child {
|
||||
@apply pr-0;
|
||||
}
|
||||
.prose th {
|
||||
@apply text-white font-medium;
|
||||
}
|
||||
.prose th:not([align]) {
|
||||
@apply text-start;
|
||||
}
|
||||
}
|
||||
5
apps/website/src/utils/cn.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { ClassValue } from 'clsx';
|
||||
import clsx from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export const cn = (...classLists: ClassValue[]) => twMerge(clsx(classLists));
|
||||
13
apps/website/src/utils/urls.test.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { formatCanonicalUrl } from './urls';
|
||||
|
||||
describe('urls', () => {
|
||||
describe('formatCanonicalUrl', () => {
|
||||
test('canonical URLs should have a trailing slash unless there are query params', () => {
|
||||
expect(formatCanonicalUrl('https://example.com')).toBe('https://example.com/');
|
||||
expect(formatCanonicalUrl('https://example.com/')).toBe('https://example.com/');
|
||||
expect(formatCanonicalUrl('https://example.com?foo=bar')).toBe('https://example.com?foo=bar');
|
||||
expect(formatCanonicalUrl('https://example.com?foo=bar/')).toBe('https://example.com?foo=bar');
|
||||
});
|
||||
});
|
||||
});
|
||||