initial version (alpha)
This commit is contained in:
commit
d16d7a128f
57 changed files with 11087 additions and 0 deletions
21
.githooks/pre-commit
Executable file
21
.githooks/pre-commit
Executable file
|
@ -0,0 +1,21 @@
|
||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
clear
|
||||||
|
|
||||||
|
echo "Running Biome format..."
|
||||||
|
bunx @biomejs/biome format --write
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "❌ Biome format failed. Commit rejected."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Running Biome check on staged files..."
|
||||||
|
bunx @biomejs/biome check --staged --error-on-warnings --no-errors-on-unmatched
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ All checks passed. Proceeding with commit."
|
||||||
|
exit 0
|
34
.gitignore
vendored
Normal file
34
.gitignore
vendored
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
# dependencies (bun install)
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# output
|
||||||
|
out
|
||||||
|
dist
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# code coverage
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# logs
|
||||||
|
logs
|
||||||
|
_.log
|
||||||
|
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# caches
|
||||||
|
.eslintcache
|
||||||
|
.cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# IntelliJ based IDEs
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Finder (MacOS) folder config
|
||||||
|
.DS_Store
|
7
.zed/settings.json
Normal file
7
.zed/settings.json
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
// Folder-specific settings
|
||||||
|
//
|
||||||
|
// For a full list of overridable settings, and general information on folder-specific settings,
|
||||||
|
// see the documentation: https://zed.dev/docs/configuring-zed#settings-files
|
||||||
|
{
|
||||||
|
"format_on_save": "on"
|
||||||
|
}
|
650
LICENSE.md
Normal file
650
LICENSE.md
Normal file
|
@ -0,0 +1,650 @@
|
||||||
|
# GNU Affero General Public License
|
||||||
|
|
||||||
|
_Version 3, 19 November 2007_
|
||||||
|
_Copyright © 2007 Free Software Foundation, Inc. <<http://fsf.org/>>_
|
||||||
|
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
## Preamble
|
||||||
|
|
||||||
|
The GNU 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 <http://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
|
||||||
|
<<http://www.gnu.org/licenses/>>.
|
113
README.md
Normal file
113
README.md
Normal file
|
@ -0,0 +1,113 @@
|
||||||
|
# 🕊️ Eve Lite
|
||||||
|
|
||||||
|
> A lightweight, encrypted Nostr relay that puts your community's privacy first
|
||||||
|
|
||||||
|
Eve Lite is your personal gateway to secure, decentralized communities It creates encrypted "Closed Community Networks" (CCNs) where your messages stay truly private, from everyone.
|
||||||
|
|
||||||
|
## ✨ What makes Eve Lite special?
|
||||||
|
|
||||||
|
**Privacy by Design**: Unlike traditional Nostr relays where your messages are visible to everyone, Eve Lite encrypts everything. If someone gains access to the data, they'll only see encrypted gibberish.
|
||||||
|
|
||||||
|
**Extensible with Arxlets**: Think of Arxlets as mini-apps that run within your CCN. Want custom functionality? Write an Arxlet. Need to share functionality? Publish it for others to use.
|
||||||
|
|
||||||
|
**Dead Simple Setup**: No complex configurations or database management. Eve Lite handles the heavy lifting so you can focus on communicating.
|
||||||
|
|
||||||
|
**Invite-Only Networks**: Create closed communities with your friends, family, or team. Share invite codes to bring people into your private space.
|
||||||
|
|
||||||
|
## 🏗️ How it works
|
||||||
|
|
||||||
|
### Closed Community Networks (CCNs)
|
||||||
|
|
||||||
|
A CCN is like your own private island in the Nostr ocean. Each CCN has:
|
||||||
|
|
||||||
|
- **Unique encryption keys** - Messages are encrypted with network-specific keys
|
||||||
|
- **Local relay instance** - Each member's own strfry relay runs locally on port 6942
|
||||||
|
- **Persistent storage** - All messages are stored locally on each member's device and encrypted
|
||||||
|
|
||||||
|
### The Magic Behind the Scenes
|
||||||
|
|
||||||
|
1. **Encrypted Message Flow**: When you send a message, Eve Lite encrypts it before sending to remote relays
|
||||||
|
2. **Smart Decryption**: Incoming encrypted messages are automatically decrypted and stored locally
|
||||||
|
3. **Dual Relay System**: Remote encrypted storage + local decrypted access = best of both worlds
|
||||||
|
4. **Event Deduplication**: Smart caching prevents processing the same event twice
|
||||||
|
|
||||||
|
### Arxlets: Your Extensibility Layer
|
||||||
|
|
||||||
|
Arxlets are JavaScript-based apps that add functionality to your CCN:
|
||||||
|
|
||||||
|
- Stored as Nostr events (kind 30420)
|
||||||
|
- Compressed with Brotli for efficiency
|
||||||
|
- Can be shared across networks
|
||||||
|
- Perfect for custom UI components, utilities, or integrations
|
||||||
|
|
||||||
|
## 🔧 API Reference
|
||||||
|
|
||||||
|
Eve Lite exposes a RESTful API for building applications:
|
||||||
|
|
||||||
|
### CCN Management
|
||||||
|
|
||||||
|
- `GET /api/ccns` - List all CCNs
|
||||||
|
- `POST /api/ccns/new` - Create a new CCN
|
||||||
|
- `POST /api/ccns/join` - Join an existing CCN
|
||||||
|
- `GET /api/ccns/active` - Get the active CCN
|
||||||
|
- `POST /api/ccns/active` - Switch active CCN
|
||||||
|
|
||||||
|
### Events & Messages
|
||||||
|
|
||||||
|
- `POST /api/events` - Query events with filters
|
||||||
|
- `PUT /api/events` - Publish a new event
|
||||||
|
- `GET /api/events/:id` - Get specific event
|
||||||
|
- `POST /api/sign` - Sign an event with your key
|
||||||
|
|
||||||
|
### Profiles & Identity
|
||||||
|
|
||||||
|
- `GET /api/avatars/:pubkey` - Get user avatar
|
||||||
|
- `GET /api/profile/:pubkey` - Get user profile
|
||||||
|
- `GET /api/pubkey` - Get your public key
|
||||||
|
|
||||||
|
### Arxlets
|
||||||
|
|
||||||
|
- `GET /api/arxlets` - List installed Arxlets
|
||||||
|
- `GET /api/arxlets/:id` - Get specific Arxlet
|
||||||
|
- `GET /api/arxlets-available` - Browse available Arxlets
|
||||||
|
|
||||||
|
## 🛠️ Development
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun test
|
||||||
|
bun test --watch # Watch mode
|
||||||
|
```
|
||||||
|
|
||||||
|
### Code Quality
|
||||||
|
|
||||||
|
This project uses Biome for linting and formatting:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bunx @biomejs/biome check
|
||||||
|
bunx @biomejs/biome format --write
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
Found a bug? Have a feature idea? Contributions are welcome!
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create a feature branch: `git checkout -b my-cool-feature`
|
||||||
|
3. Make your changes and add tests
|
||||||
|
4. Submit a pull request
|
||||||
|
|
||||||
|
## 📝 License
|
||||||
|
|
||||||
|
AGPLv3. See LICENSE.md for details.
|
||||||
|
|
||||||
|
## 🙋♀️ Questions?
|
||||||
|
|
||||||
|
- Check out the [Arxlet documentation](/docs/arxlets) for extending functionality
|
||||||
|
- Browse the source code - it's well-documented and approachable
|
||||||
|
- Open an issue if you find bugs or have suggestions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Built with ❤️ for privacy-conscious humans who believe communication should be truly private._
|
BIN
assets/logo.png
Normal file
BIN
assets/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 737 KiB |
38
biome.json
Normal file
38
biome.json
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/2.2.2/schema.json",
|
||||||
|
"vcs": {
|
||||||
|
"enabled": true,
|
||||||
|
"clientKind": "git",
|
||||||
|
"useIgnoreFile": true
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"ignoreUnknown": false,
|
||||||
|
"includes": ["**", "!src/pages/home/home.css", "!src/pages/**/highlight"]
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true,
|
||||||
|
"indentStyle": "space",
|
||||||
|
"indentWidth": 2,
|
||||||
|
"formatWithErrors": true,
|
||||||
|
"lineWidth": 120,
|
||||||
|
"lineEnding": "lf"
|
||||||
|
},
|
||||||
|
"assist": { "actions": { "source": { "organizeImports": "on" } } },
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"recommended": true,
|
||||||
|
"style": {
|
||||||
|
"noNonNullAssertion": "off"
|
||||||
|
},
|
||||||
|
"a11y": {
|
||||||
|
"noSvgWithoutTitle": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"javascript": {
|
||||||
|
"formatter": {
|
||||||
|
"quoteStyle": "double"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
164
bun.lock
Normal file
164
bun.lock
Normal file
|
@ -0,0 +1,164 @@
|
||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "eve-lite",
|
||||||
|
"dependencies": {
|
||||||
|
"@dicebear/collection": "^9.2.4",
|
||||||
|
"@dicebear/core": "^9.2.4",
|
||||||
|
"@noble/ciphers": "^1.3.0",
|
||||||
|
"@scure/base": "^2.0.0",
|
||||||
|
"bun-plugin-tailwind": "^0.0.15",
|
||||||
|
"daisyui": "^5.1.10",
|
||||||
|
"nostr-tools": "^2.16.2",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "^2.2.4",
|
||||||
|
"@types/bun": "^1.2.21",
|
||||||
|
"preact": "^10.27.1",
|
||||||
|
"prism-svelte": "^0.5.0",
|
||||||
|
"prismjs": "^1.30.0",
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5.9.2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@biomejs/biome": ["@biomejs/biome@2.2.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.2.4", "@biomejs/cli-darwin-x64": "2.2.4", "@biomejs/cli-linux-arm64": "2.2.4", "@biomejs/cli-linux-arm64-musl": "2.2.4", "@biomejs/cli-linux-x64": "2.2.4", "@biomejs/cli-linux-x64-musl": "2.2.4", "@biomejs/cli-win32-arm64": "2.2.4", "@biomejs/cli-win32-x64": "2.2.4" }, "bin": { "biome": "bin/biome" } }, "sha512-TBHU5bUy/Ok6m8c0y3pZiuO/BZoY/OcGxoLlrfQof5s8ISVwbVBdFINPQZyFfKwil8XibYWb7JMwnT8wT4WVPg=="],
|
||||||
|
|
||||||
|
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RJe2uiyaloN4hne4d2+qVj3d3gFJFbmrr5PYtkkjei1O9c+BjGXgpUPVbi8Pl8syumhzJjFsSIYkcLt2VlVLMA=="],
|
||||||
|
|
||||||
|
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-cFsdB4ePanVWfTnPVaUX+yr8qV8ifxjBKMkZwN7gKb20qXPxd/PmwqUH8mY5wnM9+U0QwM76CxFyBRJhC9tQwg=="],
|
||||||
|
|
||||||
|
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-M/Iz48p4NAzMXOuH+tsn5BvG/Jb07KOMTdSVwJpicmhN309BeEyRyQX+n1XDF0JVSlu28+hiTQ2L4rZPvu7nMw=="],
|
||||||
|
|
||||||
|
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-7TNPkMQEWfjvJDaZRSkDCPT/2r5ESFPKx+TEev+I2BXDGIjfCZk2+b88FOhnJNHtksbOZv8ZWnxrA5gyTYhSsQ=="],
|
||||||
|
|
||||||
|
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-orr3nnf2Dpb2ssl6aihQtvcKtLySLta4E2UcXdp7+RTa7mfJjBgIsbS0B9GC8gVu0hjOu021aU8b3/I1tn+pVQ=="],
|
||||||
|
|
||||||
|
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-m41nFDS0ksXK2gwXL6W6yZTYPMH0LughqbsxInSKetoH6morVj43szqKx79Iudkp8WRT5SxSh7qVb8KCUiewGg=="],
|
||||||
|
|
||||||
|
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-NXnfTeKHDFUWfxAefa57DiGmu9VyKi0cDqFpdI+1hJWQjGJhJutHPX0b5m+eXvTKOaf+brU+P0JrQAZMb5yYaQ=="],
|
||||||
|
|
||||||
|
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-3Y4V4zVRarVh/B/eSHczR4LYoSVyv3Dfuvm3cWs5w/HScccS0+Wt/lHOcDTRYeHjQmMYVC3rIRWqyN2EI52+zg=="],
|
||||||
|
|
||||||
|
"@dicebear/adventurer": ["@dicebear/adventurer@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-Xvboay3VH1qe7lH17T+bA3qPawf5EjccssDiyhCX/VT0P21c65JyjTIUJV36Nsv08HKeyDscyP0kgt9nPTRKvA=="],
|
||||||
|
|
||||||
|
"@dicebear/adventurer-neutral": ["@dicebear/adventurer-neutral@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-I9IrB4ZYbUHSOUpWoUbfX3vG8FrjcW8htoQ4bEOR7TYOKKE11Mo1nrGMuHZ7GPfwN0CQeK1YVJhWqLTmtYn7Pg=="],
|
||||||
|
|
||||||
|
"@dicebear/avataaars": ["@dicebear/avataaars@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-QKNBtA/1QGEzR+JjS4XQyrFHYGbzdOp0oa6gjhGhUDrMegDFS8uyjdRfDQsFTebVkyLWjgBQKZEiDqKqHptB6A=="],
|
||||||
|
|
||||||
|
"@dicebear/avataaars-neutral": ["@dicebear/avataaars-neutral@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-HtBvA7elRv50QTOOsBdtYB1GVimCpGEDlDgWsu1snL5Z3d1+3dIESoXQd3mXVvKTVT8Z9ciA4TEaF09WfxDjAA=="],
|
||||||
|
|
||||||
|
"@dicebear/big-ears": ["@dicebear/big-ears@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-U33tbh7Io6wG6ViUMN5fkWPER7hPKMaPPaYgafaYQlCT4E7QPKF2u8X1XGag3jCKm0uf4SLXfuZ8v+YONcHmNQ=="],
|
||||||
|
|
||||||
|
"@dicebear/big-ears-neutral": ["@dicebear/big-ears-neutral@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-pPjYu80zMFl43A9sa5+tAKPkhp4n9nd7eN878IOrA1HAowh/XePh5JN8PTkNFS9eM+rnN9m8WX08XYFe30kLYw=="],
|
||||||
|
|
||||||
|
"@dicebear/big-smile": ["@dicebear/big-smile@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-zeEfXOOXy7j9tfkPLzfQdLBPyQsctBetTdEfKRArc1k3RUliNPxfJG9j88+cXQC6GXrVW2pcT2X50NSPtugCFQ=="],
|
||||||
|
|
||||||
|
"@dicebear/bottts": ["@dicebear/bottts@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-4CTqrnVg+NQm6lZ4UuCJish8gGWe8EqSJrzvHQRO5TEyAKjYxbTdVqejpkycG1xkawha4FfxsYgtlSx7UwoVMw=="],
|
||||||
|
|
||||||
|
"@dicebear/bottts-neutral": ["@dicebear/bottts-neutral@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-eMVdofdD/udHsKIaeWEXShDRtiwk7vp4FjY7l0f79vIzfhkIsXKEhPcnvHKOl/yoArlDVS3Uhgjj0crWTO9RJA=="],
|
||||||
|
|
||||||
|
"@dicebear/collection": ["@dicebear/collection@9.2.4", "", { "dependencies": { "@dicebear/adventurer": "9.2.4", "@dicebear/adventurer-neutral": "9.2.4", "@dicebear/avataaars": "9.2.4", "@dicebear/avataaars-neutral": "9.2.4", "@dicebear/big-ears": "9.2.4", "@dicebear/big-ears-neutral": "9.2.4", "@dicebear/big-smile": "9.2.4", "@dicebear/bottts": "9.2.4", "@dicebear/bottts-neutral": "9.2.4", "@dicebear/croodles": "9.2.4", "@dicebear/croodles-neutral": "9.2.4", "@dicebear/dylan": "9.2.4", "@dicebear/fun-emoji": "9.2.4", "@dicebear/glass": "9.2.4", "@dicebear/icons": "9.2.4", "@dicebear/identicon": "9.2.4", "@dicebear/initials": "9.2.4", "@dicebear/lorelei": "9.2.4", "@dicebear/lorelei-neutral": "9.2.4", "@dicebear/micah": "9.2.4", "@dicebear/miniavs": "9.2.4", "@dicebear/notionists": "9.2.4", "@dicebear/notionists-neutral": "9.2.4", "@dicebear/open-peeps": "9.2.4", "@dicebear/personas": "9.2.4", "@dicebear/pixel-art": "9.2.4", "@dicebear/pixel-art-neutral": "9.2.4", "@dicebear/rings": "9.2.4", "@dicebear/shapes": "9.2.4", "@dicebear/thumbs": "9.2.4" }, "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-I1wCUp0yu5qSIeMQHmDYXQIXKkKjcja/SYBxppPkYFXpR2alxb0k9/swFDdMbkY6a1c9AT1kI1y+Pg6ywQ2rTA=="],
|
||||||
|
|
||||||
|
"@dicebear/core": ["@dicebear/core@9.2.4", "", { "dependencies": { "@types/json-schema": "^7.0.11" } }, "sha512-hz6zArEcUwkZzGOSJkWICrvqnEZY7BKeiq9rqKzVJIc1tRVv0MkR0FGvIxSvXiK9TTIgKwu656xCWAGAl6oh+w=="],
|
||||||
|
|
||||||
|
"@dicebear/croodles": ["@dicebear/croodles@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-CqT0NgVfm+5kd+VnjGY4WECNFeOrj5p7GCPTSEA7tCuN72dMQOX47P9KioD3wbExXYrIlJgOcxNrQeb/FMGc3A=="],
|
||||||
|
|
||||||
|
"@dicebear/croodles-neutral": ["@dicebear/croodles-neutral@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-8vAS9lIEKffSUVx256GSRAlisB8oMX38UcPWw72venO/nitLVsyZ6hZ3V7eBdII0Onrjqw1RDndslQODbVcpTw=="],
|
||||||
|
|
||||||
|
"@dicebear/dylan": ["@dicebear/dylan@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-tiih1358djAq0jDDzmW3N3S4C3ynC2yn4hhlTAq/MaUAQtAi47QxdHdFGdxH0HBMZKqA4ThLdVk3yVgN4xsukg=="],
|
||||||
|
|
||||||
|
"@dicebear/fun-emoji": ["@dicebear/fun-emoji@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-Od729skczse1HvHekgEFv+mSuJKMC4sl5hENGi/izYNe6DZDqJrrD0trkGT/IVh/SLXUFbq1ZFY9I2LoUGzFZg=="],
|
||||||
|
|
||||||
|
"@dicebear/glass": ["@dicebear/glass@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-5lxbJode1t99eoIIgW0iwZMoZU4jNMJv/6vbsgYUhAslYFX5zP0jVRscksFuo89TTtS7YKqRqZAL3eNhz4bTDw=="],
|
||||||
|
|
||||||
|
"@dicebear/icons": ["@dicebear/icons@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-bRsK1qj8u9Z76xs8XhXlgVr/oHh68tsHTJ/1xtkX9DeTQTSamo2tS26+r231IHu+oW3mePtFnwzdG9LqEPRd4A=="],
|
||||||
|
|
||||||
|
"@dicebear/identicon": ["@dicebear/identicon@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-R9nw/E8fbu9HltHOqI9iL/o9i7zM+2QauXWMreQyERc39oGR9qXiwgBxsfYGcIS4C85xPyuL5B3I2RXrLBlJPg=="],
|
||||||
|
|
||||||
|
"@dicebear/initials": ["@dicebear/initials@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-4SzHG5WoQZl1TGcpEZR4bdsSkUVqwNQCOwWSPAoBJa3BNxbVsvL08LF7I97BMgrCoknWZjQHUYt05amwTPTKtg=="],
|
||||||
|
|
||||||
|
"@dicebear/lorelei": ["@dicebear/lorelei@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-eS4mPYUgDpo89HvyFAx/kgqSSKh8W4zlUA8QJeIUCWTB0WpQmeqkSgIyUJjGDYSrIujWi+zEhhckksM5EwW0Dg=="],
|
||||||
|
|
||||||
|
"@dicebear/lorelei-neutral": ["@dicebear/lorelei-neutral@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-bWq2/GonbcJULtT+B/MGcM2UnA7kBQoH+INw8/oW83WI3GNTZ6qEwe3/W4QnCgtSOhUsuwuiSULguAFyvtkOZQ=="],
|
||||||
|
|
||||||
|
"@dicebear/micah": ["@dicebear/micah@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-XNWJ8Mx+pncIV8Ye0XYc/VkMiax8kTxcP3hLTC5vmELQyMSLXzg/9SdpI+W/tCQghtPZRYTT3JdY9oU9IUlP2g=="],
|
||||||
|
|
||||||
|
"@dicebear/miniavs": ["@dicebear/miniavs@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-k7IYTAHE/4jSO6boMBRrNlqPT3bh7PLFM1atfe0nOeCDwmz/qJUBP3HdONajbf3fmo8f2IZYhELrNWTOE7Ox3Q=="],
|
||||||
|
|
||||||
|
"@dicebear/notionists": ["@dicebear/notionists@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-zcvpAJ93EfC0xQffaPZQuJPShwPhnu9aTcoPsaYGmw0oEDLcv2XYmDhUUdX84QYCn6LtCZH053rHLVazRW+OGw=="],
|
||||||
|
|
||||||
|
"@dicebear/notionists-neutral": ["@dicebear/notionists-neutral@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-fskWzBVxQzJhCKqY24DGZbYHSBaauoRa1DgXM7+7xBuksH7mfbTmZTvnUAsAqJYBkla8IPb4ERKduDWtlWYYjQ=="],
|
||||||
|
|
||||||
|
"@dicebear/open-peeps": ["@dicebear/open-peeps@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-s6nwdjXFsplqEI7imlsel4Gt6kFVJm6YIgtZSpry0UdwDoxUUudei5bn957j9lXwVpVUcRjJW+TuEKztYjXkKQ=="],
|
||||||
|
|
||||||
|
"@dicebear/personas": ["@dicebear/personas@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-JNim8RfZYwb0MfxW6DLVfvreCFIevQg+V225Xe5tDfbFgbcYEp4OU/KaiqqO2476OBjCw7i7/8USbv2acBhjwA=="],
|
||||||
|
|
||||||
|
"@dicebear/pixel-art": ["@dicebear/pixel-art@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-4Ao45asieswUdlCTBZqcoF/0zHR3OWUWB0Mvhlu9b1Fbc6IlPBiOfx2vsp6bnVGVnMag58tJLecx2omeXdECBQ=="],
|
||||||
|
|
||||||
|
"@dicebear/pixel-art-neutral": ["@dicebear/pixel-art-neutral@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-ZITPLD1cPN4GjKkhWi80s7e5dcbXy34ijWlvmxbc4eb/V7fZSsyRa9EDUW3QStpo+xrCJLcLR+3RBE5iz0PC/A=="],
|
||||||
|
|
||||||
|
"@dicebear/rings": ["@dicebear/rings@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-teZxELYyV2ogzgb5Mvtn/rHptT0HXo9SjUGS4A52mOwhIdHSGGU71MqA1YUzfae9yJThsw6K7Z9kzuY2LlZZHA=="],
|
||||||
|
|
||||||
|
"@dicebear/shapes": ["@dicebear/shapes@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-MhK9ZdFm1wUnH4zWeKPRMZ98UyApolf5OLzhCywfu38tRN6RVbwtBRHc/42ZwoN1JU1JgXr7hzjYucMqISHtbA=="],
|
||||||
|
|
||||||
|
"@dicebear/thumbs": ["@dicebear/thumbs@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-EL4sMqv9p2+1Xy3d8e8UxyeKZV2+cgt3X2x2RTRzEOIIhobtkL8u6lJxmJbiGbpVtVALmrt5e7gjmwqpryYDpg=="],
|
||||||
|
|
||||||
|
"@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="],
|
||||||
|
|
||||||
|
"@noble/curves": ["@noble/curves@1.2.0", "", { "dependencies": { "@noble/hashes": "1.3.2" } }, "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw=="],
|
||||||
|
|
||||||
|
"@noble/hashes": ["@noble/hashes@1.3.1", "", {}, "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA=="],
|
||||||
|
|
||||||
|
"@scure/base": ["@scure/base@2.0.0", "", {}, "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w=="],
|
||||||
|
|
||||||
|
"@scure/bip32": ["@scure/bip32@1.3.1", "", { "dependencies": { "@noble/curves": "~1.1.0", "@noble/hashes": "~1.3.1", "@scure/base": "~1.1.0" } }, "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A=="],
|
||||||
|
|
||||||
|
"@scure/bip39": ["@scure/bip39@1.2.1", "", { "dependencies": { "@noble/hashes": "~1.3.0", "@scure/base": "~1.1.0" } }, "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg=="],
|
||||||
|
|
||||||
|
"@types/bun": ["@types/bun@1.2.23", "", { "dependencies": { "bun-types": "1.2.23" } }, "sha512-le8ueOY5b6VKYf19xT3McVbXqLqmxzPXHsQT/q9JHgikJ2X22wyTW3g3ohz2ZMnp7dod6aduIiq8A14Xyimm0A=="],
|
||||||
|
|
||||||
|
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||||
|
|
||||||
|
"@types/node": ["@types/node@24.3.2", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-6L8PkB+m1SSb2kaGGFk3iXENxl8lrs7cyVl7AXH6pgdMfulDfM6yUrVdjtxdnGrLrGzzuav8fFnZMY+rcscqcA=="],
|
||||||
|
|
||||||
|
"@types/react": ["@types/react@19.1.13", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ=="],
|
||||||
|
|
||||||
|
"bun-plugin-tailwind": ["bun-plugin-tailwind@0.0.15", "", { "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-qtAXMNGG4R0UGGI8zWrqm2B7BdXqx48vunJXBPzfDOHPA5WkRUZdTSbE7TFwO4jLhYqSE23YMWsM9NhE6ovobw=="],
|
||||||
|
|
||||||
|
"bun-types": ["bun-types@1.2.23", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-R9f0hKAZXgFU3mlrA0YpE/fiDvwV0FT9rORApt2aQVWSuJDzZOyB5QLc0N/4HF57CS8IXJ6+L5E4W1bW6NS2Aw=="],
|
||||||
|
|
||||||
|
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||||
|
|
||||||
|
"daisyui": ["daisyui@5.1.25", "", {}, "sha512-LYOGVIzTCCucEFkKmdj0fxbHHPZ83fpkYD7jXYF3/7UwrUu68TtXkIdGtEXadzeqUT361hCe6cj5tBB/7mvszw=="],
|
||||||
|
|
||||||
|
"nostr-tools": ["nostr-tools@2.17.0", "", { "dependencies": { "@noble/ciphers": "^0.5.1", "@noble/curves": "1.2.0", "@noble/hashes": "1.3.1", "@scure/base": "1.1.1", "@scure/bip32": "1.3.1", "@scure/bip39": "1.2.1", "nostr-wasm": "0.1.0" }, "peerDependencies": { "typescript": ">=5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-lrvHM7cSaGhz7F0YuBvgHMoU2s8/KuThihDoOYk8w5gpVHTy0DeUCAgCN8uLGeuSl5MAWekJr9Dkfo5HClqO9w=="],
|
||||||
|
|
||||||
|
"nostr-wasm": ["nostr-wasm@0.1.0", "", {}, "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA=="],
|
||||||
|
|
||||||
|
"preact": ["preact@10.27.2", "", {}, "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg=="],
|
||||||
|
|
||||||
|
"prism-svelte": ["prism-svelte@0.5.0", "", {}, "sha512-db91Bf3pRGKDPz1lAqLFSJXeW13mulUJxhycysFpfXV5MIK7RgWWK2E5aPAa71s8TCzQUXxF5JOV42/iOs6QkA=="],
|
||||||
|
|
||||||
|
"prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="],
|
||||||
|
|
||||||
|
"typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="],
|
||||||
|
|
||||||
|
"undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="],
|
||||||
|
|
||||||
|
"@noble/curves/@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="],
|
||||||
|
|
||||||
|
"@scure/bip32/@noble/curves": ["@noble/curves@1.1.0", "", { "dependencies": { "@noble/hashes": "1.3.1" } }, "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA=="],
|
||||||
|
|
||||||
|
"@scure/bip32/@scure/base": ["@scure/base@1.1.1", "", {}, "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA=="],
|
||||||
|
|
||||||
|
"@scure/bip39/@scure/base": ["@scure/base@1.1.1", "", {}, "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA=="],
|
||||||
|
|
||||||
|
"nostr-tools/@noble/ciphers": ["@noble/ciphers@0.5.3", "", {}, "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w=="],
|
||||||
|
|
||||||
|
"nostr-tools/@scure/base": ["@scure/base@1.1.1", "", {}, "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA=="],
|
||||||
|
}
|
||||||
|
}
|
2
bunfig.toml
Normal file
2
bunfig.toml
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
[serve.static]
|
||||||
|
plugins = ["bun-plugin-tailwind"]
|
418
index.ts
Normal file
418
index.ts
Normal file
|
@ -0,0 +1,418 @@
|
||||||
|
import { adventurerNeutral } from "@dicebear/collection";
|
||||||
|
import { createAvatar } from "@dicebear/core";
|
||||||
|
import { $ } from "bun";
|
||||||
|
import { finalizeEvent, getPublicKey, type NostrEvent } from "nostr-tools";
|
||||||
|
import type { SubCloser } from "nostr-tools/abstract-pool";
|
||||||
|
import { fetchRemoteArxlets } from "./src/arxlets";
|
||||||
|
import { CCN } from "./src/ccns";
|
||||||
|
import arxletDocs from "./src/pages/docs/arxlets/arxlet-docs.html";
|
||||||
|
import homePage from "./src/pages/home/home.html";
|
||||||
|
import { getColorFromPubkey } from "./src/utils/color";
|
||||||
|
import { decryptEvent } from "./src/utils/encryption";
|
||||||
|
import { loadSeenEvents, saveSeenEvent } from "./src/utils/files";
|
||||||
|
import {
|
||||||
|
queryRemoteEvent,
|
||||||
|
queryRemoteRelays,
|
||||||
|
sendUnencryptedEventToLocalRelay,
|
||||||
|
} from "./src/utils/general";
|
||||||
|
import { DEFAULT_PERIOD_MINUTES, RollingIndex } from "./src/rollingIndex";
|
||||||
|
|
||||||
|
let currentActiveSub: SubCloser | undefined;
|
||||||
|
let currentSubInterval: ReturnType<typeof setInterval> | undefined;
|
||||||
|
|
||||||
|
async function restartCCN() {
|
||||||
|
currentActiveSub?.close();
|
||||||
|
let ccn = await CCN.getActive();
|
||||||
|
if (!ccn) {
|
||||||
|
const allCCNs = await CCN.list();
|
||||||
|
if (allCCNs.length > 0) {
|
||||||
|
await allCCNs[0]!.setActive();
|
||||||
|
ccn = allCCNs[0]!;
|
||||||
|
} else return;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleNewEvent(original: NostrEvent) {
|
||||||
|
if (!ccn) return process.exit(1);
|
||||||
|
const seenEvents = await loadSeenEvents();
|
||||||
|
if (seenEvents.includes(original.id)) return;
|
||||||
|
await saveSeenEvent(original);
|
||||||
|
const keyAtTime = ccn.getPrivateKeyAt(
|
||||||
|
RollingIndex.at(original.created_at * 1000),
|
||||||
|
);
|
||||||
|
const decrypted = await decryptEvent(original, keyAtTime);
|
||||||
|
if (seenEvents.includes(decrypted.id)) return;
|
||||||
|
await saveSeenEvent(decrypted);
|
||||||
|
await sendUnencryptedEventToLocalRelay(decrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
await $`killall -9 strfry`.nothrow().quiet();
|
||||||
|
await ccn.writeStrfryConfig();
|
||||||
|
const strfry = Bun.spawn([
|
||||||
|
"strfry",
|
||||||
|
"--config",
|
||||||
|
ccn.strfryConfigPath,
|
||||||
|
"relay",
|
||||||
|
]);
|
||||||
|
process.on("exit", () => strfry.kill());
|
||||||
|
const allKeysForCCN = ccn.allPubkeys;
|
||||||
|
function resetActiveSub() {
|
||||||
|
console.log(`Setting new subscription for ${allKeysForCCN.join(", ")}`);
|
||||||
|
currentActiveSub?.close();
|
||||||
|
currentActiveSub = queryRemoteRelays(
|
||||||
|
{ kinds: [1060], "#p": allKeysForCCN },
|
||||||
|
handleNewEvent,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
resetActiveSub();
|
||||||
|
currentSubInterval = setInterval(
|
||||||
|
() => {
|
||||||
|
resetActiveSub();
|
||||||
|
},
|
||||||
|
DEFAULT_PERIOD_MINUTES * 60 * 1000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
restartCCN();
|
||||||
|
|
||||||
|
class CorsResponse extends Response {
|
||||||
|
constructor(body?: BodyInit, init?: ResponseInit) {
|
||||||
|
super(body, init);
|
||||||
|
this.headers.set("Access-Control-Allow-Origin", "*");
|
||||||
|
this.headers.set("Access-Control-Allow-Methods", "OPTIONS, GET, POST, PUT");
|
||||||
|
this.headers.set("Access-Control-Allow-Headers", "Content-Type");
|
||||||
|
}
|
||||||
|
|
||||||
|
static override json(json: unknown, init?: ResponseInit) {
|
||||||
|
const res = Response.json(json, init);
|
||||||
|
res.headers.set("Access-Control-Allow-Origin", "*");
|
||||||
|
res.headers.set("Access-Control-Allow-Methods", "OPTIONS, GET, POST, PUT");
|
||||||
|
res.headers.set("Access-Control-Allow-Headers", "Content-Type");
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalidRequest = CorsResponse.json(
|
||||||
|
{ error: "Invalid Request" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const httpServer = Bun.serve({
|
||||||
|
routes: {
|
||||||
|
"/": homePage,
|
||||||
|
"/docs/arxlets": arxletDocs,
|
||||||
|
"/api/ccns": {
|
||||||
|
GET: async () => {
|
||||||
|
const ccns = await CCN.list();
|
||||||
|
return CorsResponse.json(ccns.map((x) => x.toPublicJson()));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/api/ccns/active": {
|
||||||
|
GET: async () => {
|
||||||
|
const ccn = await CCN.getActive();
|
||||||
|
if (!ccn)
|
||||||
|
return CorsResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
return CorsResponse.json(ccn.toPublicJson());
|
||||||
|
},
|
||||||
|
POST: async (req) => {
|
||||||
|
if (!req.body) return invalidRequest;
|
||||||
|
const body = await req.body.json();
|
||||||
|
if (!body.pubkey) return invalidRequest;
|
||||||
|
const ccn = await CCN.fromPublicKey(body.pubkey);
|
||||||
|
if (!ccn) return invalidRequest;
|
||||||
|
await ccn.setActive();
|
||||||
|
restartCCN();
|
||||||
|
return CorsResponse.json(ccn.toPublicJson());
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/api/ccns/active/invite": {
|
||||||
|
GET: async () => {
|
||||||
|
const ccn = await CCN.getActive();
|
||||||
|
if (!ccn) return invalidRequest;
|
||||||
|
const invite = await ccn.generateInvite();
|
||||||
|
return CorsResponse.json({
|
||||||
|
invite,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/api/ccns/new": {
|
||||||
|
POST: async (req) => {
|
||||||
|
if (!req.body) return invalidRequest;
|
||||||
|
const body = await req.body.json();
|
||||||
|
if (!body.name || !body.description) return invalidRequest;
|
||||||
|
const ccn = await CCN.create(body.name, body.description);
|
||||||
|
const activeCCN = await CCN.getActive();
|
||||||
|
if (!activeCCN) {
|
||||||
|
await ccn.setActive();
|
||||||
|
restartCCN();
|
||||||
|
}
|
||||||
|
return CorsResponse.json(ccn.toPublicJson());
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/api/ccns/join": {
|
||||||
|
POST: async (req) => {
|
||||||
|
if (!req.body) return invalidRequest;
|
||||||
|
const body = await req.body.json();
|
||||||
|
if (!body.name || !body.description || !body.key) return invalidRequest;
|
||||||
|
const version = body.version ? body.version : 1;
|
||||||
|
const startIndex = body.startIndex
|
||||||
|
? RollingIndex.fromHex(body.startIndex)
|
||||||
|
: RollingIndex.get();
|
||||||
|
const ccn = await CCN.join(
|
||||||
|
version,
|
||||||
|
startIndex,
|
||||||
|
body.name,
|
||||||
|
body.description,
|
||||||
|
new Uint8Array(body.key),
|
||||||
|
);
|
||||||
|
return CorsResponse.json(ccn.toPublicJson());
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/api/ccns/:pubkey": async (req) => {
|
||||||
|
const ccns = await CCN.list();
|
||||||
|
const ccnWithPubkey = ccns.find((x) => x.publicKey === req.params.pubkey);
|
||||||
|
if (!ccnWithPubkey)
|
||||||
|
return CorsResponse.json({ error: "Not Found" }, { status: 404 });
|
||||||
|
return CorsResponse.json(ccnWithPubkey.toPublicJson());
|
||||||
|
},
|
||||||
|
"/api/ccns/icon/:pubkey": async (req) => {
|
||||||
|
const pubkey = req.params.pubkey;
|
||||||
|
if (!pubkey) return invalidRequest;
|
||||||
|
const ccn = await CCN.fromPublicKey(pubkey);
|
||||||
|
if (!ccn) return invalidRequest;
|
||||||
|
const avatar = ccn.getCommunityIcon();
|
||||||
|
return new CorsResponse(avatar, {
|
||||||
|
headers: { "Content-Type": "image/svg+xml" },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
"/api/ccns/name/:pubkey": async (req) => {
|
||||||
|
const pubkey = req.params.pubkey;
|
||||||
|
if (!pubkey) return invalidRequest;
|
||||||
|
const ccn = await CCN.fromPublicKey(pubkey);
|
||||||
|
if (!ccn) return invalidRequest;
|
||||||
|
const profile = await ccn.getProfile();
|
||||||
|
return new CorsResponse(profile.name || ccn.name);
|
||||||
|
},
|
||||||
|
"/api/ccns/avatar/:pubkey": async (req) => {
|
||||||
|
const pubkey = req.params.pubkey;
|
||||||
|
if (!pubkey) return invalidRequest;
|
||||||
|
const ccn = await CCN.fromPublicKey(pubkey);
|
||||||
|
if (!ccn) return invalidRequest;
|
||||||
|
const profile = await ccn.getProfile();
|
||||||
|
if (profile.picture) return CorsResponse.redirect(profile.picture);
|
||||||
|
const avatar = ccn.getCommunityIcon();
|
||||||
|
return new CorsResponse(avatar, {
|
||||||
|
headers: { "Content-Type": "image/svg+xml" },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
"/api/profile/:pubkey": async (req) => {
|
||||||
|
const pubkey = req.params.pubkey;
|
||||||
|
if (!pubkey) return invalidRequest;
|
||||||
|
const ccn = await CCN.getActive();
|
||||||
|
if (!ccn) return invalidRequest;
|
||||||
|
|
||||||
|
const profileEvent = await ccn.getFirstEvent({
|
||||||
|
kinds: [0],
|
||||||
|
authors: [pubkey],
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
if (!profileEvent) throw "No profile";
|
||||||
|
return new CorsResponse(profileEvent.content, {
|
||||||
|
headers: { "Content-Type": "text/json" },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return CorsResponse.json(
|
||||||
|
{ error: "profile not found" },
|
||||||
|
{ headers: { "Content-Type": "text/json" }, status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/avatars/:pubkey": async (req) => {
|
||||||
|
const pubkey = req.params.pubkey;
|
||||||
|
|
||||||
|
if (!pubkey) return invalidRequest;
|
||||||
|
const ccn = await CCN.getActive();
|
||||||
|
if (!ccn) return invalidRequest;
|
||||||
|
const profileEvent = await ccn.getFirstEvent({
|
||||||
|
kinds: [0],
|
||||||
|
authors: [pubkey],
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
if (!profileEvent) throw "No profile";
|
||||||
|
const content = JSON.parse(profileEvent.content);
|
||||||
|
if (!content.picture) throw "No picture";
|
||||||
|
return CorsResponse.redirect(content.picture);
|
||||||
|
} catch {
|
||||||
|
const avatar = createAvatar(adventurerNeutral, {
|
||||||
|
seed: pubkey,
|
||||||
|
backgroundColor: [getColorFromPubkey(pubkey)],
|
||||||
|
});
|
||||||
|
return new CorsResponse(avatar.toString(), {
|
||||||
|
headers: { "Content-Type": "image/svg+xml" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/events": {
|
||||||
|
POST: async (req) => {
|
||||||
|
const ccn = await CCN.getActive();
|
||||||
|
if (!ccn) return invalidRequest;
|
||||||
|
if (!req.body) return invalidRequest;
|
||||||
|
const { search, ...query } = await req.body.json();
|
||||||
|
let events = await ccn.getEvents(query);
|
||||||
|
if (search)
|
||||||
|
events = events.filter(
|
||||||
|
(e) =>
|
||||||
|
e.content.includes(search) ||
|
||||||
|
e.tags.some((t) => t[1]?.includes(search)),
|
||||||
|
);
|
||||||
|
return CorsResponse.json(events);
|
||||||
|
},
|
||||||
|
PUT: async (req) => {
|
||||||
|
const ccn = await CCN.getActive();
|
||||||
|
if (!ccn) return invalidRequest;
|
||||||
|
if (!req.body) return invalidRequest;
|
||||||
|
const event = await req.body.json();
|
||||||
|
try {
|
||||||
|
await ccn.publish(event);
|
||||||
|
return CorsResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: "Event published successfully",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return CorsResponse.json({
|
||||||
|
success: false,
|
||||||
|
message: "Failed to publish event",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/api/events/:id": async (req) => {
|
||||||
|
const id = req.params.id;
|
||||||
|
if (!id) return invalidRequest;
|
||||||
|
const ccn = await CCN.getActive();
|
||||||
|
if (!ccn) return invalidRequest;
|
||||||
|
const event = await ccn.getFirstEvent({ ids: [id] });
|
||||||
|
if (!event)
|
||||||
|
return CorsResponse.json({ error: "Event Not Found" }, { status: 404 });
|
||||||
|
return CorsResponse.json(event);
|
||||||
|
},
|
||||||
|
"/api/sign": {
|
||||||
|
POST: async (req) => {
|
||||||
|
const ccn = await CCN.getActive();
|
||||||
|
if (!ccn) return invalidRequest;
|
||||||
|
const userKey = await ccn.getUserKey();
|
||||||
|
if (!req.body) return invalidRequest;
|
||||||
|
const event = await req.body.json();
|
||||||
|
const signedEvent = finalizeEvent(event, userKey);
|
||||||
|
return CorsResponse.json(signedEvent);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/api/pubkey": async () => {
|
||||||
|
const ccn = await CCN.getActive();
|
||||||
|
if (!ccn) return invalidRequest;
|
||||||
|
const userKey = await ccn.getUserKey();
|
||||||
|
return CorsResponse.json({ pubkey: getPublicKey(userKey) });
|
||||||
|
},
|
||||||
|
"/api/arxlets": async () => {
|
||||||
|
const ccn = await CCN.getActive();
|
||||||
|
if (!ccn) return invalidRequest;
|
||||||
|
const arxlets = await ccn.getArxlets();
|
||||||
|
return CorsResponse.json(arxlets);
|
||||||
|
},
|
||||||
|
"/api/arxlets/:id": async (req) => {
|
||||||
|
const ccn = await CCN.getActive();
|
||||||
|
if (!ccn) return invalidRequest;
|
||||||
|
const arxlet = await ccn.getArxletById(req.params.id);
|
||||||
|
if (!arxlet)
|
||||||
|
return CorsResponse.json(
|
||||||
|
{ error: "Arxlet not found" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
return CorsResponse.json(arxlet);
|
||||||
|
},
|
||||||
|
"/api/arxlets-available": async () => {
|
||||||
|
const remoteArxlets = await fetchRemoteArxlets();
|
||||||
|
return CorsResponse.json(remoteArxlets);
|
||||||
|
},
|
||||||
|
"/api/clone-remote-event/:id": async (req) => {
|
||||||
|
const ccn = await CCN.getActive();
|
||||||
|
if (!ccn) return invalidRequest;
|
||||||
|
const remoteEvent = await queryRemoteEvent(req.params.id);
|
||||||
|
if (!remoteEvent)
|
||||||
|
return CorsResponse.json({ error: "Event not found" }, { status: 404 });
|
||||||
|
await ccn.publish(remoteEvent);
|
||||||
|
return CorsResponse.json(remoteEvent);
|
||||||
|
},
|
||||||
|
"/api/reputation/:user": async (req) => {
|
||||||
|
const ccn = await CCN.getActive();
|
||||||
|
if (!ccn) return invalidRequest;
|
||||||
|
const reputation = await ccn.getReputation(req.params.user);
|
||||||
|
return CorsResponse.json({ reputation });
|
||||||
|
},
|
||||||
|
"/systemapi/timezone": {
|
||||||
|
GET: async () => {
|
||||||
|
const timezone = (
|
||||||
|
await $`timedatectl show --property=Timezone --value`.text()
|
||||||
|
).trim();
|
||||||
|
return CorsResponse.json({ timezone });
|
||||||
|
},
|
||||||
|
POST: async (req) => {
|
||||||
|
if (!req.body) return invalidRequest;
|
||||||
|
const { timezone } = await req.body.json();
|
||||||
|
await $`sudo timedatectl set-timezone ${timezone}`; // this is fine, bun escapes it
|
||||||
|
return CorsResponse.json({ timezone });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/systemapi/wifi": {
|
||||||
|
GET: async () => {
|
||||||
|
const nmcliLines = (
|
||||||
|
await $`nmcli -f ssid,bssid,mode,freq,chan,rate,signal,security,active -t dev wifi`.text()
|
||||||
|
)
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.map((l) => l.split(/(?<!\\):/));
|
||||||
|
|
||||||
|
const wifi = nmcliLines
|
||||||
|
.map(
|
||||||
|
([
|
||||||
|
ssid,
|
||||||
|
bssid,
|
||||||
|
mode,
|
||||||
|
freq,
|
||||||
|
chan,
|
||||||
|
rate,
|
||||||
|
signal,
|
||||||
|
security,
|
||||||
|
active,
|
||||||
|
]) => ({
|
||||||
|
ssid,
|
||||||
|
bssid: bssid!.replace(/\\:/g, ":"),
|
||||||
|
mode,
|
||||||
|
freq: parseInt(freq!, 10),
|
||||||
|
chan: parseInt(chan!, 10),
|
||||||
|
rate,
|
||||||
|
signal: parseInt(signal!, 10),
|
||||||
|
security,
|
||||||
|
active: active === "yes",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.filter((network) => network.ssid !== "") // filter out hidden networks
|
||||||
|
.filter((network) => network.security !== "") // filter out open networks
|
||||||
|
.sort((a, b) => (a.active ? -1 : b.active ? 1 : b.signal - a.signal));
|
||||||
|
|
||||||
|
return CorsResponse.json(wifi);
|
||||||
|
},
|
||||||
|
POST: async (req) => {
|
||||||
|
if (!req.body) return invalidRequest;
|
||||||
|
const { ssid, password } = await req.body.json();
|
||||||
|
await $`nmcli device wifi connect ${ssid} password ${password}`; // this is fine, bun escapes it
|
||||||
|
return CorsResponse.json({ ssid });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fetch() {
|
||||||
|
return new CorsResponse("Eve Lite v0.0.1");
|
||||||
|
},
|
||||||
|
port: 4269,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Listening on ${httpServer.url.host}`);
|
30
package.json
Normal file
30
package.json
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
{
|
||||||
|
"name": "eve-lite",
|
||||||
|
"module": "index.ts",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"configure-hooks": "git config core.hooksPath .githooks",
|
||||||
|
"test": "bun test",
|
||||||
|
"test:watch": "bun test --watch"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "^2.2.4",
|
||||||
|
"@types/bun": "^1.2.23",
|
||||||
|
"preact": "^10.27.2",
|
||||||
|
"prism-svelte": "^0.5.0",
|
||||||
|
"prismjs": "^1.30.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5.9.2"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@dicebear/collection": "^9.2.4",
|
||||||
|
"@dicebear/core": "^9.2.4",
|
||||||
|
"@noble/ciphers": "^1.3.0",
|
||||||
|
"@scure/base": "^2.0.0",
|
||||||
|
"bun-plugin-tailwind": "^0.0.15",
|
||||||
|
"daisyui": "^5.1.25",
|
||||||
|
"nostr-tools": "^2.17.0"
|
||||||
|
}
|
||||||
|
}
|
59
src/arxlets.ts
Normal file
59
src/arxlets.ts
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
import { brotliDecompressSync } from "node:zlib";
|
||||||
|
import type { NostrEvent } from "nostr-tools";
|
||||||
|
import { hexToBytes } from "nostr-tools/utils";
|
||||||
|
import { CCN } from "./ccns";
|
||||||
|
import { queryRemoteRelaysSync } from "./utils/general";
|
||||||
|
|
||||||
|
export interface Arxlet {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: string;
|
||||||
|
iconColor?: string;
|
||||||
|
script: string;
|
||||||
|
version?: string;
|
||||||
|
eventId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const parseArxletFromEvent = (ccn: CCN) => (event: NostrEvent) => {
|
||||||
|
if (
|
||||||
|
[
|
||||||
|
"d45d6ee247d6a323b2e081fdd5dd91c015b34284f3fef857afb28ce508844137", // old version of howl used during dev, now gets signed with different key
|
||||||
|
].includes(event.id)
|
||||||
|
)
|
||||||
|
return; // filter out known bad arxlets
|
||||||
|
if (event.pubkey === ccn.publicKey) return;
|
||||||
|
if (event.kind !== 30420) return;
|
||||||
|
if (!event.tags.some((t) => t[0] === "d")) return;
|
||||||
|
if (!event.tags.some((t) => t[0] === "name")) return;
|
||||||
|
if (!event.tags.some((t) => t[0] === "script")) return;
|
||||||
|
if (event.tags.some((t) => t[0] === "disabled" && t[1] === "true")) return;
|
||||||
|
return {
|
||||||
|
id: event.tags.find((t) => t[0] === "d")![1]!,
|
||||||
|
author: event.pubkey,
|
||||||
|
name: event.tags.find((t) => t[0] === "name")![1]!,
|
||||||
|
description: event.tags.find((t) => t[0] === "description")?.[1],
|
||||||
|
script: brotliDecompressSync(
|
||||||
|
hexToBytes(event.tags.find((t) => t[0] === "script")![1]!),
|
||||||
|
).toString(),
|
||||||
|
icon: event.tags.find((t) => t[0] === "icon")?.[1],
|
||||||
|
iconColor: event.tags.find((t) => t[0] === "icon")?.[2],
|
||||||
|
iconForegroundColor: event.tags.find((t) => t[0] === "icon")?.[3],
|
||||||
|
iconStrokeColor: event.tags.find((t) => t[0] === "icon")?.[4],
|
||||||
|
version: event.tags.find((t) => t[0] === "version")?.[1],
|
||||||
|
versionDate: event.created_at,
|
||||||
|
eventId: event.id,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchRemoteArxlets() {
|
||||||
|
const activeCCN = await CCN.getActive();
|
||||||
|
if (!activeCCN) throw new Error("No active CCN found");
|
||||||
|
const events = await queryRemoteRelaysSync({
|
||||||
|
kinds: [30420],
|
||||||
|
limit: 10_000,
|
||||||
|
});
|
||||||
|
return events
|
||||||
|
.map((x) => parseArxletFromEvent(activeCCN)(x))
|
||||||
|
.filter((x) => x !== undefined);
|
||||||
|
}
|
408
src/ccns.ts
Normal file
408
src/ccns.ts
Normal file
|
@ -0,0 +1,408 @@
|
||||||
|
import { mkdirSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { avataaars } from "@dicebear/collection";
|
||||||
|
import { createAvatar } from "@dicebear/core";
|
||||||
|
import { bech32m } from "@scure/base";
|
||||||
|
import {
|
||||||
|
type Filter,
|
||||||
|
generateSecretKey,
|
||||||
|
getPublicKey,
|
||||||
|
type NostrEvent,
|
||||||
|
nip19,
|
||||||
|
SimplePool,
|
||||||
|
} from "nostr-tools";
|
||||||
|
import { bytesToHex } from "nostr-tools/utils";
|
||||||
|
import { type Arxlet, parseArxletFromEvent } from "./arxlets";
|
||||||
|
import { getColorFromPubkey } from "./utils/color";
|
||||||
|
import { getDataDir } from "./utils/files";
|
||||||
|
import { getSvgGroup, pool, splitIntoParts } from "./utils/general";
|
||||||
|
import { write_string, write_varint } from "./utils/Uint8Array";
|
||||||
|
import { ReputationManager } from "./ccns/reputation";
|
||||||
|
import { RollingIndex } from "./rollingIndex";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
|
const LATEST_CCN_VERSION = 2;
|
||||||
|
|
||||||
|
interface ConfigValue {
|
||||||
|
[key: string]: string | number | boolean | ConfigValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifyConfig(obj: ConfigValue, indent: number = 0): string {
|
||||||
|
let result = "";
|
||||||
|
const spaces = " ".repeat(indent);
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
|
if (typeof value === "object") {
|
||||||
|
result += `${spaces}${key} {\n`;
|
||||||
|
result += stringifyConfig(value, indent + 1);
|
||||||
|
result += `${spaces}}\n`;
|
||||||
|
} else
|
||||||
|
result += `${spaces}${key} = ${typeof value === "string" ? `"${value}"` : value}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CCN {
|
||||||
|
private reputationManager: ReputationManager;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public version: number,
|
||||||
|
public startIndex: Uint8Array,
|
||||||
|
public name: string,
|
||||||
|
public description: string,
|
||||||
|
public key: Uint8Array,
|
||||||
|
) {
|
||||||
|
this.reputationManager = new ReputationManager(this.getEvents.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromJsonString(json: string) {
|
||||||
|
const data = JSON.parse(json);
|
||||||
|
return new CCN(
|
||||||
|
data.version || 1,
|
||||||
|
data.startIndex
|
||||||
|
? RollingIndex.fromHex(data.startIndex)
|
||||||
|
: RollingIndex.get(),
|
||||||
|
data.name,
|
||||||
|
data.description,
|
||||||
|
new Uint8Array(data.privateKey),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getActive() {
|
||||||
|
const activeCCNPubKey = await Bun.secrets.get({
|
||||||
|
name: "active-ccn",
|
||||||
|
service: "eve-lite",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (activeCCNPubKey) {
|
||||||
|
const ccn = await CCN.fromPublicKey(activeCCNPubKey);
|
||||||
|
if (ccn) return ccn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async list(): Promise<CCN[]> {
|
||||||
|
const indexKey = { service: "eve-lite", name: "ccn-index" };
|
||||||
|
const indexString = await Bun.secrets.get(indexKey);
|
||||||
|
if (!indexString) return [];
|
||||||
|
|
||||||
|
const index: string[] = JSON.parse(indexString);
|
||||||
|
const ccns = await Promise.all(
|
||||||
|
index.map(async (pubkey: string) => {
|
||||||
|
const ccnString = await Bun.secrets.get({
|
||||||
|
service: "eve-lite/ccn",
|
||||||
|
name: pubkey,
|
||||||
|
});
|
||||||
|
if (!ccnString) return null;
|
||||||
|
return CCN.fromJsonString(ccnString);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return ccns.filter((ccn): ccn is CCN => ccn !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async fromPublicKey(publicKey: string) {
|
||||||
|
const ccnString = await Bun.secrets.get({
|
||||||
|
service: "eve-lite/ccn",
|
||||||
|
name: publicKey,
|
||||||
|
});
|
||||||
|
if (!ccnString) return null;
|
||||||
|
return CCN.fromJsonString(ccnString);
|
||||||
|
}
|
||||||
|
|
||||||
|
getCommunityIcon() {
|
||||||
|
const positions = [
|
||||||
|
[30, 0],
|
||||||
|
[-60, 70],
|
||||||
|
[120, 70],
|
||||||
|
];
|
||||||
|
const avatars = splitIntoParts(this.publicKey, 3)
|
||||||
|
.map((part) => createAvatar(avataaars, { seed: part }).toString())
|
||||||
|
.map((avatar, index) =>
|
||||||
|
getSvgGroup(
|
||||||
|
avatar,
|
||||||
|
`translate(${positions[index]![0]}, ${positions[index]![1]}) scale(0.8)`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 280 280" fill="none" shape-rendering="auto">
|
||||||
|
<rect width="280" height="280" fill="#${getColorFromPubkey(this.publicKey)}"/>
|
||||||
|
${avatars}
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPrivateKeyAt(index: Uint8Array) {
|
||||||
|
if (this.version === 1) return this.key;
|
||||||
|
const hmac = crypto.createHmac("sha256", this.key);
|
||||||
|
hmac.update(index);
|
||||||
|
return hmac.digest();
|
||||||
|
}
|
||||||
|
|
||||||
|
getPublicKeyAt(index: Uint8Array) {
|
||||||
|
if (this.version === 1) return this.publicKey;
|
||||||
|
return getPublicKey(this.getPrivateKeyAt(index));
|
||||||
|
}
|
||||||
|
|
||||||
|
get publicKey() {
|
||||||
|
return getPublicKey(this.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
get currentPrivateKey() {
|
||||||
|
if (this.version === 1) return this.key;
|
||||||
|
return this.getPrivateKeyAt(RollingIndex.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
get currentPublicKey() {
|
||||||
|
if (this.version === 1) return this.publicKey;
|
||||||
|
return getPublicKey(this.currentPrivateKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
get allPubkeys() {
|
||||||
|
if (this.version === 1) return [this.publicKey];
|
||||||
|
const allPeriods = RollingIndex.diff(RollingIndex.get(), this.startIndex);
|
||||||
|
return allPeriods.map((index) => this.getPublicKeyAt(index));
|
||||||
|
}
|
||||||
|
|
||||||
|
private get json() {
|
||||||
|
return {
|
||||||
|
name: this.name,
|
||||||
|
description: this.description,
|
||||||
|
privateKey: [...this.key],
|
||||||
|
version: this.version,
|
||||||
|
startIndex: RollingIndex.toHex(this.startIndex),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
toPublicJson() {
|
||||||
|
return {
|
||||||
|
name: this.name,
|
||||||
|
description: this.description,
|
||||||
|
publicKey: this.publicKey,
|
||||||
|
icon: this.icon,
|
||||||
|
version: this.version,
|
||||||
|
startIndex: this.startIndex,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async setActive() {
|
||||||
|
await Bun.secrets.set({
|
||||||
|
name: "active-ccn",
|
||||||
|
service: "eve-lite",
|
||||||
|
value: this.publicKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async create(name: string, description: string) {
|
||||||
|
return CCN.join(
|
||||||
|
LATEST_CCN_VERSION,
|
||||||
|
RollingIndex.get(),
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
generateSecretKey(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async join(
|
||||||
|
ccn_version: number,
|
||||||
|
startIndex: Uint8Array,
|
||||||
|
name: string,
|
||||||
|
description: string,
|
||||||
|
privateKey: Uint8Array,
|
||||||
|
) {
|
||||||
|
const ccn = new CCN(ccn_version, startIndex, name, description, privateKey);
|
||||||
|
const indexKey = { service: "eve-lite", name: "ccn-index" };
|
||||||
|
const indexString = await Bun.secrets.get(indexKey);
|
||||||
|
const index: string[] = indexString ? JSON.parse(indexString) : [];
|
||||||
|
|
||||||
|
if (!index.includes(ccn.publicKey)) {
|
||||||
|
await Bun.secrets.set({
|
||||||
|
service: "eve-lite/ccn",
|
||||||
|
name: ccn.publicKey,
|
||||||
|
value: JSON.stringify(ccn.json),
|
||||||
|
});
|
||||||
|
index.push(ccn.publicKey);
|
||||||
|
await Bun.secrets.set({ ...indexKey, value: JSON.stringify(index) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return ccn;
|
||||||
|
}
|
||||||
|
|
||||||
|
get strfryConfigPath() {
|
||||||
|
return join(getDataDir(), "strfry", `${this.publicKey}.conf`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeStrfryConfig() {
|
||||||
|
const dbDir = join(getDataDir(), "dbs", this.publicKey);
|
||||||
|
mkdirSync(dbDir, { recursive: true });
|
||||||
|
const config = stringifyConfig({
|
||||||
|
db: dbDir,
|
||||||
|
dbParams: {
|
||||||
|
maxreaders: 256,
|
||||||
|
mapsize: 10995116277760,
|
||||||
|
noReadAhead: false,
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
maxEventSize: 1048576,
|
||||||
|
rejectEventsNewerThanSeconds: 900,
|
||||||
|
rejectEventsOlderThanSeconds: 252460800,
|
||||||
|
rejectEphemeralEventsOlderThanSeconds: 86400,
|
||||||
|
ephemeralEventsLifetimeSeconds: 172800,
|
||||||
|
maxNumTags: 2000,
|
||||||
|
maxTagValSize: 10240,
|
||||||
|
},
|
||||||
|
relay: {
|
||||||
|
bind: "127.0.0.1",
|
||||||
|
port: 6942,
|
||||||
|
nofiles: 0,
|
||||||
|
realIpHeader: "",
|
||||||
|
info: {
|
||||||
|
name: this.name,
|
||||||
|
description: this.description,
|
||||||
|
pubkey: this.publicKey,
|
||||||
|
contact: "",
|
||||||
|
icon: "",
|
||||||
|
nips: "",
|
||||||
|
},
|
||||||
|
maxWebsocketPayloadSize: 5 * 1024 * 1024,
|
||||||
|
maxReqFilterSize: 200,
|
||||||
|
autoPingSeconds: 55,
|
||||||
|
enableTcpKeepalive: false,
|
||||||
|
queryTimesliceBudgetMicroseconds: 10000,
|
||||||
|
maxFilterLimit: 100_000,
|
||||||
|
maxSubsPerConnection: 100,
|
||||||
|
writePolicy: {
|
||||||
|
plugin: "/usr/bin/eve-lite-event-plugin",
|
||||||
|
},
|
||||||
|
compression: {
|
||||||
|
enabled: true,
|
||||||
|
slidingWindow: true,
|
||||||
|
},
|
||||||
|
logging: {
|
||||||
|
dumpInAll: false,
|
||||||
|
dumpInEvents: true,
|
||||||
|
dumpInReqs: true,
|
||||||
|
dbScanPerf: true,
|
||||||
|
invalidEvents: true,
|
||||||
|
},
|
||||||
|
numThreads: {
|
||||||
|
ingester: 3,
|
||||||
|
reqWorker: 3,
|
||||||
|
reqMonitor: 3,
|
||||||
|
negentropy: 2,
|
||||||
|
},
|
||||||
|
negentropy: {
|
||||||
|
enabled: false,
|
||||||
|
maxSyncEvents: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const configFile = Bun.file(this.strfryConfigPath);
|
||||||
|
await configFile.write(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
getEvents(filter: Filter) {
|
||||||
|
const pool = new SimplePool();
|
||||||
|
return pool.querySync(["ws://localhost:6942"], filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
getFirstEvent(filter: Filter) {
|
||||||
|
const pool = new SimplePool();
|
||||||
|
return pool.get(["ws://localhost:6942"], filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getArxlets(): Promise<Arxlet[]> {
|
||||||
|
const events = await this.getEvents({
|
||||||
|
kinds: [30420],
|
||||||
|
});
|
||||||
|
return events
|
||||||
|
.map((event) => parseArxletFromEvent(this)(event))
|
||||||
|
.filter((arxlet) => arxlet !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getArxletById(id: string): Promise<Arxlet | undefined> {
|
||||||
|
const query = {
|
||||||
|
kinds: [30420],
|
||||||
|
"#d": [id],
|
||||||
|
};
|
||||||
|
if (id.includes(":")) {
|
||||||
|
const [dTag, author] = id.split(":");
|
||||||
|
query["#d"] = [dTag];
|
||||||
|
query["authors"] = [author];
|
||||||
|
}
|
||||||
|
const event = await this.getFirstEvent(query);
|
||||||
|
if (!event) return undefined;
|
||||||
|
return parseArxletFromEvent(this)(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserKey() {
|
||||||
|
const service = "eve-lite-user-key";
|
||||||
|
const name = this.publicKey;
|
||||||
|
const nsec = await Bun.secrets.get({ service, name });
|
||||||
|
if (nsec) {
|
||||||
|
const decoded = nip19.decode(nsec);
|
||||||
|
if (decoded.type !== "nsec") throw "Invalid key";
|
||||||
|
return decoded.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = generateSecretKey();
|
||||||
|
const newNsec = nip19.nsecEncode(key);
|
||||||
|
await Bun.secrets.set({ service, name, value: newNsec });
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
publish(event: NostrEvent): Promise<string[]> {
|
||||||
|
return Promise.all(pool.publish(["ws://localhost:6942"], event));
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadSeenEvents() {
|
||||||
|
const service = "eve-lite-seen";
|
||||||
|
const name = this.publicKey;
|
||||||
|
const seen = await Bun.secrets.get({
|
||||||
|
service,
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
if (!seen) return [];
|
||||||
|
return JSON.parse(seen) as string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveSeenEvent(event: NostrEvent) {
|
||||||
|
const service = "eve-lite-seen";
|
||||||
|
const name = this.publicKey;
|
||||||
|
const seenEvents = await this.loadSeenEvents();
|
||||||
|
seenEvents.push(event.id);
|
||||||
|
await Bun.secrets.set({
|
||||||
|
service,
|
||||||
|
name,
|
||||||
|
value: JSON.stringify(seenEvents),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProfile(): Promise<{ name?: string; picture?: string }> {
|
||||||
|
const profileEvent = await this.getFirstEvent({
|
||||||
|
kinds: [0],
|
||||||
|
authors: [this.publicKey],
|
||||||
|
});
|
||||||
|
if (!profileEvent) return {};
|
||||||
|
return JSON.parse(profileEvent.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateInvite() {
|
||||||
|
const INVITE_VERSION = 2;
|
||||||
|
const data: number[] = [];
|
||||||
|
const userKey = await this.getUserKey();
|
||||||
|
const userPubkey = getPublicKey(userKey);
|
||||||
|
const ccnKeyMaterial = bytesToHex(this.key);
|
||||||
|
write_varint(data, INVITE_VERSION);
|
||||||
|
write_string(data, this.name);
|
||||||
|
write_string(data, this.description);
|
||||||
|
write_string(data, userPubkey);
|
||||||
|
write_string(data, ccnKeyMaterial);
|
||||||
|
write_string(data, RollingIndex.toHex(this.startIndex));
|
||||||
|
|
||||||
|
const combinedBytes = bech32m.toWords(new Uint8Array(data));
|
||||||
|
return bech32m.encode("evelite", combinedBytes, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getReputation(userId: string): Promise<number> {
|
||||||
|
return await this.reputationManager.getReputation(userId);
|
||||||
|
}
|
||||||
|
}
|
150
src/ccns/reputation.ts
Normal file
150
src/ccns/reputation.ts
Normal file
|
@ -0,0 +1,150 @@
|
||||||
|
import { type Filter, type NostrEvent } from "nostr-tools";
|
||||||
|
|
||||||
|
interface ReputationInfo {
|
||||||
|
reputation: number;
|
||||||
|
voteCount: number;
|
||||||
|
lastEventTimestamp: number;
|
||||||
|
processedVotes: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ReputationManager {
|
||||||
|
private reputationCache = new Map<string, ReputationInfo>();
|
||||||
|
private reputationCalculationInProgress = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
|
constructor(private getEvents: (filter: Filter) => Promise<NostrEvent[]>) {}
|
||||||
|
|
||||||
|
private _updateReputation(
|
||||||
|
targetReputation: number,
|
||||||
|
targetVoteCount: number,
|
||||||
|
voteScore: number, // 1 for up, 0 for down
|
||||||
|
voterReputation: number,
|
||||||
|
scaleFactor = 400,
|
||||||
|
): number {
|
||||||
|
const ratingDiff = voterReputation - targetReputation;
|
||||||
|
|
||||||
|
const exponent = ratingDiff / scaleFactor;
|
||||||
|
|
||||||
|
const powerTerm = 10 ** exponent;
|
||||||
|
const denom = 1 + powerTerm;
|
||||||
|
const expectedScore = 1.0 / denom;
|
||||||
|
|
||||||
|
const sqrtVotes = Math.sqrt(targetVoteCount);
|
||||||
|
const denomAdjust = 1 + sqrtVotes;
|
||||||
|
const adjustmentFactor = 32.0 / denomAdjust;
|
||||||
|
|
||||||
|
const scoreDiff = voteScore - expectedScore;
|
||||||
|
return targetReputation + adjustmentFactor * scoreDiff;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runReputationBatch(initialUser: string) {
|
||||||
|
const usersToFetch = new Set([initialUser]);
|
||||||
|
const allInvolved = new Set([initialUser]);
|
||||||
|
const allNewEvents: NostrEvent[] = [];
|
||||||
|
const processedEventIds = new Set<string>();
|
||||||
|
|
||||||
|
// Phase 1: Discover users (BFS) and fetch new events.
|
||||||
|
while (usersToFetch.size > 0) {
|
||||||
|
const batch = Array.from(usersToFetch);
|
||||||
|
usersToFetch.clear();
|
||||||
|
|
||||||
|
// For any other users discovered in this batch, hook them to the main promise.
|
||||||
|
for (const u of batch) {
|
||||||
|
if (!this.reputationCalculationInProgress.has(u))
|
||||||
|
this.reputationCalculationInProgress.set(
|
||||||
|
u,
|
||||||
|
this.reputationCalculationInProgress.get(initialUser)!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventPromises = batch.map((u) => {
|
||||||
|
const since = this.reputationCache.get(u)?.lastEventTimestamp;
|
||||||
|
return this.getEvents({
|
||||||
|
kinds: [7],
|
||||||
|
"#p": [u],
|
||||||
|
since: since ? since + 1 : 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const eventSets = await Promise.all(eventPromises);
|
||||||
|
const newEvents = eventSets.flat();
|
||||||
|
|
||||||
|
for (const event of newEvents) {
|
||||||
|
if (processedEventIds.has(event.id)) continue;
|
||||||
|
processedEventIds.add(event.id);
|
||||||
|
allNewEvents.push(event);
|
||||||
|
|
||||||
|
const voter = event.pubkey;
|
||||||
|
if (!allInvolved.has(voter)) {
|
||||||
|
allInvolved.add(voter);
|
||||||
|
usersToFetch.add(voter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: Initialize cache for new users.
|
||||||
|
for (const u of allInvolved) {
|
||||||
|
if (!this.reputationCache.has(u))
|
||||||
|
this.reputationCache.set(u, {
|
||||||
|
reputation: 500,
|
||||||
|
voteCount: 0,
|
||||||
|
lastEventTimestamp: 0,
|
||||||
|
processedVotes: new Set<string>(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
allNewEvents.sort((a, b) => a.created_at - b.created_at);
|
||||||
|
|
||||||
|
for (const event of allNewEvents) {
|
||||||
|
const targetUserTag = event.tags.find((t) => t[0] === "p");
|
||||||
|
if (!targetUserTag?.[1]) continue;
|
||||||
|
const targetUser = targetUserTag[1];
|
||||||
|
|
||||||
|
if (!allInvolved.has(targetUser)) continue;
|
||||||
|
|
||||||
|
const targetUserInfo = this.reputationCache.get(targetUser)!;
|
||||||
|
const voter = event.pubkey;
|
||||||
|
|
||||||
|
if (voter === targetUser) continue;
|
||||||
|
|
||||||
|
const eTag = event.tags.find((t) => t[0] === "e");
|
||||||
|
if (!eTag?.[1]) continue;
|
||||||
|
|
||||||
|
const voteId = `${voter}:${eTag[1]}`;
|
||||||
|
if (targetUserInfo.processedVotes.has(voteId)) continue;
|
||||||
|
|
||||||
|
const voteContent = event.content;
|
||||||
|
if (voteContent !== "+" && voteContent !== "-") continue;
|
||||||
|
|
||||||
|
targetUserInfo.processedVotes.add(voteId);
|
||||||
|
const voteScore = voteContent === "+" ? 1 : 0;
|
||||||
|
targetUserInfo.voteCount++;
|
||||||
|
|
||||||
|
const voterReputation = this.reputationCache.get(voter)!.reputation;
|
||||||
|
|
||||||
|
targetUserInfo.reputation = this._updateReputation(
|
||||||
|
targetUserInfo.reputation,
|
||||||
|
targetUserInfo.voteCount,
|
||||||
|
voteScore,
|
||||||
|
voterReputation,
|
||||||
|
);
|
||||||
|
|
||||||
|
targetUserInfo.lastEventTimestamp = event.created_at;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const u of allInvolved) {
|
||||||
|
this.reputationCalculationInProgress.delete(u);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getReputation(user: string): Promise<number> {
|
||||||
|
if (this.reputationCalculationInProgress.has(user))
|
||||||
|
await this.reputationCalculationInProgress.get(user);
|
||||||
|
else {
|
||||||
|
const batchPromise = this.runReputationBatch(user);
|
||||||
|
this.reputationCalculationInProgress.set(user, batchPromise);
|
||||||
|
await batchPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.reputationCache.get(user)?.reputation ?? 500;
|
||||||
|
}
|
||||||
|
}
|
4
src/consts.ts
Normal file
4
src/consts.ts
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
export const EVENT_PLUGIN_VERSION = "0.0.1";
|
||||||
|
export const POW_TO_ACCEPT = 10;
|
||||||
|
export const POW_TO_MINE = 12;
|
||||||
|
export const CURRENT_VERSION = 0x01;
|
45
src/eventPlugin.ts
Normal file
45
src/eventPlugin.ts
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
import { CCN } from "./ccns";
|
||||||
|
import { EVENT_PLUGIN_VERSION } from "./consts";
|
||||||
|
import { RollingIndex } from "./rollingIndex";
|
||||||
|
import { createEncryptedEvent } from "./utils/encryption";
|
||||||
|
import { loadSeenEvents, saveSeenEvent } from "./utils/files";
|
||||||
|
import { sendEncryptedEventToRelays } from "./utils/general";
|
||||||
|
|
||||||
|
if (process.argv[process.argv.length - 1] === "--version") {
|
||||||
|
console.log(EVENT_PLUGIN_VERSION);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
for await (const line of console) {
|
||||||
|
const req = JSON.parse(line);
|
||||||
|
|
||||||
|
const ccn = await CCN.getActive();
|
||||||
|
if (!ccn) {
|
||||||
|
console.error("No CCN");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.type !== "new") {
|
||||||
|
console.error("unexpected request type");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seenEvents = await loadSeenEvents();
|
||||||
|
if (!seenEvents.includes(req.event.id)) {
|
||||||
|
let index = RollingIndex.at(req.event.created_at * 1000);
|
||||||
|
if (RollingIndex.compare(index, ccn.startIndex) < 0) index = ccn.startIndex;
|
||||||
|
|
||||||
|
const keyForEvent = ccn.getPrivateKeyAt(index);
|
||||||
|
const encryptedEvent = await createEncryptedEvent(req.event, keyForEvent);
|
||||||
|
await saveSeenEvent(req.event);
|
||||||
|
await saveSeenEvent(encryptedEvent);
|
||||||
|
await sendEncryptedEventToRelays(encryptedEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
JSON.stringify({
|
||||||
|
id: req.event.id,
|
||||||
|
action: "accept",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
1825
src/pages/docs/arxlets/arxlet-docs-out.html
Normal file
1825
src/pages/docs/arxlets/arxlet-docs-out.html
Normal file
File diff suppressed because it is too large
Load diff
979
src/pages/docs/arxlets/arxlet-docs.adoc
Normal file
979
src/pages/docs/arxlets/arxlet-docs.adoc
Normal file
|
@ -0,0 +1,979 @@
|
||||||
|
= Arxlets API Context
|
||||||
|
:description: Arxlets are secure, sandboxed JavaScript applications that extend Eve's functionality.
|
||||||
|
:doctype: book
|
||||||
|
:icons: font
|
||||||
|
:source-highlighter: highlight.js
|
||||||
|
:toc: left
|
||||||
|
:toclevels: 2
|
||||||
|
:sectlinks:
|
||||||
|
|
||||||
|
== Installing EveOS
|
||||||
|
|
||||||
|
sudo coreos-installer install /dev/sda --ignition-url https://arx-ccn.com/eveos.ign
|
||||||
|
|
||||||
|
// Overview Section
|
||||||
|
== Overview
|
||||||
|
|
||||||
|
Arxlets are secure, sandboxed JavaScript applications that extend Eve's functionality. They run in isolated iframes and are registered on your CCN (Closed Community Network) for member-only access. Arxlets provide a powerful way to build custom applications that interact with Nostr events and profiles through Eve.
|
||||||
|
|
||||||
|
=== Core Concepts
|
||||||
|
|
||||||
|
What are Arxlets?
|
||||||
|
- **Sandboxed Applications**: Run in isolated iframes for security
|
||||||
|
- **JavaScript-based**: Written in TypeScript/JavaScript with wasm support coming in the future
|
||||||
|
- **CCN Integration**: Registered on your Closed Community Network
|
||||||
|
- **Nostr-native**: Built-in access to Nostr protocol operations
|
||||||
|
- **Real-time**: Support for live event subscriptions and updates
|
||||||
|
|
||||||
|
NOTE: WASM support will be added in future releases for even more powerful applications.
|
||||||
|
|
||||||
|
=== CCN Local-First Architecture
|
||||||
|
|
||||||
|
CCNs (Closed Community Networks) are designed with a local-first approach that ensures data availability and functionality even when offline:
|
||||||
|
|
||||||
|
* **Local Data Storage**: All Nostr events and profiles are stored locally on your device, providing instant access without network dependencies
|
||||||
|
* **Offline Functionality**: Arxlets can read, display, and interact with locally cached data when disconnected from the internet
|
||||||
|
* **Sync When Connected**: When network connectivity is restored, the CCN automatically synchronizes with remote relays to fetch new events and propagate local changes
|
||||||
|
* **Resilient Operation**: Your applications continue to work seamlessly regardless of network conditions, making CCNs ideal for unreliable connectivity scenarios
|
||||||
|
* **Privacy by Design**: Local-first storage means your data remains on your device, reducing exposure to external services and improving privacy
|
||||||
|
|
||||||
|
=== Architecture
|
||||||
|
|
||||||
|
- **Frontend**: TypeScript applications with render functions
|
||||||
|
- **Backend**: Eve relay providing Nostr protocol access
|
||||||
|
- **Communication**: window.eve API or direct WebSocket connections
|
||||||
|
|
||||||
|
// API Reference Section
|
||||||
|
== API Reference
|
||||||
|
|
||||||
|
The primary interface for Arxlets to interact with Eve's Nostr relay. All methods return promises for async operations.
|
||||||
|
|
||||||
|
=== window.eve API
|
||||||
|
|
||||||
|
[source,typescript]
|
||||||
|
----
|
||||||
|
// Using window.eve API for Nostr operations
|
||||||
|
import type { Filter, NostrEvent } from "./types";
|
||||||
|
|
||||||
|
// Publish a new event
|
||||||
|
const event: NostrEvent = {
|
||||||
|
kind: 1,
|
||||||
|
content: "Hello from my Arxlet!",
|
||||||
|
tags: [],
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
pubkey: "your-pubkey-here",
|
||||||
|
};
|
||||||
|
|
||||||
|
await window.eve.publish(event);
|
||||||
|
|
||||||
|
// Get a specific event by ID
|
||||||
|
const eventId = "event-id-here";
|
||||||
|
const event = await window.eve.getSingleEventById(eventId);
|
||||||
|
|
||||||
|
// Query events with a filter
|
||||||
|
const filter: Filter = {
|
||||||
|
kinds: [1],
|
||||||
|
authors: ["pubkey-here"],
|
||||||
|
limit: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
const singleEvent = await window.eve.getSingleEventWithFilter(filter);
|
||||||
|
const allEvents = await window.eve.getAllEventsWithFilter(filter);
|
||||||
|
|
||||||
|
// Real-time subscription with RxJS Observable
|
||||||
|
const subscription = window.eve.subscribeToEvents(filter).subscribe({
|
||||||
|
next: (event) => {
|
||||||
|
console.log("New event received:", event);
|
||||||
|
// Update your UI with the new event
|
||||||
|
},
|
||||||
|
error: (err) => console.error("Subscription error:", err),
|
||||||
|
complete: () => console.log("Subscription completed"),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subscribe to profile updates for a specific user
|
||||||
|
const profileSubscription = window.eve.subscribeToProfile(pubkey).subscribe({
|
||||||
|
next: (profile) => {
|
||||||
|
console.log("Profile updated:", profile);
|
||||||
|
// Update your UI with the new profile data
|
||||||
|
},
|
||||||
|
error: (err) => console.error("Profile subscription error:", err),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Don't forget to unsubscribe when done
|
||||||
|
// subscription.unsubscribe();
|
||||||
|
// profileSubscription.unsubscribe();
|
||||||
|
|
||||||
|
// Get user profile and avatar
|
||||||
|
const pubkey = "user-pubkey-here";
|
||||||
|
const profile = await window.eve.getProfile(pubkey);
|
||||||
|
const avatarUrl = await window.eve.getAvatar(pubkey);
|
||||||
|
----
|
||||||
|
|
||||||
|
=== Real-time Subscriptions
|
||||||
|
|
||||||
|
[source,typescript]
|
||||||
|
----
|
||||||
|
// Real-time subscription examples
|
||||||
|
import { filter, map, takeUntil } from "rxjs/operators";
|
||||||
|
|
||||||
|
// Basic subscription
|
||||||
|
const subscription = window.eve
|
||||||
|
.subscribeToEvents({
|
||||||
|
kinds: [1], // Text notes
|
||||||
|
limit: 50,
|
||||||
|
})
|
||||||
|
.subscribe((event) => {
|
||||||
|
console.log("New text note:", event.content);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Advanced filtering with RxJS operators
|
||||||
|
const filteredSubscription = window.eve
|
||||||
|
.subscribeToEvents({
|
||||||
|
kinds: [1, 6, 7], // Notes, reposts, reactions
|
||||||
|
authors: ["pubkey1", "pubkey2"],
|
||||||
|
})
|
||||||
|
.pipe(
|
||||||
|
filter((event) => event.content.includes("#arxlet")), // Only events mentioning arxlets
|
||||||
|
map((event) => ({
|
||||||
|
id: event.id,
|
||||||
|
author: event.pubkey,
|
||||||
|
content: event.content,
|
||||||
|
timestamp: new Date(event.created_at * 1000),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.subscribe({
|
||||||
|
next: (processedEvent) => {
|
||||||
|
// Update your UI
|
||||||
|
updateEventsList(processedEvent);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
console.error("Subscription error:", err);
|
||||||
|
showErrorMessage("Failed to receive real-time updates");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Profile subscription example
|
||||||
|
const profileSubscription = window.eve.subscribeToProfile("user-pubkey-here").subscribe({
|
||||||
|
next: (profile) => {
|
||||||
|
console.log("Profile updated:", profile);
|
||||||
|
updateUserProfile(profile);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
console.error("Profile subscription error:", err);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up subscriptions when component unmounts
|
||||||
|
// subscription.unsubscribe();
|
||||||
|
// filteredSubscription.unsubscribe();
|
||||||
|
// profileSubscription.unsubscribe();
|
||||||
|
----
|
||||||
|
|
||||||
|
=== WebSocket Alternative
|
||||||
|
|
||||||
|
For advanced use cases, connect directly to Eve's WebSocket relay, or use any nostr library. This is not recommended:
|
||||||
|
|
||||||
|
[source,typescript]
|
||||||
|
----
|
||||||
|
// Alternative: Direct WebSocket connection
|
||||||
|
const ws = new WebSocket("ws://localhost:6942");
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
// Subscribe to events
|
||||||
|
ws.send(JSON.stringify(["REQ", "sub1", { kinds: [1], limit: 10 }]));
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const [type, subId, data] = JSON.parse(event.data);
|
||||||
|
if (type === "EVENT") {
|
||||||
|
console.log("Received event:", data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Publish an event
|
||||||
|
const signedEvent = await window.nostr.signEvent(unsignedEvent);
|
||||||
|
ws.send(JSON.stringify(["EVENT", signedEvent]));
|
||||||
|
----
|
||||||
|
|
||||||
|
// Type Definitions Section
|
||||||
|
== Type Definitions
|
||||||
|
|
||||||
|
[source,typescript]
|
||||||
|
----
|
||||||
|
import type { Observable } from "rxjs";
|
||||||
|
|
||||||
|
export interface NostrEvent {
|
||||||
|
id?: string;
|
||||||
|
pubkey: string;
|
||||||
|
created_at: number;
|
||||||
|
kind: number;
|
||||||
|
tags: string[][];
|
||||||
|
content: string;
|
||||||
|
sig?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Filter {
|
||||||
|
ids?: string[];
|
||||||
|
authors?: string[];
|
||||||
|
kinds?: number[];
|
||||||
|
since?: number;
|
||||||
|
until?: number;
|
||||||
|
limit?: number;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Profile {
|
||||||
|
name?: string;
|
||||||
|
about?: string;
|
||||||
|
picture?: string;
|
||||||
|
nip05?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WindowEve {
|
||||||
|
publish(event: NostrEvent): Promise<void>;
|
||||||
|
getSingleEventById(id: string): Promise<NostrEvent | null>;
|
||||||
|
getSingleEventWithFilter(filter: Filter): Promise<NostrEvent | null>;
|
||||||
|
getAllEventsWithFilter(filter: Filter): Promise<NostrEvent[]>;
|
||||||
|
subscribeToEvents(filter: Filter): Observable<NostrEvent>;
|
||||||
|
subscribeToProfile(pubkey: string): Observable<Profile>;
|
||||||
|
getProfile(pubkey: string): Promise<Profile | null>;
|
||||||
|
getAvatar(pubkey: string): Promise<string | null>;
|
||||||
|
signEvent(event: NostrEvent): Promise<NostrEvent>;
|
||||||
|
get publicKey(): Promise<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
eve: WindowEve;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
----
|
||||||
|
|
||||||
|
// Registration Section
|
||||||
|
== Registration
|
||||||
|
|
||||||
|
Arxlets are registered using Nostr events with kind `30420`:
|
||||||
|
|
||||||
|
[source,json]
|
||||||
|
----
|
||||||
|
{
|
||||||
|
"kind": 30420,
|
||||||
|
"tags": [
|
||||||
|
["d", "my-calculator"],
|
||||||
|
["name", "Simple Calculator"],
|
||||||
|
["description", "A basic calculator for quick math"],
|
||||||
|
["script", "export function render(el) { /* your code */ }"],
|
||||||
|
["icon", "mdi:calculator", "#3b82f6"]
|
||||||
|
],
|
||||||
|
"content": "",
|
||||||
|
"created_at": 1735171200
|
||||||
|
}
|
||||||
|
----
|
||||||
|
|
||||||
|
=== Required Tags
|
||||||
|
* `d`: Unique identifier (alphanumeric, hyphens, underscores)
|
||||||
|
* `name`: Human-readable display name
|
||||||
|
* `script`: Complete JavaScript code with render export function
|
||||||
|
|
||||||
|
=== Optional Tags
|
||||||
|
* `description`: Brief description of functionality
|
||||||
|
* `icon`: Iconify icon name and hex color
|
||||||
|
|
||||||
|
// Development Patterns Section
|
||||||
|
== Development Patterns
|
||||||
|
|
||||||
|
=== Basic Arxlet Structure
|
||||||
|
|
||||||
|
[source,typescript]
|
||||||
|
----
|
||||||
|
/**
|
||||||
|
* Required export function - Entry point for your Arxlet
|
||||||
|
*/
|
||||||
|
export function render(container: HTMLElement): void {
|
||||||
|
// Initialize your application
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="p-6">
|
||||||
|
<h1 class="text-3xl font-bold mb-4">My Arxlet</h1>
|
||||||
|
<p class="text-lg">Hello from Eve!</p>
|
||||||
|
<button class="btn btn-primary mt-4" id="myButton">
|
||||||
|
Click me!
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add event listeners with proper typing
|
||||||
|
const button = container.querySelector<HTMLButtonElement>("#myButton");
|
||||||
|
button?.addEventListener("click", (): void => {
|
||||||
|
alert("Button clicked!");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Your app logic here...
|
||||||
|
}
|
||||||
|
----
|
||||||
|
|
||||||
|
=== Real-time Updates
|
||||||
|
[source,typescript]
|
||||||
|
----
|
||||||
|
export function render(container: HTMLElement): void {
|
||||||
|
let subscription: Subscription;
|
||||||
|
|
||||||
|
// Set up UI
|
||||||
|
container.innerHTML = `<div id="events"></div>`;
|
||||||
|
const eventsContainer = container.querySelector("#events");
|
||||||
|
|
||||||
|
// Subscribe to real-time events
|
||||||
|
subscription = window.eve
|
||||||
|
.subscribeToEvents({
|
||||||
|
kinds: [1],
|
||||||
|
limit: 50,
|
||||||
|
})
|
||||||
|
.subscribe({
|
||||||
|
next: (event) => {
|
||||||
|
// Update UI with new event
|
||||||
|
const eventElement = document.createElement("div");
|
||||||
|
eventElement.textContent = event.content;
|
||||||
|
eventsContainer?.prepend(eventElement);
|
||||||
|
},
|
||||||
|
error: (err) => console.error("Subscription error:", err),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup when arxlet is destroyed
|
||||||
|
window.addEventListener("beforeunload", () => {
|
||||||
|
subscription?.unsubscribe();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
----
|
||||||
|
|
||||||
|
=== Publishing Events
|
||||||
|
|
||||||
|
[source,typescript]
|
||||||
|
----
|
||||||
|
import type { NostrEvent } from "./type-definitions.ts";
|
||||||
|
|
||||||
|
export async function render(container: HTMLElement): Promise<void> {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="card bg-base-100 shadow-xl max-w-2xl mx-auto">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title">📝 Publish a Note</h2>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">What's on your mind?</span>
|
||||||
|
<span class="label-text-alt" id="charCount">0/280</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
class="textarea textarea-bordered h-32"
|
||||||
|
id="noteContent"
|
||||||
|
placeholder="Share your thoughts with your CCN..."
|
||||||
|
maxlength="280">
|
||||||
|
</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-actions justify-between items-center">
|
||||||
|
<div id="status" class="flex-1"></div>
|
||||||
|
<button class="btn btn-primary" id="publishBtn" disabled>
|
||||||
|
Publish Note
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const textarea = container.querySelector<HTMLTextAreaElement>("#noteContent")!;
|
||||||
|
const publishBtn = container.querySelector<HTMLButtonElement>("#publishBtn")!;
|
||||||
|
const status = container.querySelector<HTMLDivElement>("#status")!;
|
||||||
|
const charCount = container.querySelector<HTMLSpanElement>("#charCount")!;
|
||||||
|
|
||||||
|
textarea.oninput = (): void => {
|
||||||
|
const length: number = textarea.value.length;
|
||||||
|
charCount.textContent = `${length}/280`;
|
||||||
|
publishBtn.disabled = length === 0 || length > 280;
|
||||||
|
};
|
||||||
|
|
||||||
|
publishBtn.onclick = async (e): Promise<void> => {
|
||||||
|
const content: string = textarea.value.trim();
|
||||||
|
if (!content) return;
|
||||||
|
|
||||||
|
publishBtn.disabled = true;
|
||||||
|
publishBtn.textContent = "Publishing...";
|
||||||
|
status.innerHTML = '<span class="loading loading-spinner loading-sm"></span>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const unsignedEvent: NostrEvent = {
|
||||||
|
kind: 1, // Text note
|
||||||
|
content: content,
|
||||||
|
tags: [["client", "arxlet-publisher"]],
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
pubkey: await window.eve.publicKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
const signedEvent: NostrEvent = await window.eve.signEvent(unsignedEvent);
|
||||||
|
|
||||||
|
await window.eve.publish(signedEvent);
|
||||||
|
|
||||||
|
status.innerHTML = `
|
||||||
|
<div class="alert alert-success">
|
||||||
|
<span>✅ Note published successfully!</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
textarea.value = "";
|
||||||
|
textarea.oninput?.(e);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
console.error("Publishing failed:", error);
|
||||||
|
status.innerHTML = `
|
||||||
|
<div class="alert alert-error">
|
||||||
|
<span>❌ Failed to publish: ${errorMessage}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} finally {
|
||||||
|
publishBtn.disabled = false;
|
||||||
|
publishBtn.textContent = "Publish Note";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
----
|
||||||
|
|
||||||
|
// Best Practices Section
|
||||||
|
== Best Practices
|
||||||
|
|
||||||
|
=== Error Handling
|
||||||
|
- Always wrap API calls in try-catch blocks
|
||||||
|
- Check for null returns from query methods
|
||||||
|
- Provide user feedback for failed operations
|
||||||
|
|
||||||
|
=== Performance
|
||||||
|
- Use specific filters to limit result sets
|
||||||
|
- Cache profile data to avoid repeated lookups
|
||||||
|
- Unsubscribe from observables when done
|
||||||
|
- Debounce rapid API calls
|
||||||
|
- Consider pagination for large datasets
|
||||||
|
|
||||||
|
=== Security
|
||||||
|
- Validate all user inputs
|
||||||
|
- Sanitize content before displaying
|
||||||
|
- Use proper event signing for authenticity
|
||||||
|
- Follow principle of least privilege
|
||||||
|
|
||||||
|
=== Memory Management
|
||||||
|
- Always unsubscribe from RxJS observables
|
||||||
|
- Clean up event listeners on component destruction
|
||||||
|
- Avoid memory leaks in long-running subscriptions
|
||||||
|
- Use weak references where appropriate
|
||||||
|
|
||||||
|
// Common Use Cases Section
|
||||||
|
== Common Use Cases
|
||||||
|
|
||||||
|
=== Social Feed
|
||||||
|
- Subscribe to events from followed users
|
||||||
|
- Display real-time updates
|
||||||
|
- Handle profile information and avatars
|
||||||
|
- Implement engagement features
|
||||||
|
|
||||||
|
=== Publishing Tools
|
||||||
|
- Create and sign events
|
||||||
|
- Validate content before publishing
|
||||||
|
- Handle publishing errors gracefully
|
||||||
|
- Provide user feedback
|
||||||
|
|
||||||
|
=== Data Visualization
|
||||||
|
- Query historical events
|
||||||
|
- Process and aggregate data
|
||||||
|
- Create interactive charts and graphs
|
||||||
|
- Real-time data updates
|
||||||
|
|
||||||
|
=== Communication Apps
|
||||||
|
- Direct messaging interfaces
|
||||||
|
- Group chat functionality
|
||||||
|
- Notification systems
|
||||||
|
- Presence indicators
|
||||||
|
|
||||||
|
// Framework Integration Section
|
||||||
|
== Framework Integration
|
||||||
|
|
||||||
|
Arxlets support various JavaScript frameworks. All frameworks must export a `render` function that accepts a container element:
|
||||||
|
|
||||||
|
=== Vanilla JavaScript
|
||||||
|
|
||||||
|
[source,typescript]
|
||||||
|
----
|
||||||
|
export function render(container: HTMLElement) {
|
||||||
|
let count: number = 0;
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="card bg-base-100 shadow-xl max-w-sm mx-auto">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<h2 class="card-title justify-center">Counter App</h2>
|
||||||
|
<div class="text-6xl font-bold text-primary my-4" id="display">
|
||||||
|
${count}
|
||||||
|
</div>
|
||||||
|
<div class="card-actions justify-center gap-4">
|
||||||
|
<button class="btn btn-error" id="decrement">−</button>
|
||||||
|
<button class="btn btn-success" id="increment">+</button>
|
||||||
|
<button class="btn btn-ghost" id="reset">Reset</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const display = container.querySelector<HTMLDivElement>("#display")!;
|
||||||
|
const incrementBtn = container.querySelector<HTMLButtonElement>("#increment")!;
|
||||||
|
const decrementBtn = container.querySelector<HTMLButtonElement>("#decrement")!;
|
||||||
|
const resetBtn = container.querySelector<HTMLButtonElement>("#reset")!;
|
||||||
|
|
||||||
|
const updateDisplay = (): void => {
|
||||||
|
display.textContent = count.toString();
|
||||||
|
display.className = `text-6xl font-bold my-4 ${
|
||||||
|
count > 0 ? "text-success" : count < 0 ? "text-error" : "text-primary"
|
||||||
|
}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
incrementBtn.onclick = (): void => {
|
||||||
|
count++;
|
||||||
|
updateDisplay();
|
||||||
|
};
|
||||||
|
decrementBtn.onclick = (): void => {
|
||||||
|
count--;
|
||||||
|
updateDisplay();
|
||||||
|
};
|
||||||
|
resetBtn.onclick = (): void => {
|
||||||
|
count = 0;
|
||||||
|
updateDisplay();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
----
|
||||||
|
|
||||||
|
=== Preact/React
|
||||||
|
|
||||||
|
[source,tsx]
|
||||||
|
----
|
||||||
|
// @jsx h
|
||||||
|
// @jsxImportSource preact
|
||||||
|
|
||||||
|
import { render as renderPreact } from "preact";
|
||||||
|
import { useState } from "preact/hooks";
|
||||||
|
|
||||||
|
const CounterApp = () => {
|
||||||
|
const [count, setCount] = useState(0);
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
|
||||||
|
const increment = () => {
|
||||||
|
setCount((prev) => prev + 1);
|
||||||
|
setMessage(`Clicked ${count + 1} times!`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const decrement = () => {
|
||||||
|
setCount((prev) => prev - 1);
|
||||||
|
setMessage(`Count decreased to ${count - 1}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setCount(0);
|
||||||
|
setMessage("Counter reset!");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="card bg-base-100 shadow-xl max-w-sm mx-auto">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<h2 class="card-title justify-center"> Preact Counter </h2>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class={`text-6xl font-bold my-4 ${count > 0 ? "text-success" : count < 0 ? "text-error" : "text-primary"}`}
|
||||||
|
>
|
||||||
|
{count}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-actions justify-center gap-4">
|
||||||
|
<button class="btn btn-error" onClick={decrement}>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-success" onClick={increment}>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-ghost" onClick={reset}>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<div class="alert alert-info mt-4">
|
||||||
|
<span>{message} </span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function render(container: HTMLElement): void {
|
||||||
|
renderPreact(<CounterApp />, container);
|
||||||
|
}
|
||||||
|
----
|
||||||
|
|
||||||
|
=== Svelte
|
||||||
|
|
||||||
|
[source,svelte]
|
||||||
|
----
|
||||||
|
<script lang="ts">
|
||||||
|
let count = $state(0);
|
||||||
|
let message = $state("");
|
||||||
|
|
||||||
|
function increment() {
|
||||||
|
count += 1;
|
||||||
|
message = `Clicked ${count} times!`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decrement() {
|
||||||
|
count -= 1;
|
||||||
|
message = `Count decreased to ${count}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
count = 0;
|
||||||
|
message = "Counter reset!";
|
||||||
|
}
|
||||||
|
|
||||||
|
const countColor = $derived(count > 0 ? "text-success" : count < 0 ? "text-error" : "text-primary");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-xl max-w-sm mx-auto">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<h2 class="card-title justify-center">🔥 Svelte Counter</h2>
|
||||||
|
|
||||||
|
<div class="text-6xl font-bold my-4 {countColor}">
|
||||||
|
{count}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-actions justify-center gap-4">
|
||||||
|
<button class="btn btn-error" onclick={decrement}> − </button>
|
||||||
|
<button class="btn btn-success" onclick={increment}> + </button>
|
||||||
|
<button class="btn btn-ghost" onclick={reset}> Reset </button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if message}
|
||||||
|
<div class="alert alert-info mt-4">
|
||||||
|
<span>{message}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.card-title {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
----
|
||||||
|
|
||||||
|
=== Build Process
|
||||||
|
All frameworks require bundling into a single JavaScript file:
|
||||||
|
|
||||||
|
[source,bash]
|
||||||
|
----
|
||||||
|
# For TypeScript/JavaScript projects
|
||||||
|
bun build index.ts --outfile=build.js --minify --target=browser --production
|
||||||
|
|
||||||
|
# The resulting build.js content goes in your registration event's script tag
|
||||||
|
----
|
||||||
|
|
||||||
|
==== Svelte Build Requirements
|
||||||
|
|
||||||
|
IMPORTANT: The standard build command above will NOT work for Svelte projects. Svelte requires specific Vite configuration to compile properly.
|
||||||
|
|
||||||
|
For Svelte arxlets:
|
||||||
|
|
||||||
|
. Use the https://git.arx-ccn.com/Arx/arxlets-template[arxlets-template] which includes the correct Vite configuration
|
||||||
|
. Run `bun run build` instead of the standard build command
|
||||||
|
. Your compiled file will be available at `dist/bundle.js`
|
||||||
|
|
||||||
|
While the initial setup is more complex, Svelte provides an excellent development experience once configured, with features like:
|
||||||
|
|
||||||
|
- Built-in reactivity with runes (`$state()`, `$derived()`, etc.)
|
||||||
|
- Scoped CSS
|
||||||
|
- Compile-time optimizations
|
||||||
|
- No runtime overhead
|
||||||
|
|
||||||
|
// Debugging and Development Section
|
||||||
|
== Debugging and Development
|
||||||
|
|
||||||
|
=== Console Logging
|
||||||
|
- Use `console.log()` for debugging
|
||||||
|
- Events and errors are logged to browser console
|
||||||
|
|
||||||
|
=== Error Handling
|
||||||
|
- Catch and log API errors
|
||||||
|
- Display user-friendly error messages
|
||||||
|
- Implement retry mechanisms for transient failures
|
||||||
|
|
||||||
|
=== Testing
|
||||||
|
- Test with various event types and filters
|
||||||
|
- Verify subscription cleanup
|
||||||
|
- Test error scenarios and edge cases
|
||||||
|
- Validate event signing and publishing
|
||||||
|
|
||||||
|
// Limitations and Considerations Section
|
||||||
|
== Limitations and Considerations
|
||||||
|
|
||||||
|
=== Sandbox Restrictions
|
||||||
|
- Limited access to browser APIs
|
||||||
|
- No direct file system access
|
||||||
|
- Restricted network access (only to Eve relay)
|
||||||
|
- No access to parent window context
|
||||||
|
|
||||||
|
=== Performance Constraints
|
||||||
|
- Iframe overhead for each arxlet
|
||||||
|
- Memory usage for subscriptions
|
||||||
|
- Event processing limitations
|
||||||
|
|
||||||
|
=== Security Considerations
|
||||||
|
- All events are public on Nostr
|
||||||
|
- Private key management handled by Eve
|
||||||
|
- Content sanitization required
|
||||||
|
- XSS prevention necessary
|
||||||
|
|
||||||
|
// DaisyUI Components Section
|
||||||
|
== DaisyUI Components
|
||||||
|
|
||||||
|
Arxlets have access to DaisyUI 5, a comprehensive CSS component library. Use these pre-built components for consistent, accessible UI:
|
||||||
|
|
||||||
|
=== Essential Components
|
||||||
|
[source,html]
|
||||||
|
----
|
||||||
|
<!-- Cards for content containers -->
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title">Card Title</h2>
|
||||||
|
<p>Card content goes here</p>
|
||||||
|
<div class="card-actions justify-end">
|
||||||
|
<button class="btn btn-primary">Action</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Buttons with various styles -->
|
||||||
|
<button class="btn btn-primary">Primary</button>
|
||||||
|
<button class="btn btn-secondary">Secondary</button>
|
||||||
|
<button class="btn btn-success">Success</button>
|
||||||
|
<button class="btn btn-error">Error</button>
|
||||||
|
<button class="btn btn-ghost">Ghost</button>
|
||||||
|
|
||||||
|
<!-- Form controls -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Input Label</span>
|
||||||
|
</label>
|
||||||
|
<input type="text" class="input input-bordered" placeholder="Enter text" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Alerts for feedback -->
|
||||||
|
<div class="alert alert-success">
|
||||||
|
<span>✅ Success message</span>
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-error">
|
||||||
|
<span>❌ Error message</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading states -->
|
||||||
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
|
<button class="btn btn-primary">
|
||||||
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
|
Loading...
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Modals for dialogs -->
|
||||||
|
<dialog class="modal" id="my-modal">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3 class="font-bold text-lg">Modal Title</h3>
|
||||||
|
<p class="py-4">Modal content</p>
|
||||||
|
<div class="modal-action">
|
||||||
|
<button class="btn" onclick="document.getElementById('my-modal').close()">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
----
|
||||||
|
|
||||||
|
=== Layout Utilities
|
||||||
|
[source,html]
|
||||||
|
----
|
||||||
|
<!-- Responsive grid -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<div class="card">Content 1</div>
|
||||||
|
<div class="card">Content 2</div>
|
||||||
|
<div class="card">Content 3</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Flexbox utilities -->
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span>Left content</span>
|
||||||
|
<button class="btn">Right button</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Spacing -->
|
||||||
|
<div class="p-4 m-2 space-y-4">
|
||||||
|
<!-- p-4 = padding, m-2 = margin, space-y-4 = vertical spacing -->
|
||||||
|
</div>
|
||||||
|
----
|
||||||
|
|
||||||
|
=== Color System
|
||||||
|
[source,html]
|
||||||
|
----
|
||||||
|
<!-- Background colors -->
|
||||||
|
<div class="bg-base-100">Default background</div>
|
||||||
|
<div class="bg-base-200">Slightly darker</div>
|
||||||
|
<div class="bg-primary">Primary color</div>
|
||||||
|
<div class="bg-secondary">Secondary color</div>
|
||||||
|
|
||||||
|
<!-- Text colors -->
|
||||||
|
<span class="text-primary">Primary text</span>
|
||||||
|
<span class="text-success">Success text</span>
|
||||||
|
<span class="text-error">Error text</span>
|
||||||
|
<span class="text-base-content">Default text</span>
|
||||||
|
----
|
||||||
|
|
||||||
|
// Complete Example Patterns Section
|
||||||
|
== Complete Example Patterns
|
||||||
|
|
||||||
|
=== Simple Counter Arxlet
|
||||||
|
|
||||||
|
[source,typescript]
|
||||||
|
----
|
||||||
|
export function render(container: HTMLElement) {
|
||||||
|
let count: number = 0;
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="card bg-base-100 shadow-xl max-w-sm mx-auto">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<h2 class="card-title justify-center">Counter App</h2>
|
||||||
|
<div class="text-6xl font-bold text-primary my-4" id="display">
|
||||||
|
${count}
|
||||||
|
</div>
|
||||||
|
<div class="card-actions justify-center gap-4">
|
||||||
|
<button class="btn btn-error" id="decrement">−</button>
|
||||||
|
<button class="btn btn-success" id="increment">+</button>
|
||||||
|
<button class="btn btn-ghost" id="reset">Reset</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const display = container.querySelector<HTMLDivElement>("#display")!;
|
||||||
|
const incrementBtn = container.querySelector<HTMLButtonElement>("#increment")!;
|
||||||
|
const decrementBtn = container.querySelector<HTMLButtonElement>("#decrement")!;
|
||||||
|
const resetBtn = container.querySelector<HTMLButtonElement>("#reset")!;
|
||||||
|
|
||||||
|
const updateDisplay = (): void => {
|
||||||
|
display.textContent = count.toString();
|
||||||
|
display.className = `text-6xl font-bold my-4 ${
|
||||||
|
count > 0 ? "text-success" : count < 0 ? "text-error" : "text-primary"
|
||||||
|
}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
incrementBtn.onclick = (): void => {
|
||||||
|
count++;
|
||||||
|
updateDisplay();
|
||||||
|
};
|
||||||
|
decrementBtn.onclick = (): void => {
|
||||||
|
count--;
|
||||||
|
updateDisplay();
|
||||||
|
};
|
||||||
|
resetBtn.onclick = (): void => {
|
||||||
|
count = 0;
|
||||||
|
updateDisplay();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
----
|
||||||
|
|
||||||
|
=== Nostr Event Publisher
|
||||||
|
|
||||||
|
[source,typescript]
|
||||||
|
----
|
||||||
|
import type { NostrEvent } from "./type-definitions.ts";
|
||||||
|
|
||||||
|
export async function render(container: HTMLElement): Promise<void> {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="card bg-base-100 shadow-xl max-w-2xl mx-auto">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title">📝 Publish a Note</h2>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">What's on your mind?</span>
|
||||||
|
<span class="label-text-alt" id="charCount">0/280</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
class="textarea textarea-bordered h-32"
|
||||||
|
id="noteContent"
|
||||||
|
placeholder="Share your thoughts with your CCN..."
|
||||||
|
maxlength="280">
|
||||||
|
</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-actions justify-between items-center">
|
||||||
|
<div id="status" class="flex-1"></div>
|
||||||
|
<button class="btn btn-primary" id="publishBtn" disabled>
|
||||||
|
Publish Note
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const textarea = container.querySelector<HTMLTextAreaElement>("#noteContent")!;
|
||||||
|
const publishBtn = container.querySelector<HTMLButtonElement>("#publishBtn")!;
|
||||||
|
const status = container.querySelector<HTMLDivElement>("#status")!;
|
||||||
|
const charCount = container.querySelector<HTMLSpanElement>("#charCount")!;
|
||||||
|
|
||||||
|
textarea.oninput = (): void => {
|
||||||
|
const length: number = textarea.value.length;
|
||||||
|
charCount.textContent = `${length}/280`;
|
||||||
|
publishBtn.disabled = length === 0 || length > 280;
|
||||||
|
};
|
||||||
|
|
||||||
|
publishBtn.onclick = async (e): Promise<void> => {
|
||||||
|
const content: string = textarea.value.trim();
|
||||||
|
if (!content) return;
|
||||||
|
|
||||||
|
publishBtn.disabled = true;
|
||||||
|
publishBtn.textContent = "Publishing...";
|
||||||
|
status.innerHTML = '<span class="loading loading-spinner loading-sm"></span>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const unsignedEvent: NostrEvent = {
|
||||||
|
kind: 1, // Text note
|
||||||
|
content: content,
|
||||||
|
tags: [["client", "arxlet-publisher"]],
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
pubkey: await window.eve.publicKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
const signedEvent: NostrEvent = await window.eve.signEvent(unsignedEvent);
|
||||||
|
|
||||||
|
await window.eve.publish(signedEvent);
|
||||||
|
|
||||||
|
status.innerHTML = `
|
||||||
|
<div class="alert alert-success">
|
||||||
|
<span>✅ Note published successfully!</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
textarea.value = "";
|
||||||
|
textarea.oninput?.(e);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
console.error("Publishing failed:", error);
|
||||||
|
status.innerHTML = `
|
||||||
|
<div class="alert alert-error">
|
||||||
|
<span>❌ Failed to publish: ${errorMessage}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} finally {
|
||||||
|
publishBtn.disabled = false;
|
||||||
|
publishBtn.textContent = "Publish Note";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
----
|
746
src/pages/docs/arxlets/arxlet-docs.css
Normal file
746
src/pages/docs/arxlets/arxlet-docs.css
Normal file
|
@ -0,0 +1,746 @@
|
||||||
|
@import url("https://esm.sh/prismjs/themes/prism-tomorrow.css");
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
code[class*="language-"],
|
||||||
|
pre[class*="language-"] {
|
||||||
|
font-family: "Fira Code", "Monaco", "Cascadia Code", "Roboto Mono", monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
kbd {
|
||||||
|
border-radius: 25%;
|
||||||
|
color: white;
|
||||||
|
margin-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-code pre::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Next-Level Sidebar Styling */
|
||||||
|
.sidebar-container {
|
||||||
|
background: linear-gradient(180deg, hsl(var(--b2)) 0%, hsl(var(--b1)) 100%);
|
||||||
|
border-right: 1px solid hsl(var(--b3));
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-container::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(90deg, transparent, hsl(var(--p) / 0.5), transparent);
|
||||||
|
animation: shimmer 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Global Progress Bar */
|
||||||
|
.global-progress-container {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: hsl(var(--b3) / 0.1);
|
||||||
|
z-index: 9999;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-progress-bar {
|
||||||
|
height: 100%;
|
||||||
|
background: cyan;
|
||||||
|
transition: width 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
box-shadow:
|
||||||
|
0 0 12px hsl(var(--p) / 0.5),
|
||||||
|
0 1px 3px hsl(var(--p) / 0.3);
|
||||||
|
position: relative;
|
||||||
|
border-radius: 0 1px 1px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-progress-bar::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: linear-gradient(90deg, hsl(var(--p) / 0.8) 0%, hsl(var(--s) / 0.9) 50%, hsl(var(--a) / 0.8) 100%);
|
||||||
|
animation: shimmer-progress 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-progress-bar::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: -1px;
|
||||||
|
right: -2px;
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
background: hsl(var(--pc));
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 0 6px hsl(var(--pc) / 0.8);
|
||||||
|
animation: pulse-dot 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer-progress {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-dot {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
transform: scale(1.2);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure content doesn't get hidden behind progress bar */
|
||||||
|
body {
|
||||||
|
padding-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enhanced Header */
|
||||||
|
.sidebar-header {
|
||||||
|
background: hsl(var(--b1));
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-bottom: 1px solid hsl(var(--b3) / 0.5);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-icon {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-icon::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: -50%;
|
||||||
|
left: -50%;
|
||||||
|
width: 200%;
|
||||||
|
height: 200%;
|
||||||
|
background: linear-gradient(45deg, transparent, hsl(var(--pc) / 0.1), transparent);
|
||||||
|
animation: rotate 3s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rotate {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navigation Container */
|
||||||
|
.navigation-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navigation-container.scrolling {
|
||||||
|
background: hsl(var(--b2) / 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-side .menu {
|
||||||
|
padding: 1rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navigation Items */
|
||||||
|
.nav-item {
|
||||||
|
animation: slideInLeft 0.6s ease-out;
|
||||||
|
animation-delay: calc(var(--item-index) * 0.1s);
|
||||||
|
animation-fill-mode: both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInLeft {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section Links */
|
||||||
|
.section-link {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
position: relative;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
overflow: hidden;
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-link::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -100%;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, transparent, hsl(var(--p) / 0.1), transparent);
|
||||||
|
transition: left 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-link:hover::before {
|
||||||
|
left: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-link:hover {
|
||||||
|
background: hsl(var(--b3) / 0.7);
|
||||||
|
transform: translateX(4px) scale(1.02);
|
||||||
|
box-shadow: 0 8px 25px hsl(var(--b3) / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-link.active {
|
||||||
|
background: linear-gradient(135deg, hsl(var(--p)) 0%, hsl(var(--s)) 100%);
|
||||||
|
color: hsl(var(--pc));
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow:
|
||||||
|
0 8px 25px hsl(var(--p) / 0.4),
|
||||||
|
0 0 0 1px hsl(var(--p) / 0.2),
|
||||||
|
inset 0 1px 0 hsl(var(--pc) / 0.1);
|
||||||
|
transform: translateX(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-link.active::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: -1rem;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 4px;
|
||||||
|
height: 70%;
|
||||||
|
background: linear-gradient(180deg, hsl(var(--p)), hsl(var(--s)));
|
||||||
|
border-radius: 2px;
|
||||||
|
box-shadow: 0 0 10px hsl(var(--p) / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section Icon Container */
|
||||||
|
.section-icon-container {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-icon {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-glow {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: radial-gradient(circle, hsl(var(--p) / 0.2) 0%, transparent 70%);
|
||||||
|
border-radius: 50%;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-link.active .icon-glow {
|
||||||
|
opacity: 1;
|
||||||
|
animation: pulse-glow 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-glow {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translate(-50%, -50%) scale(1);
|
||||||
|
opacity: 0.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
transform: translate(-50%, -50%) scale(1.2);
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-link.active .section-icon {
|
||||||
|
transform: scale(1.1);
|
||||||
|
filter: drop-shadow(0 0 8px hsl(var(--pc) / 0.5));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section Text */
|
||||||
|
.section-text {
|
||||||
|
flex: 1;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-link.active .section-text {
|
||||||
|
text-shadow: 0 0 10px hsl(var(--pc) / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section Badge */
|
||||||
|
.section-badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subsection-count {
|
||||||
|
background: hsl(var(--b3) / 0.5);
|
||||||
|
color: hsl(var(--bc) / 0.7);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
min-width: 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-link.active .subsection-count {
|
||||||
|
background: hsl(var(--pc) / 0.2);
|
||||||
|
color: hsl(var(--pc));
|
||||||
|
box-shadow: 0 0 10px hsl(var(--pc) / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enhanced Subsection Styling */
|
||||||
|
.subsection-menu {
|
||||||
|
margin-left: 1rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
border-left: 2px solid hsl(var(--b3) / 0.3);
|
||||||
|
gap: 0.25rem;
|
||||||
|
position: relative;
|
||||||
|
animation: slideDown 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subsection-menu::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: -2px;
|
||||||
|
top: 0;
|
||||||
|
width: 2px;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(180deg, hsl(var(--p) / 0.5), transparent);
|
||||||
|
transform: scaleY(0);
|
||||||
|
transform-origin: top;
|
||||||
|
animation: expandLine 0.6s ease-out 0.2s forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes expandLine {
|
||||||
|
to {
|
||||||
|
transform: scaleY(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.subsection-menu li {
|
||||||
|
animation: slideInRight 0.4s ease-out;
|
||||||
|
animation-delay: calc(var(--sub-index) * 0.05s + 0.1s);
|
||||||
|
animation-fill-mode: both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInRight {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-15px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.subsection-link {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
color: hsl(var(--bc) / 0.7);
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subsection-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: hsl(var(--bc) / 0.3);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subsection-dot::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
background: hsl(var(--p));
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subsection-text {
|
||||||
|
flex: 1;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subsection-link:hover {
|
||||||
|
background: hsl(var(--b3) / 0.4);
|
||||||
|
color: hsl(var(--bc));
|
||||||
|
transform: translateX(4px);
|
||||||
|
box-shadow: 0 4px 12px hsl(var(--b3) / 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subsection-link:hover .subsection-dot {
|
||||||
|
background: hsl(var(--p) / 0.7);
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subsection-link:hover .subsection-dot::after {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subsection-link.active {
|
||||||
|
background: linear-gradient(135deg, hsl(var(--p) / 0.15), hsl(var(--s) / 0.1));
|
||||||
|
color: hsl(var(--p));
|
||||||
|
font-weight: 600;
|
||||||
|
transform: translateX(6px);
|
||||||
|
box-shadow:
|
||||||
|
0 4px 15px hsl(var(--p) / 0.2),
|
||||||
|
inset 0 1px 0 hsl(var(--p) / 0.1);
|
||||||
|
border-left: 3px solid hsl(var(--p));
|
||||||
|
padding-left: calc(1rem - 3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subsection-link.active .subsection-dot {
|
||||||
|
background: hsl(var(--p));
|
||||||
|
transform: scale(1.3);
|
||||||
|
box-shadow: 0 0 10px hsl(var(--p) / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subsection-link.active .subsection-dot::after {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
background: hsl(var(--p) / 0.3);
|
||||||
|
animation: ripple 1.5s ease-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ripple {
|
||||||
|
0% {
|
||||||
|
transform: translate(-50%, -50%) scale(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: translate(-50%, -50%) scale(2);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.subsection-link.active .subsection-text {
|
||||||
|
text-shadow: 0 0 8px hsl(var(--p) / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth animations for section changes */
|
||||||
|
.drawer-side .menu ul {
|
||||||
|
animation: slideDown 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideDown {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enhanced scrollbar for sidebar */
|
||||||
|
.drawer-side .menu {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: hsl(var(--p) / 0.3) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-side .menu::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-side .menu::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-side .menu::-webkit-scrollbar-thumb {
|
||||||
|
background: hsl(var(--p) / 0.3);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-side .menu::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: hsl(var(--p) / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enhance
|
||||||
|
d Sidebar Structure */
|
||||||
|
.drawer-side aside {
|
||||||
|
background: hsl(var(--b2));
|
||||||
|
border-right: 1px solid hsl(var(--b3));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section Links with Icons */
|
||||||
|
.section-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-icon {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-link.active .section-icon {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Subsection Menu */
|
||||||
|
.subsection-menu {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding-left: 1rem;
|
||||||
|
border-left: 2px solid hsl(var(--b3));
|
||||||
|
gap: 0.125rem;
|
||||||
|
animation: slideDown 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subsection-link {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
color: hsl(var(--bc) / 0.7);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subsection-link:hover {
|
||||||
|
background: hsl(var(--b3) / 0.5);
|
||||||
|
color: hsl(var(--bc));
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subsection-link.active {
|
||||||
|
background: hsl(var(--p) / 0.1);
|
||||||
|
color: hsl(var(--p));
|
||||||
|
font-weight: 600;
|
||||||
|
border-left: 3px solid hsl(var(--p));
|
||||||
|
padding-left: calc(0.75rem - 3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subsection-link.active::before {
|
||||||
|
content: "▶";
|
||||||
|
position: absolute;
|
||||||
|
left: -1.25rem;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
font-size: 0.625rem;
|
||||||
|
color: hsl(var(--p));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enhanced scrollbar for navigation */
|
||||||
|
.drawer-side nav {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: hsl(var(--p) / 0.3) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-side nav::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-side nav::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-side nav::-webkit-scrollbar-thumb {
|
||||||
|
background: hsl(var(--p) / 0.3);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-side nav::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: hsl(var(--p) / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enhanced Footer */
|
||||||
|
.sidebar-footer {
|
||||||
|
background: linear-gradient(180deg, hsl(var(--b1)) 0%, hsl(var(--b2)) 100%);
|
||||||
|
border-top: 1px solid hsl(var(--b3) / 0.5);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress Dots */
|
||||||
|
.progress-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: hsl(var(--bc) / 0.2);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-dot.active {
|
||||||
|
background: hsl(var(--p));
|
||||||
|
box-shadow: 0 0 10px hsl(var(--p) / 0.5);
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-dot.active::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid hsl(var(--p) / 0.3);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: expand-ring 2s ease-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes expand-ring {
|
||||||
|
0% {
|
||||||
|
transform: translate(-50%, -50%) scale(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: translate(-50%, -50%) scale(1);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enhanced Animations */
|
||||||
|
@keyframes pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: 0.7;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth Transitions */
|
||||||
|
* {
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glassmorphism Effects */
|
||||||
|
.sidebar-header,
|
||||||
|
.sidebar-footer {
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover Glow Effects */
|
||||||
|
.section-link:hover,
|
||||||
|
.subsection-link:hover {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-link:hover::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: -2px;
|
||||||
|
left: -2px;
|
||||||
|
right: -2px;
|
||||||
|
bottom: -2px;
|
||||||
|
background: linear-gradient(45deg, hsl(var(--p) / 0.1), hsl(var(--s) / 0.1));
|
||||||
|
border-radius: inherit;
|
||||||
|
z-index: -1;
|
||||||
|
filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Enhancements */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sidebar-container {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-link {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subsection-link {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark Mode Optimizations */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.sidebar-container {
|
||||||
|
background: linear-gradient(180deg, hsl(var(--b2)) 0%, hsl(220 13% 9%) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
box-shadow: 0 0 20px hsl(var(--p) / 0.4);
|
||||||
|
}
|
||||||
|
}
|
26
src/pages/docs/arxlets/arxlet-docs.html
Normal file
26
src/pages/docs/arxlets/arxlet-docs.html
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Arxlets Documentation - Eve</title>
|
||||||
|
<link rel="stylesheet" href="./arxlet-docs.css" />
|
||||||
|
<link
|
||||||
|
href="https://cdn.jsdelivr.net/npm/daisyui@5"
|
||||||
|
rel="stylesheet"
|
||||||
|
type="text/css"
|
||||||
|
/>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||||
|
<link
|
||||||
|
href="https://cdn.jsdelivr.net/npm/daisyui@5/themes.css"
|
||||||
|
rel="stylesheet"
|
||||||
|
type="text/css"
|
||||||
|
/>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-core.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/autoloader/prism-autoloader.min.js"></script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body data-theme="dark" class="bg-base-100 text-base-content">
|
||||||
|
<script src="./arxlet-docs.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
343
src/pages/docs/arxlets/arxlet-docs.jsx
Normal file
343
src/pages/docs/arxlets/arxlet-docs.jsx
Normal file
|
@ -0,0 +1,343 @@
|
||||||
|
// @jsx h
|
||||||
|
// @jsxImportSource preact
|
||||||
|
|
||||||
|
import { render } from "preact";
|
||||||
|
import { useEffect, useState } from "preact/hooks";
|
||||||
|
import "./arxlet-docs.css";
|
||||||
|
|
||||||
|
import { APISection } from "./components/APISection.jsx";
|
||||||
|
import { BestPracticesSection } from "./components/BestPracticesSection.jsx";
|
||||||
|
import { DevelopmentSection } from "./components/DevelopmentSection.jsx";
|
||||||
|
import { ExamplesSection } from "./components/ExamplesSection.jsx";
|
||||||
|
import { LLMsSection } from "./components/LLMsSection.jsx";
|
||||||
|
import { OverviewSection } from "./components/OverviewSection.jsx";
|
||||||
|
import { RegistrationSection } from "./components/RegistrationSection.jsx";
|
||||||
|
import { useSyntaxHighlighting } from "./hooks/useSyntaxHighlighting.js";
|
||||||
|
|
||||||
|
const SECTIONS = {
|
||||||
|
Overview: {
|
||||||
|
component: <OverviewSection />,
|
||||||
|
subsections: {},
|
||||||
|
},
|
||||||
|
Registration: {
|
||||||
|
component: <RegistrationSection />,
|
||||||
|
subsections: {
|
||||||
|
"nostr-event-structure": "Nostr Event Structure",
|
||||||
|
"tag-reference": "Tag Reference",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Development: {
|
||||||
|
component: <DevelopmentSection />,
|
||||||
|
subsections: {
|
||||||
|
"understanding-arxlets": "Understanding the Arxlet Environment",
|
||||||
|
"nostr-vs-arxlets": "Nostr Apps vs Arxlets",
|
||||||
|
"available-apis": "Available APIs",
|
||||||
|
"security-restrictions": "Security Restrictions",
|
||||||
|
"typescript-development": "TypeScript Development",
|
||||||
|
"required-export-function": "Required Export Function",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Best Practices": {
|
||||||
|
component: <BestPracticesSection />,
|
||||||
|
subsections: {
|
||||||
|
"error-handling": "Error Handling & Reliability",
|
||||||
|
performance: "Performance & Efficiency",
|
||||||
|
subscriptions: "Subscription Management",
|
||||||
|
"user-experience": "User Experience Excellence",
|
||||||
|
security: "Security & Privacy",
|
||||||
|
"production-checklist": "Production Readiness",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"API Reference": {
|
||||||
|
component: <APISection />,
|
||||||
|
subsections: {
|
||||||
|
"window-eve-api": "window.eve API",
|
||||||
|
"real-time-subscriptions": "Real-time Subscriptions",
|
||||||
|
"websocket-alternative": "WebSocket Alternative",
|
||||||
|
"best-practices": "Best Practices",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Examples: {
|
||||||
|
component: <ExamplesSection />,
|
||||||
|
subsections: {
|
||||||
|
vanilla: "Vanilla JS",
|
||||||
|
svelte: "Svelte",
|
||||||
|
preact: "Preact + JSX",
|
||||||
|
nostr: "Nostr Publisher",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
LLMs: {
|
||||||
|
component: <LLMsSection />,
|
||||||
|
subsections: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ArxletDocs = () => {
|
||||||
|
useSyntaxHighlighting();
|
||||||
|
const [activeSection, setActiveSection] = useState("Overview");
|
||||||
|
const [activeExample, setActiveExample] = useState("vanilla");
|
||||||
|
const [activeSubsection, setActiveSubsection] = useState("");
|
||||||
|
const [scrollProgress, setScrollProgress] = useState(0);
|
||||||
|
const [isScrolling, setIsScrolling] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleHashChange = () => {
|
||||||
|
const hash = window.location.hash.substring(1);
|
||||||
|
if (hash) {
|
||||||
|
setActiveExample(hash);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("hashchange", handleHashChange);
|
||||||
|
handleHashChange();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("hashchange", handleHashChange);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Enhanced Intersection Observer to track which subsection is currently visible
|
||||||
|
useEffect(() => {
|
||||||
|
const observerOptions = {
|
||||||
|
root: null,
|
||||||
|
rootMargin: "-10% 0px -60% 0px", // More responsive triggering
|
||||||
|
threshold: [0, 0.1, 0.5, 1.0], // Multiple thresholds for better detection
|
||||||
|
};
|
||||||
|
|
||||||
|
const visibleSections = new Set();
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver((entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
visibleSections.add(entry.target.id);
|
||||||
|
} else {
|
||||||
|
visibleSections.delete(entry.target.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set the active subsection to the first visible one (topmost)
|
||||||
|
if (visibleSections.size > 0) {
|
||||||
|
const currentSectionData = SECTIONS[activeSection];
|
||||||
|
if (currentSectionData?.subsections) {
|
||||||
|
const subsectionIds = Object.keys(currentSectionData.subsections);
|
||||||
|
const firstVisible = subsectionIds.find((id) => visibleSections.has(id));
|
||||||
|
if (firstVisible) {
|
||||||
|
setActiveSubsection(firstVisible);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, observerOptions);
|
||||||
|
|
||||||
|
// Get all subsection IDs from the current section
|
||||||
|
const currentSectionData = SECTIONS[activeSection];
|
||||||
|
if (currentSectionData?.subsections) {
|
||||||
|
const subsectionIds = Object.keys(currentSectionData.subsections);
|
||||||
|
|
||||||
|
// Clear previous observations
|
||||||
|
visibleSections.clear();
|
||||||
|
|
||||||
|
// Observe elements with these IDs
|
||||||
|
subsectionIds.forEach((id) => {
|
||||||
|
const element = document.getElementById(id);
|
||||||
|
if (element) {
|
||||||
|
observer.observe(element);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
subsectionIds.forEach((id) => {
|
||||||
|
const element = document.getElementById(id);
|
||||||
|
if (element) {
|
||||||
|
observer.unobserve(element);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
visibleSections.clear();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [activeSection]);
|
||||||
|
|
||||||
|
// Scroll progress tracking
|
||||||
|
useEffect(() => {
|
||||||
|
let scrollTimeout;
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
const scrollTop = window.scrollY;
|
||||||
|
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
|
||||||
|
const progress = Math.min((scrollTop / docHeight) * 100, 100);
|
||||||
|
|
||||||
|
setScrollProgress(progress);
|
||||||
|
setIsScrolling(true);
|
||||||
|
|
||||||
|
clearTimeout(scrollTimeout);
|
||||||
|
scrollTimeout = setTimeout(() => {
|
||||||
|
setIsScrolling(false);
|
||||||
|
}, 150);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("scroll", handleScroll);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("scroll", handleScroll);
|
||||||
|
clearTimeout(scrollTimeout);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
}, [activeSection, activeExample]);
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
const section = SECTIONS[activeSection];
|
||||||
|
if (!section) return <div>Section not found</div>;
|
||||||
|
|
||||||
|
if (activeSection === "Examples") {
|
||||||
|
return <ExamplesSection activeExample={activeExample} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return section.component;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNavClick = (e, section) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveSection(section);
|
||||||
|
window.location.hash = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubNavClick = (e, subsectionId) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (activeSection === "Examples") {
|
||||||
|
window.location.hash = subsectionId;
|
||||||
|
} else {
|
||||||
|
document.getElementById(subsectionId)?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="drawer drawer-open" data-theme="dark">
|
||||||
|
{/* Global Progress Bar */}
|
||||||
|
<div class="global-progress-container">
|
||||||
|
<div class="global-progress-bar" style={`width: ${scrollProgress}%`}></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input id="my-drawer-2" type="checkbox" class="drawer-toggle" />
|
||||||
|
<div class="drawer-content flex flex-col p-8">
|
||||||
|
{/* Header */}
|
||||||
|
<header class="mb-12">
|
||||||
|
<div class="flex items-center gap-4 mb-6">
|
||||||
|
<div class="w-16 h-16 bg-gradient-to-br from-primary to-secondary rounded-xl flex items-center justify-center shadow-lg">
|
||||||
|
<span class="text-2xl">🚀</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-5xl font-bold">Arxlets</h1>
|
||||||
|
<p class="text-xl text-base-content/70 mt-2">Secure Applications for Eve</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="divider"></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{renderContent()}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer class="mt-16 pt-8 border-t border-base-300">
|
||||||
|
<div class="text-center text-base-content/60">
|
||||||
|
<p class="text-lg">Arxlets Documentation • Eve</p>
|
||||||
|
<p class="text-sm mt-2">Build secure, sandboxed applications for your CCN</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
<div class="drawer-side">
|
||||||
|
<label for="my-drawer-2" aria-label="close sidebar" class="drawer-overlay"></label>
|
||||||
|
<aside class="sidebar-container w-80 min-h-full bg-base-200 flex flex-col">
|
||||||
|
{/* Sidebar Header */}
|
||||||
|
<div class="sidebar-header p-4 border-b border-base-300">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="header-icon w-8 h-8 bg-gradient-to-br from-primary to-secondary rounded-lg flex items-center justify-center">
|
||||||
|
<span class="text-sm">📚</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="font-bold text-lg">Documentation</h2>
|
||||||
|
<p class="text-xs text-base-content/60">Navigate sections</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation Menu */}
|
||||||
|
<nav class={`navigation-container flex-1 overflow-y-auto ${isScrolling ? "scrolling" : ""}`}>
|
||||||
|
<ul class="menu p-4 w-full text-base-content">
|
||||||
|
{Object.entries(SECTIONS).map(([section, { subsections }], index) => (
|
||||||
|
<li key={section} class="nav-item" style={`--item-index: ${index}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={`${activeSection === section ? "active" : ""} section-link`}
|
||||||
|
onClick={(e) => handleNavClick(e, section)}
|
||||||
|
>
|
||||||
|
<span class="section-icon-container">
|
||||||
|
<span class="section-icon">
|
||||||
|
{section === "Overview" && "🏠"}
|
||||||
|
{section === "Registration" && "📝"}
|
||||||
|
{section === "Development" && "⚡"}
|
||||||
|
{section === "Best Practices" && "✨"}
|
||||||
|
{section === "API Reference" && "🔧"}
|
||||||
|
{section === "Examples" && "💡"}
|
||||||
|
{section === "LLMs" && "🤖"}
|
||||||
|
</span>
|
||||||
|
<span class="icon-glow"></span>
|
||||||
|
</span>
|
||||||
|
<span class="section-text">{section}</span>
|
||||||
|
<span class="section-badge">
|
||||||
|
{Object.keys(subsections).length > 0 && (
|
||||||
|
<span class="subsection-count">{Object.keys(subsections).length}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{activeSection === section && Object.keys(subsections).length > 0 && (
|
||||||
|
<ul class="subsection-menu">
|
||||||
|
{Object.entries(subsections).map(([subsectionId, subsectionLabel], subIndex) => (
|
||||||
|
<li key={subsectionId} style={`--sub-index: ${subIndex}`}>
|
||||||
|
<a
|
||||||
|
href={`#${subsectionId}`}
|
||||||
|
class={`subsection-link ${
|
||||||
|
activeSection === "Examples"
|
||||||
|
? activeExample === subsectionId
|
||||||
|
? "active"
|
||||||
|
: ""
|
||||||
|
: activeSubsection === subsectionId
|
||||||
|
? "active"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
onClick={(e) => handleSubNavClick(e, subsectionId)}
|
||||||
|
>
|
||||||
|
<span class="subsection-dot"></span>
|
||||||
|
<span class="subsection-text">{subsectionLabel}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Sidebar Footer */}
|
||||||
|
<div class="sidebar-footer p-4 border-t border-base-300">
|
||||||
|
<div class="text-center space-y-2">
|
||||||
|
<div class="flex items-center justify-center gap-2">
|
||||||
|
<div class="w-2 h-2 rounded-full bg-success animate-pulse"></div>
|
||||||
|
<p class="text-xs text-base-content/50">Arxlets v0.1b</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-center gap-1">
|
||||||
|
{Object.keys(SECTIONS).map((_, index) => (
|
||||||
|
<div
|
||||||
|
class={`progress-dot ${index <= Object.keys(SECTIONS).findIndex(([key]) => key === activeSection) ? "active" : ""}`}
|
||||||
|
></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<ArxletDocs />, document.body);
|
678
src/pages/docs/arxlets/components/APISection.jsx
Normal file
678
src/pages/docs/arxlets/components/APISection.jsx
Normal file
|
@ -0,0 +1,678 @@
|
||||||
|
// @jsx h
|
||||||
|
// @jsxImportSource preact
|
||||||
|
|
||||||
|
import eveApiExample from "../highlight/eve-api-example.ts" with { type: "text" };
|
||||||
|
import subscriptionExamples from "../highlight/subscription-examples.ts" with { type: "text" };
|
||||||
|
import websocketExample from "../highlight/websocket-example.ts" with { type: "text" };
|
||||||
|
import { useSyntaxHighlighting } from "../hooks/useSyntaxHighlighting.js";
|
||||||
|
import { CodeBlock } from "./CodeBlock.jsx";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Section - Comprehensive guide to available APIs
|
||||||
|
* Covers window.eve API and WebSocket alternatives
|
||||||
|
*/
|
||||||
|
export const APISection = () => {
|
||||||
|
useSyntaxHighlighting();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="space-y-6">
|
||||||
|
<h2 class="text-3xl font-bold">API Reference</h2>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title text-info mb-4">Understanding Arxlet APIs</h3>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<p>
|
||||||
|
Your Arxlet has access to powerful APIs that let you interact with Nostr data, manage user profiles, and
|
||||||
|
create real-time applications. Think of these APIs as your toolkit for building social, decentralized
|
||||||
|
applications within the CCN ecosystem.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<strong>Two approaches available:</strong> You can use the convenient <code>window.eve</code> API
|
||||||
|
(recommended for most cases) or connect directly via WebSocket for advanced scenarios. Both give you full
|
||||||
|
access to Nostr events and CCN features.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title text-primary mb-4">🎯 Which API Should You Use?</h3>
|
||||||
|
|
||||||
|
<div class="grid md:grid-cols-2 gap-6">
|
||||||
|
<div class="border-2 border-primary rounded-lg p-4">
|
||||||
|
<h4 class="font-bold text-primary mb-3">✨ window.eve API (Recommended)</h4>
|
||||||
|
<p class="text-sm mb-3">
|
||||||
|
<strong>Best for most Arxlets.</strong> This high-level API handles all the complex Nostr protocol
|
||||||
|
details for you.
|
||||||
|
</p>
|
||||||
|
<div class="space-y-2 text-sm">
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<span class="text-success">✓</span>
|
||||||
|
<span>Simple promise-based functions</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<span class="text-success">✓</span>
|
||||||
|
<span>Automatic error handling</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<span class="text-success">✓</span>
|
||||||
|
<span>Built-in RxJS observables for real-time data</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<span class="text-success">✓</span>
|
||||||
|
<span>Profile and avatar helpers</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<span class="text-success">✓</span>
|
||||||
|
<span>Perfect for beginners</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-2 border-accent rounded-lg p-4">
|
||||||
|
<h4 class="font-bold text-accent mb-3">⚡ Direct WebSocket</h4>
|
||||||
|
<p class="text-sm mb-3">
|
||||||
|
<strong>For advanced use cases.</strong> Direct connection to the Nostr relay with full protocol
|
||||||
|
control.
|
||||||
|
</p>
|
||||||
|
<div class="space-y-2 text-sm">
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<span class="text-success">✓</span>
|
||||||
|
<span>Maximum performance and control</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<span class="text-success">✓</span>
|
||||||
|
<span>Custom subscription management</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<span class="text-success">✓</span>
|
||||||
|
<span>Raw Nostr protocol access</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<span class="text-warning">!</span>
|
||||||
|
<span>Requires Nostr protocol knowledge</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<span class="text-warning">!</span>
|
||||||
|
<span>More complex error handling</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-info mt-6">
|
||||||
|
<span>
|
||||||
|
💡 <strong>Our Recommendation:</strong> Start with <code>window.eve</code> for your first Arxlet. You can
|
||||||
|
always switch to WebSocket later if you need more control or performance.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* window.eve API */}
|
||||||
|
<div class="card bg-base-200 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 id="window-eve-api" class="card-title text-primary mb-4">
|
||||||
|
🚀 window.eve API - Your Main Toolkit
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-4 mb-6">
|
||||||
|
<p>
|
||||||
|
The <code>window.eve</code> API is your primary interface for working with Nostr data in Arxlets. It
|
||||||
|
provides simple, promise-based functions that handle all the complex protocol details behind the scenes.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<strong>How it works:</strong> Each function communicates with the local Nostr relay, processes the
|
||||||
|
results, and returns clean JavaScript objects. No need to understand Nostr protocol internals - just call
|
||||||
|
the functions and get your data.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h4 class="font-bold text-lg mb-4 text-success">📤 Publishing & Writing Data</h4>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="border-l-4 border-success pl-4">
|
||||||
|
<h5 class="font-bold">publish(event)</h5>
|
||||||
|
<p class="text-sm opacity-80 mb-2">
|
||||||
|
Publishes a Nostr event to the relay. This is how you save data, post messages, or create any
|
||||||
|
content.
|
||||||
|
</p>
|
||||||
|
<p class="text-sm mb-2">
|
||||||
|
<strong>Use cases:</strong> Posting messages, saving user preferences, creating notes, updating
|
||||||
|
profiles
|
||||||
|
</p>
|
||||||
|
<div class="badge badge-outline">Promise<void></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-l-4 border-success pl-4">
|
||||||
|
<h5 class="font-bold">signEvent(event)</h5>
|
||||||
|
<p class="text-sm opacity-80 mb-2">
|
||||||
|
Signs an unsigned Nostr event with the user's private key. Required before publishing most events.
|
||||||
|
</p>
|
||||||
|
<p class="text-sm mb-2">
|
||||||
|
<strong>Use cases:</strong> Preparing events for publication, authenticating user actions
|
||||||
|
</p>
|
||||||
|
<div class="badge badge-outline">Promise<NostrEvent></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 class="font-bold text-lg mb-4 text-info">🔍 Reading & Querying Data</h4>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="border-l-4 border-info pl-4">
|
||||||
|
<h5 class="font-bold">getSingleEventById(id)</h5>
|
||||||
|
<p class="text-sm opacity-80 mb-2">
|
||||||
|
Retrieves a specific event when you know its exact ID. Perfect for loading specific posts or data.
|
||||||
|
</p>
|
||||||
|
<p class="text-sm mb-2">
|
||||||
|
<strong>Use cases:</strong> Loading a specific message, fetching referenced content, getting event
|
||||||
|
details
|
||||||
|
</p>
|
||||||
|
<div class="badge badge-outline">Promise<NostrEvent | null></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-l-4 border-info pl-4">
|
||||||
|
<h5 class="font-bold">getSingleEventWithFilter(filter)</h5>
|
||||||
|
<p class="text-sm opacity-80 mb-2">
|
||||||
|
Gets the first event matching your criteria. Useful when you expect only one result or want the most
|
||||||
|
recent.
|
||||||
|
</p>
|
||||||
|
<p class="text-sm mb-2">
|
||||||
|
<strong>Use cases:</strong> Getting a user's latest profile, finding the most recent post, checking
|
||||||
|
if something exists
|
||||||
|
</p>
|
||||||
|
<div class="badge badge-outline">Promise<NostrEvent | null></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-l-4 border-info pl-4">
|
||||||
|
<h5 class="font-bold">getAllEventsWithFilter(filter)</h5>
|
||||||
|
<p class="text-sm opacity-80 mb-2">
|
||||||
|
Gets all events matching your criteria. Use this for lists, feeds, or when you need multiple
|
||||||
|
results.
|
||||||
|
</p>
|
||||||
|
<p class="text-sm mb-2">
|
||||||
|
<strong>Use cases:</strong> Building feeds, loading message history, getting all posts by a user
|
||||||
|
</p>
|
||||||
|
<div class="badge badge-outline">Promise<NostrEvent[]></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 class="font-bold text-lg mb-4 text-accent">🔄 Real-time Subscriptions</h4>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="border-l-4 border-accent pl-4">
|
||||||
|
<h5 class="font-bold">subscribeToEvents(filter)</h5>
|
||||||
|
<p class="text-sm opacity-80 mb-2">
|
||||||
|
Creates a live stream of events matching your filter. Your app updates automatically when new events
|
||||||
|
arrive.
|
||||||
|
</p>
|
||||||
|
<p class="text-sm mb-2">
|
||||||
|
<strong>Use cases:</strong> Live chat, real-time feeds, notifications, collaborative features
|
||||||
|
</p>
|
||||||
|
<div class="badge badge-outline">Observable<NostrEvent></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-l-4 border-accent pl-4">
|
||||||
|
<h5 class="font-bold">subscribeToProfile(pubkey)</h5>
|
||||||
|
<p class="text-sm opacity-80 mb-2">
|
||||||
|
Watches for profile changes for a specific user. Updates automatically when they change their name,
|
||||||
|
bio, avatar, etc.
|
||||||
|
</p>
|
||||||
|
<p class="text-sm mb-2">
|
||||||
|
<strong>Use cases:</strong> User profile displays, contact lists, member directories
|
||||||
|
</p>
|
||||||
|
<div class="badge badge-outline">Observable<Profile></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 class="font-bold text-lg mb-4 text-warning">👤 User & Profile Helpers</h4>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="border-l-4 border-warning pl-4">
|
||||||
|
<h5 class="font-bold">getProfile(pubkey)</h5>
|
||||||
|
<p class="text-sm opacity-80 mb-2">
|
||||||
|
Retrieves user profile information (name, bio, avatar, etc.) for any user by their public key.
|
||||||
|
</p>
|
||||||
|
<p class="text-sm mb-2">
|
||||||
|
<strong>Use cases:</strong> Displaying user info, building contact lists, showing message authors
|
||||||
|
</p>
|
||||||
|
<div class="badge badge-outline">Promise<Profile | null></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-l-4 border-warning pl-4">
|
||||||
|
<h5 class="font-bold">getAvatar(pubkey)</h5>
|
||||||
|
<p class="text-sm opacity-80 mb-2">
|
||||||
|
Quick helper to get just the avatar URL from a user's profile. Saves you from parsing the full
|
||||||
|
profile.
|
||||||
|
</p>
|
||||||
|
<p class="text-sm mb-2">
|
||||||
|
<strong>Use cases:</strong> Profile pictures, user avatars in lists, message author images
|
||||||
|
</p>
|
||||||
|
<div class="badge badge-outline">Promise<string | null></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-l-4 border-warning pl-4">
|
||||||
|
<h5 class="font-bold">publicKey</h5>
|
||||||
|
<p class="text-sm opacity-80 mb-2">
|
||||||
|
Gets the current user's public key. This identifies the user and is needed for many operations.
|
||||||
|
</p>
|
||||||
|
<p class="text-sm mb-2">
|
||||||
|
<strong>Use cases:</strong> Identifying the current user, filtering their content, permission checks
|
||||||
|
</p>
|
||||||
|
<div class="badge badge-outline">Promise<string></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8">
|
||||||
|
<h4 class="font-semibold mb-3 text-lg">Practical Example:</h4>
|
||||||
|
<p class="mb-3 text-sm">
|
||||||
|
Here's how these functions work together in a real Arxlet. This example shows fetching events, displaying
|
||||||
|
user profiles, and handling real-time updates:
|
||||||
|
</p>
|
||||||
|
<CodeBlock language="typescript" code={eveApiExample} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Real-time Subscriptions */}
|
||||||
|
<div class="card bg-base-200 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 id="real-time-subscriptions" class="card-title text-accent mb-4">
|
||||||
|
🔄 Understanding Real-time Subscriptions
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="space-y-4 mb-6">
|
||||||
|
<p>
|
||||||
|
<strong>What are subscriptions?</strong> Think of subscriptions as "live feeds" that automatically notify
|
||||||
|
your Arxlet when new data arrives. Instead of repeatedly asking "is there new data?", subscriptions push
|
||||||
|
updates to you instantly.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<strong>How they work:</strong> When you subscribe to events or profiles, you get an RxJS Observable - a
|
||||||
|
stream of data that flows over time. Your Arxlet can "listen" to this stream and update the UI whenever
|
||||||
|
new data arrives.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<strong>Why use them?</strong> Subscriptions make your Arxlet feel alive and responsive. Users see new
|
||||||
|
messages instantly, profile changes update immediately, and collaborative features work in real-time.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid md:grid-cols-2 gap-6 mb-6">
|
||||||
|
<div class="border-2 border-accent rounded-lg p-4">
|
||||||
|
<h4 class="font-bold text-accent mb-3">🎯 Event Subscriptions</h4>
|
||||||
|
<p class="text-sm mb-3">
|
||||||
|
<code>subscribeToEvents(filter)</code> gives you a live stream of events matching your criteria.
|
||||||
|
</p>
|
||||||
|
<div class="space-y-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<strong>Perfect for:</strong>
|
||||||
|
</div>
|
||||||
|
<ul class="list-disc list-inside space-y-1 ml-2">
|
||||||
|
<li>Live chat applications</li>
|
||||||
|
<li>Real-time feeds and timelines</li>
|
||||||
|
<li>Notification systems</li>
|
||||||
|
<li>Collaborative tools</li>
|
||||||
|
<li>Activity monitoring</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-2 border-warning rounded-lg p-4">
|
||||||
|
<h4 class="font-bold text-warning mb-3">👤 Profile Subscriptions</h4>
|
||||||
|
<p class="text-sm mb-3">
|
||||||
|
<code>subscribeToProfile(pubkey)</code> watches for changes to a specific user's profile.
|
||||||
|
</p>
|
||||||
|
<div class="space-y-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<strong>Perfect for:</strong>
|
||||||
|
</div>
|
||||||
|
<ul class="list-disc list-inside space-y-1 ml-2">
|
||||||
|
<li>User profile displays</li>
|
||||||
|
<li>Contact lists that stay current</li>
|
||||||
|
<li>Member directories</li>
|
||||||
|
<li>Avatar/name displays</li>
|
||||||
|
<li>User status indicators</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<h4 class="font-semibold mb-3 text-lg">How to Use Subscriptions:</h4>
|
||||||
|
<p class="mb-3 text-sm">
|
||||||
|
Here's a complete example showing how to set up subscriptions, handle incoming data, and clean up
|
||||||
|
properly:
|
||||||
|
</p>
|
||||||
|
<CodeBlock language="typescript" code={subscriptionExamples} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid md:grid-cols-2 gap-4">
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<div>
|
||||||
|
<h4 class="font-bold mb-2">! Memory Management</h4>
|
||||||
|
<div class="text-sm space-y-1">
|
||||||
|
<p>
|
||||||
|
Always call <code>unsubscribe()</code> when:
|
||||||
|
</p>
|
||||||
|
<ul class="list-disc list-inside ml-2">
|
||||||
|
<li>Your component unmounts</li>
|
||||||
|
<li>User navigates away</li>
|
||||||
|
<li>You no longer need the data</li>
|
||||||
|
<li>Your Arxlet is closing</li>
|
||||||
|
</ul>
|
||||||
|
<p class="mt-2">
|
||||||
|
<strong>Why?</strong> Prevents memory leaks and unnecessary disk i/o.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-success">
|
||||||
|
<div>
|
||||||
|
<h4 class="font-bold mb-2">✨ Pro Tips</h4>
|
||||||
|
<div class="text-sm space-y-1">
|
||||||
|
<ul class="list-disc list-inside">
|
||||||
|
<li>Use specific filters to reduce data volume</li>
|
||||||
|
<li>Debounce rapid updates for better UX</li>
|
||||||
|
<li>Cache data to avoid duplicate processing</li>
|
||||||
|
<li>
|
||||||
|
Handle errors gracefully with <code>catchError</code>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Consider using <code>takeUntil</code> for automatic cleanup
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* WebSocket Alternative */}
|
||||||
|
<div class="card bg-base-200 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 id="websocket-alternative" class="card-title text-accent mb-4">
|
||||||
|
🔌 Direct WebSocket Connection - Advanced Usage
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="space-y-4 mb-6">
|
||||||
|
<p>
|
||||||
|
<strong>What is the WebSocket approach?</strong> Instead of using the convenient <code>window.eve</code>{" "}
|
||||||
|
API, you can connect directly to the Nostr relay at <code>ws://localhost:6942</code> and speak the raw
|
||||||
|
Nostr protocol.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<strong>Why would you use this?</strong> Direct WebSocket gives you maximum control and performance. You
|
||||||
|
can implement custom subscription logic, handle multiple concurrent subscriptions efficiently, or
|
||||||
|
integrate with existing Nostr libraries.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<strong>The trade-off:</strong> You'll need to understand the Nostr protocol, handle JSON message parsing,
|
||||||
|
manage connection states, and implement your own error handling. It's more work but gives you complete
|
||||||
|
flexibility.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<h4 class="font-semibold mb-3 text-lg">WebSocket Implementation Example:</h4>
|
||||||
|
<p class="mb-3 text-sm">
|
||||||
|
Here's how to establish a WebSocket connection and communicate using standard Nostr protocol messages:
|
||||||
|
</p>
|
||||||
|
<CodeBlock language="typescript" code={websocketExample} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid md:grid-cols-2 gap-6 mb-6">
|
||||||
|
<div class="border-2 border-success rounded-lg p-4">
|
||||||
|
<h4 class="font-bold text-success mb-3">✨ Use window.eve When:</h4>
|
||||||
|
<div class="space-y-2 text-sm">
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<span class="text-success">✓</span>
|
||||||
|
<span>Building your first Arxlet</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<span class="text-success">✓</span>
|
||||||
|
<span>You want simple, clean code</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<span class="text-success">✓</span>
|
||||||
|
<span>Standard CRUD operations are enough</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<span class="text-success">✓</span>
|
||||||
|
<span>You prefer promise-based APIs</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<span class="text-success">✓</span>
|
||||||
|
<span>Built-in RxJS observables work for you</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<span class="text-success">✓</span>
|
||||||
|
<span>You don't need custom protocol handling</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-2 border-accent rounded-lg p-4">
|
||||||
|
<h4 class="font-bold text-accent mb-3">⚡ Use WebSocket When:</h4>
|
||||||
|
<div class="space-y-2 text-sm">
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<span class="text-accent">⚡</span>
|
||||||
|
<span>You need maximum performance</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<span class="text-accent">⚡</span>
|
||||||
|
<span>Custom subscription management required</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<span class="text-accent">⚡</span>
|
||||||
|
<span>Integrating existing Nostr libraries</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<span class="text-accent">⚡</span>
|
||||||
|
<span>You understand the Nostr protocol</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<span class="text-accent">⚡</span>
|
||||||
|
<span>Need fine-grained connection control</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<span class="text-accent">⚡</span>
|
||||||
|
<span>Building high-frequency applications</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<div>
|
||||||
|
<h4 class="font-bold mb-2">🎯 Choosing the Right Approach</h4>
|
||||||
|
<div class="text-sm space-y-2">
|
||||||
|
<p>
|
||||||
|
<strong>Start with window.eve:</strong> Even if you think you might need WebSocket later, begin with
|
||||||
|
the high-level API. You can always refactor specific parts to use WebSocket once you understand your
|
||||||
|
performance requirements.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Hybrid approach:</strong> Many successful Arxlets use <code>window.eve</code> for most
|
||||||
|
operations and WebSocket only for specific high-performance features like real-time chat or live
|
||||||
|
collaboration.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Migration path:</strong> The data structures are the same, so you can gradually migrate from{" "}
|
||||||
|
<code>window.eve</code>
|
||||||
|
to WebSocket for specific features without rewriting your entire application.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Best Practices */}
|
||||||
|
<div class="card bg-base-200 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 id="best-practices" class="card-title text-warning mb-4">
|
||||||
|
💡 Best Practices for Robust Arxlets
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="border-l-4 border-error pl-4">
|
||||||
|
<h4 class="font-bold text-lg text-error mb-3">🛡 Error Handling & Reliability</h4>
|
||||||
|
<div class="space-y-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<strong>Always use try-catch blocks:</strong>
|
||||||
|
<p>
|
||||||
|
Network requests can fail, relays can be down, or data might be malformed. Wrap all API calls to
|
||||||
|
prevent crashes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Check for null/undefined returns:</strong>
|
||||||
|
<p>
|
||||||
|
Query methods return <code>null</code> when no data is found. Always check before using the result.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Provide meaningful user feedback:</strong>
|
||||||
|
<p>
|
||||||
|
Show loading states, error messages, and success confirmations. Users should know what's happening.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Implement retry logic for critical operations:</strong>
|
||||||
|
<p>Publishing events or loading essential data should retry on failure with exponential backoff.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-l-4 border-success pl-4">
|
||||||
|
<h4 class="font-bold text-lg text-success mb-3">⚡ Performance & Efficiency</h4>
|
||||||
|
<div class="space-y-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<strong>Use specific, narrow filters:</strong>
|
||||||
|
<p>
|
||||||
|
Instead of fetching all events and filtering in JavaScript, use precise Nostr filters to reduce data
|
||||||
|
transfer.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Cache frequently accessed data:</strong>
|
||||||
|
<p>Profile information, avatars, and static content should be cached to avoid repeated API calls.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Implement pagination for large datasets:</strong>
|
||||||
|
<p>
|
||||||
|
Don't load thousands of events at once. Use <code>limit</code> and <code>until</code> parameters for
|
||||||
|
pagination.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Debounce rapid user actions:</strong>
|
||||||
|
<p>
|
||||||
|
If users can trigger API calls quickly (like typing in search), debounce to avoid overwhelming the
|
||||||
|
relay.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Unsubscribe from observables:</strong>
|
||||||
|
<p>Always clean up subscriptions to prevent memory leaks and unnecessary network traffic.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-l-4 border-info pl-4">
|
||||||
|
<h4 class="font-bold text-lg text-info mb-3">🎯 User Experience</h4>
|
||||||
|
<div class="space-y-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<strong>Show loading states:</strong>
|
||||||
|
<p>Use spinners, skeletons, or progress indicators while data loads. Empty screens feel broken.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Handle empty states gracefully:</strong>
|
||||||
|
<p>When no data is found, show helpful messages or suggestions rather than blank areas.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Implement optimistic updates:</strong>
|
||||||
|
<p>
|
||||||
|
Update the UI immediately when users take actions, then sync with the server. Makes apps feel
|
||||||
|
faster.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Provide offline indicators:</strong>
|
||||||
|
<p>Let users know when they're disconnected or when operations might not work.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-l-4 border-warning pl-4">
|
||||||
|
<h4 class="font-bold text-lg text-warning mb-3">🔒 Security & Privacy</h4>
|
||||||
|
<div class="space-y-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<strong>Validate all user inputs:</strong>
|
||||||
|
<p>Never trust user input. Validate, sanitize, and escape data before using it in events or UI.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Be mindful of public data:</strong>
|
||||||
|
<p>
|
||||||
|
Remember that events are visible to everyone in your CCN by default. Don't accidentally expose
|
||||||
|
private information.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Handle signing errors gracefully:</strong>
|
||||||
|
<p>Users might reject signing requests. Always have fallbacks and clear error messages.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Respect user privacy preferences:</strong>
|
||||||
|
<p>Some users prefer pseudonymous usage. Don't force real names or personal information.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-success mt-6">
|
||||||
|
<div>
|
||||||
|
<h4 class="font-bold mb-2">🚀 Quick Checklist for Production Arxlets</h4>
|
||||||
|
<div class="grid md:grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<strong>Code Quality:</strong>
|
||||||
|
<ul class="list-disc list-inside mt-1 space-y-1">
|
||||||
|
<li>All API calls wrapped in try-catch</li>
|
||||||
|
<li>Null checks before using data</li>
|
||||||
|
<li>Subscriptions properly cleaned up</li>
|
||||||
|
<li>Input validation implemented</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>User Experience:</strong>
|
||||||
|
<ul class="list-disc list-inside mt-1 space-y-1">
|
||||||
|
<li>Loading states for all async operations</li>
|
||||||
|
<li>Error messages are user-friendly</li>
|
||||||
|
<li>Empty states handled gracefully</li>
|
||||||
|
<li>Performance tested with large datasets</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
628
src/pages/docs/arxlets/components/BestPracticesSection.jsx
Normal file
628
src/pages/docs/arxlets/components/BestPracticesSection.jsx
Normal file
|
@ -0,0 +1,628 @@
|
||||||
|
// @jsx h
|
||||||
|
// @jsxImportSource preact
|
||||||
|
|
||||||
|
import { useSyntaxHighlighting } from "../hooks/useSyntaxHighlighting.js";
|
||||||
|
import { CodeBlock } from "./CodeBlock.jsx";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best Practices Section - Comprehensive development guidelines for Arxlets
|
||||||
|
*/
|
||||||
|
export const BestPracticesSection = () => {
|
||||||
|
useSyntaxHighlighting();
|
||||||
|
|
||||||
|
const errorHandlingExample = `// Always wrap API calls in try-catch blocks
|
||||||
|
async function loadUserData() {
|
||||||
|
try {
|
||||||
|
const events = await window.eve.getAllEventsWithFilter({
|
||||||
|
kinds: [0], // Profile events
|
||||||
|
limit: 10
|
||||||
|
});
|
||||||
|
|
||||||
|
if (events.length === 0) {
|
||||||
|
showEmptyState("No profiles found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
displayProfiles(events);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load profiles:", error);
|
||||||
|
showErrorMessage("Unable to load profiles. Please try again.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provide user feedback for all states
|
||||||
|
function showErrorMessage(message) {
|
||||||
|
const alert = document.createElement('div');
|
||||||
|
alert.className = 'alert alert-error';
|
||||||
|
alert.innerHTML = \`<span>\${message}</span>\`;
|
||||||
|
container.appendChild(alert);
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const performanceExample = `// Use specific filters to reduce data transfer
|
||||||
|
const efficientFilter = {
|
||||||
|
kinds: [1], // Only text notes
|
||||||
|
authors: [userPubkey], // Only from specific user
|
||||||
|
since: Math.floor(Date.now() / 1000) - 86400, // Last 24 hours
|
||||||
|
limit: 20 // Reasonable limit
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cache frequently accessed data
|
||||||
|
const profileCache = new Map();
|
||||||
|
|
||||||
|
async function getCachedProfile(pubkey) {
|
||||||
|
if (profileCache.has(pubkey)) {
|
||||||
|
return profileCache.get(pubkey);
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = await window.eve.getProfile(pubkey);
|
||||||
|
if (profile) {
|
||||||
|
profileCache.set(pubkey, profile);
|
||||||
|
}
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce rapid user actions
|
||||||
|
let searchTimeout;
|
||||||
|
function handleSearchInput(query) {
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
searchTimeout = setTimeout(() => {
|
||||||
|
performSearch(query);
|
||||||
|
}, 300); // Wait 300ms after user stops typing
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const subscriptionExample = `// Proper subscription management
|
||||||
|
let eventSubscription;
|
||||||
|
|
||||||
|
function startListening() {
|
||||||
|
eventSubscription = window.eve.subscribeToEvents({
|
||||||
|
kinds: [1],
|
||||||
|
limit: 50
|
||||||
|
}).subscribe({
|
||||||
|
next: (event) => {
|
||||||
|
addEventToUI(event);
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
console.error("Subscription error:", error);
|
||||||
|
showErrorMessage("Lost connection. Reconnecting...");
|
||||||
|
// Implement retry logic
|
||||||
|
setTimeout(startListening, 5000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL: Always clean up subscriptions
|
||||||
|
function cleanup() {
|
||||||
|
if (eventSubscription) {
|
||||||
|
eventSubscription.unsubscribe();
|
||||||
|
eventSubscription = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up when Arxlet is closed or user navigates away
|
||||||
|
window.addEventListener('beforeunload', cleanup);`;
|
||||||
|
|
||||||
|
const uiExample = `// Use DaisyUI components for consistency
|
||||||
|
function createLoadingState() {
|
||||||
|
return \`
|
||||||
|
<div class="flex justify-center items-center p-8">
|
||||||
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
|
<span class="ml-4">Loading profiles...</span>
|
||||||
|
</div>
|
||||||
|
\`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEmptyState() {
|
||||||
|
return \`
|
||||||
|
<div class="text-center p-8">
|
||||||
|
<div class="text-6xl mb-4">📭</div>
|
||||||
|
<h3 class="text-lg font-semibold mb-2">No messages yet</h3>
|
||||||
|
<p class="text-base-content/70">Be the first to start a conversation!</p>
|
||||||
|
<button class="btn btn-primary mt-4" onclick="openComposer()">
|
||||||
|
Write a message
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
\`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Implement optimistic updates for better UX
|
||||||
|
async function publishMessage(content) {
|
||||||
|
// Show message immediately (optimistic)
|
||||||
|
const tempId = 'temp-' + Date.now();
|
||||||
|
addMessageToUI({ id: tempId, content, pending: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const event = await window.eve.publish({
|
||||||
|
kind: 1,
|
||||||
|
content: content,
|
||||||
|
tags: []
|
||||||
|
});
|
||||||
|
|
||||||
|
// Replace temp message with real one
|
||||||
|
replaceMessageInUI(tempId, event);
|
||||||
|
} catch (error) {
|
||||||
|
// Remove temp message and show error
|
||||||
|
removeMessageFromUI(tempId);
|
||||||
|
showErrorMessage("Failed to send message");
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="space-y-8">
|
||||||
|
<div class="text-center mb-12">
|
||||||
|
<h2 class="text-4xl font-bold mb-4">✨ Best Practices</h2>
|
||||||
|
<p class="text-xl text-base-content/70 max-w-3xl mx-auto">
|
||||||
|
Master the art of building production-ready Arxlets with these comprehensive development guidelines
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title text-info mb-4">Building Production-Ready Arxlets</h3>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<p>
|
||||||
|
Creating a great Arxlet goes beyond just making it work - it needs to be reliable, performant, and provide
|
||||||
|
an excellent user experience. These best practices will help you build Arxlets that users love and that
|
||||||
|
work consistently in the CCN environment.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<strong>Why these practices matter:</strong> Arxlets run in a shared environment where performance issues
|
||||||
|
can affect other applications, and users expect the same level of polish they get from native apps.
|
||||||
|
Following these guidelines ensures your Arxlet integrates seamlessly with the CCN ecosystem.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-200 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 id="error-handling" class="card-title text-error mb-4">
|
||||||
|
🛡 Error Handling & Reliability
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="space-y-4 mb-6">
|
||||||
|
<p>
|
||||||
|
<strong>User experience first:</strong> When something goes wrong, users should know what happened and
|
||||||
|
what they can do about it. Silent failures are frustrating and make your app feel broken.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="border-l-4 border-error pl-4">
|
||||||
|
<h4 class="font-bold text-lg mb-3">Essential Error Handling Patterns</h4>
|
||||||
|
<div class="space-y-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<strong>Wrap all API calls in try-catch blocks:</strong>
|
||||||
|
<p>
|
||||||
|
Every call to <code>window.eve</code> functions can potentially fail. Always handle exceptions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Check for null/undefined returns:</strong>
|
||||||
|
<p>
|
||||||
|
Query methods return <code>null</code> when no data is found. Verify results before using them.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Provide meaningful user feedback:</strong>
|
||||||
|
<p>Show specific error messages that help users understand what went wrong and how to fix it.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Implement retry logic for critical operations:</strong>
|
||||||
|
<p>
|
||||||
|
Publishing events or loading essential data should retry automatically with exponential backoff.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<h4 class="font-semibold mb-3 text-lg">Practical Error Handling Example:</h4>
|
||||||
|
<CodeBlock language="typescript" code={errorHandlingExample} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid md:grid-cols-2 gap-4 mt-6">
|
||||||
|
<div class="alert alert-error">
|
||||||
|
<div>
|
||||||
|
<h4 class="font-bold mb-2">❌ Common Mistakes</h4>
|
||||||
|
<ul class="text-sm list-disc list-inside space-y-1">
|
||||||
|
<li>Not handling API failures</li>
|
||||||
|
<li>Assuming data will always exist</li>
|
||||||
|
<li>Silent failures with no user feedback</li>
|
||||||
|
<li>Generic "Something went wrong" messages</li>
|
||||||
|
<li>No retry mechanisms for critical operations</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-success">
|
||||||
|
<div>
|
||||||
|
<h4 class="font-bold mb-2">✅ Best Practices</h4>
|
||||||
|
<ul class="text-sm list-disc list-inside space-y-1">
|
||||||
|
<li>Specific, actionable error messages</li>
|
||||||
|
<li>Graceful degradation when features fail</li>
|
||||||
|
<li>Loading states for all async operations</li>
|
||||||
|
<li>Retry buttons for failed operations</li>
|
||||||
|
<li>Offline indicators when appropriate</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-200 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 id="performance" class="card-title text-success mb-4">
|
||||||
|
⚡ Performance & Efficiency
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="border-l-4 border-success pl-4">
|
||||||
|
<h4 class="font-bold text-lg mb-3">Performance Optimization Strategies</h4>
|
||||||
|
<div class="space-y-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<strong>Use specific, narrow filters:</strong>
|
||||||
|
<p>
|
||||||
|
Instead of fetching all events and filtering in JavaScript, use precise Nostr filters to reduce data
|
||||||
|
transfer.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Implement intelligent caching:</strong>
|
||||||
|
<p>Cache profile information, avatars, and other static content to avoid repeated API calls.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Paginate large datasets:</strong>
|
||||||
|
<p>
|
||||||
|
Don't load thousands of events at once. Use <code>limit</code> and <code>until</code> parameters for
|
||||||
|
pagination.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Debounce rapid user actions:</strong>
|
||||||
|
<p>
|
||||||
|
If users can trigger API calls quickly (like typing in search), debounce to avoid overwhelming the
|
||||||
|
relay.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<h4 class="font-semibold mb-3 text-lg">Performance Optimization Example:</h4>
|
||||||
|
<CodeBlock language="typescript" code={performanceExample} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-warning mt-6">
|
||||||
|
<div>
|
||||||
|
<h4 class="font-bold mb-2">! Performance Pitfalls to Avoid</h4>
|
||||||
|
<div class="text-sm space-y-2">
|
||||||
|
<p>
|
||||||
|
<strong>Overly broad filters:</strong> Fetching all events and filtering client-side wastes bandwidth.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>No pagination:</strong> Loading thousands of items at once can freeze the interface.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Repeated API calls:</strong> Fetching the same profile data multiple times is inefficient.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Unthrottled user input:</strong> Search-as-you-type without debouncing can overwhelm the
|
||||||
|
relay.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-200 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 id="subscriptions" class="card-title text-accent mb-4">
|
||||||
|
🔄 Subscription Management
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="space-y-4 mb-6">
|
||||||
|
<p>
|
||||||
|
<strong>Subscriptions power real-time features.</strong> They make your Arxlet feel alive by automatically
|
||||||
|
updating when new data arrives. However, they need careful management to prevent memory leaks.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<strong>Clean up is critical.</strong> Forgetting to unsubscribe from observables can cause memory leaks,
|
||||||
|
unnecessary disk i/o, and performance degradation over time.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="border-l-4 border-accent pl-4">
|
||||||
|
<h4 class="font-bold text-lg mb-3">Subscription Best Practices</h4>
|
||||||
|
<div class="space-y-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<strong>Always store subscription references:</strong>
|
||||||
|
<p>Keep references to all subscriptions so you can unsubscribe when needed.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Implement proper cleanup:</strong>
|
||||||
|
<p>Unsubscribe when components unmount, users navigate away, or the Arxlet closes.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Use specific filters:</strong>
|
||||||
|
<p>Narrow subscription filters reduce unnecessary data and improve performance.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<h4 class="font-semibold mb-3 text-lg">Proper Subscription Management:</h4>
|
||||||
|
<CodeBlock language="typescript" code={subscriptionExample} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-error mt-6">
|
||||||
|
<div>
|
||||||
|
<h4 class="font-bold mb-2">🚨 Memory Leak Prevention</h4>
|
||||||
|
<div class="text-sm space-y-2">
|
||||||
|
<p>
|
||||||
|
<strong>Always unsubscribe:</strong> Every <code>subscribe()</code> call must have a corresponding{" "}
|
||||||
|
<code>unsubscribe()</code>.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Clean up on navigation:</strong> Users might navigate away without properly closing your
|
||||||
|
Arxlet.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Handle page refresh:</strong> Use <code>beforeunload</code> event to clean up subscriptions.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Monitor subscription count:</strong> Too many active subscriptions can impact performance.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-200 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 id="user-experience" class="card-title text-info mb-4">
|
||||||
|
🎯 User Experience Excellence
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="space-y-4 mb-6">
|
||||||
|
<p>
|
||||||
|
<strong>Great UX makes the difference.</strong> Users expect responsive, intuitive interfaces that provide
|
||||||
|
clear feedback. Small details like loading states and empty state messages significantly impact user
|
||||||
|
satisfaction.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<strong>Consistency with CCN design.</strong> Using DaisyUI components ensures your Arxlet feels
|
||||||
|
integrated with the rest of the platform while saving you development time.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="border-l-4 border-info pl-4">
|
||||||
|
<h4 class="font-bold text-lg mb-3">UX Best Practices</h4>
|
||||||
|
<div class="space-y-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<strong>Show loading states for all async operations:</strong>
|
||||||
|
<p>Users should never see blank screens or wonder if something is happening.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Handle empty states gracefully:</strong>
|
||||||
|
<p>When no data is available, provide helpful messages or suggestions for next steps.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Implement optimistic updates:</strong>
|
||||||
|
<p>Update the UI immediately when users take actions, then sync with the server.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Use consistent DaisyUI components:</strong>
|
||||||
|
<p>Leverage the pre-built component library for consistent styling and behavior.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<h4 class="font-semibold mb-3 text-lg">UI/UX Implementation Examples:</h4>
|
||||||
|
<CodeBlock language="typescript" code={uiExample} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid md:grid-cols-2 gap-4 mt-6">
|
||||||
|
<div class="border-2 border-success rounded-lg p-4">
|
||||||
|
<h4 class="font-bold text-success mb-3">✨ Excellent UX Includes</h4>
|
||||||
|
<ul class="text-sm space-y-1 list-disc list-inside">
|
||||||
|
<li>Loading spinners for async operations</li>
|
||||||
|
<li>Helpful empty state messages</li>
|
||||||
|
<li>Immediate feedback for user actions</li>
|
||||||
|
<li>Clear error messages with solutions</li>
|
||||||
|
<li>Consistent visual design</li>
|
||||||
|
<li>Accessible keyboard navigation</li>
|
||||||
|
<li>Responsive layout for different screen sizes</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-2 border-warning rounded-lg p-4">
|
||||||
|
<h4 class="font-bold text-warning mb-3">! UX Anti-patterns</h4>
|
||||||
|
<ul class="text-sm space-y-1 list-disc list-inside">
|
||||||
|
<li>Blank screens during loading</li>
|
||||||
|
<li>No feedback for user actions</li>
|
||||||
|
<li>Generic or confusing error messages</li>
|
||||||
|
<li>Inconsistent styling with CCN</li>
|
||||||
|
<li>Broken layouts on mobile devices</li>
|
||||||
|
<li>Inaccessible interface elements</li>
|
||||||
|
<li>Slow or unresponsive interactions</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-200 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 id="security" class="card-title text-warning mb-4">
|
||||||
|
🔒 Security & Privacy Considerations
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="space-y-4 mb-6">
|
||||||
|
<p>
|
||||||
|
<strong>Security is everyone's responsibility.</strong> Even though Arxlets run in a sandboxed
|
||||||
|
environment, you still need to validate inputs, handle user data responsibly, and follow security best
|
||||||
|
practices.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<strong>Privacy by design.</strong> Remember that Nostr events are public by default. Be mindful of what
|
||||||
|
data you're storing and how you're handling user information.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="border-l-4 border-warning pl-4">
|
||||||
|
<h4 class="font-bold text-lg mb-3">Security Best Practices</h4>
|
||||||
|
<div class="space-y-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<strong>Validate all user inputs:</strong>
|
||||||
|
<p>Never trust user input. Validate, sanitize, and escape data before using it in events or UI.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Be mindful of public data:</strong>
|
||||||
|
<p>Nostr events are public by default. Don't accidentally expose private information.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Handle signing errors gracefully:</strong>
|
||||||
|
<p>Users might reject signing requests. Always have fallbacks and clear error messages.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Respect user privacy preferences:</strong>
|
||||||
|
<p>Some users prefer pseudonymous usage. Don't force real names or personal information.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Sanitize HTML content:</strong>
|
||||||
|
<p>If displaying user-generated content, sanitize it to prevent XSS attacks.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-error mt-6">
|
||||||
|
<div>
|
||||||
|
<h4 class="font-bold mb-2">🚨 Security Checklist</h4>
|
||||||
|
<div class="grid md:grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<strong>Input Validation:</strong>
|
||||||
|
<ul class="list-disc list-inside mt-1 space-y-1">
|
||||||
|
<li>Validate all form inputs</li>
|
||||||
|
<li>Sanitize user-generated content</li>
|
||||||
|
<li>Check data types and ranges</li>
|
||||||
|
<li>Escape HTML when displaying content</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Privacy Protection:</strong>
|
||||||
|
<ul class="list-disc list-inside mt-1 space-y-1">
|
||||||
|
<li>Don't store sensitive data in events</li>
|
||||||
|
<li>Respect user anonymity preferences</li>
|
||||||
|
<li>Handle signing rejections gracefully</li>
|
||||||
|
<li>Be transparent about data usage</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 id="production-checklist" class="card-title text-success mb-4">
|
||||||
|
🚀 Production Readiness Checklist
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="space-y-4 mb-6">
|
||||||
|
<p>
|
||||||
|
Before publishing your Arxlet, run through this comprehensive checklist to ensure it meets production
|
||||||
|
quality standards. A well-tested Arxlet provides a better user experience and reflects positively on the
|
||||||
|
entire CCN ecosystem.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid md:grid-cols-2 gap-6">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="border-l-4 border-success pl-4">
|
||||||
|
<h4 class="font-bold text-success mb-3">✅ Code Quality</h4>
|
||||||
|
<ul class="text-sm space-y-1 list-disc list-inside">
|
||||||
|
<li>All API calls wrapped in try-catch blocks</li>
|
||||||
|
<li>Null/undefined checks before using data</li>
|
||||||
|
<li>Subscriptions properly cleaned up</li>
|
||||||
|
<li>Input validation implemented</li>
|
||||||
|
<li>Error handling with user feedback</li>
|
||||||
|
<li>Performance optimizations applied</li>
|
||||||
|
<li>Code is well-commented and organized</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-l-4 border-info pl-4">
|
||||||
|
<h4 class="font-bold text-info mb-3">🎯 User Experience</h4>
|
||||||
|
<ul class="text-sm space-y-1 list-disc list-inside">
|
||||||
|
<li>Loading states for all async operations</li>
|
||||||
|
<li>Error messages are user-friendly</li>
|
||||||
|
<li>Empty states handled gracefully</li>
|
||||||
|
<li>Consistent DaisyUI styling</li>
|
||||||
|
<li>Responsive design for mobile</li>
|
||||||
|
<li>Keyboard navigation works</li>
|
||||||
|
<li>Accessibility features implemented</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="border-l-4 border-warning pl-4">
|
||||||
|
<h4 class="font-bold text-warning mb-3">🔒 Security & Privacy</h4>
|
||||||
|
<ul class="text-sm space-y-1 list-disc list-inside">
|
||||||
|
<li>User inputs are validated and sanitized</li>
|
||||||
|
<li>No sensitive data in public events</li>
|
||||||
|
<li>Signing errors handled gracefully</li>
|
||||||
|
<li>Privacy preferences respected</li>
|
||||||
|
<li>HTML content properly escaped</li>
|
||||||
|
<li>No hardcoded secrets or keys</li>
|
||||||
|
<li>Data usage is transparent</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-l-4 border-accent pl-4">
|
||||||
|
<h4 class="font-bold text-accent mb-3">⚡ Performance</h4>
|
||||||
|
<ul class="text-sm space-y-1 list-disc list-inside">
|
||||||
|
<li>Efficient Nostr filters used</li>
|
||||||
|
<li>Data caching implemented</li>
|
||||||
|
<li>Pagination for large datasets</li>
|
||||||
|
<li>User actions are debounced</li>
|
||||||
|
<li>Memory leaks prevented</li>
|
||||||
|
<li>Bundle size optimized</li>
|
||||||
|
<li>Performance tested with large datasets</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-success mt-6">
|
||||||
|
<div>
|
||||||
|
<h4 class="font-bold mb-2">🎉 Ready for Production!</h4>
|
||||||
|
<p class="text-sm">
|
||||||
|
Once you've checked off all these items, your Arxlet is ready to provide an excellent experience for CCN
|
||||||
|
users. Remember that you can always iterate and improve based on user feedback and changing
|
||||||
|
requirements.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
53
src/pages/docs/arxlets/components/CodeBlock.jsx
Normal file
53
src/pages/docs/arxlets/components/CodeBlock.jsx
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
// @jsx h
|
||||||
|
// @jsxImportSource preact
|
||||||
|
import { useState } from "preact/hooks";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reusable code block component with syntax highlighting and a copy button.
|
||||||
|
*/
|
||||||
|
export const CodeBlock = ({ language = "javascript", code }) => {
|
||||||
|
const [isCopied, setIsCopied] = useState(false);
|
||||||
|
|
||||||
|
const handleCopy = () => {
|
||||||
|
if (code) {
|
||||||
|
navigator.clipboard.writeText(code.trim());
|
||||||
|
setIsCopied(true);
|
||||||
|
setTimeout(() => setIsCopied(false), 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="mockup-code relative">
|
||||||
|
<button
|
||||||
|
class="absolute top-2 right-2 btn btn-ghost btn-sm"
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopy}
|
||||||
|
aria-label="Copy code"
|
||||||
|
>
|
||||||
|
{isCopied ? (
|
||||||
|
<span class="text-success">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<pre>
|
||||||
|
<code class={`language-${language}`}>{code?.trim()}</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
471
src/pages/docs/arxlets/components/DevelopmentSection.jsx
Normal file
471
src/pages/docs/arxlets/components/DevelopmentSection.jsx
Normal file
|
@ -0,0 +1,471 @@
|
||||||
|
// @jsx h
|
||||||
|
// @jsxImportSource preact
|
||||||
|
|
||||||
|
import buildCommand from "../highlight/build-command.sh" with { type: "text" };
|
||||||
|
import renderFunction from "../highlight/render-function.ts" with { type: "text" };
|
||||||
|
import { useSyntaxHighlighting } from "../hooks/useSyntaxHighlighting.js";
|
||||||
|
import { CodeBlock } from "./CodeBlock.jsx";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Development Section - Guide for building Arxlets
|
||||||
|
* Covers APIs, restrictions, and the required render function
|
||||||
|
*/
|
||||||
|
export const DevelopmentSection = () => {
|
||||||
|
useSyntaxHighlighting();
|
||||||
|
return (
|
||||||
|
<div class="space-y-6">
|
||||||
|
<h2 class="text-3xl font-bold">Development Guide</h2>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 id="understanding-arxlets" class="card-title text-info mb-4">
|
||||||
|
Understanding the Arxlet Environment
|
||||||
|
</h3>
|
||||||
|
<p class="mb-6">
|
||||||
|
When you build an Arxlet, you're creating a web application that runs inside the CCN. Think of it like
|
||||||
|
building a mini-website that has access to special CCN features and Nostr data. Here's what you have
|
||||||
|
available and what the limitations are:
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 id="nostr-vs-arxlets" class="card-title text-purple-600 mb-4">
|
||||||
|
🔄 Nostr Apps vs Arxlets: What's the Difference?
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="space-y-4 mb-6">
|
||||||
|
<p>
|
||||||
|
If you're coming from the broader Nostr ecosystem, you might be wondering how Arxlets relate to regular
|
||||||
|
Nostr applications. Here's the key relationship to understand:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="grid md:grid-cols-2 gap-6">
|
||||||
|
<div class="border-2 border-success rounded-lg p-4">
|
||||||
|
<h4 class="font-bold text-success mb-3">✅ Nostr App → Arxlet</h4>
|
||||||
|
<p class="text-sm mb-3">
|
||||||
|
<strong>Most Nostr apps CAN become Arxlets</strong> with some modifications:
|
||||||
|
</p>
|
||||||
|
<ul class="text-sm space-y-1 list-disc list-inside">
|
||||||
|
<li>
|
||||||
|
Replace external API calls with <code>window.eve</code> or local relay
|
||||||
|
</li>
|
||||||
|
<li>Adapt the UI to work within a container element</li>
|
||||||
|
<li>Remove routing if it conflicts with CCN navigation</li>
|
||||||
|
<li>
|
||||||
|
Use the provided <code>window.nostr</code> for signing
|
||||||
|
</li>
|
||||||
|
<li>Bundle everything into a single JavaScript file</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-2 border-warning rounded-lg p-4">
|
||||||
|
<h4 class="font-bold text-warning mb-3">! Arxlet → Nostr App</h4>
|
||||||
|
<p class="text-sm mb-3">
|
||||||
|
<strong>Not every Arxlet works as a standalone Nostr app</strong> because:
|
||||||
|
</p>
|
||||||
|
<ul class="text-sm space-y-1 list-disc list-inside">
|
||||||
|
<li>
|
||||||
|
May depend on CCN-specific APIs (<code>window.eve</code>)
|
||||||
|
</li>
|
||||||
|
<li>Designed for the sandboxed environment</li>
|
||||||
|
<li>Might rely on CCN member data or community features</li>
|
||||||
|
<li>UI optimized for container-based rendering</li>
|
||||||
|
<li>No independent relay connections</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<div>
|
||||||
|
<h4 class="font-bold mb-2">💡 Practical Examples:</h4>
|
||||||
|
<div class="text-sm space-y-2">
|
||||||
|
<p>
|
||||||
|
<strong>Easy to Port:</strong> A simple note-taking app, image gallery, or profile viewer can usually
|
||||||
|
be adapted to work as both.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>CCN-Specific:</strong> A CCN member directory, community chat, or collaborative workspace
|
||||||
|
might only make sense as an Arxlet.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Hybrid Approach:</strong> Many developers create a core library that works in both
|
||||||
|
environments, then build different interfaces for standalone vs Arxlet deployment.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-200 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 id="available-apis" class="card-title text-success mb-4">
|
||||||
|
✅ What You Can Use
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="border-l-4 border-success pl-4">
|
||||||
|
<h4 class="font-bold text-lg">window.eve - Your CCN Toolkit</h4>
|
||||||
|
<p class="text-sm opacity-80 mb-2">
|
||||||
|
This is your main interface to the CCN. It provides functions to read and write Nostr events, manage
|
||||||
|
data, and interact with other CCN members.
|
||||||
|
</p>
|
||||||
|
<p class="text-sm">
|
||||||
|
<strong>What it does:</strong> Lets you fetch events, publish new ones, manage user data, and access
|
||||||
|
CCN-specific features like member directories.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-l-4 border-success pl-4">
|
||||||
|
<h4 class="font-bold text-lg">window.nostr - Cryptographic Signing (NIP-07)</h4>
|
||||||
|
<p class="text-sm opacity-80 mb-2">
|
||||||
|
This is the standard Nostr extension API that lets your Arxlet create and sign events using the user's
|
||||||
|
private key.
|
||||||
|
</p>
|
||||||
|
<p class="text-sm">
|
||||||
|
<strong>What it does:</strong> Allows your app to publish events on behalf of the user, like posting
|
||||||
|
messages, creating profiles, or any other Nostr activity that requires authentication.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-l-4 border-success pl-4">
|
||||||
|
<h4 class="font-bold text-lg">DaisyUI 5 - Pre-built UI Components</h4>
|
||||||
|
<p class="text-sm opacity-80 mb-2">
|
||||||
|
A complete CSS framework with beautiful, accessible components already loaded and ready to use.
|
||||||
|
</p>
|
||||||
|
<p class="text-sm">
|
||||||
|
<strong>What it does:</strong> Provides buttons, cards, modals, forms, and dozens of other UI components
|
||||||
|
with consistent styling. No need to write CSS from scratch.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-l-4 border-success pl-4">
|
||||||
|
<h4 class="font-bold text-lg">Local Relay Connection</h4>
|
||||||
|
<p class="text-sm opacity-80 mb-2">
|
||||||
|
Direct WebSocket connection to <code>ws://localhost:6942</code> for real-time Nostr event streaming.
|
||||||
|
</p>
|
||||||
|
<p class="text-sm">
|
||||||
|
<strong>What it does:</strong> Lets you subscribe to live event feeds, get real-time updates, and
|
||||||
|
implement features like live chat or notifications.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-l-4 border-success pl-4">
|
||||||
|
<h4 class="font-bold text-lg">CCN Member Events</h4>
|
||||||
|
<p class="text-sm opacity-80 mb-2">Access to events from other members of your current CCN community.</p>
|
||||||
|
<p class="text-sm">
|
||||||
|
<strong>What it does:</strong> Enables community features like member directories, shared content, group
|
||||||
|
discussions, and collaborative tools.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-l-4 border-success pl-4">
|
||||||
|
<h4 class="font-bold text-lg">Standard Web APIs</h4>
|
||||||
|
<p class="text-sm opacity-80 mb-2">
|
||||||
|
Full access to modern browser APIs like localStorage, fetch (for local requests), DOM manipulation, and
|
||||||
|
more.
|
||||||
|
</p>
|
||||||
|
<p class="text-sm">
|
||||||
|
<strong>What it does:</strong> Everything you'd expect in a web app - store data locally, manipulate the
|
||||||
|
page, handle user interactions, etc.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-200 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 id="security-restrictions" class="card-title text-warning mb-4">
|
||||||
|
🔒 Security & Limitations
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="border-l-4 border-warning pl-4">
|
||||||
|
<h4 class="font-bold">No External Network Access</h4>
|
||||||
|
<p class="text-sm">
|
||||||
|
You can't make HTTP requests to external websites or APIs. All data must come through the CCN. This
|
||||||
|
prevents data leaks and ensures all communication goes through Nostr protocols.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-l-4 border-warning pl-4">
|
||||||
|
<h4 class="font-bold">CCN-Scoped Data</h4>
|
||||||
|
<p class="text-sm">
|
||||||
|
You only have access to events and data from your current CCN community. You can't see events from other
|
||||||
|
CCNs or the broader Nostr network unless they're specifically shared with your community.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-info mt-6">
|
||||||
|
<span>
|
||||||
|
💡 <strong>Why These Restrictions?</strong> These limitations ensure your Arxlet is secure, respects user
|
||||||
|
privacy, and works reliably within the CCN ecosystem. They also make your app more predictable and easier
|
||||||
|
to debug.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<span>
|
||||||
|
📚 <strong>Need More Details?</strong> Check the{" "}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="link link-primary font-semibold underline"
|
||||||
|
onClick={() => {
|
||||||
|
// Find and click the API Reference tab
|
||||||
|
const tabs = document.querySelectorAll(".menu > li > a");
|
||||||
|
const apiTab = Array.from(tabs).find((tab) => tab.textContent.trim() === "API Reference");
|
||||||
|
if (apiTab) {
|
||||||
|
apiTab.click();
|
||||||
|
// Scroll to top after tab switch
|
||||||
|
setTimeout(() => {
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
API Reference
|
||||||
|
</button>{" "}
|
||||||
|
tab for comprehensive documentation of the <code>window.eve</code> API, code examples, and WebSocket usage
|
||||||
|
patterns.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-200 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 id="typescript-development" class="card-title text-info mb-4">
|
||||||
|
🚀 Building Your Arxlet with TypeScript
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="space-y-4 mb-6">
|
||||||
|
<p>
|
||||||
|
<strong>What is TypeScript?</strong> TypeScript is JavaScript with type checking. It helps catch errors
|
||||||
|
before your code runs and provides better autocomplete in your editor. While you can write Arxlets in
|
||||||
|
plain JavaScript, TypeScript makes development much smoother.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<strong>Why use it for Arxlets?</strong> Eve provides TypeScript definitions for all APIs, so you'll get
|
||||||
|
autocomplete for <code>window.eve</code> functions, proper error checking, and better documentation right
|
||||||
|
in your editor.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<h4 class="font-semibold mb-3 text-lg">Building Your Code</h4>
|
||||||
|
<p class="mb-3">
|
||||||
|
Since Arxlets need to be a single JavaScript file, you'll use <strong>Bun</strong> (a fast JavaScript
|
||||||
|
runtime and bundler) to compile your TypeScript code. Here's the command that does everything:
|
||||||
|
</p>
|
||||||
|
<CodeBlock language="bash" code={buildCommand} />
|
||||||
|
|
||||||
|
<div class="bg-amber-50 border-l-4 border-amber-400 p-4 mt-4">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="ml-3">
|
||||||
|
<p class="text-sm text-amber-700">
|
||||||
|
<strong>⚠️ Svelte Exception:</strong> The above build command will NOT work for Svelte projects.
|
||||||
|
Svelte requires specific Vite configuration to compile properly. Instead, use our{" "}
|
||||||
|
<a
|
||||||
|
href="https://git.arx-ccn.com/Arx/arxlets-template"
|
||||||
|
class="link link-primary"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
arxlets-template
|
||||||
|
</a>{" "}
|
||||||
|
and simply run <code class="bg-amber-100 px-1 rounded">bun run build</code>. Your compiled file will
|
||||||
|
be available at <code class="bg-amber-100 px-1 rounded">dist/bundle.js</code> once built.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid md:grid-cols-2 gap-6 mt-6">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<h4 class="font-semibold text-lg">What Each Build Option Does:</h4>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="border-l-4 border-info pl-3">
|
||||||
|
<code class="font-bold">--minify</code>
|
||||||
|
<p class="text-sm">
|
||||||
|
Removes whitespace and shortens variable names to make your file smaller. Smaller files load faster.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="border-l-4 border-info pl-3">
|
||||||
|
<code class="font-bold">--target=browser</code>
|
||||||
|
<p class="text-sm">Tells Bun to optimize the code for web browsers instead of server environments.</p>
|
||||||
|
</div>
|
||||||
|
<div class="border-l-4 border-info pl-3">
|
||||||
|
<code class="font-bold">--production</code>
|
||||||
|
<p class="text-sm">
|
||||||
|
Enables all optimizations and removes development-only code for better performance.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<h4 class="font-semibold text-lg">Why TypeScript Helps:</h4>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="border-l-4 border-success pl-3">
|
||||||
|
<strong>Catch Errors Early</strong>
|
||||||
|
<p class="text-sm">
|
||||||
|
TypeScript finds mistakes like typos in function names or wrong parameter types before you run your
|
||||||
|
code.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="border-l-4 border-success pl-3">
|
||||||
|
<strong>Better Autocomplete</strong>
|
||||||
|
<p class="text-sm">
|
||||||
|
Your editor will suggest available functions and show you what parameters they expect.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="border-l-4 border-success pl-3">
|
||||||
|
<strong>Easier Refactoring</strong>
|
||||||
|
<p class="text-sm">
|
||||||
|
When you rename functions or change interfaces, TypeScript helps update all the places that use
|
||||||
|
them.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="border-l-4 border-success pl-3">
|
||||||
|
<strong>Self-Documenting Code</strong>
|
||||||
|
<p class="text-sm">
|
||||||
|
Type annotations serve as inline documentation, making your code easier to understand later.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-success mt-6">
|
||||||
|
<div>
|
||||||
|
<h4 class="font-bold mb-2">Recommended Development Workflow:</h4>
|
||||||
|
<ol class="list-decimal list-inside space-y-1 text-sm">
|
||||||
|
<li>
|
||||||
|
Create your main file as <kbd class="kbd">index.ts</kbd> (TypeScript)
|
||||||
|
</li>
|
||||||
|
<li>Write your Arxlet code with full TypeScript features</li>
|
||||||
|
<li>
|
||||||
|
Run the build command to create <kbd class="kbd">build.js</kbd>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Copy the contents of <kbd class="kbd">build.js</kbd> into your Nostr registration event
|
||||||
|
</li>
|
||||||
|
<li>Repeat steps 2-4 as you develop and test</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-200 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 id="required-export-function" class="card-title mb-4">
|
||||||
|
The Heart of Your Arxlet: The Render Function
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="space-y-4 mb-6">
|
||||||
|
<p>
|
||||||
|
<strong>What is the render function?</strong> This is the main entry point of your Arxlet - think of it as
|
||||||
|
the <code>main()</code>
|
||||||
|
function in other programming languages. When someone opens your Arxlet, Eve calls this function and gives
|
||||||
|
it a container element where your app should display itself.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<strong>How it works:</strong> The platform creates an empty <code><div></code> element and passes
|
||||||
|
it to your render function. Your job is to fill that container with your app's interface - buttons, forms,
|
||||||
|
content, whatever your Arxlet does.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<strong>Why this pattern?</strong> This approach gives you complete control over your app's interface
|
||||||
|
while keeping it isolated from other Arxlets and the main CCN interface.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 class="font-semibold mb-3 text-lg">Basic Structure:</h4>
|
||||||
|
<CodeBlock language="typescript" code={renderFunction} />
|
||||||
|
|
||||||
|
<div class="mt-6 space-y-4">
|
||||||
|
<h4 class="font-semibold text-lg">Breaking Down the Example:</h4>
|
||||||
|
|
||||||
|
<div class="grid gap-4">
|
||||||
|
<div class="border-l-4 border-primary pl-4">
|
||||||
|
<code class="font-bold">export function render(container: HTMLElement)</code>
|
||||||
|
<p class="text-sm mt-1">
|
||||||
|
This declares your main function. The <code>container</code> parameter is the DOM element where your
|
||||||
|
app will live. The <code>export</code> keyword makes it available to the CCN.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-l-4 border-primary pl-4">
|
||||||
|
<code class="font-bold">container.innerHTML = '...'</code>
|
||||||
|
<p class="text-sm mt-1">
|
||||||
|
This is the simplest way to add content - just set the HTML directly. For simple Arxlets, this might
|
||||||
|
be all you need.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-l-4 border-primary pl-4">
|
||||||
|
<code class="font-bold">const button = container.querySelector('button')</code>
|
||||||
|
<p class="text-sm mt-1">
|
||||||
|
After adding HTML, you can find elements and attach event listeners to make your app interactive.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-l-4 border-primary pl-4">
|
||||||
|
<code class="font-bold">window.eve.getEvents(...)</code>
|
||||||
|
<p class="text-sm mt-1">
|
||||||
|
This shows how to use the CCN API to fetch Nostr events. Most Arxlets will interact with Nostr data in
|
||||||
|
some way.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-success mt-6">
|
||||||
|
<div>
|
||||||
|
<h4 class="font-bold mb-2">Development Tips:</h4>
|
||||||
|
<ul class="list-disc list-inside space-y-1 text-sm">
|
||||||
|
<li>
|
||||||
|
<strong>Start Simple:</strong> Begin with basic HTML and gradually add interactivity
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Use Modern JavaScript:</strong> async/await, destructuring, arrow functions - it all works
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Leverage DaisyUI:</strong> Use pre-built components instead of writing CSS from scratch
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Handle Errors:</strong> Wrap API calls in try/catch blocks for better user experience
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Think Reactive:</strong> Update the UI when data changes, don't just set it once
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-info mt-4">
|
||||||
|
<span>
|
||||||
|
💡 <strong>Advanced Patterns:</strong> You can use any frontend framework (React, Vue, Svelte) by
|
||||||
|
rendering it into the container, or build complex apps with routing, state management, and real-time
|
||||||
|
updates. The render function is just your starting point!
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
60
src/pages/docs/arxlets/components/ExamplesSection.jsx
Normal file
60
src/pages/docs/arxlets/components/ExamplesSection.jsx
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
// @jsx h
|
||||||
|
// @jsxImportSource preact
|
||||||
|
|
||||||
|
import { CounterExample } from "../examples/CounterExample.jsx";
|
||||||
|
import { NostrPublisherExample } from "../examples/NostrPublisherExample.jsx";
|
||||||
|
import { PreactCounterExample } from "../examples/PreactCounterExample.jsx";
|
||||||
|
import { SvelteCounterExample } from "../examples/SvelteCounterExample.jsx";
|
||||||
|
|
||||||
|
const examples = {
|
||||||
|
vanilla: <CounterExample />,
|
||||||
|
preact: <PreactCounterExample />,
|
||||||
|
svelte: <SvelteCounterExample />,
|
||||||
|
nostr: <NostrPublisherExample />,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Examples Section - Practical Arxlet implementations
|
||||||
|
* Shows real-world examples with detailed explanations
|
||||||
|
*/
|
||||||
|
export const ExamplesSection = ({ activeExample }) => {
|
||||||
|
const ActiveComponent = examples[activeExample] || <div>Example not found</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="space-y-6">
|
||||||
|
<h2 class="text-3xl font-bold">Example Arxlets</h2>
|
||||||
|
|
||||||
|
<div class="bg-info border-l-4 border-info/50 p-4 mb-6">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="ml-3 text-info-content">
|
||||||
|
<p class="text-sm text-info-content">
|
||||||
|
<strong>Framework Freedom:</strong> These examples show basic implementations, but you're not limited to
|
||||||
|
vanilla JavaScript! You can use any framework you prefer - React with JSX, Preact, Vue, Svelte, or any
|
||||||
|
other modern framework. Bun's powerful plugin system supports transpilation and bundling for virtually any
|
||||||
|
JavaScript ecosystem.
|
||||||
|
</p>
|
||||||
|
<p class="text-sm t-2">
|
||||||
|
Check out{" "}
|
||||||
|
<a
|
||||||
|
href="https://bun.com/docs/runtime/plugins"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="underline hover:text-blue-900"
|
||||||
|
>
|
||||||
|
Bun's plugin documentation
|
||||||
|
</a>{" "}
|
||||||
|
to see how you can integrate your preferred tools and frameworks.
|
||||||
|
</p>
|
||||||
|
<p class="text-sm mt-2">
|
||||||
|
If you have any questions or run into problems, feel free to reach out to the team @ Arx (builders of Eve)
|
||||||
|
on Nostr: <kbd class="kbd">npub1ven4zk8xxw873876gx8y9g9l9fazkye9qnwnglcptgvfwxmygscqsxddfhif</kbd>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Content */}
|
||||||
|
<div class="mt-6">{ActiveComponent}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
15
src/pages/docs/arxlets/components/LLMsSection.jsx
Normal file
15
src/pages/docs/arxlets/components/LLMsSection.jsx
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
// @jsx h
|
||||||
|
// @jsxImportSource preact
|
||||||
|
|
||||||
|
import contextMd from "../highlight/context.md" with { type: "text" };
|
||||||
|
import { useSyntaxHighlighting } from "../hooks/useSyntaxHighlighting.js";
|
||||||
|
import { CodeBlock } from "./CodeBlock.jsx";
|
||||||
|
|
||||||
|
export const LLMsSection = () => {
|
||||||
|
useSyntaxHighlighting();
|
||||||
|
return (
|
||||||
|
<section id="llms" className="arxlet-docs-section">
|
||||||
|
<CodeBlock code={contextMd} />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
50
src/pages/docs/arxlets/components/OverviewSection.jsx
Normal file
50
src/pages/docs/arxlets/components/OverviewSection.jsx
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
// @jsx h
|
||||||
|
// @jsxImportSource preact
|
||||||
|
|
||||||
|
import typeDefinitions from "../highlight/type-definitions.ts" with { type: "text" };
|
||||||
|
import { useSyntaxHighlighting } from "../hooks/useSyntaxHighlighting.js";
|
||||||
|
import { CodeBlock } from "./CodeBlock.jsx";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overview Section - Introduction to Arxlets
|
||||||
|
* Explains what Arxlets are and their key features
|
||||||
|
*/
|
||||||
|
export const OverviewSection = () => {
|
||||||
|
useSyntaxHighlighting();
|
||||||
|
return (
|
||||||
|
<div class="space-y-8">
|
||||||
|
<div class="card bg-base-200 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-2xl mb-4">What are Arxlets?</h2>
|
||||||
|
<p class="text-lg mb-4">
|
||||||
|
Arxlets are secure, sandboxed JavaScript applications that extend Eve's functionality. They run inside Eve
|
||||||
|
and are registered on your CCN for member-only access.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<span>
|
||||||
|
<strong>Coming Soon:</strong> WASM support will be added in future releases for even more powerful
|
||||||
|
applications.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-200 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title text-secondary">📝 TypeScript Definitions</h3>
|
||||||
|
<p class="mb-4">Use these type definitions for full TypeScript support in your Arxlets:</p>
|
||||||
|
|
||||||
|
<CodeBlock language="typescript" code={typeDefinitions} />
|
||||||
|
|
||||||
|
<div class="alert alert-info mt-4">
|
||||||
|
<span>
|
||||||
|
<strong>Pro Tip:</strong> Save these types in a <code>types.ts</code> file and import them throughout your
|
||||||
|
Arxlet for better development experience and type safety.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
147
src/pages/docs/arxlets/components/RegistrationSection.jsx
Normal file
147
src/pages/docs/arxlets/components/RegistrationSection.jsx
Normal file
|
@ -0,0 +1,147 @@
|
||||||
|
// @jsx h
|
||||||
|
// @jsxImportSource preact
|
||||||
|
|
||||||
|
import registrationEvent from "../highlight/registration-event.json" with { type: "text" };
|
||||||
|
import { useSyntaxHighlighting } from "../hooks/useSyntaxHighlighting.js";
|
||||||
|
import { CodeBlock } from "./CodeBlock.jsx";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registration Section - How to register Arxlets on CCN
|
||||||
|
* Covers the Nostr event structure and required fields
|
||||||
|
*/
|
||||||
|
export const RegistrationSection = () => {
|
||||||
|
useSyntaxHighlighting();
|
||||||
|
return (
|
||||||
|
<div class="space-y-6">
|
||||||
|
<h2 class="text-3xl font-bold">Arxlet Registration</h2>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title text-info">What are Nostr Events?</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<p>
|
||||||
|
<strong>Nostr</strong> (Notes and Other Stuff Transmitted by Relays) is a decentralized protocol where all
|
||||||
|
data is stored as <strong>events</strong>. Think of events as structured messages that contain information
|
||||||
|
and are cryptographically signed by their authors.
|
||||||
|
</p>
|
||||||
|
<p>Each event has:</p>
|
||||||
|
<ul class="list-disc list-inside space-y-1 ml-4">
|
||||||
|
<li>
|
||||||
|
<strong>Kind:</strong> A number that defines what type of data the event contains (like a category)
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Content:</strong> The main data or message
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Tags:</strong> Additional metadata organized as key-value pairs
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Signature:</strong> Cryptographic proof that the author created this event
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
To register your Arxlet, you create a special event (kind 30420) that tells the CCN about your
|
||||||
|
application. This event acts like a "business card" for your Arxlet, containing its code, name, and other
|
||||||
|
details. If you publish this event publicly outside your CCN, this will be available in the arxlet store,
|
||||||
|
and any CCN will be able to install it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-200 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 id="nostr-event-structure" class="card-title">
|
||||||
|
Nostr Event Structure
|
||||||
|
</h3>
|
||||||
|
<p class="mb-4">
|
||||||
|
Register your Arxlet using a replaceable Nostr event with kind{" "}
|
||||||
|
<code class="badge badge-primary">30420</code>:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<CodeBlock language="json" code={registrationEvent} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="tag-reference" class="card bg-base-200 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title">Tag Reference</h3>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table table-zebra w-full">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Tag</th>
|
||||||
|
<th>Required</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Example</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<code>d</code>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge-error">Required</span>
|
||||||
|
</td>
|
||||||
|
<td>Unique identifier (alphanumeric, hyphens, underscores)</td>
|
||||||
|
<td>
|
||||||
|
<code>my-todo-app</code>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<code>name</code>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge-error">Required</span>
|
||||||
|
</td>
|
||||||
|
<td>Human-readable display name</td>
|
||||||
|
<td>
|
||||||
|
<code>Todo Manager</code>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<code>description</code>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge-warning">Optional</span>
|
||||||
|
</td>
|
||||||
|
<td>Brief description of functionality</td>
|
||||||
|
<td>
|
||||||
|
<code>Manage your tasks</code>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<code>script</code>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge-error">Required</span>
|
||||||
|
</td>
|
||||||
|
<td>Complete JavaScript code with render export</td>
|
||||||
|
<td>
|
||||||
|
<code>export function render...</code>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<code>icon</code>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge-warning">Optional</span>
|
||||||
|
</td>
|
||||||
|
<td>Iconify icon name and hex color</td>
|
||||||
|
<td>
|
||||||
|
<code>mdi:check-circle, #10b981</code>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
20
src/pages/docs/arxlets/examples/CounterExample.jsx
Normal file
20
src/pages/docs/arxlets/examples/CounterExample.jsx
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
// @jsx h
|
||||||
|
// @jsxImportSource preact
|
||||||
|
|
||||||
|
import { CodeBlock } from "../components/CodeBlock.jsx";
|
||||||
|
import code from "../highlight/counter.ts" with { type: "text" };
|
||||||
|
import { useSyntaxHighlighting } from "../hooks/useSyntaxHighlighting.js";
|
||||||
|
|
||||||
|
export const CounterExample = () => {
|
||||||
|
useSyntaxHighlighting();
|
||||||
|
return (
|
||||||
|
<div class="card bg-base-200 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title">🔢 Interactive Counter</h3>
|
||||||
|
<p class="mb-4">A simple counter demonstrating state management and event handling:</p>
|
||||||
|
|
||||||
|
<CodeBlock language="typescript" code={code} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
623
src/pages/docs/arxlets/examples/DevelopmentTips.jsx
Normal file
623
src/pages/docs/arxlets/examples/DevelopmentTips.jsx
Normal file
|
@ -0,0 +1,623 @@
|
||||||
|
// @jsx h
|
||||||
|
// @jsxImportSource preact
|
||||||
|
|
||||||
|
import { CodeBlock } from "../components/CodeBlock.jsx";
|
||||||
|
import { useSyntaxHighlighting } from "../hooks/useSyntaxHighlighting.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Development Tips Component - Comprehensive best practices for Arxlet development
|
||||||
|
*/
|
||||||
|
export const DevelopmentTips = () => {
|
||||||
|
useSyntaxHighlighting();
|
||||||
|
|
||||||
|
const errorHandlingExample = `// Always wrap API calls in try-catch blocks
|
||||||
|
async function loadUserData() {
|
||||||
|
try {
|
||||||
|
const events = await window.eve.getAllEventsWithFilter({
|
||||||
|
kinds: [0], // Profile events
|
||||||
|
limit: 10
|
||||||
|
});
|
||||||
|
|
||||||
|
if (events.length === 0) {
|
||||||
|
showEmptyState("No profiles found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
displayProfiles(events);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load profiles:", error);
|
||||||
|
showErrorMessage("Unable to load profiles. Please try again.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provide user feedback for all states
|
||||||
|
function showErrorMessage(message) {
|
||||||
|
const alert = document.createElement('div');
|
||||||
|
alert.className = 'alert alert-error';
|
||||||
|
alert.innerHTML = \`<span>\${message}</span>\`;
|
||||||
|
container.appendChild(alert);
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const performanceExample = `// Use specific filters to reduce data transfer
|
||||||
|
const efficientFilter = {
|
||||||
|
kinds: [1], // Only text notes
|
||||||
|
authors: [userPubkey], // Only from specific user
|
||||||
|
since: Math.floor(Date.now() / 1000) - 86400, // Last 24 hours
|
||||||
|
limit: 20 // Reasonable limit
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cache frequently accessed data
|
||||||
|
const profileCache = new Map();
|
||||||
|
|
||||||
|
async function getCachedProfile(pubkey) {
|
||||||
|
if (profileCache.has(pubkey)) {
|
||||||
|
return profileCache.get(pubkey);
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = await window.eve.getProfile(pubkey);
|
||||||
|
if (profile) {
|
||||||
|
profileCache.set(pubkey, profile);
|
||||||
|
}
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce rapid user actions
|
||||||
|
let searchTimeout;
|
||||||
|
function handleSearchInput(query) {
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
searchTimeout = setTimeout(() => {
|
||||||
|
performSearch(query);
|
||||||
|
}, 300); // Wait 300ms after user stops typing
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const subscriptionExample = `// Proper subscription management
|
||||||
|
let eventSubscription;
|
||||||
|
|
||||||
|
function startListening() {
|
||||||
|
eventSubscription = window.eve.subscribeToEvents({
|
||||||
|
kinds: [1],
|
||||||
|
limit: 50
|
||||||
|
}).subscribe({
|
||||||
|
next: (event) => {
|
||||||
|
addEventToUI(event);
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
console.error("Subscription error:", error);
|
||||||
|
showErrorMessage("Lost connection. Reconnecting...");
|
||||||
|
// Implement retry logic
|
||||||
|
setTimeout(startListening, 5000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL: Always clean up subscriptions
|
||||||
|
function cleanup() {
|
||||||
|
if (eventSubscription) {
|
||||||
|
eventSubscription.unsubscribe();
|
||||||
|
eventSubscription = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up when Arxlet is closed or user navigates away
|
||||||
|
window.addEventListener('beforeunload', cleanup);`;
|
||||||
|
|
||||||
|
const uiExample = `// Use DaisyUI components for consistency
|
||||||
|
function createLoadingState() {
|
||||||
|
return \`
|
||||||
|
<div class="flex justify-center items-center p-8">
|
||||||
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
|
<span class="ml-4">Loading profiles...</span>
|
||||||
|
</div>
|
||||||
|
\`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEmptyState() {
|
||||||
|
return \`
|
||||||
|
<div class="text-center p-8">
|
||||||
|
<div class="text-6xl mb-4">📭</div>
|
||||||
|
<h3 class="text-lg font-semibold mb-2">No messages yet</h3>
|
||||||
|
<p class="text-base-content/70">Be the first to start a conversation!</p>
|
||||||
|
<button class="btn btn-primary mt-4" onclick="openComposer()">
|
||||||
|
Write a message
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
\`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Implement optimistic updates for better UX
|
||||||
|
async function publishMessage(content) {
|
||||||
|
// Show message immediately (optimistic)
|
||||||
|
const tempId = 'temp-' + Date.now();
|
||||||
|
addMessageToUI({ id: tempId, content, pending: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const event = await window.eve.publish({
|
||||||
|
kind: 1,
|
||||||
|
content: content,
|
||||||
|
tags: []
|
||||||
|
});
|
||||||
|
|
||||||
|
// Replace temp message with real one
|
||||||
|
replaceMessageInUI(tempId, event);
|
||||||
|
} catch (error) {
|
||||||
|
// Remove temp message and show error
|
||||||
|
removeMessageFromUI(tempId);
|
||||||
|
showErrorMessage("Failed to send message");
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="space-y-6">
|
||||||
|
<h2 class="text-3xl font-bold">💡 Development Best Practices</h2>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title text-info mb-4">Building Production-Ready Arxlets</h3>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<p>
|
||||||
|
Creating a great Arxlet goes beyond just making it work - it needs to be reliable, performant, and provide
|
||||||
|
an excellent user experience. These best practices will help you build Arxlets that users love and that
|
||||||
|
work consistently in the CCN environment.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<strong>Why these practices matter:</strong> Arxlets run in a shared environment where performance issues
|
||||||
|
can affect other applications, and users expect the same level of polish they get from native apps.
|
||||||
|
Following these guidelines ensures your Arxlet integrates seamlessly with the CCN ecosystem.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-200 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 id="error-handling" class="card-title text-error mb-4">
|
||||||
|
🛡 Error Handling & Reliability
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="space-y-4 mb-6">
|
||||||
|
<p>
|
||||||
|
<strong>User experience first:</strong> When something goes wrong, users should know what happened and
|
||||||
|
what they can do about it. Silent failures are frustrating and make your app feel broken.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="border-l-4 border-error pl-4">
|
||||||
|
<h4 class="font-bold text-lg mb-3">Essential Error Handling Patterns</h4>
|
||||||
|
<div class="space-y-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<strong>Wrap all API calls in try-catch blocks:</strong>
|
||||||
|
<p>
|
||||||
|
Every call to <code>window.eve</code> functions can potentially fail. Always handle exceptions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Check for null/undefined returns:</strong>
|
||||||
|
<p>
|
||||||
|
Query methods return <code>null</code> when no data is found. Verify results before using them.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Provide meaningful user feedback:</strong>
|
||||||
|
<p>Show specific error messages that help users understand what went wrong and how to fix it.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Implement retry logic for critical operations:</strong>
|
||||||
|
<p>
|
||||||
|
Publishing events or loading essential data should retry automatically with exponential backoff.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<h4 class="font-semibold mb-3 text-lg">Practical Error Handling Example:</h4>
|
||||||
|
<CodeBlock language="typescript" code={errorHandlingExample} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid md:grid-cols-2 gap-4 mt-6">
|
||||||
|
<div class="alert alert-error">
|
||||||
|
<div>
|
||||||
|
<h4 class="font-bold mb-2">❌ Common Mistakes</h4>
|
||||||
|
<ul class="text-sm list-disc list-inside space-y-1">
|
||||||
|
<li>Not handling API failures</li>
|
||||||
|
<li>Assuming data will always exist</li>
|
||||||
|
<li>Silent failures with no user feedback</li>
|
||||||
|
<li>Generic "Something went wrong" messages</li>
|
||||||
|
<li>No retry mechanisms for critical operations</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-success">
|
||||||
|
<div>
|
||||||
|
<h4 class="font-bold mb-2">✅ Best Practices</h4>
|
||||||
|
<ul class="text-sm list-disc list-inside space-y-1">
|
||||||
|
<li>Specific, actionable error messages</li>
|
||||||
|
<li>Graceful degradation when features fail</li>
|
||||||
|
<li>Loading states for all async operations</li>
|
||||||
|
<li>Retry buttons for failed operations</li>
|
||||||
|
<li>Offline indicators when appropriate</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-200 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 id="performance" class="card-title text-success mb-4">
|
||||||
|
⚡ Performance & Efficiency
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="border-l-4 border-success pl-4">
|
||||||
|
<h4 class="font-bold text-lg mb-3">Performance Optimization Strategies</h4>
|
||||||
|
<div class="space-y-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<strong>Use specific, narrow filters:</strong>
|
||||||
|
<p>
|
||||||
|
Instead of fetching all events and filtering in JavaScript, use precise Nostr filters to reduce data
|
||||||
|
transfer.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Implement intelligent caching:</strong>
|
||||||
|
<p>Cache profile information, avatars, and other static content to avoid repeated API calls.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Paginate large datasets:</strong>
|
||||||
|
<p>
|
||||||
|
Don't load thousands of events at once. Use <code>limit</code> and <code>until</code> parameters for
|
||||||
|
pagination.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Debounce rapid user actions:</strong>
|
||||||
|
<p>
|
||||||
|
If users can trigger API calls quickly (like typing in search), debounce to avoid overwhelming the
|
||||||
|
relay.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<h4 class="font-semibold mb-3 text-lg">Performance Optimization Example:</h4>
|
||||||
|
<CodeBlock language="typescript" code={performanceExample} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-warning mt-6">
|
||||||
|
<div>
|
||||||
|
<h4 class="font-bold mb-2">! Performance Pitfalls to Avoid</h4>
|
||||||
|
<div class="text-sm space-y-2">
|
||||||
|
<p>
|
||||||
|
<strong>Overly broad filters:</strong> Fetching all events and filtering client-side wastes bandwidth.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>No pagination:</strong> Loading thousands of items at once can freeze the interface.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Repeated API calls:</strong> Fetching the same profile data multiple times is inefficient.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Unthrottled user input:</strong> Search-as-you-type without debouncing can overwhelm the
|
||||||
|
relay.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-200 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 id="subscriptions" class="card-title text-accent mb-4">
|
||||||
|
🔄 Subscription Management
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="space-y-4 mb-6">
|
||||||
|
<p>
|
||||||
|
<strong>Subscriptions power real-time features.</strong> They make your Arxlet feel alive by automatically
|
||||||
|
updating when new data arrives. However, they need careful management to prevent memory leaks.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<strong>Clean up is critical.</strong> Forgetting to unsubscribe from observables can cause memory leaks,
|
||||||
|
unnecessary i/o usage, and performance degradation over time.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="border-l-4 border-accent pl-4">
|
||||||
|
<h4 class="font-bold text-lg mb-3">Subscription Best Practices</h4>
|
||||||
|
<div class="space-y-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<strong>Always store subscription references:</strong>
|
||||||
|
<p>Keep references to all subscriptions so you can unsubscribe when needed.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Implement proper cleanup:</strong>
|
||||||
|
<p>Unsubscribe when components unmount, users navigate away, or the Arxlet closes.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Use specific filters:</strong>
|
||||||
|
<p>Narrow subscription filters reduce unnecessary data and improve performance.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<h4 class="font-semibold mb-3 text-lg">Proper Subscription Management:</h4>
|
||||||
|
<CodeBlock language="typescript" code={subscriptionExample} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-error mt-6">
|
||||||
|
<div>
|
||||||
|
<h4 class="font-bold mb-2">🚨 Memory Leak Prevention</h4>
|
||||||
|
<div class="text-sm space-y-2">
|
||||||
|
<p>
|
||||||
|
<strong>Always unsubscribe:</strong> Every <code>subscribe()</code> call must have a corresponding{" "}
|
||||||
|
<code>unsubscribe()</code>.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Clean up on navigation:</strong> Users might navigate away without properly closing your
|
||||||
|
Arxlet.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Handle page refresh:</strong> Use <code>beforeunload</code> event to clean up subscriptions.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Monitor subscription count:</strong> Too many active subscriptions can impact performance.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-200 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 id="user-experience" class="card-title text-info mb-4">
|
||||||
|
🎯 User Experience Excellence
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="space-y-4 mb-6">
|
||||||
|
<p>
|
||||||
|
<strong>Great UX makes the difference.</strong> Users expect responsive, intuitive interfaces that provide
|
||||||
|
clear feedback. Small details like loading states and empty state messages significantly impact user
|
||||||
|
satisfaction.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<strong>Consistency with CCN design.</strong> Using DaisyUI components ensures your Arxlet feels
|
||||||
|
integrated with the rest of the platform while saving you development time.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="border-l-4 border-info pl-4">
|
||||||
|
<h4 class="font-bold text-lg mb-3">UX Best Practices</h4>
|
||||||
|
<div class="space-y-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<strong>Show loading states for all async operations:</strong>
|
||||||
|
<p>Users should never see blank screens or wonder if something is happening.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Handle empty states gracefully:</strong>
|
||||||
|
<p>When no data is available, provide helpful messages or suggestions for next steps.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Implement optimistic updates:</strong>
|
||||||
|
<p>Update the UI immediately when users take actions, then sync with the server.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Use consistent DaisyUI components:</strong>
|
||||||
|
<p>Leverage the pre-built component library for consistent styling and behavior.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<h4 class="font-semibold mb-3 text-lg">UI/UX Implementation Examples:</h4>
|
||||||
|
<CodeBlock language="typescript" code={uiExample} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid md:grid-cols-2 gap-4 mt-6">
|
||||||
|
<div class="border-2 border-success rounded-lg p-4">
|
||||||
|
<h4 class="font-bold text-success mb-3">✨ Excellent UX Includes</h4>
|
||||||
|
<ul class="text-sm space-y-1 list-disc list-inside">
|
||||||
|
<li>Loading spinners for async operations</li>
|
||||||
|
<li>Helpful empty state messages</li>
|
||||||
|
<li>Immediate feedback for user actions</li>
|
||||||
|
<li>Clear error messages with solutions</li>
|
||||||
|
<li>Consistent visual design</li>
|
||||||
|
<li>Accessible keyboard navigation</li>
|
||||||
|
<li>Responsive layout for different screen sizes</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-2 border-warning rounded-lg p-4">
|
||||||
|
<h4 class="font-bold text-warning mb-3">! UX Anti-patterns</h4>
|
||||||
|
<ul class="text-sm space-y-1 list-disc list-inside">
|
||||||
|
<li>Blank screens during loading</li>
|
||||||
|
<li>No feedback for user actions</li>
|
||||||
|
<li>Generic or confusing error messages</li>
|
||||||
|
<li>Inconsistent styling with CCN</li>
|
||||||
|
<li>Broken layouts on mobile devices</li>
|
||||||
|
<li>Inaccessible interface elements</li>
|
||||||
|
<li>Slow or unresponsive interactions</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-200 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 id="security" class="card-title text-warning mb-4">
|
||||||
|
🔒 Security & Privacy Considerations
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="space-y-4 mb-6">
|
||||||
|
<p>
|
||||||
|
<strong>Security is everyone's responsibility.</strong> Even though Arxlets run in a sandboxed
|
||||||
|
environment, you still need to validate inputs, handle user data responsibly, and follow security best
|
||||||
|
practices.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<strong>Privacy by design.</strong> Remember that events are visible to everyone in your CCN by default.
|
||||||
|
Be mindful of what data you're storing and how you're handling user information.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="border-l-4 border-warning pl-4">
|
||||||
|
<h4 class="font-bold text-lg mb-3">Security Best Practices</h4>
|
||||||
|
<div class="space-y-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<strong>Validate all user inputs:</strong>
|
||||||
|
<p>Never trust user input. Validate, sanitize, and escape data before using it in events or UI.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Be mindful of public data:</strong>
|
||||||
|
<p>Nostr events are public by default. Don't accidentally expose private information.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Handle signing errors gracefully:</strong>
|
||||||
|
<p>Users might reject signing requests. Always have fallbacks and clear error messages.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Respect user privacy preferences:</strong>
|
||||||
|
<p>Some users prefer pseudonymous usage. Don't force real names or personal information.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Sanitize HTML content:</strong>
|
||||||
|
<p>If displaying user-generated content, sanitize it to prevent XSS attacks.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-error mt-6">
|
||||||
|
<div>
|
||||||
|
<h4 class="font-bold mb-2">🚨 Security Checklist</h4>
|
||||||
|
<div class="grid md:grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<strong>Input Validation:</strong>
|
||||||
|
<ul class="list-disc list-inside mt-1 space-y-1">
|
||||||
|
<li>Validate all form inputs</li>
|
||||||
|
<li>Sanitize user-generated content</li>
|
||||||
|
<li>Check data types and ranges</li>
|
||||||
|
<li>Escape HTML when displaying content</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Privacy Protection:</strong>
|
||||||
|
<ul class="list-disc list-inside mt-1 space-y-1">
|
||||||
|
<li>Don't store sensitive data in events</li>
|
||||||
|
<li>Respect user anonymity preferences</li>
|
||||||
|
<li>Handle signing rejections gracefully</li>
|
||||||
|
<li>Be transparent about data usage</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 id="production-checklist" class="card-title text-success mb-4">
|
||||||
|
🚀 Production Readiness Checklist
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="space-y-4 mb-6">
|
||||||
|
<p>
|
||||||
|
Before publishing your Arxlet, run through this comprehensive checklist to ensure it meets production
|
||||||
|
quality standards. A well-tested Arxlet provides a better user experience and reflects positively on the
|
||||||
|
entire CCN ecosystem.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid md:grid-cols-2 gap-6">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="border-l-4 border-success pl-4">
|
||||||
|
<h4 class="font-bold text-success mb-3">✅ Code Quality</h4>
|
||||||
|
<ul class="text-sm space-y-1 list-disc list-inside">
|
||||||
|
<li>All API calls wrapped in try-catch blocks</li>
|
||||||
|
<li>Null/undefined checks before using data</li>
|
||||||
|
<li>Subscriptions properly cleaned up</li>
|
||||||
|
<li>Input validation implemented</li>
|
||||||
|
<li>Error handling with user feedback</li>
|
||||||
|
<li>Performance optimizations applied</li>
|
||||||
|
<li>Code is well-commented and organized</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-l-4 border-info pl-4">
|
||||||
|
<h4 class="font-bold text-info mb-3">🎯 User Experience</h4>
|
||||||
|
<ul class="text-sm space-y-1 list-disc list-inside">
|
||||||
|
<li>Loading states for all async operations</li>
|
||||||
|
<li>Error messages are user-friendly</li>
|
||||||
|
<li>Empty states handled gracefully</li>
|
||||||
|
<li>Consistent DaisyUI styling</li>
|
||||||
|
<li>Responsive design for mobile</li>
|
||||||
|
<li>Keyboard navigation works</li>
|
||||||
|
<li>Accessibility features implemented</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="border-l-4 border-warning pl-4">
|
||||||
|
<h4 class="font-bold text-warning mb-3">🔒 Security & Privacy</h4>
|
||||||
|
<ul class="text-sm space-y-1 list-disc list-inside">
|
||||||
|
<li>User inputs are validated and sanitized</li>
|
||||||
|
<li>No sensitive data in public events</li>
|
||||||
|
<li>Signing errors handled gracefully</li>
|
||||||
|
<li>Privacy preferences respected</li>
|
||||||
|
<li>HTML content properly escaped</li>
|
||||||
|
<li>No hardcoded secrets or keys</li>
|
||||||
|
<li>Data usage is transparent</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-l-4 border-accent pl-4">
|
||||||
|
<h4 class="font-bold text-accent mb-3">⚡ Performance</h4>
|
||||||
|
<ul class="text-sm space-y-1 list-disc list-inside">
|
||||||
|
<li>Efficient Nostr filters used</li>
|
||||||
|
<li>Data caching implemented</li>
|
||||||
|
<li>Pagination for large datasets</li>
|
||||||
|
<li>User actions are debounced</li>
|
||||||
|
<li>Memory leaks prevented</li>
|
||||||
|
<li>Bundle size optimized</li>
|
||||||
|
<li>Performance tested with large datasets</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-success mt-6">
|
||||||
|
<div>
|
||||||
|
<h4 class="font-bold mb-2">🎉 Ready for Production!</h4>
|
||||||
|
<p class="text-sm">
|
||||||
|
Once you've checked off all these items, your Arxlet is ready to provide an excellent experience for CCN
|
||||||
|
users. Remember that you can always iterate and improve based on user feedback and changing
|
||||||
|
requirements.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
20
src/pages/docs/arxlets/examples/NostrPublisherExample.jsx
Normal file
20
src/pages/docs/arxlets/examples/NostrPublisherExample.jsx
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
// @jsx h
|
||||||
|
// @jsxImportSource preact
|
||||||
|
|
||||||
|
import { CodeBlock } from "../components/CodeBlock.jsx";
|
||||||
|
import code from "../highlight/nostr-publisher.ts" with { type: "text" };
|
||||||
|
import { useSyntaxHighlighting } from "../hooks/useSyntaxHighlighting.js";
|
||||||
|
|
||||||
|
export const NostrPublisherExample = () => {
|
||||||
|
useSyntaxHighlighting();
|
||||||
|
return (
|
||||||
|
<div class="card bg-base-200 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title">📝 Nostr Note Publisher</h3>
|
||||||
|
<p class="mb-4">Publish notes to your CCN using the window.eve API:</p>
|
||||||
|
|
||||||
|
<CodeBlock language="typescript" code={code} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
20
src/pages/docs/arxlets/examples/PreactCounterExample.jsx
Normal file
20
src/pages/docs/arxlets/examples/PreactCounterExample.jsx
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
// @jsx h
|
||||||
|
// @jsxImportSource preact
|
||||||
|
|
||||||
|
import { CodeBlock } from "../components/CodeBlock.jsx";
|
||||||
|
import code from "../highlight/preact-counter.tsx" with { type: "text" };
|
||||||
|
import { useSyntaxHighlighting } from "../hooks/useSyntaxHighlighting.js";
|
||||||
|
|
||||||
|
export const PreactCounterExample = () => {
|
||||||
|
useSyntaxHighlighting();
|
||||||
|
return (
|
||||||
|
<div class="card bg-base-200 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title">⚛ Preact Counter with JSX</h3>
|
||||||
|
<p class="mb-4">A modern counter using Preact hooks and JSX syntax:</p>
|
||||||
|
|
||||||
|
<CodeBlock language="typescript" code={code} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
55
src/pages/docs/arxlets/examples/SvelteCounterExample.jsx
Normal file
55
src/pages/docs/arxlets/examples/SvelteCounterExample.jsx
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
// @jsx h
|
||||||
|
// @jsxImportSource preact
|
||||||
|
|
||||||
|
import { CodeBlock } from "../components/CodeBlock.jsx";
|
||||||
|
import code from "../highlight/svelte-counter.svelte" with { type: "text" };
|
||||||
|
import { useSyntaxHighlighting } from "../hooks/useSyntaxHighlighting.js";
|
||||||
|
|
||||||
|
export const SvelteCounterExample = () => {
|
||||||
|
useSyntaxHighlighting();
|
||||||
|
return (
|
||||||
|
<div class="card bg-base-200 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title">🔥 Svelte Counter</h3>
|
||||||
|
<p class="mb-4">A reactive counter built with Svelte's elegant syntax and built-in reactivity:</p>
|
||||||
|
|
||||||
|
<div class="bg-green-50 border-l-4 border-green-400 p-4 mb-4">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="ml-3">
|
||||||
|
<p class="text-sm text-green-700">
|
||||||
|
<strong>Why Svelte?</strong> Svelte compiles to vanilla JavaScript with no runtime overhead, making it
|
||||||
|
perfect for arxlets. Features like runes (<kbd class="kbd">$state()</kbd>,{" "}
|
||||||
|
<kbd class="kbd">$derived()</kbd>, etc), scoped CSS and intuitive event handling make development a
|
||||||
|
breeze.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-amber-50 border-l-4 border-amber-400 p-4 mb-4">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="ml-3">
|
||||||
|
<p class="text-sm text-amber-700">
|
||||||
|
<strong>Build Setup Note:</strong> Building Svelte for arxlets requires specific Vite configuration to
|
||||||
|
handle the compilation properly. While this adds some initial complexity, we've created a ready-to-use
|
||||||
|
template at{" "}
|
||||||
|
<a
|
||||||
|
href="https://git.arx-ccn.com/Arx/arxlets-template"
|
||||||
|
class="link link-primary"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
arxlets-template
|
||||||
|
</a>{" "}
|
||||||
|
with all the correct configurations. We still highly recommend Svelte because once set up, the
|
||||||
|
development experience is incredibly smooth and optimal.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CodeBlock language="svelte" code={code} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
1
src/pages/docs/arxlets/highlight/build-command.sh
Normal file
1
src/pages/docs/arxlets/highlight/build-command.sh
Normal file
|
@ -0,0 +1 @@
|
||||||
|
bun build --minify --outfile=build.js --target=browser --production index.ts
|
718
src/pages/docs/arxlets/highlight/context.md
Normal file
718
src/pages/docs/arxlets/highlight/context.md
Normal file
|
@ -0,0 +1,718 @@
|
||||||
|
# Arxlets API Context
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Arxlets are secure, sandboxed JavaScript applications that extend Eve's functionality. They run in isolated iframes and are registered on your CCN (Closed Community Network) for member-only access. Arxlets provide a powerful way to build custom applications that interact with Nostr events and profiles through Eve.
|
||||||
|
|
||||||
|
## Core Concepts
|
||||||
|
|
||||||
|
### What are Arxlets?
|
||||||
|
|
||||||
|
- **Sandboxed Applications**: Run in isolated iframes for security
|
||||||
|
- **JavaScript-based**: Written in TypeScript/JavaScript with wasm support coming in the future
|
||||||
|
- **CCN Integration**: Registered on your Closed Community Network
|
||||||
|
- **Nostr-native**: Built-in access to Nostr protocol operations
|
||||||
|
- **Real-time**: Support for live event subscriptions and updates
|
||||||
|
|
||||||
|
### CCN Local-First Architecture
|
||||||
|
|
||||||
|
CCNs (Closed Community Networks) are designed with a local-first approach that ensures data availability and functionality even when offline:
|
||||||
|
|
||||||
|
- **Local Data Storage**: All Nostr events and profiles are stored locally on your device, providing instant access without network dependencies
|
||||||
|
- **Offline Functionality**: Arxlets can read, display, and interact with locally cached data when disconnected from the internet
|
||||||
|
- **Sync When Connected**: When network connectivity is restored, the CCN automatically synchronizes with remote relays to fetch new events and propagate local changes
|
||||||
|
- **Resilient Operation**: Your applications continue to work seamlessly regardless of network conditions, making CCNs ideal for unreliable connectivity scenarios
|
||||||
|
- **Privacy by Design**: Local-first storage means your data remains on your device, reducing exposure to external services and improving privacy
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
- **Frontend**: TypeScript applications with render functions
|
||||||
|
- **Backend**: Eve relay providing Nostr protocol access
|
||||||
|
- **Communication**: window.eve API or direct WebSocket connections
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### window.eve API
|
||||||
|
|
||||||
|
The primary interface for Arxlets to interact with Eve's Nostr relay. All methods return promises for async operations.
|
||||||
|
|
||||||
|
#### Event Operations
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Publish a Nostr event
|
||||||
|
await window.eve.publish(event: NostrEvent): Promise<void>
|
||||||
|
|
||||||
|
// Get a specific event by ID
|
||||||
|
const event = await window.eve.getSingleEventById(id: string): Promise<NostrEvent | null>
|
||||||
|
|
||||||
|
// Get first event matching filter
|
||||||
|
const event = await window.eve.getSingleEventWithFilter(filter: Filter): Promise<NostrEvent | null>
|
||||||
|
|
||||||
|
// Get all events matching filter
|
||||||
|
const events = await window.eve.getAllEventsWithFilter(filter: Filter): Promise<NostrEvent[]>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Real-time Subscriptions
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Subscribe to events with RxJS Observable
|
||||||
|
const subscription = window.eve.subscribeToEvents(filter: Filter): Observable<NostrEvent>
|
||||||
|
|
||||||
|
// Subscribe to profile updates
|
||||||
|
const profileSub = window.eve.subscribeToProfile(pubkey: string): Observable<Profile>
|
||||||
|
|
||||||
|
// Always unsubscribe when done
|
||||||
|
subscription.unsubscribe()
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Profile Operations
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Get user profile
|
||||||
|
const profile = await window.eve.getProfile(pubkey: string): Promise<Profile | null>
|
||||||
|
|
||||||
|
// Get user avatar URL
|
||||||
|
const avatarUrl = await window.eve.getAvatar(pubkey: string): Promise<string | null>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Cryptographic Operations
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Sign an unsigned event
|
||||||
|
const signedEvent = await window.eve.signEvent(event: NostrEvent): Promise<NostrEvent>
|
||||||
|
|
||||||
|
// Get current user's public key
|
||||||
|
const pubkey = await window.eve.publicKey: Promise<string>
|
||||||
|
```
|
||||||
|
|
||||||
|
### WebSocket Alternative
|
||||||
|
|
||||||
|
For advanced use cases, connect directly to Eve's WebSocket relay, or use any nostr library. This is not recommended:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const ws = new WebSocket("ws://localhost:6942");
|
||||||
|
|
||||||
|
// Send Nostr protocol messages
|
||||||
|
ws.send(JSON.stringify(["REQ", subscriptionId, filter]));
|
||||||
|
ws.send(JSON.stringify(["EVENT", event]));
|
||||||
|
ws.send(JSON.stringify(["CLOSE", subscriptionId]));
|
||||||
|
```
|
||||||
|
|
||||||
|
## Type Definitions
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Observable } from "rxjs";
|
||||||
|
|
||||||
|
interface NostrEvent {
|
||||||
|
id?: string;
|
||||||
|
pubkey: string;
|
||||||
|
created_at: number;
|
||||||
|
kind: number;
|
||||||
|
tags: string[][];
|
||||||
|
content: string;
|
||||||
|
sig?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Filter {
|
||||||
|
ids?: string[];
|
||||||
|
authors?: string[];
|
||||||
|
kinds?: number[];
|
||||||
|
since?: number;
|
||||||
|
until?: number;
|
||||||
|
limit?: number;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Profile {
|
||||||
|
name?: string;
|
||||||
|
about?: string;
|
||||||
|
picture?: string;
|
||||||
|
nip05?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WindowEve {
|
||||||
|
publish(event: NostrEvent): Promise<void>;
|
||||||
|
getSingleEventById(id: string): Promise<NostrEvent | null>;
|
||||||
|
getSingleEventWithFilter(filter: Filter): Promise<NostrEvent | null>;
|
||||||
|
getAllEventsWithFilter(filter: Filter): Promise<NostrEvent[]>;
|
||||||
|
subscribeToEvents(filter: Filter): Observable<NostrEvent>;
|
||||||
|
subscribeToProfile(pubkey: string): Observable<Profile>;
|
||||||
|
getProfile(pubkey: string): Promise<Profile | null>;
|
||||||
|
getAvatar(pubkey: string): Promise<string | null>;
|
||||||
|
signEvent(event: NostrEvent): Promise<NostrEvent>;
|
||||||
|
get publicKey(): Promise<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global declarations for TypeScript
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
eve: WindowEve;
|
||||||
|
nostr?: {
|
||||||
|
getPublicKey(): Promise<string>;
|
||||||
|
signEvent(event: NostrEvent): Promise<NostrEvent>;
|
||||||
|
getRelays?(): Promise<{
|
||||||
|
"ws://localhost:6942": { read: true; write: true };
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Registration
|
||||||
|
|
||||||
|
Arxlets are registered using Nostr events with kind `30420`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kind": 30420,
|
||||||
|
"content": "",
|
||||||
|
"tags": [
|
||||||
|
["d", "unique-arxlet-id"],
|
||||||
|
["name", "Display Name"],
|
||||||
|
["description", "Brief description"],
|
||||||
|
["script", "export function render(container) { /* implementation */ }"],
|
||||||
|
["icon", "mdi:icon-name, #hexcolor"]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Required Tags
|
||||||
|
|
||||||
|
- `d`: Unique identifier (alphanumeric, hyphens, underscores)
|
||||||
|
- `name`: Human-readable display name
|
||||||
|
- `script`: Complete JavaScript code with render export function
|
||||||
|
|
||||||
|
### Optional Tags
|
||||||
|
|
||||||
|
- `description`: Brief description of functionality
|
||||||
|
- `icon`: Iconify icon name and hex color
|
||||||
|
|
||||||
|
## Development Patterns
|
||||||
|
|
||||||
|
### Basic Arxlet Structure
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function render(container: HTMLElement): void {
|
||||||
|
// Set up your UI
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title">My Arxlet</h2>
|
||||||
|
<!-- Your content here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add event listeners and logic
|
||||||
|
const button = container.querySelector("#myButton");
|
||||||
|
button?.addEventListener("click", handleClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleClick() {
|
||||||
|
try {
|
||||||
|
// Use window.eve API
|
||||||
|
const events = await window.eve.getAllEventsWithFilter({
|
||||||
|
kinds: [1],
|
||||||
|
limit: 10,
|
||||||
|
});
|
||||||
|
// Update UI with events
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch events:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Real-time Updates
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function render(container: HTMLElement): void {
|
||||||
|
let subscription: Subscription;
|
||||||
|
|
||||||
|
// Set up UI
|
||||||
|
container.innerHTML = `<div id="events"></div>`;
|
||||||
|
const eventsContainer = container.querySelector("#events");
|
||||||
|
|
||||||
|
// Subscribe to real-time events
|
||||||
|
subscription = window.eve
|
||||||
|
.subscribeToEvents({
|
||||||
|
kinds: [1],
|
||||||
|
limit: 50,
|
||||||
|
})
|
||||||
|
.subscribe({
|
||||||
|
next: (event) => {
|
||||||
|
// Update UI with new event
|
||||||
|
const eventElement = document.createElement("div");
|
||||||
|
eventElement.textContent = event.content;
|
||||||
|
eventsContainer?.prepend(eventElement);
|
||||||
|
},
|
||||||
|
error: (err) => console.error("Subscription error:", err),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup when arxlet is destroyed
|
||||||
|
window.addEventListener("beforeunload", () => {
|
||||||
|
subscription?.unsubscribe();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Publishing Events
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function publishNote(content: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const unsignedEvent: NostrEvent = {
|
||||||
|
kind: 1,
|
||||||
|
content: content,
|
||||||
|
tags: [["client", "my-arxlet"]],
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
pubkey: await window.eve.publicKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
const signedEvent = await window.eve.signEvent(unsignedEvent);
|
||||||
|
await window.eve.publish(signedEvent);
|
||||||
|
|
||||||
|
console.log("Event published successfully");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to publish event:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
- Always wrap API calls in try-catch blocks
|
||||||
|
- Check for null returns from query methods
|
||||||
|
- Provide user feedback for failed operations
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- Use specific filters to limit result sets
|
||||||
|
- Cache profile data to avoid repeated lookups
|
||||||
|
- Unsubscribe from observables when done
|
||||||
|
- Debounce rapid API calls
|
||||||
|
- Consider pagination for large datasets
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Validate all user inputs
|
||||||
|
- Sanitize content before displaying
|
||||||
|
- Use proper event signing for authenticity
|
||||||
|
- Follow principle of least privilege
|
||||||
|
|
||||||
|
### Memory Management
|
||||||
|
|
||||||
|
- Always unsubscribe from RxJS observables
|
||||||
|
- Clean up event listeners on component destruction
|
||||||
|
- Avoid memory leaks in long-running subscriptions
|
||||||
|
- Use weak references where appropriate
|
||||||
|
|
||||||
|
## Common Use Cases
|
||||||
|
|
||||||
|
### Social Feed
|
||||||
|
|
||||||
|
- Subscribe to events from followed users
|
||||||
|
- Display real-time updates
|
||||||
|
- Handle profile information and avatars
|
||||||
|
- Implement engagement features
|
||||||
|
|
||||||
|
### Publishing Tools
|
||||||
|
|
||||||
|
- Create and sign events
|
||||||
|
- Validate content before publishing
|
||||||
|
- Handle publishing errors gracefully
|
||||||
|
- Provide user feedback
|
||||||
|
|
||||||
|
### Data Visualization
|
||||||
|
|
||||||
|
- Query historical events
|
||||||
|
- Process and aggregate data
|
||||||
|
- Create interactive charts and graphs
|
||||||
|
- Real-time data updates
|
||||||
|
|
||||||
|
### Communication Apps
|
||||||
|
|
||||||
|
- Direct messaging interfaces
|
||||||
|
- Group chat functionality
|
||||||
|
- Notification systems
|
||||||
|
- Presence indicators
|
||||||
|
|
||||||
|
## Framework Integration
|
||||||
|
|
||||||
|
Arxlets support various JavaScript frameworks. All frameworks must export a `render` function that accepts a container element:
|
||||||
|
|
||||||
|
### Vanilla JavaScript
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function render(container: HTMLElement): void {
|
||||||
|
// Set up your UI with direct DOM manipulation
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title">My App</h2>
|
||||||
|
<button id="myButton" class="btn btn-primary">Click me</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add event listeners
|
||||||
|
const button = container.querySelector("#myButton");
|
||||||
|
button?.addEventListener("click", () => {
|
||||||
|
console.log("Button clicked!");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Preact/React
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// @jsx h
|
||||||
|
// @jsxImportSource preact
|
||||||
|
import { render } from 'preact';
|
||||||
|
import { useState } from 'preact/hooks';
|
||||||
|
|
||||||
|
const App = () => {
|
||||||
|
const [count, setCount] = useState(0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title">Counter: {count}</h2>
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
onClick={() => setCount(count + 1)}
|
||||||
|
>
|
||||||
|
Increment
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function render(container: HTMLElement): void {
|
||||||
|
render(<App />, container);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Svelte
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { mount } from "svelte";
|
||||||
|
import "./app.css";
|
||||||
|
import App from "./App.svelte";
|
||||||
|
|
||||||
|
export function render(container: HTMLElement) {
|
||||||
|
return mount(App, {
|
||||||
|
target: container,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build Process
|
||||||
|
|
||||||
|
All frameworks require bundling into a single JavaScript file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# For TypeScript/JavaScript projects
|
||||||
|
bun build index.ts --outfile=build.js --minify --target=browser --production
|
||||||
|
|
||||||
|
# The resulting build.js content goes in your registration event's script tag
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Svelte Build Requirements
|
||||||
|
|
||||||
|
**Important:** The standard build command above will NOT work for Svelte projects. Svelte requires specific Vite configuration to compile properly.
|
||||||
|
|
||||||
|
For Svelte arxlets:
|
||||||
|
|
||||||
|
1. Use the [arxlets-template](https://git.arx-ccn.com/Arx/arxlets-template) which includes the correct Vite configuration
|
||||||
|
2. Run `bun run build` instead of the standard build command
|
||||||
|
3. Your compiled file will be available at `dist/bundle.js`
|
||||||
|
|
||||||
|
While the initial setup is more complex, Svelte provides an excellent development experience once configured, with features like:
|
||||||
|
|
||||||
|
- Built-in reactivity with runes (`$state()`, `$derived()`, etc.)
|
||||||
|
- Scoped CSS
|
||||||
|
- Compile-time optimizations
|
||||||
|
- No runtime overhead
|
||||||
|
|
||||||
|
## Debugging and Development
|
||||||
|
|
||||||
|
### Console Logging
|
||||||
|
|
||||||
|
- Use `console.log()` for debugging
|
||||||
|
- Events and errors are logged to browser console
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
- Catch and log API errors
|
||||||
|
- Display user-friendly error messages
|
||||||
|
- Implement retry mechanisms for transient failures
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- Test with various event types and filters
|
||||||
|
- Verify subscription cleanup
|
||||||
|
- Test error scenarios and edge cases
|
||||||
|
- Validate event signing and publishing
|
||||||
|
|
||||||
|
## Limitations and Considerations
|
||||||
|
|
||||||
|
### Sandbox Restrictions
|
||||||
|
|
||||||
|
- Limited access to browser APIs
|
||||||
|
- No direct file system access
|
||||||
|
- Restricted network access (only to Eve relay)
|
||||||
|
- No access to parent window context
|
||||||
|
|
||||||
|
### Performance Constraints
|
||||||
|
|
||||||
|
- Iframe overhead for each arxlet
|
||||||
|
- Memory usage for subscriptions
|
||||||
|
- Event processing limitations
|
||||||
|
|
||||||
|
### Security Considerations
|
||||||
|
|
||||||
|
- All events are public on Nostr
|
||||||
|
- Private key management handled by Eve
|
||||||
|
- Content sanitization required
|
||||||
|
- XSS prevention necessary
|
||||||
|
|
||||||
|
## DaisyUI Components
|
||||||
|
|
||||||
|
Arxlets have access to DaisyUI 5, a comprehensive CSS component library. Use these pre-built components for consistent, accessible UI:
|
||||||
|
|
||||||
|
### Essential Components
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Cards for content containers -->
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title">Card Title</h2>
|
||||||
|
<p>Card content goes here</p>
|
||||||
|
<div class="card-actions justify-end">
|
||||||
|
<button class="btn btn-primary">Action</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Buttons with various styles -->
|
||||||
|
<button class="btn btn-primary">Primary</button>
|
||||||
|
<button class="btn btn-secondary">Secondary</button>
|
||||||
|
<button class="btn btn-success">Success</button>
|
||||||
|
<button class="btn btn-error">Error</button>
|
||||||
|
<button class="btn btn-ghost">Ghost</button>
|
||||||
|
|
||||||
|
<!-- Form controls -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Input Label</span>
|
||||||
|
</label>
|
||||||
|
<input type="text" class="input input-bordered" placeholder="Enter text" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Alerts for feedback -->
|
||||||
|
<div class="alert alert-success">
|
||||||
|
<span>✅ Success message</span>
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-error">
|
||||||
|
<span>❌ Error message</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading states -->
|
||||||
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
|
<button class="btn btn-primary">
|
||||||
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
|
Loading...
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Modals for dialogs -->
|
||||||
|
<dialog class="modal" id="my-modal">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3 class="font-bold text-lg">Modal Title</h3>
|
||||||
|
<p class="py-4">Modal content</p>
|
||||||
|
<div class="modal-action">
|
||||||
|
<button class="btn" onclick="document.getElementById('my-modal').close()">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Layout Utilities
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Responsive grid -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<div class="card">Content 1</div>
|
||||||
|
<div class="card">Content 2</div>
|
||||||
|
<div class="card">Content 3</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Flexbox utilities -->
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span>Left content</span>
|
||||||
|
<button class="btn">Right button</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Spacing -->
|
||||||
|
<div class="p-4 m-2 space-y-4">
|
||||||
|
<!-- p-4 = padding, m-2 = margin, space-y-4 = vertical spacing -->
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Color System
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Background colors -->
|
||||||
|
<div class="bg-base-100">Default background</div>
|
||||||
|
<div class="bg-base-200">Slightly darker</div>
|
||||||
|
<div class="bg-primary">Primary color</div>
|
||||||
|
<div class="bg-secondary">Secondary color</div>
|
||||||
|
|
||||||
|
<!-- Text colors -->
|
||||||
|
<span class="text-primary">Primary text</span>
|
||||||
|
<span class="text-success">Success text</span>
|
||||||
|
<span class="text-error">Error text</span>
|
||||||
|
<span class="text-base-content">Default text</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Example Patterns
|
||||||
|
|
||||||
|
### Simple Counter Arxlet
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function render(container: HTMLElement): void {
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
function updateUI() {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="card bg-base-100 shadow-xl max-w-sm mx-auto">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<h2 class="card-title justify-center">Counter</h2>
|
||||||
|
<div class="text-6xl font-bold my-4 ${count > 0 ? "text-success" : count < 0 ? "text-error" : "text-primary"}">
|
||||||
|
${count}
|
||||||
|
</div>
|
||||||
|
<div class="card-actions justify-center gap-4">
|
||||||
|
<button class="btn btn-error" id="decrement">−</button>
|
||||||
|
<button class="btn btn-success" id="increment">+</button>
|
||||||
|
<button class="btn btn-ghost" id="reset">Reset</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Attach event listeners
|
||||||
|
container.querySelector("#increment")?.addEventListener("click", () => {
|
||||||
|
count++;
|
||||||
|
updateUI();
|
||||||
|
});
|
||||||
|
|
||||||
|
container.querySelector("#decrement")?.addEventListener("click", () => {
|
||||||
|
count--;
|
||||||
|
updateUI();
|
||||||
|
});
|
||||||
|
|
||||||
|
container.querySelector("#reset")?.addEventListener("click", () => {
|
||||||
|
count = 0;
|
||||||
|
updateUI();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateUI();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nostr Event Publisher
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function render(container: HTMLElement): Promise<void> {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="card bg-base-100 shadow-xl max-w-2xl mx-auto">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title">📝 Publish a Note</h2>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">What's on your mind?</span>
|
||||||
|
<span class="label-text-alt" id="charCount">0/280</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
class="textarea textarea-bordered h-32"
|
||||||
|
id="noteContent"
|
||||||
|
placeholder="Share your thoughts..."
|
||||||
|
maxlength="280">
|
||||||
|
</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="card-actions justify-between items-center">
|
||||||
|
<div id="status" class="flex-1"></div>
|
||||||
|
<button class="btn btn-primary" id="publishBtn" disabled>
|
||||||
|
Publish Note
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const textarea =
|
||||||
|
container.querySelector<HTMLTextAreaElement>("#noteContent")!;
|
||||||
|
const publishBtn = container.querySelector<HTMLButtonElement>("#publishBtn")!;
|
||||||
|
const status = container.querySelector<HTMLDivElement>("#status")!;
|
||||||
|
const charCount = container.querySelector<HTMLSpanElement>("#charCount")!;
|
||||||
|
|
||||||
|
textarea.oninput = () => {
|
||||||
|
const length = textarea.value.length;
|
||||||
|
charCount.textContent = `${length}/280`;
|
||||||
|
publishBtn.disabled = length === 0 || length > 280;
|
||||||
|
};
|
||||||
|
|
||||||
|
publishBtn.onclick = async () => {
|
||||||
|
const content = textarea.value.trim();
|
||||||
|
if (!content) return;
|
||||||
|
|
||||||
|
publishBtn.disabled = true;
|
||||||
|
publishBtn.textContent = "Publishing...";
|
||||||
|
status.innerHTML =
|
||||||
|
'<span class="loading loading-spinner loading-sm"></span>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const unsignedEvent: NostrEvent = {
|
||||||
|
kind: 1,
|
||||||
|
content: content,
|
||||||
|
tags: [["client", "my-arxlet"]],
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
pubkey: await window.eve.publicKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
const signedEvent = await window.eve.signEvent(unsignedEvent);
|
||||||
|
await window.eve.publish(signedEvent);
|
||||||
|
|
||||||
|
status.innerHTML = `
|
||||||
|
<div class="alert alert-success">
|
||||||
|
<span>✅ Note published successfully!</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
textarea.value = "";
|
||||||
|
textarea.oninput?.();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : "Unknown error";
|
||||||
|
console.error("Publishing failed:", error);
|
||||||
|
status.innerHTML = `
|
||||||
|
<div class="alert alert-error">
|
||||||
|
<span>❌ Failed to publish: ${errorMessage}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} finally {
|
||||||
|
publishBtn.disabled = false;
|
||||||
|
publishBtn.textContent = "Publish Note";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This context provides comprehensive information about the Arxlets API, enabling LLMs to understand and work with the system effectively.
|
44
src/pages/docs/arxlets/highlight/counter.ts
Normal file
44
src/pages/docs/arxlets/highlight/counter.ts
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
export function render(container: HTMLElement) {
|
||||||
|
let count: number = 0;
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="card bg-base-100 shadow-xl max-w-sm mx-auto">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<h2 class="card-title justify-center">Counter App</h2>
|
||||||
|
<div class="text-6xl font-bold text-primary my-4" id="display">
|
||||||
|
${count}
|
||||||
|
</div>
|
||||||
|
<div class="card-actions justify-center gap-4">
|
||||||
|
<button class="btn btn-error" id="decrement">−</button>
|
||||||
|
<button class="btn btn-success" id="increment">+</button>
|
||||||
|
<button class="btn btn-ghost" id="reset">Reset</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const display = container.querySelector<HTMLDivElement>("#display")!;
|
||||||
|
const incrementBtn = container.querySelector<HTMLButtonElement>("#increment")!;
|
||||||
|
const decrementBtn = container.querySelector<HTMLButtonElement>("#decrement")!;
|
||||||
|
const resetBtn = container.querySelector<HTMLButtonElement>("#reset")!;
|
||||||
|
|
||||||
|
const updateDisplay = (): void => {
|
||||||
|
display.textContent = count.toString();
|
||||||
|
display.className = `text-6xl font-bold my-4 ${
|
||||||
|
count > 0 ? "text-success" : count < 0 ? "text-error" : "text-primary"
|
||||||
|
}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
incrementBtn.onclick = (): void => {
|
||||||
|
count++;
|
||||||
|
updateDisplay();
|
||||||
|
};
|
||||||
|
decrementBtn.onclick = (): void => {
|
||||||
|
count--;
|
||||||
|
updateDisplay();
|
||||||
|
};
|
||||||
|
resetBtn.onclick = (): void => {
|
||||||
|
count = 0;
|
||||||
|
updateDisplay();
|
||||||
|
};
|
||||||
|
}
|
55
src/pages/docs/arxlets/highlight/eve-api-example.ts
Normal file
55
src/pages/docs/arxlets/highlight/eve-api-example.ts
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
// Using window.eve API for Nostr operations
|
||||||
|
import type { Filter, NostrEvent } from "./types";
|
||||||
|
|
||||||
|
// Publish a new event
|
||||||
|
const event: NostrEvent = {
|
||||||
|
kind: 1,
|
||||||
|
content: "Hello from my Arxlet!",
|
||||||
|
tags: [],
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
pubkey: "your-pubkey-here",
|
||||||
|
};
|
||||||
|
|
||||||
|
await window.eve.publish(event);
|
||||||
|
|
||||||
|
// Get a specific event by ID
|
||||||
|
const eventId = "event-id-here";
|
||||||
|
const event = await window.eve.getSingleEventById(eventId);
|
||||||
|
|
||||||
|
// Query events with a filter
|
||||||
|
const filter: Filter = {
|
||||||
|
kinds: [1],
|
||||||
|
authors: ["pubkey-here"],
|
||||||
|
limit: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
const singleEvent = await window.eve.getSingleEventWithFilter(filter);
|
||||||
|
const allEvents = await window.eve.getAllEventsWithFilter(filter);
|
||||||
|
|
||||||
|
// Real-time subscription with RxJS Observable
|
||||||
|
const subscription = window.eve.subscribeToEvents(filter).subscribe({
|
||||||
|
next: (event) => {
|
||||||
|
console.log("New event received:", event);
|
||||||
|
// Update your UI with the new event
|
||||||
|
},
|
||||||
|
error: (err) => console.error("Subscription error:", err),
|
||||||
|
complete: () => console.log("Subscription completed"),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subscribe to profile updates for a specific user
|
||||||
|
const profileSubscription = window.eve.subscribeToProfile(pubkey).subscribe({
|
||||||
|
next: (profile) => {
|
||||||
|
console.log("Profile updated:", profile);
|
||||||
|
// Update your UI with the new profile data
|
||||||
|
},
|
||||||
|
error: (err) => console.error("Profile subscription error:", err),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Don't forget to unsubscribe when done
|
||||||
|
// subscription.unsubscribe();
|
||||||
|
// profileSubscription.unsubscribe();
|
||||||
|
|
||||||
|
// Get user profile and avatar
|
||||||
|
const pubkey = "user-pubkey-here";
|
||||||
|
const profile = await window.eve.getProfile(pubkey);
|
||||||
|
const avatarUrl = await window.eve.getAvatar(pubkey);
|
85
src/pages/docs/arxlets/highlight/nostr-publisher.ts
Normal file
85
src/pages/docs/arxlets/highlight/nostr-publisher.ts
Normal file
|
@ -0,0 +1,85 @@
|
||||||
|
import type { NostrEvent } from "./type-definitions.ts";
|
||||||
|
|
||||||
|
export async function render(container: HTMLElement): Promise<void> {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="card bg-base-100 shadow-xl max-w-2xl mx-auto">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title">📝 Publish a Note</h2>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">What's on your mind?</span>
|
||||||
|
<span class="label-text-alt" id="charCount">0/280</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
class="textarea textarea-bordered h-32"
|
||||||
|
id="noteContent"
|
||||||
|
placeholder="Share your thoughts with your CCN..."
|
||||||
|
maxlength="280">
|
||||||
|
</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-actions justify-between items-center">
|
||||||
|
<div id="status" class="flex-1"></div>
|
||||||
|
<button class="btn btn-primary" id="publishBtn" disabled>
|
||||||
|
Publish Note
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const textarea = container.querySelector<HTMLTextAreaElement>("#noteContent")!;
|
||||||
|
const publishBtn = container.querySelector<HTMLButtonElement>("#publishBtn")!;
|
||||||
|
const status = container.querySelector<HTMLDivElement>("#status")!;
|
||||||
|
const charCount = container.querySelector<HTMLSpanElement>("#charCount")!;
|
||||||
|
|
||||||
|
textarea.oninput = (): void => {
|
||||||
|
const length: number = textarea.value.length;
|
||||||
|
charCount.textContent = `${length}/280`;
|
||||||
|
publishBtn.disabled = length === 0 || length > 280;
|
||||||
|
};
|
||||||
|
|
||||||
|
publishBtn.onclick = async (e): Promise<void> => {
|
||||||
|
const content: string = textarea.value.trim();
|
||||||
|
if (!content) return;
|
||||||
|
|
||||||
|
publishBtn.disabled = true;
|
||||||
|
publishBtn.textContent = "Publishing...";
|
||||||
|
status.innerHTML = '<span class="loading loading-spinner loading-sm"></span>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const unsignedEvent: NostrEvent = {
|
||||||
|
kind: 1, // Text note
|
||||||
|
content: content,
|
||||||
|
tags: [["client", "arxlet-publisher"]],
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
pubkey: await window.eve.publicKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
const signedEvent: NostrEvent = await window.eve.signEvent(unsignedEvent);
|
||||||
|
|
||||||
|
await window.eve.publish(signedEvent);
|
||||||
|
|
||||||
|
status.innerHTML = `
|
||||||
|
<div class="alert alert-success">
|
||||||
|
<span>✅ Note published successfully!</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
textarea.value = "";
|
||||||
|
textarea.oninput?.(e);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
console.error("Publishing failed:", error);
|
||||||
|
status.innerHTML = `
|
||||||
|
<div class="alert alert-error">
|
||||||
|
<span>❌ Failed to publish: ${errorMessage}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} finally {
|
||||||
|
publishBtn.disabled = false;
|
||||||
|
publishBtn.textContent = "Publish Note";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
61
src/pages/docs/arxlets/highlight/preact-counter.tsx
Normal file
61
src/pages/docs/arxlets/highlight/preact-counter.tsx
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
// @jsx h
|
||||||
|
// @jsxImportSource preact
|
||||||
|
|
||||||
|
import { render as renderPreact } from "preact";
|
||||||
|
import { useState } from "preact/hooks";
|
||||||
|
|
||||||
|
const CounterApp = () => {
|
||||||
|
const [count, setCount] = useState(0);
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
|
||||||
|
const increment = () => {
|
||||||
|
setCount((prev) => prev + 1);
|
||||||
|
setMessage(`Clicked ${count + 1} times!`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const decrement = () => {
|
||||||
|
setCount((prev) => prev - 1);
|
||||||
|
setMessage(`Count decreased to ${count - 1}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setCount(0);
|
||||||
|
setMessage("Counter reset!");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="card bg-base-100 shadow-xl max-w-sm mx-auto">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<h2 class="card-title justify-center"> Preact Counter </h2>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class={`text-6xl font-bold my-4 ${count > 0 ? "text-success" : count < 0 ? "text-error" : "text-primary"}`}
|
||||||
|
>
|
||||||
|
{count}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-actions justify-center gap-4">
|
||||||
|
<button class="btn btn-error" onClick={decrement}>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-success" onClick={increment}>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-ghost" onClick={reset}>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<div class="alert alert-info mt-4">
|
||||||
|
<span>{message} </span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function render(container: HTMLElement): void {
|
||||||
|
renderPreact(<CounterApp />, container);
|
||||||
|
}
|
12
src/pages/docs/arxlets/highlight/registration-event.json
Normal file
12
src/pages/docs/arxlets/highlight/registration-event.json
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"kind": 30420,
|
||||||
|
"tags": [
|
||||||
|
["d", "my-calculator"],
|
||||||
|
["name", "Simple Calculator"],
|
||||||
|
["description", "A basic calculator for quick math"],
|
||||||
|
["script", "export function render(el) { /* your code */ }"],
|
||||||
|
["icon", "mdi:calculator", "#3b82f6"]
|
||||||
|
],
|
||||||
|
"content": "",
|
||||||
|
"created_at": 1735171200
|
||||||
|
}
|
23
src/pages/docs/arxlets/highlight/render-function.ts
Normal file
23
src/pages/docs/arxlets/highlight/render-function.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
/**
|
||||||
|
* Required export function - Entry point for your Arxlet
|
||||||
|
*/
|
||||||
|
export function render(container: HTMLElement): void {
|
||||||
|
// Initialize your application
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="p-6">
|
||||||
|
<h1 class="text-3xl font-bold mb-4">My Arxlet</h1>
|
||||||
|
<p class="text-lg">Hello from Eve!</p>
|
||||||
|
<button class="btn btn-primary mt-4" id="myButton">
|
||||||
|
Click me!
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add event listeners with proper typing
|
||||||
|
const button = container.querySelector<HTMLButtonElement>("#myButton");
|
||||||
|
button?.addEventListener("click", (): void => {
|
||||||
|
alert("Button clicked!");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Your app logic here...
|
||||||
|
}
|
54
src/pages/docs/arxlets/highlight/subscription-examples.ts
Normal file
54
src/pages/docs/arxlets/highlight/subscription-examples.ts
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
// Real-time subscription examples
|
||||||
|
import { filter, map, takeUntil } from "rxjs/operators";
|
||||||
|
|
||||||
|
// Basic subscription
|
||||||
|
const subscription = window.eve
|
||||||
|
.subscribeToEvents({
|
||||||
|
kinds: [1], // Text notes
|
||||||
|
limit: 50,
|
||||||
|
})
|
||||||
|
.subscribe((event) => {
|
||||||
|
console.log("New text note:", event.content);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Advanced filtering with RxJS operators
|
||||||
|
const filteredSubscription = window.eve
|
||||||
|
.subscribeToEvents({
|
||||||
|
kinds: [1, 6, 7], // Notes, reposts, reactions
|
||||||
|
authors: ["pubkey1", "pubkey2"],
|
||||||
|
})
|
||||||
|
.pipe(
|
||||||
|
filter((event) => event.content.includes("#arxlet")), // Only events mentioning arxlets
|
||||||
|
map((event) => ({
|
||||||
|
id: event.id,
|
||||||
|
author: event.pubkey,
|
||||||
|
content: event.content,
|
||||||
|
timestamp: new Date(event.created_at * 1000),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.subscribe({
|
||||||
|
next: (processedEvent) => {
|
||||||
|
// Update your UI
|
||||||
|
updateEventsList(processedEvent);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
console.error("Subscription error:", err);
|
||||||
|
showErrorMessage("Failed to receive real-time updates");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Profile subscription example
|
||||||
|
const profileSubscription = window.eve.subscribeToProfile("user-pubkey-here").subscribe({
|
||||||
|
next: (profile) => {
|
||||||
|
console.log("Profile updated:", profile);
|
||||||
|
updateUserProfile(profile);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
console.error("Profile subscription error:", err);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up subscriptions when component unmounts
|
||||||
|
// subscription.unsubscribe();
|
||||||
|
// filteredSubscription.unsubscribe();
|
||||||
|
// profileSubscription.unsubscribe();
|
49
src/pages/docs/arxlets/highlight/svelte-counter.svelte
Normal file
49
src/pages/docs/arxlets/highlight/svelte-counter.svelte
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
<script lang="ts">
|
||||||
|
let count = $state(0);
|
||||||
|
let message = $state("");
|
||||||
|
|
||||||
|
function increment() {
|
||||||
|
count += 1;
|
||||||
|
message = `Clicked ${count} times!`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decrement() {
|
||||||
|
count -= 1;
|
||||||
|
message = `Count decreased to ${count}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
count = 0;
|
||||||
|
message = "Counter reset!";
|
||||||
|
}
|
||||||
|
|
||||||
|
const countColor = $derived(count > 0 ? "text-success" : count < 0 ? "text-error" : "text-primary");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-xl max-w-sm mx-auto">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<h2 class="card-title justify-center">🔥 Svelte Counter</h2>
|
||||||
|
|
||||||
|
<div class="text-6xl font-bold my-4 {countColor}">
|
||||||
|
{count}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-actions justify-center gap-4">
|
||||||
|
<button class="btn btn-error" onclick={decrement}> − </button>
|
||||||
|
<button class="btn btn-success" onclick={increment}> + </button>
|
||||||
|
<button class="btn btn-ghost" onclick={reset}> Reset </button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if message}
|
||||||
|
<div class="alert alert-info mt-4">
|
||||||
|
<span>{message}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.card-title {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
</style>
|
48
src/pages/docs/arxlets/highlight/type-definitions.ts
Normal file
48
src/pages/docs/arxlets/highlight/type-definitions.ts
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
import type { Observable } from "rxjs";
|
||||||
|
|
||||||
|
export interface NostrEvent {
|
||||||
|
id?: string;
|
||||||
|
pubkey: string;
|
||||||
|
created_at: number;
|
||||||
|
kind: number;
|
||||||
|
tags: string[][];
|
||||||
|
content: string;
|
||||||
|
sig?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Filter {
|
||||||
|
ids?: string[];
|
||||||
|
authors?: string[];
|
||||||
|
kinds?: number[];
|
||||||
|
since?: number;
|
||||||
|
until?: number;
|
||||||
|
limit?: number;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Profile {
|
||||||
|
name?: string;
|
||||||
|
about?: string;
|
||||||
|
picture?: string;
|
||||||
|
nip05?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WindowEve {
|
||||||
|
publish(event: NostrEvent): Promise<void>;
|
||||||
|
getSingleEventById(id: string): Promise<NostrEvent | null>;
|
||||||
|
getSingleEventWithFilter(filter: Filter): Promise<NostrEvent | null>;
|
||||||
|
getAllEventsWithFilter(filter: Filter): Promise<NostrEvent[]>;
|
||||||
|
subscribeToEvents(filter: Filter): Observable<NostrEvent>;
|
||||||
|
subscribeToProfile(pubkey: string): Observable<Profile>;
|
||||||
|
getProfile(pubkey: string): Promise<Profile | null>;
|
||||||
|
getAvatar(pubkey: string): Promise<string | null>;
|
||||||
|
signEvent(event: NostrEvent): Promise<NostrEvent>;
|
||||||
|
get publicKey(): Promise<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
eve: WindowEve;
|
||||||
|
}
|
||||||
|
}
|
18
src/pages/docs/arxlets/highlight/websocket-example.ts
Normal file
18
src/pages/docs/arxlets/highlight/websocket-example.ts
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
// Alternative: Direct WebSocket connection
|
||||||
|
const ws = new WebSocket("ws://localhost:6942");
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
// Subscribe to events
|
||||||
|
ws.send(JSON.stringify(["REQ", "sub1", { kinds: [1], limit: 10 }]));
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const [type, subId, data] = JSON.parse(event.data);
|
||||||
|
if (type === "EVENT") {
|
||||||
|
console.log("Received event:", data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Publish an event
|
||||||
|
const signedEvent = await window.nostr.signEvent(unsignedEvent);
|
||||||
|
ws.send(JSON.stringify(["EVENT", signedEvent]));
|
30
src/pages/docs/arxlets/hooks/useSyntaxHighlighting.js
Normal file
30
src/pages/docs/arxlets/hooks/useSyntaxHighlighting.js
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import { useEffect } from "preact/hooks";
|
||||||
|
import Prism from "prismjs";
|
||||||
|
import "prismjs/components/prism-json";
|
||||||
|
import "prismjs/components/prism-javascript";
|
||||||
|
import "prismjs/components/prism-typescript";
|
||||||
|
import "prismjs/components/prism-bash";
|
||||||
|
import "prism-svelte";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for managing syntax highlighting
|
||||||
|
* Handles initialization and tab change events
|
||||||
|
*/
|
||||||
|
export const useSyntaxHighlighting = () => {
|
||||||
|
useEffect(() => {
|
||||||
|
const highlightCode = () => setTimeout(() => Prism.highlightAll(), 100);
|
||||||
|
|
||||||
|
highlightCode();
|
||||||
|
|
||||||
|
const tabInputs = document.querySelectorAll('input[name="arxlet_tabs"]');
|
||||||
|
tabInputs.forEach((input) => {
|
||||||
|
input.addEventListener("change", highlightCode);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
tabInputs.forEach((input) => {
|
||||||
|
input.removeEventListener("change", highlightCode);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
};
|
5
src/pages/home/home.css
Normal file
5
src/pages/home/home.css
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
@import "tailwindcss";
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
@plugin "daisyui";
|
285
src/pages/home/home.html
Normal file
285
src/pages/home/home.html
Normal file
|
@ -0,0 +1,285 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Eve - Secure, Decentralized Communities</title>
|
||||||
|
<link rel="stylesheet" href="home.css" />
|
||||||
|
<style>
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.fade-in {
|
||||||
|
animation: fadeIn 0.5s ease-out forwards;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-base-200">
|
||||||
|
<div data-theme="cyberpunk">
|
||||||
|
<!-- Navbar -->
|
||||||
|
<div class="navbar bg-base-100 shadow-lg">
|
||||||
|
<div class="flex-1">
|
||||||
|
<a class="btn btn-ghost normal-case text-xl">
|
||||||
|
<img src="/assets/logo.png" alt="Eve Logo" class="w-8 h-8 mr-2" />
|
||||||
|
Eve
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="flex-none">
|
||||||
|
<ul class="menu menu-horizontal px-1">
|
||||||
|
<li><a href="#features">Features</a></li>
|
||||||
|
<li><a href="#how-it-works">How It Works</a></li>
|
||||||
|
<li><a href="/docs/arxlets">Arxlet Docs</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hero Section -->
|
||||||
|
<div class="hero min-h-screen">
|
||||||
|
<div class="hero-overlay bg-opacity-60"></div>
|
||||||
|
<div class="hero-content text-center text-neutral-content">
|
||||||
|
<div class="max-w-md">
|
||||||
|
<h1 class="mb-5 text-5xl font-bold">Welcome to Eve</h1>
|
||||||
|
<p class="mb-5">
|
||||||
|
Your personal gateway to secure, decentralized communities. Create
|
||||||
|
encrypted <strong>Closed Community Networks (CCNs)</strong> where
|
||||||
|
your messages and data stay truly private.
|
||||||
|
</p>
|
||||||
|
<a href="/docs/arxlets" class="btn btn-primary">Get Started</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Features Section -->
|
||||||
|
<div id="features" class="container mx-auto px-4 py-16">
|
||||||
|
<h2 class="text-4xl font-bold text-center mb-12">Why Choose Eve?</h2>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
|
<!-- Feature 1: End-to-End Encryption -->
|
||||||
|
<div
|
||||||
|
class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow duration-300 fade-in"
|
||||||
|
>
|
||||||
|
<div class="card-body items-center text-center">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-12 w-12 text-primary"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<h3 class="card-title mt-4">End-to-End Encryption</h3>
|
||||||
|
<p>
|
||||||
|
Every message, every file, every interaction is secured with
|
||||||
|
cutting-edge encryption. Only you and your community hold the
|
||||||
|
keys.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow duration-300 fade-in"
|
||||||
|
style="animation-delay: 0.1s"
|
||||||
|
>
|
||||||
|
<div class="card-body items-center text-center">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-12 w-12 text-primary"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M8.684 13.342C8.886 12.938 9 12.482 9 12s-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.368a3 3 0 105.367 2.684 3 3 0 00-5.367-2.684z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<h3 class="card-title mt-4">Decentralized by Design</h3>
|
||||||
|
<p>
|
||||||
|
No central servers, no single point of failure. Your community's
|
||||||
|
data is distributed, resilient, and censorship-resistant.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Feature 3: Extensible with Arxlets -->
|
||||||
|
<div
|
||||||
|
class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow duration-300 fade-in"
|
||||||
|
style="animation-delay: 0.2s"
|
||||||
|
>
|
||||||
|
<div class="card-body items-center text-center">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-12 w-12 text-primary"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4-8-4V7m8 4l8-4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<h3 class="card-title mt-4">Extensible with Arxlets</h3>
|
||||||
|
<p>
|
||||||
|
Supercharge your community with powerful mini-apps. From shared
|
||||||
|
calendars to collaborative tools, the possibilities are
|
||||||
|
limitless.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="how-it-works" class="bg-base-100">
|
||||||
|
<div class="container mx-auto px-4 py-16">
|
||||||
|
<h2 class="text-4xl font-bold text-center mb-12">How Eve Works</h2>
|
||||||
|
<ul
|
||||||
|
class="timeline timeline-snap-icon max-md:timeline-compact timeline-vertical"
|
||||||
|
>
|
||||||
|
<li>
|
||||||
|
<div class="timeline-middle">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
class="h-5 w-5"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="timeline-start md:text-end mb-10">
|
||||||
|
<time class="font-mono italic">Step 1</time>
|
||||||
|
<div class="text-lg font-black">Create a CCN</div>
|
||||||
|
Generate a unique, encrypted Closed Community Network. This is
|
||||||
|
your private digital space, secured by a key that only you and
|
||||||
|
your members possess.
|
||||||
|
</div>
|
||||||
|
<hr />
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<hr />
|
||||||
|
<div class="timeline-middle">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
class="h-5 w-5"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="timeline-end mb-10">
|
||||||
|
<time class="font-mono italic">Step 2</time>
|
||||||
|
<div class="text-lg font-black">Invite Members</div>
|
||||||
|
Securely share the CCN key with trusted members. Only those with
|
||||||
|
the key can join, ensuring your community remains private and
|
||||||
|
exclusive.
|
||||||
|
</div>
|
||||||
|
<hr />
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<hr />
|
||||||
|
<div class="timeline-middle">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
class="h-5 w-5"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="timeline-start md:text-end mb-10">
|
||||||
|
<time class="font-mono italic">Step 3</time>
|
||||||
|
<div class="text-lg font-black">Communicate & Collaborate</div>
|
||||||
|
Share messages, files, and use Arxlets within your secure
|
||||||
|
environment. Your data is always protected and under your
|
||||||
|
control.
|
||||||
|
</div>
|
||||||
|
<hr />
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<hr />
|
||||||
|
<div class="timeline-middle">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
class="h-5 w-5"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="timeline-end">
|
||||||
|
<time class="font-mono italic">Step 4</time>
|
||||||
|
<div class="text-lg font-black">Extend with Arxlets</div>
|
||||||
|
Browse and install Arxlets to add new features to your CCN.
|
||||||
|
Customize your community's experience with powerful,
|
||||||
|
decentralized applications.
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container mx-auto px-4 py-16">
|
||||||
|
<div class="hero bg-base-200 rounded-box">
|
||||||
|
<div class="hero-content flex-col lg:flex-row">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-3xl font-bold">Unleash the Power of Arxlets</h2>
|
||||||
|
<p class="py-6">
|
||||||
|
<strong>Arxlets</strong> are the heart of Eve's extensibility.
|
||||||
|
They are secure, sandboxed applications that run within your
|
||||||
|
CCN, allowing you to add powerful features without compromising
|
||||||
|
privacy. Imagine a decentralized social feed, a collaborative
|
||||||
|
whiteboard, or a secure voting system—all running within your
|
||||||
|
private community.
|
||||||
|
</p>
|
||||||
|
<a href="/docs/arxlets" class="btn btn-primary"
|
||||||
|
>Explore Arxlet Development</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="footer footer-center p-10 bg-base-200 text-base-content">
|
||||||
|
<aside>
|
||||||
|
<img src="/assets/logo.png" alt="Eve Logo" class="w-16 h-16" />
|
||||||
|
<p class="font-bold">Eve Lite<br />Secure, Decentralized, Yours.</p>
|
||||||
|
<p>Copyright © 2025 - All right reserved</p>
|
||||||
|
</aside>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
100
src/rollingIndex.ts
Normal file
100
src/rollingIndex.ts
Normal file
|
@ -0,0 +1,100 @@
|
||||||
|
import { bytesToHex, hexToBytes } from "nostr-tools/utils";
|
||||||
|
|
||||||
|
export const DEFAULT_PERIOD_MINUTES = 8 * 60;
|
||||||
|
|
||||||
|
export class RollingIndex {
|
||||||
|
private static PERIOD_BYTES = 2;
|
||||||
|
private static PERIOD_OFFSET_BYTES = 6;
|
||||||
|
|
||||||
|
static diff(left: Uint8Array, right: Uint8Array): Uint8Array[] {
|
||||||
|
const leftData = this.extract(left);
|
||||||
|
const rightData = this.extract(right);
|
||||||
|
|
||||||
|
if (leftData.periodMinutes !== rightData.periodMinutes)
|
||||||
|
throw new Error(
|
||||||
|
`Period minutes mismatch! Left: ${leftData.periodMinutes}, Right: ${rightData.periodMinutes}.`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const startPeriod = Math.min(leftData.periodNumber, rightData.periodNumber);
|
||||||
|
const endPeriod = Math.max(leftData.periodNumber, rightData.periodNumber);
|
||||||
|
const periodMinutes = leftData.periodMinutes;
|
||||||
|
|
||||||
|
const result: Uint8Array[] = [];
|
||||||
|
|
||||||
|
for (
|
||||||
|
let periodNumber = startPeriod;
|
||||||
|
periodNumber <= endPeriod;
|
||||||
|
periodNumber++
|
||||||
|
) {
|
||||||
|
const buffer = new ArrayBuffer(
|
||||||
|
this.PERIOD_BYTES + this.PERIOD_OFFSET_BYTES,
|
||||||
|
);
|
||||||
|
const view = new DataView(buffer);
|
||||||
|
|
||||||
|
view.setUint16(0, periodMinutes, false);
|
||||||
|
view.setUint32(2, Math.floor(periodNumber / 0x10000), false);
|
||||||
|
view.setUint16(6, periodNumber & 0xffff, false);
|
||||||
|
|
||||||
|
result.push(new Uint8Array(buffer));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
static compare(left: Uint8Array, right: Uint8Array): number {
|
||||||
|
const leftData = this.extract(left);
|
||||||
|
const rightData = this.extract(right);
|
||||||
|
|
||||||
|
if (leftData.periodMinutes < rightData.periodMinutes) return -1;
|
||||||
|
if (leftData.periodMinutes > rightData.periodMinutes) return 1;
|
||||||
|
if (leftData.periodNumber < rightData.periodNumber) return -1;
|
||||||
|
if (leftData.periodNumber > rightData.periodNumber) return 1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static at(
|
||||||
|
nowMs: number,
|
||||||
|
periodMinutes: number = DEFAULT_PERIOD_MINUTES,
|
||||||
|
): Uint8Array {
|
||||||
|
const now = Math.floor(nowMs / 1000);
|
||||||
|
const periodSeconds = periodMinutes * 60;
|
||||||
|
const periodNumber = Math.floor(now / periodSeconds);
|
||||||
|
|
||||||
|
const buffer = new ArrayBuffer(
|
||||||
|
this.PERIOD_BYTES + this.PERIOD_OFFSET_BYTES,
|
||||||
|
);
|
||||||
|
const view = new DataView(buffer);
|
||||||
|
|
||||||
|
view.setUint16(0, periodMinutes, false);
|
||||||
|
view.setUint32(2, Math.floor(periodNumber / 0x10000), false);
|
||||||
|
view.setUint16(6, periodNumber & 0xffff, false);
|
||||||
|
|
||||||
|
return new Uint8Array(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
static get(periodMinutes: number = DEFAULT_PERIOD_MINUTES): Uint8Array {
|
||||||
|
return this.at(Date.now(), periodMinutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
static extract(index: Uint8Array): {
|
||||||
|
periodMinutes: number;
|
||||||
|
periodNumber: number;
|
||||||
|
} {
|
||||||
|
const view = new DataView(index.buffer);
|
||||||
|
|
||||||
|
const periodMinutes = view.getUint16(0, false);
|
||||||
|
const periodNumberHigh = view.getUint32(2, false);
|
||||||
|
const periodNumberLow = view.getUint16(6, false);
|
||||||
|
const periodNumber = periodNumberHigh * 0x10000 + periodNumberLow;
|
||||||
|
|
||||||
|
return { periodMinutes, periodNumber };
|
||||||
|
}
|
||||||
|
|
||||||
|
static toHex(index: Uint8Array): string {
|
||||||
|
return bytesToHex(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromHex(hex: string): Uint8Array {
|
||||||
|
return hexToBytes(hex);
|
||||||
|
}
|
||||||
|
}
|
83
src/utils/Uint8Array.ts
Normal file
83
src/utils/Uint8Array.ts
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
import { bytesToHex, hexToBytes } from "nostr-tools/utils";
|
||||||
|
import { isHex } from "./general";
|
||||||
|
|
||||||
|
export function write_varint(bytes: number[], n: number): number {
|
||||||
|
let len = 0;
|
||||||
|
while (true) {
|
||||||
|
let b = n & 0x7f;
|
||||||
|
n >>= 7;
|
||||||
|
if (n !== 0) b |= 0x80;
|
||||||
|
bytes.push(b);
|
||||||
|
len += 1;
|
||||||
|
if (n === 0) break;
|
||||||
|
}
|
||||||
|
return len;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const write_tagged_varint = (
|
||||||
|
bytes: number[],
|
||||||
|
value: number,
|
||||||
|
tagged: boolean,
|
||||||
|
): number => write_varint(bytes, (value << 1) | (tagged ? 1 : 0));
|
||||||
|
|
||||||
|
export function write_string(bytes: number[], s: string) {
|
||||||
|
if (s.length === 0) return write_tagged_varint(bytes, 0, false);
|
||||||
|
|
||||||
|
if (isHex(s)) {
|
||||||
|
const parsed = hexToBytes(s);
|
||||||
|
write_tagged_varint(bytes, parsed.length, true);
|
||||||
|
bytes.push(...parsed);
|
||||||
|
} else {
|
||||||
|
const contentBytes = new TextEncoder().encode(s);
|
||||||
|
write_tagged_varint(bytes, contentBytes.length, false);
|
||||||
|
bytes.push(...contentBytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const read_tagged_varint = (
|
||||||
|
data: Uint8Array,
|
||||||
|
offset: number,
|
||||||
|
): [number, boolean, number] => {
|
||||||
|
const [value, bytes_read] = read_varint(data, offset);
|
||||||
|
const tagged = (value & 1) === 1;
|
||||||
|
const actual_value = value >> 1;
|
||||||
|
return [actual_value, tagged, bytes_read];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function read_varint(
|
||||||
|
buffer: Uint8Array,
|
||||||
|
offset: number,
|
||||||
|
): [number, number] {
|
||||||
|
let value = 0;
|
||||||
|
let shift = 0;
|
||||||
|
let bytesRead = 0;
|
||||||
|
|
||||||
|
while (offset + bytesRead < buffer.length) {
|
||||||
|
const byte = buffer[offset + bytesRead];
|
||||||
|
if (typeof byte === "undefined") return [value, bytesRead];
|
||||||
|
bytesRead++;
|
||||||
|
|
||||||
|
value |= (byte & 0x7f) << shift;
|
||||||
|
|
||||||
|
if ((byte & 0x80) === 0) break;
|
||||||
|
|
||||||
|
shift += 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [value, bytesRead];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function read_string(
|
||||||
|
data: Uint8Array,
|
||||||
|
offset: number,
|
||||||
|
): [string, number] {
|
||||||
|
const [length, tagged, varint_bytes] = read_tagged_varint(data, offset);
|
||||||
|
offset += varint_bytes;
|
||||||
|
|
||||||
|
if (length === 0) return ["", varint_bytes];
|
||||||
|
|
||||||
|
const stringData = data.slice(offset, offset + length);
|
||||||
|
|
||||||
|
if (tagged) return [bytesToHex(stringData), varint_bytes + length];
|
||||||
|
return [new TextDecoder().decode(stringData), varint_bytes + length];
|
||||||
|
}
|
46
src/utils/color.ts
Normal file
46
src/utils/color.ts
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
export function getColorFromPubkey(pubkey: string): string {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < pubkey.length; i++) hash = ((hash << 5) - hash + pubkey.charCodeAt(i)) & 0xffffffff;
|
||||||
|
hash = Math.abs(hash);
|
||||||
|
|
||||||
|
const hue = hash % 360;
|
||||||
|
|
||||||
|
const saturation = hue >= 216 && hue <= 273 ? 0.8 : 0.9;
|
||||||
|
const lightness = hue >= 32 && hue <= 212 ? 0.85 : 0.65;
|
||||||
|
|
||||||
|
const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation;
|
||||||
|
const huePrime = hue / 60;
|
||||||
|
const secondComponent = chroma * (1 - Math.abs((huePrime % 2) - 1));
|
||||||
|
const lightnessAdjustment = lightness - chroma / 2;
|
||||||
|
|
||||||
|
let [r, g, b] = [0, 0, 0];
|
||||||
|
|
||||||
|
const sector = Math.floor(huePrime);
|
||||||
|
switch (sector) {
|
||||||
|
case 0:
|
||||||
|
[r, g, b] = [chroma, secondComponent, 0];
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
[r, g, b] = [secondComponent, chroma, 0];
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
[r, g, b] = [0, chroma, secondComponent];
|
||||||
|
break;
|
||||||
|
case 3:
|
||||||
|
[r, g, b] = [0, secondComponent, chroma];
|
||||||
|
break;
|
||||||
|
case 4:
|
||||||
|
[r, g, b] = [secondComponent, 0, chroma];
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
[r, g, b] = [chroma, 0, secondComponent];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toHex = (value: number): string =>
|
||||||
|
Math.round((value + lightnessAdjustment) * 255)
|
||||||
|
.toString(16)
|
||||||
|
.padStart(2, "0");
|
||||||
|
|
||||||
|
return `${toHex(r)}${toHex(g)}${toHex(b)}`;
|
||||||
|
}
|
193
src/utils/encryption.ts
Normal file
193
src/utils/encryption.ts
Normal file
|
@ -0,0 +1,193 @@
|
||||||
|
import { xchacha20poly1305 } from "@noble/ciphers/chacha";
|
||||||
|
import { bytesToHex, hexToBytes } from "@noble/ciphers/utils";
|
||||||
|
import { managedNonce } from "@noble/ciphers/webcrypto";
|
||||||
|
import {
|
||||||
|
finalizeEvent,
|
||||||
|
generateSecretKey,
|
||||||
|
getPublicKey,
|
||||||
|
type NostrEvent,
|
||||||
|
nip13,
|
||||||
|
verifyEvent,
|
||||||
|
} from "nostr-tools";
|
||||||
|
import { CURRENT_VERSION, POW_TO_ACCEPT, POW_TO_MINE } from "../consts";
|
||||||
|
import { isHex } from "./general";
|
||||||
|
import {
|
||||||
|
read_string,
|
||||||
|
read_varint,
|
||||||
|
write_string,
|
||||||
|
write_varint,
|
||||||
|
} from "./Uint8Array";
|
||||||
|
|
||||||
|
const secureClear = (data: Uint8Array) => data.fill(0);
|
||||||
|
|
||||||
|
export function serializeEventData(event: NostrEvent): Uint8Array {
|
||||||
|
const bytes: number[] = [];
|
||||||
|
bytes.push(CURRENT_VERSION);
|
||||||
|
const id = hexToBytes(event.id);
|
||||||
|
bytes.push(...id);
|
||||||
|
const pk = hexToBytes(event.pubkey);
|
||||||
|
bytes.push(...pk);
|
||||||
|
const sig = hexToBytes(event.sig);
|
||||||
|
bytes.push(...sig);
|
||||||
|
write_varint(bytes, event.created_at);
|
||||||
|
write_varint(bytes, event.kind);
|
||||||
|
write_string(bytes, event.content);
|
||||||
|
write_varint(bytes, event.tags.length);
|
||||||
|
for (const tag of event.tags) {
|
||||||
|
write_varint(bytes, tag.length);
|
||||||
|
for (const element of tag) write_string(bytes, element);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Uint8Array(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deserializeEventData(buffer: Uint8Array): NostrEvent {
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
const version = buffer[offset++];
|
||||||
|
if (version !== CURRENT_VERSION)
|
||||||
|
throw new Error(`Unsupported version: ${version}`);
|
||||||
|
|
||||||
|
const id = bytesToHex(buffer.slice(offset, offset + 32));
|
||||||
|
offset += 32;
|
||||||
|
const pubkey = bytesToHex(buffer.slice(offset, offset + 32));
|
||||||
|
offset += 32;
|
||||||
|
const sig = bytesToHex(buffer.slice(offset, offset + 64));
|
||||||
|
offset += 64;
|
||||||
|
const [created_at, created_at_bytes] = read_varint(buffer, offset);
|
||||||
|
offset += created_at_bytes;
|
||||||
|
const [kind, kind_bytes] = read_varint(buffer, offset);
|
||||||
|
offset += kind_bytes;
|
||||||
|
const [content, content_bytes] = read_string(buffer, offset);
|
||||||
|
offset += content_bytes;
|
||||||
|
const [tags_length, tags_length_bytes] = read_varint(buffer, offset);
|
||||||
|
offset += tags_length_bytes;
|
||||||
|
|
||||||
|
const tags: string[][] = [];
|
||||||
|
for (let i = 0; i < tags_length; i++) {
|
||||||
|
const [tag_length, tag_length_bytes] = read_varint(buffer, offset);
|
||||||
|
offset += tag_length_bytes;
|
||||||
|
|
||||||
|
const tag: string[] = [];
|
||||||
|
for (let j = 0; j < tag_length; j++) {
|
||||||
|
const [element, element_bytes] = read_string(buffer, offset);
|
||||||
|
offset += element_bytes;
|
||||||
|
|
||||||
|
tag.push(element);
|
||||||
|
}
|
||||||
|
tags.push(tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
pubkey,
|
||||||
|
sig,
|
||||||
|
created_at,
|
||||||
|
kind,
|
||||||
|
content,
|
||||||
|
tags,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypts a given Uint8Array using the XChaCha20-Poly1305 algorithm.
|
||||||
|
*
|
||||||
|
* @param data - The data to be encrypted as a Uint8Array.
|
||||||
|
* @param key - The encryption key as a Uint8Array.
|
||||||
|
* @returns The encrypted data as a Uint8Array.
|
||||||
|
*
|
||||||
|
* @note The key being cloned is not a mistake in the function. If the key is not a copy, it will be cleared from memory, causing future encryptions and decryptions to use key = 0
|
||||||
|
*/
|
||||||
|
export function encryptUint8Array(
|
||||||
|
data: Uint8Array,
|
||||||
|
key: Uint8Array,
|
||||||
|
): Uint8Array {
|
||||||
|
if (key.length !== 32) throw new Error("Encryption key must be 32 bytes");
|
||||||
|
if (data.length === 0) throw new Error("Cannot encrypt empty data");
|
||||||
|
return managedNonce(xchacha20poly1305)(new Uint8Array(key)).encrypt(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypts a given Uint8Array using the XChaCha20-Poly1305 algorithm.
|
||||||
|
*
|
||||||
|
* @param data - The data to be decrypted as a Uint8Array.
|
||||||
|
* @param key - The decryption key as a Uint8Array.
|
||||||
|
* @returns The decrypted data as a Uint8Array.
|
||||||
|
*
|
||||||
|
* @note The key being cloned is not a mistake in the function. If the key is not a copy, it will be cleared from memory, causing future encryptions and decryptions to use key = 0
|
||||||
|
*/
|
||||||
|
export function decryptUint8Array(
|
||||||
|
data: Uint8Array,
|
||||||
|
key: Uint8Array,
|
||||||
|
): Uint8Array {
|
||||||
|
if (key.length !== 32) throw new Error("Decryption key must be 32 bytes");
|
||||||
|
if (data.length === 0) throw new Error("Cannot decrypt empty data");
|
||||||
|
return managedNonce(xchacha20poly1305)(new Uint8Array(key)).decrypt(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createEncryptedEvent(
|
||||||
|
event: NostrEvent,
|
||||||
|
encryptionKey: Uint8Array,
|
||||||
|
): Promise<NostrEvent> {
|
||||||
|
const ccnPubkey = getPublicKey(encryptionKey);
|
||||||
|
let randomKey: Uint8Array | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const serializedData = serializeEventData(event);
|
||||||
|
|
||||||
|
const encryptedEvent = encryptUint8Array(serializedData, encryptionKey);
|
||||||
|
|
||||||
|
const randomTimeUpTo2DaysInThePast =
|
||||||
|
Math.floor(Date.now() / 1000) - Math.floor(Math.random() * 2 * 86400);
|
||||||
|
|
||||||
|
randomKey = generateSecretKey();
|
||||||
|
const randomKeyPub = getPublicKey(randomKey);
|
||||||
|
|
||||||
|
const mainEvent = nip13.minePow(
|
||||||
|
{
|
||||||
|
kind: 1060,
|
||||||
|
content: bytesToHex(encryptedEvent),
|
||||||
|
created_at: randomTimeUpTo2DaysInThePast,
|
||||||
|
tags: [["p", ccnPubkey]],
|
||||||
|
pubkey: randomKeyPub,
|
||||||
|
},
|
||||||
|
POW_TO_MINE,
|
||||||
|
);
|
||||||
|
|
||||||
|
return finalizeEvent(mainEvent, randomKey);
|
||||||
|
} finally {
|
||||||
|
if (randomKey) secureClear(randomKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decryptEvent(
|
||||||
|
event: NostrEvent,
|
||||||
|
encryptionKey: Uint8Array,
|
||||||
|
) {
|
||||||
|
let decryptedData: Uint8Array | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!verifyEvent(event)) throw new Error("Operation failed: invalid event");
|
||||||
|
|
||||||
|
if (nip13.getPow(event.id) < POW_TO_ACCEPT)
|
||||||
|
throw new Error("Operation failed: insufficient proof of work");
|
||||||
|
|
||||||
|
if (!isHex(event.content))
|
||||||
|
throw new Error("Operation failed: invalid content encoding");
|
||||||
|
|
||||||
|
if (event.kind !== 1060) throw new Error("Operation failed: invalid kind");
|
||||||
|
|
||||||
|
decryptedData = decryptUint8Array(hexToBytes(event.content), encryptionKey);
|
||||||
|
|
||||||
|
const innerEvent = deserializeEventData(decryptedData);
|
||||||
|
|
||||||
|
console.log(innerEvent);
|
||||||
|
|
||||||
|
if (!verifyEvent(innerEvent))
|
||||||
|
throw new Error("Operation failed: invalid inner event");
|
||||||
|
|
||||||
|
return innerEvent;
|
||||||
|
} finally {
|
||||||
|
if (decryptedData) secureClear(decryptedData);
|
||||||
|
}
|
||||||
|
}
|
29
src/utils/files.ts
Normal file
29
src/utils/files.ts
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
import { mkdirSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import type { NostrEvent } from "nostr-tools";
|
||||||
|
import { CCN } from "../ccns";
|
||||||
|
|
||||||
|
export function getDataDir() {
|
||||||
|
if (Bun.env.NODE_ENV === "production") {
|
||||||
|
const baseDir = "/var/lib/eve-lite";
|
||||||
|
mkdirSync(baseDir, { recursive: true });
|
||||||
|
return baseDir;
|
||||||
|
}
|
||||||
|
let home = Bun.env.XDG_CONFIG_HOME;
|
||||||
|
if (!home) home = join(Bun.env.HOME!, ".config");
|
||||||
|
const configDir = join(home, "arx", "eve-lite");
|
||||||
|
mkdirSync(configDir, { recursive: true });
|
||||||
|
return configDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadSeenEvents() {
|
||||||
|
const ccn = await CCN.getActive();
|
||||||
|
if (!ccn) throw "No CCN";
|
||||||
|
return ccn.loadSeenEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveSeenEvent(event: NostrEvent) {
|
||||||
|
const ccn = await CCN.getActive();
|
||||||
|
if (!ccn) throw "No CCN";
|
||||||
|
return ccn.saveSeenEvent(event);
|
||||||
|
}
|
98
src/utils/general.ts
Normal file
98
src/utils/general.ts
Normal file
|
@ -0,0 +1,98 @@
|
||||||
|
import { type Filter, type NostrEvent, SimplePool } from "nostr-tools";
|
||||||
|
import type { SubCloser } from "nostr-tools/abstract-pool";
|
||||||
|
|
||||||
|
export const isHex = (hex: string) =>
|
||||||
|
/^[0-9a-fA-F]+$/.test(hex) && hex.length % 2 === 0;
|
||||||
|
|
||||||
|
export const pool = new SimplePool();
|
||||||
|
|
||||||
|
export const relays = [
|
||||||
|
"wss://relay.arx-ccn.com/",
|
||||||
|
"wss://nos.lol/",
|
||||||
|
"wss://nostr.einundzwanzig.space/",
|
||||||
|
"wss://nostr.massmux.com/",
|
||||||
|
"wss://nostr.mom/",
|
||||||
|
"wss://purplerelay.com/",
|
||||||
|
"wss://relay.damus.io/",
|
||||||
|
"wss://relay.goodmorningbitcoin.com/",
|
||||||
|
"wss://relay.lexingtonbitcoin.org/",
|
||||||
|
"wss://relay.nostr.band/",
|
||||||
|
"wss://relay.snort.social/",
|
||||||
|
"wss://strfry.iris.to/",
|
||||||
|
"wss://cache2.primal.net/v1",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const queryRemoteRelays = (
|
||||||
|
filer: Filter,
|
||||||
|
callback: (event: NostrEvent) => void,
|
||||||
|
): SubCloser =>
|
||||||
|
pool.subscribe(relays, filer, {
|
||||||
|
onevent: callback,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const queryRemoteRelaysSync = (filter: Filter): Promise<NostrEvent[]> =>
|
||||||
|
pool.querySync(relays, filter);
|
||||||
|
|
||||||
|
export const queryRemoteEvent = (id: string): Promise<NostrEvent | null> =>
|
||||||
|
pool.get(relays, {
|
||||||
|
ids: [id],
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function sendEncryptedEventToRelays(
|
||||||
|
event: NostrEvent,
|
||||||
|
): Promise<string> {
|
||||||
|
if (event.kind !== 1059 && event.kind !== 1060)
|
||||||
|
throw new Error("Event is not an eve encrypted event");
|
||||||
|
|
||||||
|
const pool = new SimplePool();
|
||||||
|
|
||||||
|
return Promise.any(pool.publish(relays, event));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendUnencryptedEventToLocalRelay(
|
||||||
|
event: NostrEvent,
|
||||||
|
): Promise<string> {
|
||||||
|
const pool = new SimplePool();
|
||||||
|
return Promise.any(pool.publish(["ws://localhost:6942"], event));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitIntoParts(str: string, partsCount: number): string[] {
|
||||||
|
let remainder = str.length % partsCount;
|
||||||
|
const partSize = (str.length - remainder) / partsCount;
|
||||||
|
const parts: string[] = new Array(partsCount).fill("");
|
||||||
|
let currentPart = 0;
|
||||||
|
for (let i = 0; i < str.length; ) {
|
||||||
|
let end = i + partSize;
|
||||||
|
if (remainder) {
|
||||||
|
end++;
|
||||||
|
remainder--;
|
||||||
|
}
|
||||||
|
parts[currentPart++] = str.slice(i, end);
|
||||||
|
i = end;
|
||||||
|
}
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSvgGroup(svg: string, transform: string): string {
|
||||||
|
const gOpen = svg.match(/<g(\s[^>]*)?>/);
|
||||||
|
if (!gOpen) throw new Error("Malformed SVG");
|
||||||
|
|
||||||
|
let depth = 1;
|
||||||
|
let i = gOpen.index! + gOpen[0].length;
|
||||||
|
while (depth && i < svg.length) {
|
||||||
|
const open = svg.indexOf("<g", i);
|
||||||
|
const close = svg.indexOf("</g>", i);
|
||||||
|
if (close === -1) throw new Error("Malformed SVG");
|
||||||
|
if (open !== -1 && open < close) {
|
||||||
|
depth++;
|
||||||
|
i = open + 2;
|
||||||
|
} else {
|
||||||
|
depth--;
|
||||||
|
i = close + 4;
|
||||||
|
if (!depth)
|
||||||
|
return `<g${gOpen[1]?.replace(/(mask|transform|stroke)="[^"]*"/g, "").trim()} transform="${transform}" stroke="black" stroke-width="1.5">${svg.slice(gOpen.index! + gOpen[0].length, close)}</g>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("Malformed SVG");
|
||||||
|
}
|
119
src/validation/index.ts
Normal file
119
src/validation/index.ts
Normal file
|
@ -0,0 +1,119 @@
|
||||||
|
import { hexToBytes } from "@noble/ciphers/utils";
|
||||||
|
import { getPublicKey } from "nostr-tools";
|
||||||
|
import { isHex } from "../utils/general";
|
||||||
|
|
||||||
|
export function validatePrivateKey(privateKey: string) {
|
||||||
|
if (!privateKey)
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
error: "Private key is required",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isHex(privateKey))
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
error: "Private key must be a valid hexadecimal string",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (privateKey.length !== 64)
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
error: "Private key must be exactly 32 bytes (64 hex characters)",
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const keyBytes = hexToBytes(privateKey);
|
||||||
|
getPublicKey(keyBytes);
|
||||||
|
return { isValid: true };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
error: "Invalid private key format",
|
||||||
|
details: error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateCommunityName(name: string) {
|
||||||
|
if (typeof name !== "string")
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
error: "Community name is required and must be a string",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!name || name.trim().length === 0)
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
error: "Community name cannot be empty",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (name.length > 100)
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
error: "Community name must be 100 characters or less",
|
||||||
|
};
|
||||||
|
|
||||||
|
return { isValid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateCommunityDescription(description: string) {
|
||||||
|
if (typeof description !== "string")
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
error: "Community description must be a string",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (description.length > 500)
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
error: "Community description must be 500 characters or less",
|
||||||
|
};
|
||||||
|
|
||||||
|
return { isValid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateRelayUrl(url: string) {
|
||||||
|
if (!url || typeof url !== "string")
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
error: "Relay URL is required and must be a string",
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedUrl = new URL(url);
|
||||||
|
if (!["ws:", "wss:"].includes(parsedUrl.protocol))
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
error: "Relay URL must use ws:// or wss:// protocol",
|
||||||
|
};
|
||||||
|
return { isValid: true };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
error: "Invalid URL format",
|
||||||
|
details: error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validatePowDifficulty(difficulty: number) {
|
||||||
|
if (typeof difficulty !== "number")
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
error: "PoW difficulty must be a number",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!Number.isInteger(difficulty))
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
error: "PoW difficulty must be an integer",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (difficulty < 0 || difficulty > 64)
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
error: "PoW difficulty must be between 0 and 64",
|
||||||
|
};
|
||||||
|
|
||||||
|
return { isValid: true };
|
||||||
|
}
|
29
tsconfig.json
Normal file
29
tsconfig.json
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
// Environment setup & latest features
|
||||||
|
"lib": ["ESNext"],
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "Preserve",
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"allowJs": true,
|
||||||
|
|
||||||
|
// Bundler mode
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
// Best practices
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
|
||||||
|
// Some stricter flags (disabled by default)
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noPropertyAccessFromIndexSignature": false
|
||||||
|
}
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue