init
147
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
.DS_Store
|
||||
.vercel
|
||||
stats.html
|
||||
fsd-high-level-dependencies.html
|
||||
|
||||
wip/**
|
||||
wip/
|
||||
|
||||
build.info.json
|
||||
|
||||
public/wasm_exec.js
|
||||
public/xray.schema.json
|
||||
public/main.wasm
|
||||
public/xray.schema.cn.json
|
||||
|
||||
.vscode/
|
||||
1
.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
legacy-peer-deps=true
|
||||
8
.prettierrc
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"singleQuote": true,
|
||||
"singleAttributePerLine": false,
|
||||
"tabWidth": 4,
|
||||
"printWidth": 100,
|
||||
"semi": false,
|
||||
"trailingComma": "none"
|
||||
}
|
||||
1
.stylelintignore
Normal file
|
|
@ -0,0 +1 @@
|
|||
dist
|
||||
28
.stylelintrc.json
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"extends": ["stylelint-config-standard-scss"],
|
||||
"rules": {
|
||||
"custom-property-pattern": null,
|
||||
"selector-class-pattern": null,
|
||||
"scss/no-duplicate-mixins": null,
|
||||
"declaration-empty-line-before": null,
|
||||
"declaration-block-no-redundant-longhand-properties": null,
|
||||
"alpha-value-notation": null,
|
||||
"custom-property-empty-line-before": null,
|
||||
"property-no-vendor-prefix": null,
|
||||
"color-function-notation": null,
|
||||
"length-zero-no-unit": null,
|
||||
"selector-not-notation": null,
|
||||
"no-descending-specificity": null,
|
||||
"comment-empty-line-before": null,
|
||||
"scss/at-mixin-pattern": null,
|
||||
"scss/at-rule-no-unknown": null,
|
||||
"value-keyword-case": null,
|
||||
"media-feature-range-notation": null,
|
||||
"selector-pseudo-class-no-unknown": [
|
||||
true,
|
||||
{
|
||||
"ignorePseudoClasses": ["global"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
3
@types/client.d.ts
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
declare const __DOMAIN_BACKEND__: string
|
||||
declare const __NODE_ENV__: string
|
||||
declare const __DOMAIN_OVERRIDE__: string
|
||||
19
@types/mantine.d.ts
vendored
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { ThemeIconVariant } from '@mantine/core'
|
||||
|
||||
type ExtendedThemeIconVariant =
|
||||
| 'gradient-blue'
|
||||
| 'gradient-cyan'
|
||||
| 'gradient-gray'
|
||||
| 'gradient-green'
|
||||
| 'gradient-orange'
|
||||
| 'gradient-red'
|
||||
| 'gradient-teal'
|
||||
| 'gradient-violet'
|
||||
| 'gradient-yellow'
|
||||
| ThemeIconVariant
|
||||
|
||||
declare module '@mantine/core' {
|
||||
export interface ThemeIconProps {
|
||||
variant?: ExtendedThemeIconVariant
|
||||
}
|
||||
}
|
||||
661
LICENCE
Normal file
|
|
@ -0,0 +1,661 @@
|
|||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 9 January 2025
|
||||
|
||||
Copyright (C) 2025 Remnawave <https://github.com/remnawave>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
37
Makefile
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
.PHONY: bump-patch bump-minor bump-major help tag-release
|
||||
|
||||
# Default target
|
||||
help:
|
||||
@echo "Available targets:"
|
||||
@echo " bump-patch - Bump patch version (x.x.X) and install dependencies"
|
||||
@echo " bump-minor - Bump minor version (x.X.x) and install dependencies"
|
||||
@echo " bump-major - Bump major version (X.x.x) and install dependencies"
|
||||
@echo " tag-release - Create and push git tag for current version"
|
||||
|
||||
# Bump patch version (0.0.1 -> 0.0.2)
|
||||
bump-patch:
|
||||
@echo "Bumping patch version..."
|
||||
npm version patch --no-git-tag-version
|
||||
@echo "New version: $$(node -p "require('./package.json').version")"
|
||||
npm install
|
||||
|
||||
# Bump minor version (0.1.0 -> 0.2.0)
|
||||
bump-minor:
|
||||
@echo "Bumping minor version..."
|
||||
npm version minor --no-git-tag-version
|
||||
@echo "New version: $$(node -p "require('./package.json').version")"
|
||||
npm install
|
||||
# Bump major version (1.0.0 -> 2.0.0)
|
||||
bump-major:
|
||||
@echo "Bumping major version..."
|
||||
npm version major --no-git-tag-version
|
||||
@echo "New version: $$(node -p "require('./package.json').version")"
|
||||
npm install
|
||||
|
||||
# Create and push git tag for current version
|
||||
tag-release:
|
||||
@VERSION=$$(node -p "require('./package.json').version") && \
|
||||
echo "Creating signed tag for version $$VERSION..." && \
|
||||
git tag -s "$$VERSION" -m "Release $$VERSION" && \
|
||||
git push origin --follow-tags && \
|
||||
echo "Signed tag $$VERSION created and pushed"
|
||||
177
eslint.config.mjs
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import { fixupConfigRules, fixupPluginRules } from '@eslint/compat'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tsParser from '@typescript-eslint/parser'
|
||||
import stylistic from '@stylistic/eslint-plugin'
|
||||
import { FlatCompat } from '@eslint/eslintrc'
|
||||
import _import from 'eslint-plugin-import'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import globals from 'globals'
|
||||
import path from 'node:path'
|
||||
import js from '@eslint/js'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
recommendedConfig: js.configs.recommended,
|
||||
allConfig: js.configs.all
|
||||
})
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores([
|
||||
'**/dist',
|
||||
'**/.eslintrc.cjs',
|
||||
'**/plop',
|
||||
'plop/**/*',
|
||||
'**/plopfile.js',
|
||||
'**/.stylelintrc.js',
|
||||
'node_modules/'
|
||||
]),
|
||||
{
|
||||
extends: fixupConfigRules(
|
||||
compat.extends(
|
||||
'eslint:recommended',
|
||||
'airbnb-base',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
'plugin:storybook/recommended',
|
||||
'plugin:perfectionist/recommended-natural-legacy',
|
||||
'prettier'
|
||||
)
|
||||
),
|
||||
|
||||
plugins: {
|
||||
'react-refresh': reactRefresh,
|
||||
'@stylistic': stylistic,
|
||||
import: fixupPluginRules(_import)
|
||||
},
|
||||
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser
|
||||
},
|
||||
parser: tsParser
|
||||
},
|
||||
|
||||
settings: {
|
||||
'import/parsers': {
|
||||
'@typescript-eslint/parser': ['.ts', '.tsx']
|
||||
},
|
||||
|
||||
'import/resolver': {
|
||||
node: true,
|
||||
|
||||
typescript: {
|
||||
project: '.'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
rules: {
|
||||
'perfectionist/sort-imports': [
|
||||
'error',
|
||||
{
|
||||
type: 'line-length',
|
||||
order: 'desc',
|
||||
ignoreCase: true,
|
||||
specialCharacters: 'keep',
|
||||
internalPattern: ['^~/.+'],
|
||||
tsconfigRootDir: '.',
|
||||
partitionByComment: false,
|
||||
partitionByNewLine: false,
|
||||
newlinesBetween: 'always',
|
||||
|
||||
groups: [
|
||||
'type',
|
||||
['builtin', 'external'],
|
||||
'internal-type',
|
||||
'internal',
|
||||
['parent-type', 'sibling-type', 'index-type'],
|
||||
['parent', 'sibling', 'index'],
|
||||
'object',
|
||||
'unknown'
|
||||
],
|
||||
|
||||
customGroups: {
|
||||
type: {},
|
||||
value: {}
|
||||
},
|
||||
|
||||
environment: 'node'
|
||||
}
|
||||
],
|
||||
|
||||
'perfectionist/sort-objects': ['off'],
|
||||
'perfectionist/sort-modules': ['off'],
|
||||
|
||||
// indent: [
|
||||
// 'error',
|
||||
// 4,
|
||||
// {
|
||||
// SwitchCase: 1
|
||||
// }
|
||||
// ],
|
||||
|
||||
'@stylistic/indent': ['error', 4, { SwitchCase: 1 }],
|
||||
|
||||
'max-classes-per-file': 'off',
|
||||
'import/no-extraneous-dependencies': ['off'],
|
||||
'import/no-unresolved': 'error',
|
||||
'import/prefer-default-export': 'off',
|
||||
'import/extensions': 'off',
|
||||
'no-bitwise': 'off',
|
||||
'no-plusplus': 'off',
|
||||
'no-restricted-syntax': ['off', 'ForInStatement'],
|
||||
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{
|
||||
allowConstantExport: true
|
||||
}
|
||||
],
|
||||
|
||||
'no-shadow': ['off'],
|
||||
'arrow-body-style': ['off'],
|
||||
'object-curly-spacing': ['error', 'always'],
|
||||
'array-bracket-spacing': ['error', 'never'],
|
||||
|
||||
'no-underscore-dangle': [
|
||||
'off',
|
||||
{
|
||||
allow: ['_'],
|
||||
allowAfterThis: true,
|
||||
allowAfterSuper: true,
|
||||
allowAfterThisConstructor: true,
|
||||
enforceInMethodNames: false
|
||||
}
|
||||
],
|
||||
|
||||
semi: ['error', 'never'],
|
||||
'comma-dangle': ['off'],
|
||||
|
||||
'brace-style': [
|
||||
'error',
|
||||
'1tbs',
|
||||
{
|
||||
allowSingleLine: true
|
||||
}
|
||||
],
|
||||
|
||||
'object-curly-newline': [
|
||||
'error',
|
||||
{
|
||||
multiline: true,
|
||||
consistent: true
|
||||
}
|
||||
],
|
||||
|
||||
'react-hooks/exhaustive-deps': 'off',
|
||||
'no-empty-pattern': 'warn',
|
||||
|
||||
'@typescript-eslint/no-empty-object-type': 'off',
|
||||
'@typescript-eslint/no-unsafe-function-type': 'error',
|
||||
'@typescript-eslint/no-wrapper-object-types': 'error'
|
||||
}
|
||||
}
|
||||
])
|
||||
52
index.html
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<!doctype html>
|
||||
<html lang="en" dir="ltr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
|
||||
<meta name="apple-mobile-web-app-title" content="Remnawave" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
|
||||
<meta name="apple-touch-fullscreen" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
|
||||
<link rel="icon" href="/favicons/logo.svg" type="image/svg+xml" />
|
||||
<link rel="icon" href="/favicons/favicon-32x32.png" sizes="32x32" type="image/png" />
|
||||
<link rel="icon" href="/favicons/favicon-16x16.png" sizes="16x16" type="image/png" />
|
||||
<link rel="apple-touch-icon" href="/favicons/favicon-180x180.png" sizes="180x180" />
|
||||
|
||||
<link rel="preconnect" crossorigin="anonymous" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" crossorigin="anonymous" href="https://fonts.gstatic.com" />
|
||||
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Fira+Mono:wght@400;500;700&family=Unbounded:wght@200..900&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<meta name="color-scheme" content="dark only" />
|
||||
<meta name="theme-color" content="#161B23" />
|
||||
|
||||
<meta
|
||||
name="viewport"
|
||||
content="minimum-scale=1, initial-scale=1, width=device-width, user-scalable=no, viewport-fit=cover"
|
||||
/>
|
||||
|
||||
<meta
|
||||
name="description"
|
||||
content="Remnawave – user and proxy management solution built on top of Xray Core. Easily add users, nodes, configure Xray and much more with a feature-rich REST API."
|
||||
/>
|
||||
<meta property="og:url" content="https://utils.docs.rw" data-rh="true" />
|
||||
<meta property="og:title" content="Remnawave Utils" data-rh="true" />
|
||||
<meta name="description" content="Remnawave Utils" data-rh="true" />
|
||||
<meta property="og:description" content="Remnawave Utils" data-rh="true" />
|
||||
<!-- <meta property="og:image" content="/favicons/logo.svg" data-rh="true" />
|
||||
<meta name="twitter:image" content="/favicons/logo.svg" data-rh="true" /> -->
|
||||
<!-- <meta data-rh="true" name="twitter:card" content="summary_large_image" /> -->
|
||||
<title>Remnawave Utils</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
10924
package-lock.json
generated
Normal file
108
package.json
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
{
|
||||
"name": "@remnawave/utils",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"license": "AGPL-3.0-only",
|
||||
"author": "REMNAWAVE <github.com/remnawave>",
|
||||
"homepage": "https://github.com/remnawave/utils",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/remnawave/utils.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/remnawave/utils/issues"
|
||||
},
|
||||
"scripts": {
|
||||
"start:dev": "vite",
|
||||
"start:build": "NODE_ENV=production tsc && vite build",
|
||||
"cb": "vite build",
|
||||
"start:preview": "vite preview --port 3333",
|
||||
"serve": "NODE_ENV=production DOMAIN_OVERRIDE=1 tsc && vite build && vite preview --port 3333",
|
||||
"serve:dev": "DOMAIN_OVERRIDE=1 tsc && vite build && vite preview --port 3333",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "npm run lint:eslint && npm run lint:stylelint",
|
||||
"lint:eslint": "eslint . --ext .ts,.tsx --cache",
|
||||
"lint:stylelint": "stylelint '**/*.css' --cache",
|
||||
"prettier": "prettier --check \"**/*.{ts,tsx}\"",
|
||||
"prettier:write": "prettier --write \"**/*.{ts,tsx}\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@kastov/cryptohapp": "^1.1.2",
|
||||
"@mantine/core": "^8.3.10",
|
||||
"@mantine/hooks": "^8.3.10",
|
||||
"@tabler/icons-react": "^3.36.0",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^16.5.0",
|
||||
"jsencrypt": "^3.5.4",
|
||||
"motion": "12.23.26",
|
||||
"react": "19.2.3",
|
||||
"@mantine/modals": "^8.3.10",
|
||||
"react-dom": "19.2.3",
|
||||
"react-error-boundary": "^6.0.1",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-router-dom": "6.27.0",
|
||||
"uqr": "^0.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^1.3.2",
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@eslint/js": "^9.34.0",
|
||||
"@ianvs/prettier-plugin-sort-imports": "^4.7.0",
|
||||
"@stylistic/eslint-plugin": "^5.3.1",
|
||||
"@swc/core": "^1.13.5",
|
||||
"@types/lodash": "^4.17.20",
|
||||
"@types/node": "^22.15.30",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@typescript-eslint/eslint-plugin": "^8.41.0",
|
||||
"@typescript-eslint/parser": "^8.41.0",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"@vitejs/plugin-react-swc": "^4.1.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^9.34.0",
|
||||
"eslint-config-airbnb-base": "^15.0.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-perfectionist": "^4.15.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"eslint-plugin-storybook": "^0.12.0",
|
||||
"globals": "^16.3.0",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss-preset-mantine": "1.18.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"prettier": "^3.7.4",
|
||||
"prop-types": "^15.8.1",
|
||||
"rollup-plugin-visualizer": "^5.14.0",
|
||||
"steiger": "^0.5.7",
|
||||
"stylelint": "^16.19.1",
|
||||
"stylelint-config-standard-scss": "^14.0.0",
|
||||
"terser": "^5.43.1",
|
||||
"typescript": "^5.9.2",
|
||||
"typescript-eslint": "^8.41.0",
|
||||
"vite": "7.1.8",
|
||||
"vite-plugin-javascript-obfuscator": "^3.1.0",
|
||||
"vite-plugin-preload": "^0.4.4",
|
||||
"vite-plugin-remove-console": "^2.2.0",
|
||||
"vite-plugin-svgr": "^4.5.0",
|
||||
"vite-plugin-webfont-dl": "^3.11.1",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
},
|
||||
"commitlint": {
|
||||
"extends": [
|
||||
"@commitlint/config-conventional"
|
||||
]
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-linux-x64-gnu": "4.9.5"
|
||||
},
|
||||
"overrides": {
|
||||
"node-plop": {
|
||||
"inquirer": "9.3.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
19
postcss.config.cjs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
'postcss-preset-mantine': {
|
||||
autoRem: true
|
||||
},
|
||||
'postcss-simple-vars': {
|
||||
variables: {
|
||||
'mantine-breakpoint-xs': '30em',
|
||||
'mantine-breakpoint-sm': '40em',
|
||||
'mantine-breakpoint-md': '48em',
|
||||
'mantine-breakpoint-lg': '64em',
|
||||
'mantine-breakpoint-xl': '80em',
|
||||
'mantine-breakpoint-2xl': '96em',
|
||||
'mantine-breakpoint-3xl': '120em',
|
||||
'mantine-breakpoint-4xl': '160em'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
public/favicons/favicon-128x128.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
public/favicons/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 301 B |
BIN
public/favicons/favicon-180x180.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
public/favicons/favicon-192x192.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
public/favicons/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 557 B |
BIN
public/favicons/favicon-48x48.png
Normal file
|
After Width: | Height: | Size: 770 B |
BIN
public/favicons/favicon-512x512.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
public/favicons/favicon-64x64.png
Normal file
|
After Width: | Height: | Size: 882 B |
6
public/favicons/logo.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
|
||||
<rect width="32" height="32" rx="6" fill="#151A23"/>
|
||||
<g transform="translate(4, 4) scale(1.5)">
|
||||
<path fill="#3CC9DB" fill-rule="evenodd" d="M8 1a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-1.5 0V1.75A.75.75 0 0 1 8 1Zm6 2a.75.75 0 0 1 .75.75v8.5a.75.75 0 0 1-1.5 0v-8.5A.75.75 0 0 1 14 3ZM5 4a.75.75 0 0 1 .75.75v6.5a.75.75 0 0 1-1.5 0v-6.5A.75.75 0 0 1 5 4Zm6 1a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 11 5ZM2 6a.75.75 0 0 1 .75.75v2.5a.75.75 0 0 1-1.5 0v-2.5A.75.75 0 0 1 2 6Z" clip-rule="evenodd"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 622 B |
20
src/app.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import '@mantine/core/styles.css'
|
||||
|
||||
import './global.css'
|
||||
|
||||
import { ModalsProvider } from '@mantine/modals'
|
||||
import { MantineProvider } from '@mantine/core'
|
||||
|
||||
import { theme, variantColorResolver } from '@shared/constants'
|
||||
|
||||
import { Router } from './app/router/router'
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<MantineProvider defaultColorScheme="dark" theme={{ ...theme, variantColorResolver }}>
|
||||
<ModalsProvider>
|
||||
<Router />
|
||||
</ModalsProvider>
|
||||
</MantineProvider>
|
||||
)
|
||||
}
|
||||
1
src/app/router/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './router'
|
||||
28
src/app/router/router.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import {
|
||||
createBrowserRouter,
|
||||
createRoutesFromElements,
|
||||
Navigate,
|
||||
Route,
|
||||
RouterProvider
|
||||
} from 'react-router-dom'
|
||||
|
||||
import { CryptohappPlaygroundPage } from '@pages/cryptohapp-playground'
|
||||
import { HappRoutingBuilderPage } from '@pages/happ-routing-builder'
|
||||
import { HomePage } from '@pages/home'
|
||||
|
||||
import { ROUTES } from '../../shared/constants'
|
||||
|
||||
const router = createBrowserRouter(
|
||||
createRoutesFromElements(
|
||||
<>
|
||||
<Route element={<HomePage />} path={ROUTES.ROOT} />
|
||||
<Route element={<CryptohappPlaygroundPage />} path={ROUTES.CRYPTOHAPP_PLAYGROUND} />
|
||||
<Route element={<HappRoutingBuilderPage />} path={ROUTES.HAPP_ROUTING_BUILDER} />
|
||||
<Route element={<Navigate replace to={ROUTES.ROOT} />} path="*" />
|
||||
</>
|
||||
)
|
||||
)
|
||||
|
||||
export function Router() {
|
||||
return <RouterProvider router={router} />
|
||||
}
|
||||
2
src/features/cryptohapp/decrypt/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { DecryptCard } from './ui/decrypt-card'
|
||||
|
||||
34
src/features/cryptohapp/decrypt/ui/decrypt-card.module.css
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
.card {
|
||||
position: relative;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
overflow: hidden;
|
||||
transition:
|
||||
background 0.3s ease,
|
||||
border-color 0.3s ease,
|
||||
box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: rgba(251, 146, 60, 0.3);
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
border-radius: inherit;
|
||||
background: transparent;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.icon {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.card:hover .icon {
|
||||
box-shadow: 0 0 30px rgba(251, 146, 60, 0.3);
|
||||
}
|
||||
151
src/features/cryptohapp/decrypt/ui/decrypt-card.tsx
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import { Box, Button, Card, Group, Stack, Text, Textarea, ThemeIcon } from '@mantine/core'
|
||||
import { IconLockOpen } from '@tabler/icons-react'
|
||||
import { motion } from 'motion/react'
|
||||
import JSEncrypt from 'jsencrypt'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { CopyableCodeBlock } from '@shared/ui/copyable-code-block'
|
||||
|
||||
import styles from './decrypt-card.module.css'
|
||||
|
||||
const EXAMPLE_PRIVATE_KEY = `-----BEGIN RSA PRIVATE KEY-----
|
||||
Paste your private key here...
|
||||
-----END RSA PRIVATE KEY-----`
|
||||
|
||||
export function DecryptCard() {
|
||||
const [privateKey, setPrivateKey] = useState('')
|
||||
const [encryptedContent, setEncryptedContent] = useState('')
|
||||
const [decryptedContent, setDecryptedContent] = useState<null | string>(null)
|
||||
const [error, setError] = useState<null | string>(null)
|
||||
|
||||
const handleDecrypt = () => {
|
||||
setError(null)
|
||||
setDecryptedContent(null)
|
||||
|
||||
if (!privateKey.trim()) {
|
||||
setError('Please enter a valid private key')
|
||||
return
|
||||
}
|
||||
|
||||
if (!encryptedContent.trim()) {
|
||||
setError('Please enter encrypted content')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const decrypt = new JSEncrypt()
|
||||
decrypt.setPrivateKey(privateKey)
|
||||
|
||||
let contentToDecrypt = encryptedContent.trim()
|
||||
const prefixes = ['happ://crypt2/', 'happ://crypt3/', 'happ://crypt4/']
|
||||
for (const prefix of prefixes) {
|
||||
if (contentToDecrypt.startsWith(prefix)) {
|
||||
contentToDecrypt = contentToDecrypt.slice(prefix.length)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const decrypted = decrypt.decrypt(contentToDecrypt)
|
||||
|
||||
if (decrypted) {
|
||||
setDecryptedContent(decrypted)
|
||||
} else {
|
||||
setError(
|
||||
'Decryption failed. Make sure you are using the correct private key for this encrypted content.'
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
setError('Decryption error. Please check your private key format.')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div transition={{ type: 'spring', stiffness: 300, damping: 20 }}>
|
||||
<Card className={styles.card} padding="xl" radius="lg">
|
||||
<Stack gap="lg">
|
||||
<Group gap="md">
|
||||
<ThemeIcon
|
||||
className={styles.icon}
|
||||
radius="xl"
|
||||
size={48}
|
||||
variant="gradient-orange"
|
||||
>
|
||||
<IconLockOpen size={24} />
|
||||
</ThemeIcon>
|
||||
<Box>
|
||||
<Text fw={700} size="xl">
|
||||
Decrypt
|
||||
</Text>
|
||||
<Text c="dimmed" size="sm">
|
||||
Decrypt content with private key
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
<Textarea
|
||||
autosize
|
||||
description="Your RSA private key (PEM format)"
|
||||
label="Private Key"
|
||||
maxRows={8}
|
||||
minRows={8}
|
||||
onChange={(e) => setPrivateKey(e.currentTarget.value)}
|
||||
placeholder={EXAMPLE_PRIVATE_KEY}
|
||||
size="md"
|
||||
styles={{
|
||||
input: {
|
||||
fontFamily: 'Fira Mono, monospace',
|
||||
fontSize: '0.70rem'
|
||||
}
|
||||
}}
|
||||
value={privateKey}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
autosize
|
||||
description="Base64 encoded encrypted content or full deep link"
|
||||
label="Encrypted Content"
|
||||
maxRows={6}
|
||||
minRows={6}
|
||||
onChange={(e) => setEncryptedContent(e.currentTarget.value)}
|
||||
placeholder="happ://crypt4/base64encodeddata... or just base64encodeddata..."
|
||||
size="md"
|
||||
styles={{
|
||||
input: {
|
||||
fontFamily: 'Fira Mono, monospace',
|
||||
fontSize: '0.70rem'
|
||||
}
|
||||
}}
|
||||
value={encryptedContent}
|
||||
/>
|
||||
|
||||
<Button
|
||||
color="orange"
|
||||
fullWidth
|
||||
leftSection={<IconLockOpen size={18} />}
|
||||
onClick={handleDecrypt}
|
||||
size="md"
|
||||
variant="light"
|
||||
>
|
||||
Decrypt
|
||||
</Button>
|
||||
|
||||
{error && (
|
||||
<Text c="red" size="sm">
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{decryptedContent && (
|
||||
<Stack>
|
||||
<Text fw={500} size="sm">
|
||||
Decrypted Content
|
||||
</Text>
|
||||
|
||||
<CopyableCodeBlock color="green.4" value={decryptedContent} />
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
2
src/features/cryptohapp/encrypt/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { EncryptCard } from './ui/encrypt-card'
|
||||
|
||||
61
src/features/cryptohapp/encrypt/ui/encrypt-card.module.css
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
.card {
|
||||
position: relative;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
overflow: hidden;
|
||||
transition:
|
||||
background 0.3s ease,
|
||||
border-color 0.3s ease,
|
||||
box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: rgba(34, 211, 238, 0.3);
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
border-radius: inherit;
|
||||
background: transparent;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.icon {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.card:hover .icon {
|
||||
box-shadow: 0 0 30px rgba(34, 211, 238, 0.3);
|
||||
}
|
||||
|
||||
.segmentedRoot {
|
||||
background: rgba(22, 27, 35, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
padding: 4px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.segmentedIndicator {
|
||||
background: linear-gradient(135deg, rgba(34, 211, 238, 0.2) 0%, rgba(6, 182, 212, 0.15) 100%);
|
||||
border: 1px solid rgba(34, 211, 238, 0.4);
|
||||
}
|
||||
|
||||
.segmentedLabel {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.segmentedLabel:hover {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.segmentedLabel[data-active] {
|
||||
color: var(--mantine-color-cyan-4);
|
||||
}
|
||||
143
src/features/cryptohapp/encrypt/ui/encrypt-card.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon
|
||||
} from '@mantine/core'
|
||||
import { createHappCryptoLink, type HappCryptoVersion } from '@kastov/cryptohapp'
|
||||
import { IconLock } from '@tabler/icons-react'
|
||||
import { motion } from 'motion/react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { CopyableCodeBlock } from '@shared/ui/copyable-code-block'
|
||||
|
||||
import styles from './encrypt-card.module.css'
|
||||
|
||||
export function EncryptCard() {
|
||||
const [content, setContent] = useState('https://subscription.link.com/s/remnawavetop')
|
||||
const [version, setVersion] = useState<HappCryptoVersion>('v4')
|
||||
const [result, setResult] = useState<null | { deepLink: string; encryptedContent: string }>(
|
||||
null
|
||||
)
|
||||
const [error, setError] = useState<null | string>(null)
|
||||
|
||||
const handleEncrypt = () => {
|
||||
setError(null)
|
||||
const encrypted = createHappCryptoLink(content, version)
|
||||
|
||||
if (encrypted) {
|
||||
setResult(encrypted)
|
||||
} else {
|
||||
setError('Encryption failed. Please check your input.')
|
||||
setResult(null)
|
||||
}
|
||||
}
|
||||
|
||||
const fullLink = result ? result.deepLink + result.encryptedContent : ''
|
||||
|
||||
return (
|
||||
<motion.div transition={{ type: 'spring', stiffness: 300, damping: 20 }}>
|
||||
<Card className={styles.card} padding="xl" radius="lg">
|
||||
<Stack gap="lg">
|
||||
<Group gap="md">
|
||||
<ThemeIcon
|
||||
className={styles.icon}
|
||||
radius="xl"
|
||||
size={48}
|
||||
variant="gradient-cyan"
|
||||
>
|
||||
<IconLock size={24} />
|
||||
</ThemeIcon>
|
||||
<Box>
|
||||
<Text fw={700} size="xl">
|
||||
Encrypt
|
||||
</Text>
|
||||
<Text c="dimmed" size="sm">
|
||||
Create Happ crypto deep link
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
<TextInput
|
||||
description="URL or text to encrypt"
|
||||
label="Content"
|
||||
onChange={(e) => setContent(e.currentTarget.value)}
|
||||
placeholder="https://subscription.link.com/s/..."
|
||||
size="md"
|
||||
value={content}
|
||||
/>
|
||||
|
||||
<Box>
|
||||
<Text fw={500} mb="xs" size="sm">
|
||||
Version
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
classNames={{
|
||||
root: styles.segmentedRoot,
|
||||
indicator: styles.segmentedIndicator,
|
||||
label: styles.segmentedLabel
|
||||
}}
|
||||
data={[
|
||||
{ label: 'v2', value: 'v2' },
|
||||
{ label: 'v3', value: 'v3' },
|
||||
{ label: 'v4', value: 'v4' }
|
||||
]}
|
||||
fullWidth
|
||||
onChange={(v) => setVersion(v as HappCryptoVersion)}
|
||||
radius="md"
|
||||
value={version}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
color="cyan"
|
||||
fullWidth
|
||||
leftSection={<IconLock size={18} />}
|
||||
onClick={handleEncrypt}
|
||||
size="md"
|
||||
variant="light"
|
||||
>
|
||||
Encrypt
|
||||
</Button>
|
||||
|
||||
{error && (
|
||||
<Text c="red" size="sm">
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<Stack gap="md">
|
||||
<Stack>
|
||||
<Text fw={500} size="sm">
|
||||
Deep Link Prefix
|
||||
</Text>
|
||||
<CopyableCodeBlock value={result.deepLink} />
|
||||
</Stack>
|
||||
|
||||
<Stack>
|
||||
<Text fw={500} size="sm">
|
||||
Encrypted Content
|
||||
</Text>
|
||||
|
||||
<CopyableCodeBlock value={result.encryptedContent} />
|
||||
</Stack>
|
||||
|
||||
<Stack>
|
||||
<Text fw={500} size="sm">
|
||||
Full Link
|
||||
</Text>
|
||||
<CopyableCodeBlock value={fullLink} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
3
src/features/cryptohapp/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { DecryptCard } from './decrypt'
|
||||
export { EncryptCard } from './encrypt'
|
||||
|
||||
3
src/features/happ-routing-builder/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { HappRoutingBuilder } from './ui'
|
||||
export type { FormData, HappRoutingData } from './model'
|
||||
|
||||
121
src/features/happ-routing-builder/lib/codec.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import type { FormData, HappRoutingData } from '../model/types'
|
||||
|
||||
export function formatJsonData(formData: FormData): HappRoutingData {
|
||||
let dnsHostsObj = {}
|
||||
try {
|
||||
dnsHostsObj = JSON.parse(formData.dnsHosts)
|
||||
} catch {
|
||||
// Invalid JSON, use empty object
|
||||
}
|
||||
|
||||
const splitLines = (text: string) => text.split('\n').filter((line) => line.trim() !== '')
|
||||
|
||||
return {
|
||||
Name: formData.name,
|
||||
GlobalProxy: formData.globalProxy,
|
||||
RemoteDNSType: formData.remoteDnsType,
|
||||
RemoteDNSDomain: formData.remoteDnsDomain,
|
||||
RemoteDNSIP: formData.remoteDnsIp,
|
||||
DomesticDNSType: formData.domesticDnsType,
|
||||
DomesticDNSDomain: formData.domesticDnsDomain,
|
||||
DomesticDNSIP: formData.domesticDnsIp,
|
||||
Geoipurl: formData.geoipUrl,
|
||||
Geositeurl: formData.geositeUrl,
|
||||
LastUpdated: formData.lastUpdated,
|
||||
DnsHosts: dnsHostsObj as Record<string, string>,
|
||||
DirectSites: splitLines(formData.directSites),
|
||||
DirectIp: splitLines(formData.directIp),
|
||||
ProxySites: splitLines(formData.proxySites),
|
||||
ProxyIp: splitLines(formData.proxyIp),
|
||||
BlockSites: splitLines(formData.blockSites),
|
||||
BlockIp: splitLines(formData.blockIp),
|
||||
DomainStrategy: formData.domainStrategy,
|
||||
FakeDNS: formData.fakeDns,
|
||||
UseChunkFiles: formData.useChunkFiles
|
||||
}
|
||||
}
|
||||
|
||||
export function encodeToBase64(formData: FormData): string {
|
||||
const data = formatJsonData(formData)
|
||||
const jsonString = JSON.stringify(data)
|
||||
return btoa(unescape(encodeURIComponent(jsonString)))
|
||||
}
|
||||
|
||||
export function generateHappLink(formData: FormData): string {
|
||||
return `happ://routing/add/${encodeToBase64(formData)}`
|
||||
}
|
||||
|
||||
export function decodeHappLink(
|
||||
input: string
|
||||
): { data: HappRoutingData; error: null } | { data: null; error: string } {
|
||||
try {
|
||||
const cleanInput = input
|
||||
.replace('happ://routing/add/', '')
|
||||
.replace('happ://routing/onadd/', '')
|
||||
const jsonString = decodeURIComponent(escape(atob(cleanInput)))
|
||||
const data = JSON.parse(jsonString) as HappRoutingData
|
||||
return { data, error: null }
|
||||
} catch {
|
||||
return { data: null, error: 'Invalid Base64 or Happ link format' }
|
||||
}
|
||||
}
|
||||
|
||||
export function jsonDataToFormData(jsonData: HappRoutingData, currentFormData: FormData): FormData {
|
||||
const newFormData = { ...currentFormData }
|
||||
|
||||
if (jsonData.Name !== undefined) newFormData.name = jsonData.Name
|
||||
if (jsonData.GlobalProxy !== undefined) newFormData.globalProxy = String(jsonData.GlobalProxy)
|
||||
|
||||
if (jsonData.RemoteDNSType !== undefined) newFormData.remoteDnsType = jsonData.RemoteDNSType
|
||||
if (jsonData.RemoteDNSDomain !== undefined)
|
||||
newFormData.remoteDnsDomain = jsonData.RemoteDNSDomain
|
||||
if (jsonData.RemoteDNSIP !== undefined) newFormData.remoteDnsIp = jsonData.RemoteDNSIP
|
||||
if (jsonData.RemoteDNSIp !== undefined) newFormData.remoteDnsIp = jsonData.RemoteDNSIp
|
||||
|
||||
if (jsonData.DomesticDNSType !== undefined)
|
||||
newFormData.domesticDnsType = jsonData.DomesticDNSType
|
||||
if (jsonData.DomesticDNSDomain !== undefined)
|
||||
newFormData.domesticDnsDomain = jsonData.DomesticDNSDomain
|
||||
if (jsonData.DomesticDNSIP !== undefined) newFormData.domesticDnsIp = jsonData.DomesticDNSIP
|
||||
if (jsonData.DomesticDNSIp !== undefined) newFormData.domesticDnsIp = jsonData.DomesticDNSIp
|
||||
|
||||
if (jsonData.Geoipurl !== undefined) newFormData.geoipUrl = jsonData.Geoipurl
|
||||
if (jsonData.GeoipUrl !== undefined) newFormData.geoipUrl = jsonData.GeoipUrl
|
||||
if (jsonData.Geositeurl !== undefined) newFormData.geositeUrl = jsonData.Geositeurl
|
||||
if (jsonData.GeositeUrl !== undefined) newFormData.geositeUrl = jsonData.GeositeUrl
|
||||
|
||||
if (jsonData.LastUpdated !== undefined) newFormData.lastUpdated = String(jsonData.LastUpdated)
|
||||
if (jsonData.DomainStrategy !== undefined) newFormData.domainStrategy = jsonData.DomainStrategy
|
||||
|
||||
if (jsonData.FakeDNS !== undefined) newFormData.fakeDns = String(jsonData.FakeDNS)
|
||||
if (jsonData.FakeDns !== undefined) newFormData.fakeDns = String(jsonData.FakeDns)
|
||||
if (jsonData.UseChunkFiles !== undefined)
|
||||
newFormData.useChunkFiles = String(jsonData.UseChunkFiles)
|
||||
if (jsonData.useChunkFiles !== undefined)
|
||||
newFormData.useChunkFiles = String(jsonData.useChunkFiles)
|
||||
|
||||
if (jsonData.DnsHosts !== undefined) {
|
||||
newFormData.dnsHosts = JSON.stringify(jsonData.DnsHosts, null, 2)
|
||||
}
|
||||
|
||||
if (jsonData.DirectSites !== undefined && Array.isArray(jsonData.DirectSites)) {
|
||||
newFormData.directSites = jsonData.DirectSites.join('\n')
|
||||
}
|
||||
if (jsonData.DirectIp !== undefined && Array.isArray(jsonData.DirectIp)) {
|
||||
newFormData.directIp = jsonData.DirectIp.join('\n')
|
||||
}
|
||||
if (jsonData.ProxySites !== undefined && Array.isArray(jsonData.ProxySites)) {
|
||||
newFormData.proxySites = jsonData.ProxySites.join('\n')
|
||||
}
|
||||
if (jsonData.ProxyIp !== undefined && Array.isArray(jsonData.ProxyIp)) {
|
||||
newFormData.proxyIp = jsonData.ProxyIp.join('\n')
|
||||
}
|
||||
if (jsonData.BlockSites !== undefined && Array.isArray(jsonData.BlockSites)) {
|
||||
newFormData.blockSites = jsonData.BlockSites.join('\n')
|
||||
}
|
||||
if (jsonData.BlockIp !== undefined && Array.isArray(jsonData.BlockIp)) {
|
||||
newFormData.blockIp = jsonData.BlockIp.join('\n')
|
||||
}
|
||||
|
||||
return newFormData
|
||||
}
|
||||
2
src/features/happ-routing-builder/lib/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { decodeHappLink, encodeToBase64, formatJsonData, generateHappLink, jsonDataToFormData } from './codec'
|
||||
|
||||
46
src/features/happ-routing-builder/model/constants.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import type { FormData } from './types'
|
||||
|
||||
export const DEFAULT_FORM_DATA: FormData = {
|
||||
name: '',
|
||||
globalProxy: 'true',
|
||||
remoteDnsType: 'DoH',
|
||||
remoteDnsDomain: '',
|
||||
remoteDnsIp: '',
|
||||
domesticDnsType: 'DoU',
|
||||
domesticDnsDomain: '',
|
||||
domesticDnsIp: '',
|
||||
geoipUrl: '',
|
||||
geositeUrl: '',
|
||||
lastUpdated: '',
|
||||
dnsHosts: '{}',
|
||||
directSites: '',
|
||||
directIp: '',
|
||||
proxySites: '',
|
||||
proxyIp: '',
|
||||
blockSites: '',
|
||||
blockIp: '',
|
||||
domainStrategy: 'IPIfNonMatch',
|
||||
fakeDns: 'false',
|
||||
useChunkFiles: 'true'
|
||||
}
|
||||
|
||||
export const DNS_TYPE_OPTIONS = [
|
||||
{ value: 'DoH', label: 'DNS over HTTPS (DoH)' },
|
||||
{ value: 'DoU', label: 'DNS over UDP (DoU)' }
|
||||
]
|
||||
|
||||
export const GLOBAL_PROXY_OPTIONS = [
|
||||
{ value: 'true', label: 'Proxy all (whitelist mode)' },
|
||||
{ value: 'false', label: 'Direct all (blacklist mode)' }
|
||||
]
|
||||
|
||||
export const TOGGLE_OPTIONS = [
|
||||
{ value: 'true', label: 'Enabled' },
|
||||
{ value: 'false', label: 'Disabled' }
|
||||
]
|
||||
|
||||
export const DOMAIN_STRATEGY_OPTIONS = [
|
||||
{ value: 'IPIfNonMatch', label: 'IPIfNonMatch' },
|
||||
{ value: 'AsIs', label: 'AsIs' },
|
||||
{ value: 'IPOnDemand', label: 'IPOnDemand' }
|
||||
]
|
||||
10
src/features/happ-routing-builder/model/index.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export {
|
||||
DEFAULT_FORM_DATA,
|
||||
DNS_TYPE_OPTIONS,
|
||||
DOMAIN_STRATEGY_OPTIONS,
|
||||
GLOBAL_PROXY_OPTIONS,
|
||||
TOGGLE_OPTIONS
|
||||
} from './constants'
|
||||
export type { FormData, HappRoutingData } from './types'
|
||||
export { useRoutingBuilder } from './use-routing-builder'
|
||||
|
||||
54
src/features/happ-routing-builder/model/types.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
export interface HappRoutingData {
|
||||
[key: string]: unknown
|
||||
BlockIp?: string[]
|
||||
BlockSites?: string[]
|
||||
DirectIp?: string[]
|
||||
DirectSites?: string[]
|
||||
DnsHosts?: Record<string, string>
|
||||
DomainStrategy?: string
|
||||
DomesticDNSDomain?: string
|
||||
DomesticDNSIP?: string
|
||||
DomesticDNSIp?: string
|
||||
DomesticDNSType?: string
|
||||
FakeDNS?: boolean | string
|
||||
FakeDns?: boolean | string
|
||||
Geoipurl?: string
|
||||
GeoipUrl?: string
|
||||
Geositeurl?: string
|
||||
GeositeUrl?: string
|
||||
GlobalProxy?: boolean | string
|
||||
LastUpdated?: number | string
|
||||
Name?: string
|
||||
ProxyIp?: string[]
|
||||
ProxySites?: string[]
|
||||
RemoteDNSDomain?: string
|
||||
RemoteDNSIP?: string
|
||||
RemoteDNSIp?: string
|
||||
RemoteDNSType?: string
|
||||
UseChunkFiles?: string
|
||||
useChunkFiles?: string
|
||||
}
|
||||
|
||||
export interface FormData {
|
||||
blockIp: string
|
||||
blockSites: string
|
||||
directIp: string
|
||||
directSites: string
|
||||
dnsHosts: string
|
||||
domainStrategy: string
|
||||
domesticDnsDomain: string
|
||||
domesticDnsIp: string
|
||||
domesticDnsType: string
|
||||
fakeDns: string
|
||||
geoipUrl: string
|
||||
geositeUrl: string
|
||||
globalProxy: string
|
||||
lastUpdated: string
|
||||
name: string
|
||||
proxyIp: string
|
||||
proxySites: string
|
||||
remoteDnsDomain: string
|
||||
remoteDnsIp: string
|
||||
remoteDnsType: string
|
||||
useChunkFiles: string
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import type { FormData, HappRoutingData } from './types'
|
||||
|
||||
import { decodeHappLink, formatJsonData, generateHappLink, jsonDataToFormData } from '../lib/codec'
|
||||
import { DEFAULT_FORM_DATA } from './constants'
|
||||
|
||||
export function useRoutingBuilder() {
|
||||
const [formData, setFormData] = useState<FormData>(DEFAULT_FORM_DATA)
|
||||
const [jsonEditorValue, setJsonEditorValue] = useState('')
|
||||
const [importInput, setImportInput] = useState('')
|
||||
const [importError, setImportError] = useState<null | string>(null)
|
||||
|
||||
const updateField = useCallback((field: keyof FormData, value: string) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }))
|
||||
}, [])
|
||||
|
||||
const happLink = generateHappLink(formData)
|
||||
|
||||
const handleImport = useCallback(() => {
|
||||
setImportError(null)
|
||||
const result = decodeHappLink(importInput)
|
||||
|
||||
if (result.error || !result.data) {
|
||||
setImportError(result.error ?? 'Failed to decode')
|
||||
return
|
||||
}
|
||||
|
||||
const newFormData = jsonDataToFormData(result.data, formData)
|
||||
setFormData(newFormData)
|
||||
setJsonEditorValue(JSON.stringify(result.data, null, 2))
|
||||
}, [importInput, formData])
|
||||
|
||||
const handleJsonChange = useCallback(
|
||||
(value: string) => {
|
||||
setJsonEditorValue(value)
|
||||
try {
|
||||
const jsonData = JSON.parse(value) as HappRoutingData
|
||||
const newFormData = jsonDataToFormData(jsonData, formData)
|
||||
setFormData(newFormData)
|
||||
} catch {
|
||||
// Invalid JSON, ignore
|
||||
}
|
||||
},
|
||||
[formData]
|
||||
)
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setFormData(DEFAULT_FORM_DATA)
|
||||
setImportInput('')
|
||||
setImportError(null)
|
||||
}, [])
|
||||
|
||||
// Sync JSON editor with form data
|
||||
useEffect(() => {
|
||||
const data = formatJsonData(formData)
|
||||
setJsonEditorValue(JSON.stringify(data, null, 2))
|
||||
}, [formData])
|
||||
|
||||
return {
|
||||
formData,
|
||||
updateField,
|
||||
happLink,
|
||||
jsonEditorValue,
|
||||
handleJsonChange,
|
||||
importInput,
|
||||
setImportInput,
|
||||
importError,
|
||||
handleImport,
|
||||
resetForm
|
||||
}
|
||||
}
|
||||
38
src/features/happ-routing-builder/ui/code-editor-panel.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { JsonInput, Paper, Stack } from '@mantine/core'
|
||||
import { IconCode } from '@tabler/icons-react'
|
||||
|
||||
import { BaseOverlayHeader } from '@shared/ui/base-overlay-header'
|
||||
|
||||
import styles from './happ-routing-builder.module.css'
|
||||
|
||||
interface CodeEditorPanelProps {
|
||||
onChange: (value: string) => void
|
||||
value: string
|
||||
}
|
||||
|
||||
export function CodeEditorPanel({ value, onChange }: CodeEditorPanelProps) {
|
||||
return (
|
||||
<Paper className={styles.paper} h="100%" p="md">
|
||||
<Stack gap="md" h="100%">
|
||||
<BaseOverlayHeader
|
||||
IconComponent={IconCode}
|
||||
iconSize={24}
|
||||
iconVariant="gradient-blue"
|
||||
subtitle="Edit your routing rules in JSON format"
|
||||
title="JSON Editor"
|
||||
/>
|
||||
|
||||
<JsonInput
|
||||
autosize
|
||||
classNames={{ input: styles.jsonEditor }}
|
||||
formatOnBlur
|
||||
maxRows={25}
|
||||
minRows={15}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
|
||||
62
src/features/happ-routing-builder/ui/export-panel.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { Button, Paper, Stack } from '@mantine/core'
|
||||
import { modals } from '@mantine/modals'
|
||||
import { IconLink, IconQrcode } from '@tabler/icons-react'
|
||||
import { renderSVG } from 'uqr'
|
||||
|
||||
import { BaseOverlayHeader } from '@shared/ui/base-overlay-header'
|
||||
import { CopyableCodeBlock } from '@shared/ui'
|
||||
|
||||
import styles from './happ-routing-builder.module.css'
|
||||
|
||||
interface ExportPanelProps {
|
||||
happLink: string
|
||||
}
|
||||
|
||||
export function ExportPanel({ happLink }: ExportPanelProps) {
|
||||
const showQrCode = () => {
|
||||
const qrCode = renderSVG(happLink, {
|
||||
whiteColor: '#161B22',
|
||||
blackColor: '#3CC9DB'
|
||||
})
|
||||
|
||||
modals.open({
|
||||
centered: true,
|
||||
title: 'Happ Routing QR Code',
|
||||
children: (
|
||||
<>
|
||||
<div dangerouslySetInnerHTML={{ __html: qrCode }} />
|
||||
<Button fullWidth mt="md" onClick={() => modals.closeAll()} variant="light">
|
||||
Close
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper className={styles.paper} h="100%" p="md">
|
||||
<Stack h="100%">
|
||||
<BaseOverlayHeader
|
||||
IconComponent={IconLink}
|
||||
iconSize={24}
|
||||
iconVariant="gradient-teal"
|
||||
subtitle="Copy this link or scan QR to import in Happ"
|
||||
title="Generated Link"
|
||||
/>
|
||||
|
||||
<CopyableCodeBlock value={happLink} />
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
leftSection={<IconQrcode size={16} />}
|
||||
mt="auto"
|
||||
onClick={showQrCode}
|
||||
variant="gradient-teal"
|
||||
>
|
||||
Show QR Code
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
.paper {
|
||||
position: relative;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
overflow: hidden;
|
||||
transition:
|
||||
background 0.3s ease,
|
||||
border-color 0.3s ease,
|
||||
box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.paper:hover {
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.paper::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
border-radius: inherit;
|
||||
background: transparent;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.jsonEditor {
|
||||
font-family: 'Fira Mono', 'Fira Code', monospace;
|
||||
font-size: 0.85rem;
|
||||
background: rgba(22, 27, 35, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.jsonEditor:focus {
|
||||
border-color: rgba(34, 139, 230, 0.4);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import { Grid, Paper, SimpleGrid, Stack } from '@mantine/core'
|
||||
import { IconPalette } from '@tabler/icons-react'
|
||||
|
||||
import { BaseOverlayHeader } from '@shared/ui/base-overlay-header'
|
||||
|
||||
import styles from './happ-routing-builder.module.css'
|
||||
import { CodeEditorPanel } from './code-editor-panel'
|
||||
import { VisualEditor } from './visual-editor'
|
||||
import { useRoutingBuilder } from '../model'
|
||||
import { ExportPanel } from './export-panel'
|
||||
import { ImportPanel } from './import-panel'
|
||||
|
||||
export function HappRoutingBuilder() {
|
||||
const {
|
||||
formData,
|
||||
updateField,
|
||||
happLink,
|
||||
jsonEditorValue,
|
||||
handleJsonChange,
|
||||
importInput,
|
||||
setImportInput,
|
||||
importError,
|
||||
handleImport
|
||||
} = useRoutingBuilder()
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Import / Export Row */}
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
|
||||
<ImportPanel
|
||||
error={importError}
|
||||
onImport={handleImport}
|
||||
onInputChange={setImportInput}
|
||||
value={importInput}
|
||||
/>
|
||||
<ExportPanel happLink={happLink} />
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Visual Editor / Code Editor Row */}
|
||||
<Grid gutter="md">
|
||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||
<Paper className={styles.paper} p="md">
|
||||
<Stack gap="md">
|
||||
<BaseOverlayHeader
|
||||
IconComponent={IconPalette}
|
||||
iconSize={24}
|
||||
iconVariant="gradient-violet"
|
||||
subtitle="Configure your routing rules visually"
|
||||
title="Visual Editor"
|
||||
/>
|
||||
<VisualEditor formData={formData} updateField={updateField} />
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||
<CodeEditorPanel onChange={handleJsonChange} value={jsonEditorValue} />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
53
src/features/happ-routing-builder/ui/import-panel.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { Button, Paper, Stack, Text } from '@mantine/core'
|
||||
import { IconFileImport, IconRefresh } from '@tabler/icons-react'
|
||||
|
||||
import { BaseOverlayHeader } from '@shared/ui/base-overlay-header'
|
||||
import { StyledInput } from '@shared/ui'
|
||||
|
||||
import styles from './happ-routing-builder.module.css'
|
||||
|
||||
interface ImportPanelProps {
|
||||
error: null | string
|
||||
onImport: () => void
|
||||
onInputChange: (value: string) => void
|
||||
value: string
|
||||
}
|
||||
|
||||
export function ImportPanel({ value, onInputChange, onImport, error }: ImportPanelProps) {
|
||||
return (
|
||||
<Paper className={styles.paper} h="100%" p="md">
|
||||
<Stack h="100%">
|
||||
<BaseOverlayHeader
|
||||
IconComponent={IconFileImport}
|
||||
iconSize={24}
|
||||
iconVariant="gradient-cyan"
|
||||
subtitle="Paste a Happ routing link or Base64-encoded config"
|
||||
title="Import Configuration"
|
||||
/>
|
||||
|
||||
<StyledInput
|
||||
onChange={onInputChange}
|
||||
placeholder="happ://routing/add/... or Base64 string"
|
||||
value={value}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<Text c="red" size="sm">
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
leftSection={<IconRefresh size={16} />}
|
||||
mt="auto"
|
||||
onClick={onImport}
|
||||
variant="gradient-cyan"
|
||||
>
|
||||
Decode & Import
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
|
||||
2
src/features/happ-routing-builder/ui/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { HappRoutingBuilder } from './happ-routing-builder'
|
||||
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import { Grid, Select, TextInput } from '@mantine/core'
|
||||
|
||||
import {
|
||||
DOMAIN_STRATEGY_OPTIONS,
|
||||
type FormData,
|
||||
GLOBAL_PROXY_OPTIONS,
|
||||
TOGGLE_OPTIONS
|
||||
} from '../../model'
|
||||
|
||||
interface BasicTabProps {
|
||||
formData: FormData
|
||||
updateField: (field: keyof FormData, value: string) => void
|
||||
}
|
||||
|
||||
export function BasicTab({ formData, updateField }: BasicTabProps) {
|
||||
return (
|
||||
<Grid>
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<TextInput
|
||||
description="A friendly name for this configuration"
|
||||
label="Configuration Name"
|
||||
onChange={(e) => updateField('name', e.target.value)}
|
||||
placeholder="e.g. Russia, Office, Home"
|
||||
value={formData.name}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<Select
|
||||
data={GLOBAL_PROXY_OPTIONS}
|
||||
description="Default routing behavior for unmatched traffic"
|
||||
label="Routing Mode"
|
||||
onChange={(value) => updateField('globalProxy', value || 'true')}
|
||||
value={formData.globalProxy}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<Select
|
||||
data={DOMAIN_STRATEGY_OPTIONS}
|
||||
description="How to resolve domains when routing"
|
||||
label="Domain Strategy"
|
||||
onChange={(value) => updateField('domainStrategy', value || 'IPIfNonMatch')}
|
||||
value={formData.domainStrategy}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<Select
|
||||
data={TOGGLE_OPTIONS}
|
||||
description="Improve performance with fake DNS responses"
|
||||
label="FakeDNS"
|
||||
onChange={(value) => updateField('fakeDns', value || 'false')}
|
||||
value={formData.fakeDns}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<Select
|
||||
data={TOGGLE_OPTIONS}
|
||||
description="Split large geo files into chunks"
|
||||
label="Use Chunk Files"
|
||||
onChange={(value) => updateField('useChunkFiles', value || 'true')}
|
||||
value={formData.useChunkFiles}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
)
|
||||
}
|
||||
108
src/features/happ-routing-builder/ui/visual-editor/dns-tab.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { Divider, Grid, Select, Stack, Text, TextInput, Textarea } from '@mantine/core'
|
||||
import { IconGlobe, IconGlobeFilled } from '@tabler/icons-react'
|
||||
|
||||
import { DNS_TYPE_OPTIONS, type FormData } from '../../model'
|
||||
|
||||
interface DnsTabProps {
|
||||
formData: FormData
|
||||
updateField: (field: keyof FormData, value: string) => void
|
||||
}
|
||||
|
||||
export function DnsTab({ formData, updateField }: DnsTabProps) {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{/* Remote DNS */}
|
||||
<Stack gap="sm">
|
||||
<Text c="cyan.4" fw={600} size="sm">
|
||||
<IconGlobeFilled
|
||||
size={16}
|
||||
style={{ marginRight: 8, verticalAlign: 'middle' }}
|
||||
/>
|
||||
Remote DNS (for proxied traffic)
|
||||
</Text>
|
||||
<Grid>
|
||||
<Grid.Col span={{ base: 12, sm: 4 }}>
|
||||
<Select
|
||||
data={DNS_TYPE_OPTIONS}
|
||||
label="Protocol"
|
||||
onChange={(value) => updateField('remoteDnsType', value || 'DoH')}
|
||||
value={formData.remoteDnsType}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, sm: 4 }}>
|
||||
<TextInput
|
||||
label="Domain"
|
||||
onChange={(e) => updateField('remoteDnsDomain', e.target.value)}
|
||||
placeholder="dns.google"
|
||||
value={formData.remoteDnsDomain}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, sm: 4 }}>
|
||||
<TextInput
|
||||
label="IP Address"
|
||||
onChange={(e) => updateField('remoteDnsIp', e.target.value)}
|
||||
placeholder="8.8.8.8"
|
||||
value={formData.remoteDnsIp}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Domestic DNS */}
|
||||
<Stack gap="sm">
|
||||
<Text c="teal.4" fw={600} size="sm">
|
||||
<IconGlobe size={16} style={{ marginRight: 8, verticalAlign: 'middle' }} />
|
||||
Domestic DNS (for direct traffic)
|
||||
</Text>
|
||||
<Grid>
|
||||
<Grid.Col span={{ base: 12, sm: 4 }}>
|
||||
<Select
|
||||
data={DNS_TYPE_OPTIONS}
|
||||
label="Protocol"
|
||||
onChange={(value) => updateField('domesticDnsType', value || 'DoU')}
|
||||
value={formData.domesticDnsType}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, sm: 4 }}>
|
||||
<TextInput
|
||||
label="Domain"
|
||||
onChange={(e) => updateField('domesticDnsDomain', e.target.value)}
|
||||
placeholder="dns.yandex"
|
||||
value={formData.domesticDnsDomain}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, sm: 4 }}>
|
||||
<TextInput
|
||||
label="IP Address"
|
||||
onChange={(e) => updateField('domesticDnsIp', e.target.value)}
|
||||
placeholder="77.88.8.8"
|
||||
value={formData.domesticDnsIp}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* DNS Hosts */}
|
||||
<Stack gap="sm">
|
||||
<Text c="violet.4" fw={600} size="sm">
|
||||
Custom DNS Hosts
|
||||
</Text>
|
||||
<Textarea
|
||||
autosize
|
||||
description="JSON object mapping domains to IP addresses"
|
||||
maxRows={8}
|
||||
minRows={3}
|
||||
onChange={(e) => updateField('dnsHosts', e.target.value)}
|
||||
placeholder={'{\n "example.com": "1.2.3.4"\n}'}
|
||||
styles={{ input: { fontFamily: 'monospace' } }}
|
||||
value={formData.dnsHosts}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import { Grid, TextInput } from '@mantine/core'
|
||||
|
||||
import type { FormData } from '../../model'
|
||||
|
||||
interface GeoTabProps {
|
||||
formData: FormData
|
||||
updateField: (field: keyof FormData, value: string) => void
|
||||
}
|
||||
|
||||
export function GeoTab({ formData, updateField }: GeoTabProps) {
|
||||
return (
|
||||
<Grid>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<TextInput
|
||||
description="URL to download GeoIP database (.dat file)"
|
||||
label="GeoIP Database URL"
|
||||
onChange={(e) => updateField('geoipUrl', e.target.value)}
|
||||
placeholder="https://github.com/v2fly/geoip/releases/latest/download/geoip.dat"
|
||||
value={formData.geoipUrl}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<TextInput
|
||||
description="URL to download Geosite database (.dat file)"
|
||||
label="Geosite Database URL"
|
||||
onChange={(e) => updateField('geositeUrl', e.target.value)}
|
||||
placeholder="https://github.com/v2fly/domain-list-community/releases/latest/download/dlc.dat"
|
||||
value={formData.geositeUrl}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<TextInput
|
||||
description="Unix timestamp when config was last updated"
|
||||
label="Last Updated"
|
||||
onChange={(e) => updateField('lastUpdated', e.target.value)}
|
||||
placeholder="e.g. 1693826255"
|
||||
value={formData.lastUpdated}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { VisualEditor } from './visual-editor'
|
||||
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
import { IconArrowRight, IconShield, IconX } from '@tabler/icons-react'
|
||||
import { Badge, Grid, Stack, Text, Textarea } from '@mantine/core'
|
||||
|
||||
import type { FormData } from '../../model'
|
||||
|
||||
interface RoutingTabProps {
|
||||
formData: FormData
|
||||
updateField: (field: keyof FormData, value: string) => void
|
||||
}
|
||||
|
||||
export function RoutingTab({ formData, updateField }: RoutingTabProps) {
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
<Stack gap="sm">
|
||||
<Text fw={600} size="sm">
|
||||
<Badge
|
||||
color="teal"
|
||||
leftSection={<IconArrowRight size={12} />}
|
||||
mr="xs"
|
||||
size="md"
|
||||
variant="light"
|
||||
>
|
||||
DIRECT
|
||||
</Badge>
|
||||
Bypass proxy for these destinations
|
||||
</Text>
|
||||
<Grid>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<Textarea
|
||||
autosize
|
||||
description="Domains/geosite rules (one per line)"
|
||||
label="Sites"
|
||||
maxRows={6}
|
||||
minRows={3}
|
||||
onChange={(e) => updateField('directSites', e.target.value)}
|
||||
placeholder="geosite:ru geosite:geolocation-ru domain:example.com"
|
||||
styles={{ input: { fontFamily: 'monospace', fontSize: '0.85rem' } }}
|
||||
value={formData.directSites}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<Textarea
|
||||
autosize
|
||||
description="IP addresses/geoip rules (one per line)"
|
||||
label="IPs"
|
||||
maxRows={6}
|
||||
minRows={3}
|
||||
onChange={(e) => updateField('directIp', e.target.value)}
|
||||
placeholder="geoip:ru 192.168.0.0/16 10.0.0.0/8"
|
||||
styles={{ input: { fontFamily: 'monospace', fontSize: '0.85rem' } }}
|
||||
value={formData.directIp}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="sm">
|
||||
<Text fw={600} size="sm">
|
||||
<Badge
|
||||
color="cyan"
|
||||
leftSection={<IconShield size={12} />}
|
||||
mr="xs"
|
||||
size="md"
|
||||
variant="light"
|
||||
>
|
||||
PROXY
|
||||
</Badge>
|
||||
Force proxy for these destinations
|
||||
</Text>
|
||||
<Grid>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<Textarea
|
||||
autosize
|
||||
description="Domains/geosite rules (one per line)"
|
||||
label="Sites"
|
||||
maxRows={6}
|
||||
minRows={3}
|
||||
onChange={(e) => updateField('proxySites', e.target.value)}
|
||||
placeholder="geosite:google geosite:youtube domain:blocked.com"
|
||||
styles={{ input: { fontFamily: 'monospace', fontSize: '0.85rem' } }}
|
||||
value={formData.proxySites}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<Textarea
|
||||
autosize
|
||||
description="IP addresses/geoip rules (one per line)"
|
||||
label="IPs"
|
||||
maxRows={6}
|
||||
minRows={3}
|
||||
onChange={(e) => updateField('proxyIp', e.target.value)}
|
||||
placeholder="1.1.1.1/32 geoip:us"
|
||||
styles={{ input: { fontFamily: 'monospace', fontSize: '0.85rem' } }}
|
||||
value={formData.proxyIp}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="sm">
|
||||
<Text fw={600} size="sm">
|
||||
<Badge
|
||||
color="red"
|
||||
leftSection={<IconX size={12} />}
|
||||
mr="xs"
|
||||
size="md"
|
||||
variant="light"
|
||||
>
|
||||
BLOCK
|
||||
</Badge>
|
||||
Block these destinations completely
|
||||
</Text>
|
||||
<Grid>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<Textarea
|
||||
autosize
|
||||
description="Domains/geosite rules (one per line)"
|
||||
label="Sites"
|
||||
maxRows={6}
|
||||
minRows={3}
|
||||
onChange={(e) => updateField('blockSites', e.target.value)}
|
||||
placeholder="geosite:category-ads-all domain:tracking.com"
|
||||
styles={{ input: { fontFamily: 'monospace', fontSize: '0.85rem' } }}
|
||||
value={formData.blockSites}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<Textarea
|
||||
autosize
|
||||
description="IP addresses/geoip rules (one per line)"
|
||||
label="IPs"
|
||||
maxRows={6}
|
||||
minRows={3}
|
||||
onChange={(e) => updateField('blockIp', e.target.value)}
|
||||
placeholder="0.0.0.0/8 geoip:private"
|
||||
styles={{ input: { fontFamily: 'monospace', fontSize: '0.85rem' } }}
|
||||
value={formData.blockIp}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
.tabsList {
|
||||
background: rgba(22, 27, 35, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
padding: 4px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
transition: color 0.2s ease;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.tab[data-active] {
|
||||
background: linear-gradient(135deg, rgba(34, 211, 238, 0.2) 0%, rgba(6, 182, 212, 0.15) 100%);
|
||||
border: 1px solid rgba(34, 211, 238, 0.4);
|
||||
color: var(--mantine-color-cyan-4);
|
||||
}
|
||||
|
||||
.tab[data-active]:hover {
|
||||
background: linear-gradient(135deg, rgba(34, 211, 238, 0.25) 0%, rgba(6, 182, 212, 0.2) 100%);
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
import { IconGlobe, IconRoute, IconServer, IconSettings } from '@tabler/icons-react'
|
||||
import { Tabs, Transition } from '@mantine/core'
|
||||
import { useState } from 'react'
|
||||
|
||||
import type { FormData } from '../../model'
|
||||
|
||||
import styles from './visual-editor.module.css'
|
||||
import { RoutingTab } from './routing-tab'
|
||||
import { BasicTab } from './basic-tab'
|
||||
import { DnsTab } from './dns-tab'
|
||||
import { GeoTab } from './geo-tab'
|
||||
|
||||
interface VisualEditorProps {
|
||||
formData: FormData
|
||||
updateField: (field: keyof FormData, value: string) => void
|
||||
}
|
||||
|
||||
export function VisualEditor({ formData, updateField }: VisualEditorProps) {
|
||||
const [activeTab, setActiveTab] = useState<null | string>('routing')
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
classNames={{ list: styles.tabsList, tab: styles.tab }}
|
||||
onChange={setActiveTab}
|
||||
value={activeTab}
|
||||
variant="unstyled"
|
||||
>
|
||||
<Tabs.List grow mb="lg">
|
||||
<Tabs.Tab leftSection={<IconRoute size={16} />} value="routing">
|
||||
Routing
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab leftSection={<IconSettings size={16} />} value="basic">
|
||||
Basic
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab leftSection={<IconServer size={16} />} value="dns">
|
||||
DNS
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab leftSection={<IconGlobe size={16} />} value="geo">
|
||||
Geo
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="routing">
|
||||
<Transition duration={150} mounted={activeTab === 'routing'} transition="fade">
|
||||
{(transitionStyles) => (
|
||||
<div style={transitionStyles}>
|
||||
<RoutingTab formData={formData} updateField={updateField} />
|
||||
</div>
|
||||
)}
|
||||
</Transition>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="basic">
|
||||
<Transition duration={150} mounted={activeTab === 'basic'} transition="fade">
|
||||
{(transitionStyles) => (
|
||||
<div style={transitionStyles}>
|
||||
<BasicTab formData={formData} updateField={updateField} />
|
||||
</div>
|
||||
)}
|
||||
</Transition>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="dns">
|
||||
<Transition duration={150} mounted={activeTab === 'dns'} transition="fade">
|
||||
{(transitionStyles) => (
|
||||
<div style={transitionStyles}>
|
||||
<DnsTab formData={formData} updateField={updateField} />
|
||||
</div>
|
||||
)}
|
||||
</Transition>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="geo">
|
||||
<Transition duration={150} mounted={activeTab === 'geo'} transition="fade">
|
||||
{(transitionStyles) => (
|
||||
<div style={transitionStyles}>
|
||||
<GeoTab formData={formData} updateField={updateField} />
|
||||
</div>
|
||||
)}
|
||||
</Transition>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
3
src/features/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { DecryptCard, EncryptCard } from './cryptohapp'
|
||||
export { HappRoutingBuilder } from './happ-routing-builder'
|
||||
|
||||
144
src/global.css
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
html,
|
||||
body {
|
||||
min-height: 100vh;
|
||||
background-color: #161b23;
|
||||
background-image:
|
||||
linear-gradient(to right, rgba(128, 128, 128, 0.05) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(128, 128, 128, 0.05) 1px, transparent 1px);
|
||||
background-size: 60px 60px;
|
||||
background-attachment: fixed;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#root {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.background {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
background:
|
||||
radial-gradient(ellipse 60% 40% at 25% 40%, rgba(151, 117, 250, 0.08), transparent 50%),
|
||||
radial-gradient(ellipse 50% 50% at 85% 85%, rgba(34, 211, 238, 0.06), transparent 50%);
|
||||
}
|
||||
|
||||
.header-wrapper {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
background: rgba(22, 27, 35, 0.8);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.header-wrapper {
|
||||
border-radius: 0 0 var(--mantine-radius-lg) var(--mantine-radius-lg);
|
||||
}
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.wrapper .mantine-Image-root {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.wrapper .mantine-Image-root:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 48em) {
|
||||
html,
|
||||
body {
|
||||
background-size: 40px 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.step-card:hover {
|
||||
background: rgba(255, 255, 255, 0.04) !important;
|
||||
border-color: rgba(34, 211, 238, 0.3) !important;
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3) !important;
|
||||
}
|
||||
|
||||
:root {
|
||||
--section-gap: 2rem;
|
||||
}
|
||||
|
||||
@media (min-width: 48em) {
|
||||
:root {
|
||||
--section-gap: 3rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 62em) {
|
||||
:root {
|
||||
--section-gap: 4rem;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blob-float-1 {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1) translate(0, 0);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
33% {
|
||||
transform: scale(1.1) translate(-20px, 20px);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
66% {
|
||||
transform: scale(1.2) translate(20px, -20px);
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blob-float-2 {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1.2) translate(0, 0);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
33% {
|
||||
transform: scale(1) translate(30px, -30px);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
66% {
|
||||
transform: scale(1.1) translate(-30px, 30px);
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rotate-icon {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.rotate-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
animation: rotate-icon 10s linear infinite;
|
||||
}
|
||||
6
src/main.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import ReactDOM from 'react-dom/client'
|
||||
|
||||
import { App } from './app'
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root')!)
|
||||
root.render(<App />)
|
||||
100
src/pages/cryptohapp-playground/cryptohapp-playground.page.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import {
|
||||
Anchor,
|
||||
Divider,
|
||||
Group,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
ThemeIconProps,
|
||||
Title
|
||||
} from '@mantine/core'
|
||||
import { IconBook, IconBrandGithub, IconBrandNpm, IconLock } from '@tabler/icons-react'
|
||||
|
||||
import { DecryptCard, EncryptCard } from '@features/cryptohapp'
|
||||
import { AnimatedSection, PageLayout } from '@shared/ui'
|
||||
|
||||
interface IFooterLink {
|
||||
href: string
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
variant: ThemeIconProps['variant']
|
||||
}
|
||||
|
||||
const FOOTER_LINKS = [
|
||||
{
|
||||
href: 'https://www.npmjs.com/package/@kastov/cryptohapp',
|
||||
icon: <IconBrandNpm size={18} />,
|
||||
label: 'NPM Package',
|
||||
variant: 'gradient-red'
|
||||
},
|
||||
{
|
||||
href: 'https://github.com/remnawave/cryptohapp',
|
||||
icon: <IconBrandGithub size={18} />,
|
||||
label: 'GitHub',
|
||||
variant: 'gradient-gray'
|
||||
},
|
||||
{
|
||||
href: 'https://www.happ.su/main/developer-documentation/crypto-link',
|
||||
icon: <IconBook size={18} />,
|
||||
label: 'Happ Docs',
|
||||
variant: 'gradient-blue'
|
||||
}
|
||||
] satisfies IFooterLink[]
|
||||
|
||||
export function CryptohappPlaygroundPage() {
|
||||
return (
|
||||
<PageLayout maxWidth={1200}>
|
||||
<AnimatedSection>
|
||||
<Stack align="center" gap="lg" mb="xl">
|
||||
<Group gap="md">
|
||||
<ThemeIcon radius="lg" size={56} variant="gradient-cyan">
|
||||
<IconLock size={28} />
|
||||
</ThemeIcon>
|
||||
<Title order={1} size="2.5rem">
|
||||
CryptoHapp Playground
|
||||
</Title>
|
||||
</Group>
|
||||
<Text c="dimmed" size="lg" ta="center">
|
||||
Encrypt content to create Happ deep links, or decrypt content using your
|
||||
private key.
|
||||
</Text>
|
||||
</Stack>
|
||||
</AnimatedSection>
|
||||
|
||||
<AnimatedSection>
|
||||
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing={{ base: 'md', sm: 'lg', lg: 'xl' }}>
|
||||
<EncryptCard />
|
||||
<DecryptCard />
|
||||
</SimpleGrid>
|
||||
</AnimatedSection>
|
||||
|
||||
<AnimatedSection>
|
||||
<Stack gap="lg" pt="xl">
|
||||
<Divider opacity={0.3} />
|
||||
|
||||
<Group gap="xl" justify="center" wrap="wrap">
|
||||
{FOOTER_LINKS.map((link, index) => (
|
||||
<Anchor
|
||||
href={link.href}
|
||||
key={index}
|
||||
rel="noopener noreferrer"
|
||||
style={{ textDecoration: 'none' }}
|
||||
target="_blank"
|
||||
>
|
||||
<Group gap="xs">
|
||||
<ThemeIcon radius="md" size="lg" variant={link.variant}>
|
||||
{link.icon}
|
||||
</ThemeIcon>
|
||||
<Text c="dimmed" fw={500} size="sm">
|
||||
{link.label}
|
||||
</Text>
|
||||
</Group>
|
||||
</Anchor>
|
||||
))}
|
||||
</Group>
|
||||
</Stack>
|
||||
</AnimatedSection>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
2
src/pages/cryptohapp-playground/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { CryptohappPlaygroundPage } from './cryptohapp-playground.page'
|
||||
|
||||
85
src/pages/happ-routing-builder/happ-routing-builder.page.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import {
|
||||
Anchor,
|
||||
Divider,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
ThemeIconProps,
|
||||
Title
|
||||
} from '@mantine/core'
|
||||
import { IconBook, IconRoute } from '@tabler/icons-react'
|
||||
|
||||
import { HappRoutingBuilder } from '@features/happ-routing-builder'
|
||||
import { AnimatedSection, PageLayout } from '@shared/ui'
|
||||
|
||||
interface IFooterLink {
|
||||
href: string
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
variant: ThemeIconProps['variant']
|
||||
}
|
||||
|
||||
const FOOTER_LINKS = [
|
||||
{
|
||||
href: 'https://www.happ.su/main/developer-documentation/routing',
|
||||
icon: <IconBook size={18} />,
|
||||
label: 'Routing Docs',
|
||||
variant: 'gradient-blue'
|
||||
}
|
||||
] satisfies IFooterLink[]
|
||||
|
||||
export function HappRoutingBuilderPage() {
|
||||
return (
|
||||
<PageLayout gap="2rem">
|
||||
<AnimatedSection>
|
||||
<Stack align="center" gap="lg" mb="md">
|
||||
<Group gap="md">
|
||||
<ThemeIcon radius="lg" size={56} variant="gradient-cyan">
|
||||
<IconRoute size={28} />
|
||||
</ThemeIcon>
|
||||
<Title order={1} size="2.5rem">
|
||||
Happ Routing Builder
|
||||
</Title>
|
||||
</Group>
|
||||
<Text c="dimmed" size="lg" ta="center">
|
||||
Create and configure routing rules for Happ.
|
||||
<br />
|
||||
Build your routing configuration visually and export it as a Happ deep link.
|
||||
</Text>
|
||||
</Stack>
|
||||
</AnimatedSection>
|
||||
|
||||
<AnimatedSection>
|
||||
<HappRoutingBuilder />
|
||||
</AnimatedSection>
|
||||
|
||||
<AnimatedSection>
|
||||
<Stack gap="lg" pt="xl">
|
||||
<Divider opacity={0.3} />
|
||||
|
||||
<Group gap="xl" justify="center" wrap="wrap">
|
||||
{FOOTER_LINKS.map((link, index) => (
|
||||
<Anchor
|
||||
href={link.href}
|
||||
key={index}
|
||||
rel="noopener noreferrer"
|
||||
style={{ textDecoration: 'none' }}
|
||||
target="_blank"
|
||||
>
|
||||
<Group gap="xs">
|
||||
<ThemeIcon radius="md" size="lg" variant={link.variant}>
|
||||
{link.icon}
|
||||
</ThemeIcon>
|
||||
<Text c="dimmed" fw={500} size="sm">
|
||||
{link.label}
|
||||
</Text>
|
||||
</Group>
|
||||
</Anchor>
|
||||
))}
|
||||
</Group>
|
||||
</Stack>
|
||||
</AnimatedSection>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
1
src/pages/happ-routing-builder/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { HappRoutingBuilderPage } from './happ-routing-builder.page'
|
||||
86
src/pages/home/home.page.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { SimpleGrid, Stack, Text, ThemeIconProps, Title } from '@mantine/core'
|
||||
import { IconLock, IconRocket, IconRoute } from '@tabler/icons-react'
|
||||
|
||||
import { AnimatedSection, PageLayout } from '@shared/ui'
|
||||
import { ROUTES } from '@shared/constants'
|
||||
import { UtilityCard } from '@widgets'
|
||||
|
||||
interface IUtility {
|
||||
badge?: string
|
||||
badgeColor?: string
|
||||
description: string
|
||||
external?: boolean
|
||||
href: string
|
||||
icon: React.ReactNode
|
||||
title: string
|
||||
variant?: ThemeIconProps['variant']
|
||||
}
|
||||
|
||||
const UTILITIES = [
|
||||
{
|
||||
title: 'Try Remnawave',
|
||||
description:
|
||||
'Experience the power of Remnawave with zero setup. Deploy your own instance in minutes and explore all features risk-free.',
|
||||
icon: <IconRocket size={28} />,
|
||||
href: 'https://try.tg',
|
||||
badge: 'Free',
|
||||
badgeColor: 'violet',
|
||||
external: true,
|
||||
variant: 'gradient-violet'
|
||||
},
|
||||
{
|
||||
title: 'Happ Routing Builder',
|
||||
description:
|
||||
'Create and configure routing rules for Happ client. Build routing configurations visually and export as Happ deep links.',
|
||||
icon: <IconRoute size={28} />,
|
||||
href: ROUTES.HAPP_ROUTING_BUILDER,
|
||||
badgeColor: 'green'
|
||||
},
|
||||
{
|
||||
title: 'Cryptohapp Playground',
|
||||
description:
|
||||
'Test encryption and decryption of Happ crypto deep links. Create encrypted links or decrypt them with your private key.',
|
||||
icon: <IconLock size={28} />,
|
||||
href: ROUTES.CRYPTOHAPP_PLAYGROUND,
|
||||
badgeColor: 'green'
|
||||
}
|
||||
] satisfies IUtility[]
|
||||
|
||||
export function HomePage() {
|
||||
return (
|
||||
<PageLayout gap="4rem">
|
||||
<AnimatedSection>
|
||||
<Stack align="center" gap="lg" mb="xl">
|
||||
<Title order={1} size="3rem" ta="center">
|
||||
Remnawave Utilities
|
||||
</Title>
|
||||
<Text c="dimmed" size="lg" ta="center">
|
||||
A collection of useful tools for working with Remnawave.
|
||||
<br /> Select the utility you need from the list below.
|
||||
</Text>
|
||||
</Stack>
|
||||
</AnimatedSection>
|
||||
|
||||
<AnimatedSection>
|
||||
<SimpleGrid
|
||||
cols={{ base: 1, sm: 2, lg: 3 }}
|
||||
spacing={{ base: 'md', sm: 'lg', lg: 'xl' }}
|
||||
>
|
||||
{UTILITIES.map((utility) => (
|
||||
<UtilityCard
|
||||
badge={utility.badge}
|
||||
badgeColor={utility.badgeColor}
|
||||
description={utility.description}
|
||||
external={utility.external}
|
||||
href={utility.href}
|
||||
icon={utility.icon}
|
||||
key={utility.href}
|
||||
title={utility.title}
|
||||
variant={utility.variant}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</AnimatedSection>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
2
src/pages/home/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { HomePage } from './home.page'
|
||||
|
||||
23
src/shared/config/animation.config.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
export const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1,
|
||||
delayChildren: 0.2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const itemVariants = {
|
||||
hidden: { opacity: 0, y: 30 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
type: 'spring' as const,
|
||||
stiffness: 80,
|
||||
damping: 15
|
||||
}
|
||||
}
|
||||
}
|
||||
3
src/shared/config/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from './animation.config'
|
||||
export * from './landing.config'
|
||||
export * from './navigation.config'
|
||||
140
src/shared/config/landing.config.tsx
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import {
|
||||
IconBox,
|
||||
IconBrandGithub,
|
||||
IconBrandTelegram,
|
||||
IconCloudDataConnection,
|
||||
IconDevices,
|
||||
IconRocket,
|
||||
IconServer,
|
||||
IconShieldCheck,
|
||||
IconUsers
|
||||
} from '@tabler/icons-react'
|
||||
|
||||
import type { IFeature, ILink, ISocialLink, IStat } from '@shared/types'
|
||||
|
||||
export const FEATURES: IFeature[] = [
|
||||
{
|
||||
icon: <IconServer size={24} />,
|
||||
title: 'Multiple nodes support',
|
||||
description: 'Connect as many nodes as you want, and manage them all in one place.',
|
||||
color: 'cyan'
|
||||
},
|
||||
{
|
||||
icon: <IconUsers size={24} />,
|
||||
title: 'User Management',
|
||||
description: 'Create and manage users with flexible settings.',
|
||||
color: 'violet'
|
||||
},
|
||||
{
|
||||
icon: <IconCloudDataConnection size={24} />,
|
||||
title: 'REST API',
|
||||
description:
|
||||
'Full-featured REST API with comprehensive documentation for easy integration.',
|
||||
color: 'blue'
|
||||
},
|
||||
{
|
||||
icon: <IconBox size={24} />,
|
||||
title: 'Protocols support',
|
||||
description: 'Support for all major protocols: VLESS, Trojan, Shadowsocks.',
|
||||
color: 'green'
|
||||
},
|
||||
{
|
||||
icon: <IconDevices size={24} />,
|
||||
title: 'Subscription Links',
|
||||
description:
|
||||
'Remnawave providers for your users to connect to. Supports all major client applications.',
|
||||
color: 'cyan'
|
||||
},
|
||||
{
|
||||
icon: <IconShieldCheck size={24} />,
|
||||
title: 'Security First',
|
||||
description:
|
||||
'Auth with Passkeys, GitHub, and more. Connections to nodes are secured with mTLS.',
|
||||
color: 'violet'
|
||||
}
|
||||
]
|
||||
|
||||
export const STATS: IStat[] = [
|
||||
{
|
||||
icon: <IconUsers size={32} />,
|
||||
label: 'Community members',
|
||||
value: '3K+',
|
||||
color: 'violet'
|
||||
},
|
||||
{
|
||||
icon: <IconRocket size={32} />,
|
||||
label: 'DockerHub pulls',
|
||||
value: '40K+',
|
||||
color: 'cyan'
|
||||
}
|
||||
]
|
||||
|
||||
export const PRIMARY_ACTIONS: ILink[] = [
|
||||
{
|
||||
href: '/docs/overview/quick-start',
|
||||
label: 'Get started'
|
||||
},
|
||||
{
|
||||
href: 'https://try.tg',
|
||||
label: 'Try risk-free',
|
||||
external: true
|
||||
}
|
||||
]
|
||||
|
||||
export const SECONDARY_ACTIONS: ILink[] = [
|
||||
{
|
||||
href: 'https://t.me/remnawave',
|
||||
label: 'Join Community',
|
||||
external: true
|
||||
},
|
||||
{
|
||||
href: '/docs/donate',
|
||||
label: 'Sponsor'
|
||||
}
|
||||
]
|
||||
|
||||
export const SOCIAL_LINKS: ISocialLink[] = [
|
||||
{
|
||||
href: 'https://github.com/remnawave',
|
||||
icon: <IconBrandGithub size={18} />,
|
||||
label: 'GitHub',
|
||||
color: 'gray'
|
||||
},
|
||||
{
|
||||
href: 'https://t.me/remnawave',
|
||||
icon: <IconBrandTelegram size={18} />,
|
||||
label: 'Telegram',
|
||||
color: 'blue'
|
||||
}
|
||||
]
|
||||
|
||||
export const LANDING_CONTENT = {
|
||||
hero: {
|
||||
title: {
|
||||
highlighted: 'Remna',
|
||||
normal: 'wave'
|
||||
},
|
||||
subtitle: {
|
||||
highlighted: 'Proxy and user management',
|
||||
normal: 'solution'
|
||||
},
|
||||
description:
|
||||
'Built on top of Xray Core, Remnawave provides rich functionality for user and proxy management. Easily add users, nodes, configure Xray and much more with a feature-rich REST API powered by NestJS.'
|
||||
},
|
||||
features: {
|
||||
badge: 'KEY FEATURES',
|
||||
title: 'Everything You Need'
|
||||
},
|
||||
footer: {
|
||||
creator: {
|
||||
text: 'Created by',
|
||||
name: 'kastov',
|
||||
link: 'https://github.com/kastov'
|
||||
},
|
||||
community: {
|
||||
text: 'and',
|
||||
name: 'community',
|
||||
link: 'https://t.me/remnawave'
|
||||
}
|
||||
}
|
||||
}
|
||||
17
src/shared/config/navigation.config.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { IconBrandGithub, IconHome } from '@tabler/icons-react'
|
||||
|
||||
import type { INavigationLink } from '@shared/types'
|
||||
|
||||
export const NAVIGATION_LINKS: INavigationLink[] = [
|
||||
{
|
||||
href: '/',
|
||||
label: 'Home',
|
||||
icon: <IconHome size={18} />
|
||||
},
|
||||
{
|
||||
href: 'https://github.com/remnawave',
|
||||
label: 'GitHub',
|
||||
icon: <IconBrandGithub size={18} />,
|
||||
external: true
|
||||
}
|
||||
]
|
||||
2
src/shared/constants/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './routes'
|
||||
export * from './theme'
|
||||
5
src/shared/constants/routes.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export const ROUTES = {
|
||||
ROOT: '/',
|
||||
CRYPTOHAPP_PLAYGROUND: '/cryptohapp-playground',
|
||||
HAPP_ROUTING_BUILDER: '/happ-routing-builder'
|
||||
} as const
|
||||
2
src/shared/constants/theme/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './theme'
|
||||
export * from './variant-color-resolver'
|
||||
93
src/shared/constants/theme/theme.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { createTheme } from '@mantine/core'
|
||||
|
||||
export const theme = createTheme({
|
||||
cursorType: 'pointer',
|
||||
fontFamily: 'Unbounded, sans-serif',
|
||||
fontFamilyMonospace: 'Fira Mono, monospace',
|
||||
breakpoints: {
|
||||
xs: '30em',
|
||||
sm: '40em',
|
||||
md: '48em',
|
||||
lg: '64em',
|
||||
xl: '80em',
|
||||
'2xl': '96em',
|
||||
'3xl': '120em',
|
||||
'4xl': '160em'
|
||||
},
|
||||
scale: 1,
|
||||
fontSmoothing: true,
|
||||
focusRing: 'never',
|
||||
white: '#ffffff',
|
||||
black: '#24292f',
|
||||
colors: {
|
||||
dark: [
|
||||
'#c9d1d9',
|
||||
'#b1bac4',
|
||||
'#8b949e',
|
||||
'#6e7681',
|
||||
'#484f58',
|
||||
'#30363d',
|
||||
'#21262d',
|
||||
'#161b22',
|
||||
'#0d1117',
|
||||
'#010409'
|
||||
],
|
||||
|
||||
blue: [
|
||||
'#ddf4ff',
|
||||
'#b6e3ff',
|
||||
'#80ccff',
|
||||
'#54aeff',
|
||||
'#218bff',
|
||||
'#0969da',
|
||||
'#0550ae',
|
||||
'#033d8b',
|
||||
'#0a3069',
|
||||
'#002155'
|
||||
],
|
||||
green: [
|
||||
'#dafbe1',
|
||||
'#aceebb',
|
||||
'#6fdd8b',
|
||||
'#4ac26b',
|
||||
'#2da44e',
|
||||
'#1a7f37',
|
||||
'#116329',
|
||||
'#044f1e',
|
||||
'#003d16',
|
||||
'#002d11'
|
||||
],
|
||||
yellow: [
|
||||
'#fff8c5',
|
||||
'#fae17d',
|
||||
'#eac54f',
|
||||
'#d4a72c',
|
||||
'#bf8700',
|
||||
'#9a6700',
|
||||
'#7d4e00',
|
||||
'#633c01',
|
||||
'#4d2d00',
|
||||
'#3b2300'
|
||||
],
|
||||
orange: [
|
||||
'#fff1e5',
|
||||
'#ffd8b5',
|
||||
'#ffb77c',
|
||||
'#fb8f44',
|
||||
'#e16f24',
|
||||
'#bc4c00',
|
||||
'#953800',
|
||||
'#762c00',
|
||||
'#5c2200',
|
||||
'#471700'
|
||||
]
|
||||
},
|
||||
primaryShade: 8,
|
||||
primaryColor: 'cyan',
|
||||
autoContrast: true,
|
||||
luminanceThreshold: 0.3,
|
||||
headings: {
|
||||
fontWeight: '600'
|
||||
},
|
||||
defaultRadius: 'md'
|
||||
})
|
||||
97
src/shared/constants/theme/variant-color-resolver.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { defaultVariantColorsResolver, VariantColorsResolver } from '@mantine/core'
|
||||
|
||||
export const variantColorResolver: VariantColorsResolver = (input) => {
|
||||
const defaultResolvedColors = defaultVariantColorsResolver(input)
|
||||
|
||||
if (input.variant === 'gradient-cyan') {
|
||||
return {
|
||||
background:
|
||||
'linear-gradient(135deg, rgba(34, 211, 238, 0.15) 0%, rgba(6, 182, 212, 0.1) 100%)',
|
||||
border: '1px solid rgba(34, 211, 238, 0.3)',
|
||||
color: 'var(--mantine-color-cyan-4)',
|
||||
hover: 'linear-gradient(135deg, rgba(34, 211, 238, 0.25) 0%, rgba(6, 182, 212, 0.2) 100%)'
|
||||
}
|
||||
}
|
||||
|
||||
if (input.variant === 'gradient-teal') {
|
||||
return {
|
||||
background:
|
||||
'linear-gradient(135deg, rgba(18, 184, 134, 0.15) 0%, rgba(12, 166, 120, 0.1) 100%)',
|
||||
border: '1px solid rgba(18, 184, 134, 0.3)',
|
||||
color: 'var(--mantine-color-teal-4)',
|
||||
hover: 'linear-gradient(135deg, rgba(18, 184, 134, 0.25) 0%, rgba(12, 166, 120, 0.2) 100%)'
|
||||
}
|
||||
}
|
||||
|
||||
if (input.variant === 'gradient-violet') {
|
||||
return {
|
||||
background:
|
||||
'linear-gradient(135deg, rgba(151, 117, 250, 0.15) 0%, rgba(132, 94, 247, 0.1) 100%)',
|
||||
border: '1px solid rgba(151, 117, 250, 0.3)',
|
||||
color: 'var(--mantine-color-violet-4)',
|
||||
hover: 'linear-gradient(135deg, rgba(151, 117, 250, 0.25) 0%, rgba(132, 94, 247, 0.2) 100%)'
|
||||
}
|
||||
}
|
||||
|
||||
if (input.variant === 'gradient-yellow') {
|
||||
return {
|
||||
background:
|
||||
'linear-gradient(135deg, rgba(250, 176, 5, 0.15) 0%, rgba(245, 159, 0, 0.1) 100%)',
|
||||
border: '1px solid rgba(250, 176, 5, 0.3)',
|
||||
color: 'var(--mantine-color-yellow-4)',
|
||||
hover: 'linear-gradient(135deg, rgba(250, 176, 5, 0.25) 0%, rgba(245, 159, 0, 0.2) 100%)'
|
||||
}
|
||||
}
|
||||
|
||||
if (input.variant === 'gradient-blue') {
|
||||
return {
|
||||
background:
|
||||
'linear-gradient(135deg, rgba(34, 139, 230, 0.15) 0%, rgba(28, 126, 214, 0.1) 100%)',
|
||||
border: '1px solid rgba(34, 139, 230, 0.3)',
|
||||
color: 'var(--mantine-color-blue-4)',
|
||||
hover: 'linear-gradient(135deg, rgba(34, 139, 230, 0.25) 0%, rgba(28, 126, 214, 0.2) 100%)'
|
||||
}
|
||||
}
|
||||
|
||||
if (input.variant === 'gradient-red') {
|
||||
return {
|
||||
background:
|
||||
'linear-gradient(135deg, rgba(250, 82, 82, 0.15) 0%, rgba(224, 49, 49, 0.1) 100%)',
|
||||
border: '1px solid rgba(250, 82, 82, 0.3)',
|
||||
color: 'var(--mantine-color-red-4)',
|
||||
hover: 'linear-gradient(135deg, rgba(250, 82, 82, 0.25) 0%, rgba(224, 49, 49, 0.2) 100%)'
|
||||
}
|
||||
}
|
||||
|
||||
if (input.variant === 'gradient-gray') {
|
||||
return {
|
||||
background:
|
||||
'linear-gradient(135deg, rgba(128, 128, 128, 0.15) 0%, rgba(160, 160, 160, 0.1) 100%)',
|
||||
border: '1px solid rgba(128, 128, 128, 0.3)',
|
||||
color: 'var(--mantine-color-gray-6)',
|
||||
hover: 'linear-gradient(135deg, rgba(128, 128, 128, 0.25) 0%, rgba(160, 160, 160, 0.2) 100%)'
|
||||
}
|
||||
}
|
||||
|
||||
if (input.variant === 'gradient-green') {
|
||||
return {
|
||||
background:
|
||||
'linear-gradient(135deg, rgba(64, 192, 87, 0.15) 0%, rgba(55, 178, 77, 0.1) 100%)',
|
||||
border: '1px solid rgba(64, 192, 87, 0.3)',
|
||||
color: 'var(--mantine-color-green-4)',
|
||||
hover: 'linear-gradient(135deg, rgba(64, 192, 87, 0.25) 0%, rgba(55, 178, 77, 0.2) 100%)'
|
||||
}
|
||||
}
|
||||
|
||||
if (input.variant === 'gradient-orange') {
|
||||
return {
|
||||
background:
|
||||
'linear-gradient(135deg, rgba(255, 146, 43, 0.15) 0%, rgba(253, 126, 20, 0.1) 100%)',
|
||||
border: '1px solid rgba(255, 146, 43, 0.3)',
|
||||
color: 'var(--mantine-color-orange-4)',
|
||||
hover: 'linear-gradient(135deg, rgba(255, 146, 43, 0.25) 0%, rgba(253, 126, 20, 0.2) 100%)'
|
||||
}
|
||||
}
|
||||
|
||||
return defaultResolvedColors
|
||||
}
|
||||
29
src/shared/remnawave-logo.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
/* eslint-disable @stylistic/indent */
|
||||
|
||||
import { Box, BoxProps, ElementProps } from '@mantine/core'
|
||||
|
||||
interface RemnawaveLogoProps
|
||||
extends ElementProps<'svg', keyof BoxProps>,
|
||||
Omit<BoxProps, 'children' | 'ref'> {
|
||||
size?: number | string
|
||||
}
|
||||
|
||||
export function RemnawaveLogo({ size, style, ...props }: RemnawaveLogoProps) {
|
||||
return (
|
||||
<Box
|
||||
component="svg"
|
||||
fill="none"
|
||||
style={{ width: size, height: size, ...style }}
|
||||
viewBox="0 0 16 16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M8 1a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-1.5 0V1.75A.75.75 0 0 1 8 1Zm6 2a.75.75 0 0 1 .75.75v8.5a.75.75 0 0 1-1.5 0v-8.5A.75.75 0 0 1 14 3ZM5 4a.75.75 0 0 1 .75.75v6.5a.75.75 0 0 1-1.5 0v-6.5A.75.75 0 0 1 5 4Zm6 1a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 11 5ZM2 6a.75.75 0 0 1 .75.75v2.5a.75.75 0 0 1-1.5 0v-2.5A.75.75 0 0 1 2 6Z"
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
1
src/shared/types/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './landing.types'
|
||||
37
src/shared/types/landing.types.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { ReactNode } from 'react'
|
||||
|
||||
export type CardColor = 'blue' | 'cyan' | 'green' | 'violet'
|
||||
|
||||
export interface IFeature {
|
||||
color: CardColor
|
||||
description: string
|
||||
icon: ReactNode
|
||||
title: string
|
||||
}
|
||||
|
||||
export interface IStat {
|
||||
color: CardColor
|
||||
icon: ReactNode
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface ILink {
|
||||
external?: boolean
|
||||
href: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface INavigationLink {
|
||||
external?: boolean
|
||||
href: string
|
||||
icon: ReactNode
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface ISocialLink {
|
||||
color: string
|
||||
href: string
|
||||
icon: ReactNode
|
||||
label: string
|
||||
}
|
||||
14
src/shared/ui/animated-section/animated-section.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import type { ReactNode } from 'react'
|
||||
|
||||
import { motion } from 'motion/react'
|
||||
|
||||
import { itemVariants } from '@shared/config'
|
||||
|
||||
interface AnimatedSectionProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function AnimatedSection({ children }: AnimatedSectionProps) {
|
||||
return <motion.div variants={itemVariants}>{children}</motion.div>
|
||||
}
|
||||
|
||||
2
src/shared/ui/animated-section/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { AnimatedSection } from './animated-section'
|
||||
|
||||
42
src/shared/ui/base-overlay-header/index.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { Group, Stack, Text, ThemeIcon, ThemeIconProps, Title, TitleProps } from '@mantine/core'
|
||||
|
||||
type IProps = {
|
||||
actionIconProps?: ThemeIconProps
|
||||
IconComponent: React.ComponentType<{ size: number }>
|
||||
iconSize?: number
|
||||
iconVariant: ThemeIconProps['variant']
|
||||
subtitle?: string
|
||||
themeIconSize?: ThemeIconProps['size']
|
||||
title: string
|
||||
titleOrder?: TitleProps['order']
|
||||
}
|
||||
|
||||
export const BaseOverlayHeader = (props: IProps) => {
|
||||
const {
|
||||
actionIconProps,
|
||||
IconComponent,
|
||||
iconSize = 20,
|
||||
iconVariant,
|
||||
subtitle,
|
||||
themeIconSize = 'xl',
|
||||
title,
|
||||
titleOrder = 4
|
||||
} = props
|
||||
|
||||
return (
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon size={themeIconSize} variant={iconVariant} {...actionIconProps}>
|
||||
<IconComponent size={iconSize} />
|
||||
</ThemeIcon>
|
||||
|
||||
<Stack gap="0">
|
||||
<Title c="white" order={titleOrder}>
|
||||
{title}
|
||||
</Title>
|
||||
<Text c="dimmed" size="xs">
|
||||
{subtitle}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
.container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: rgba(22, 27, 35, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
font-family: var(--mantine-font-family-monospace);
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
color: var(--mantine-color-gray-3);
|
||||
cursor: pointer;
|
||||
transition: border-color 150ms ease;
|
||||
}
|
||||
|
||||
.container.small {
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
}
|
||||
|
||||
.container:hover {
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.codeWrapper {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
mask-image: linear-gradient(to right, black 85%, transparent 100%);
|
||||
-webkit-mask-image: linear-gradient(to right, black 85%, transparent 100%);
|
||||
}
|
||||
|
||||
.container.small .codeWrapper {
|
||||
mask-image: linear-gradient(to right, black 80%, transparent 100%);
|
||||
-webkit-mask-image: linear-gradient(to right, black 80%, transparent 100%);
|
||||
}
|
||||
|
||||
.code {
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.code::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.copyButton {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.6;
|
||||
z-index: 2;
|
||||
transition:
|
||||
opacity 150ms ease,
|
||||
color 150ms ease;
|
||||
}
|
||||
|
||||
.container:hover .copyButton {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.copyButton[data-copied='true'] {
|
||||
opacity: 1;
|
||||
color: var(--mantine-color-teal-5);
|
||||
}
|
||||
43
src/shared/ui/copyable-code-block/copyable-code-block.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { ActionIcon, Box, CopyButton, MantineColor } from '@mantine/core'
|
||||
import { PiCheck, PiCopy } from 'react-icons/pi'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import styles from './copyable-code-block.module.css'
|
||||
|
||||
interface IProps {
|
||||
color?: MantineColor
|
||||
size?: 'normal' | 'small'
|
||||
value: string
|
||||
}
|
||||
|
||||
export function CopyableCodeBlock({ color, value, size = 'normal' }: IProps) {
|
||||
const isSmall = size === 'small'
|
||||
const iconSize = isSmall ? 14 : 18
|
||||
|
||||
return (
|
||||
<CopyButton timeout={2000} value={value}>
|
||||
{({ copied, copy }) => (
|
||||
<Box
|
||||
className={clsx(styles.container, {
|
||||
[styles.small]: isSmall
|
||||
})}
|
||||
onClick={copy}
|
||||
>
|
||||
<Box className={styles.codeWrapper}>
|
||||
<Box c={color} className={styles.code}>
|
||||
{value}
|
||||
</Box>
|
||||
</Box>
|
||||
<ActionIcon
|
||||
className={styles.copyButton}
|
||||
data-copied={copied}
|
||||
size={isSmall ? 'xs' : 'sm'}
|
||||
variant="transparent"
|
||||
>
|
||||
{copied ? <PiCheck size={iconSize} /> : <PiCopy size={iconSize} />}
|
||||
</ActionIcon>
|
||||
</Box>
|
||||
)}
|
||||
</CopyButton>
|
||||
)
|
||||
}
|
||||
1
src/shared/ui/copyable-code-block/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { CopyableCodeBlock } from './copyable-code-block'
|
||||
4
src/shared/ui/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export { AnimatedSection } from './animated-section'
|
||||
export { CopyableCodeBlock } from './copyable-code-block'
|
||||
export { PageLayout } from './page-layout'
|
||||
export { StyledInput } from './styled-input'
|
||||
2
src/shared/ui/page-layout/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { PageLayout } from './page-layout'
|
||||
|
||||
43
src/shared/ui/page-layout/page-layout.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import type { ReactNode } from 'react'
|
||||
|
||||
import { Container } from '@mantine/core'
|
||||
import { motion } from 'motion/react'
|
||||
|
||||
import { containerVariants } from '@shared/config'
|
||||
import { Header } from '@widgets/header'
|
||||
|
||||
interface PageLayoutProps {
|
||||
children: ReactNode
|
||||
gap?: string
|
||||
maxWidth?: number
|
||||
}
|
||||
|
||||
export function PageLayout({ children, maxWidth = 1400, gap = '3rem' }: PageLayoutProps) {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<div className="background" />
|
||||
<div className="wrapper">
|
||||
<Container
|
||||
maw={maxWidth}
|
||||
px={{ base: 'md', sm: 'lg', md: 'xl' }}
|
||||
py={{ base: 'xl', sm: '3rem', md: '4rem' }}
|
||||
style={{ position: 'relative', zIndex: 1 }}
|
||||
>
|
||||
<motion.div
|
||||
animate="visible"
|
||||
initial="hidden"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: `var(--section-gap, ${gap})`
|
||||
}}
|
||||
variants={containerVariants}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</Container>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
1
src/shared/ui/styled-input/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { StyledInput } from './styled-input'
|
||||
27
src/shared/ui/styled-input/styled-input.module.css
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
.input {
|
||||
background: rgba(22, 27, 35, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
font-family: var(--mantine-font-family-monospace);
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
color: var(--mantine-color-gray-3);
|
||||
padding-block: 12px;
|
||||
padding-inline: 16px;
|
||||
height: auto;
|
||||
min-height: unset;
|
||||
line-height: 1.5;
|
||||
transition: border-color 150ms ease;
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--mantine-color-dark-3);
|
||||
}
|
||||
|
||||
.input:hover {
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: rgba(34, 211, 238, 0.4);
|
||||
outline: none;
|
||||
}
|
||||
34
src/shared/ui/styled-input/styled-input.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { CloseButton, Input } from '@mantine/core'
|
||||
|
||||
import styles from './styled-input.module.css'
|
||||
|
||||
interface StyledInputProps {
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export function StyledInput({ value, onChange, placeholder }: StyledInputProps) {
|
||||
return (
|
||||
<Input
|
||||
classNames={{
|
||||
input: styles.input,
|
||||
wrapper: styles.wrapper
|
||||
}}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
rightSection={
|
||||
value && (
|
||||
<CloseButton
|
||||
aria-label="Clear input"
|
||||
onClick={() => onChange('')}
|
||||
size="sm"
|
||||
variant="transparent"
|
||||
/>
|
||||
)
|
||||
}
|
||||
rightSectionPointerEvents="all"
|
||||
value={value}
|
||||
/>
|
||||
)
|
||||
}
|
||||
1
src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
251
src/widgets/Header/Header.module.css
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
.header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.headerDefault {
|
||||
backdrop-filter: blur(8px);
|
||||
background: rgba(22, 27, 35, 0.6);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.headerScrolled {
|
||||
backdrop-filter: blur(12px);
|
||||
background: rgba(22, 27, 35, 0.9);
|
||||
border-bottom: 1px solid rgba(34, 211, 238, 0.15);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.logo {
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.logo:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.logoText {
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.logoTextHighlight {
|
||||
color: var(--mantine-color-cyan-4);
|
||||
}
|
||||
|
||||
.logoTextNormal {
|
||||
color: white;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.logo {
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.navLink {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
text-decoration: none !important;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.navLink::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--mantine-color-cyan-4), transparent);
|
||||
transform: translateX(-50%);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.navLink:hover::after {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.navLink:hover {
|
||||
background: rgba(34, 211, 238, 0.08);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.navLink:hover .navIcon,
|
||||
.navLink:hover .navText {
|
||||
color: var(--mantine-color-cyan-4);
|
||||
}
|
||||
|
||||
.navIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.navText {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.drawer {
|
||||
background: rgba(15, 20, 28, 0.98);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.drawerHeader {
|
||||
background: rgba(34, 211, 238, 0.05);
|
||||
border-bottom: 2px solid rgba(34, 211, 238, 0.2);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.drawerTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.drawerTitleText {
|
||||
font-family: Unbounded, sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 1.25rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.mobileNav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.mobileNavLink {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: 0.75rem;
|
||||
text-decoration: none;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mobileNavLink::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
background: linear-gradient(180deg, var(--mantine-color-cyan-4), var(--mantine-color-blue-5));
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.mobileNavLink:hover::before {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.mobileNavLink:hover {
|
||||
background: rgba(34, 211, 238, 0.1);
|
||||
border-color: rgba(34, 211, 238, 0.3);
|
||||
transform: translateX(8px);
|
||||
}
|
||||
|
||||
.mobileNavIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 0.5rem;
|
||||
background: rgba(34, 211, 238, 0.1);
|
||||
color: var(--mantine-color-cyan-4);
|
||||
transition: all 0.3s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobileNavLink:hover .mobileNavIcon {
|
||||
background: rgba(34, 211, 238, 0.2);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.mobileNavText {
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.mobileNavLink:hover .mobileNavText {
|
||||
color: var(--mantine-color-cyan-4);
|
||||
}
|
||||
|
||||
.fadeInLeft {
|
||||
animation: fade-in-left 0.5s ease-out forwards;
|
||||
}
|
||||
|
||||
.fadeInDown {
|
||||
animation: fade-in-down 0.5s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes fade-in-left {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in-down {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.nav {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1025px) {
|
||||
.burger {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
137
src/widgets/Header/Header.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { Anchor, Box, Burger, Container, Drawer, Group, Stack, Text } from '@mantine/core'
|
||||
import { useDisclosure, useWindowScroll } from '@mantine/hooks'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { motion } from 'motion/react'
|
||||
|
||||
import { RemnawaveLogo } from '@shared/remnawave-logo'
|
||||
import { NAVIGATION_LINKS } from '@shared/config'
|
||||
|
||||
import styles from './header.module.css'
|
||||
|
||||
export function Header() {
|
||||
const [opened, { toggle, close }] = useDisclosure(false)
|
||||
const [scroll] = useWindowScroll()
|
||||
const [isScrolled, setIsScrolled] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setIsScrolled(scroll.y > 50)
|
||||
}, [scroll.y])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
className={`${styles.header} ${isScrolled ? styles.headerScrolled : styles.headerDefault}`}
|
||||
component="header"
|
||||
>
|
||||
<Container maw={1400} px={{ base: 'md', sm: 'lg', md: 'xl' }} py="md">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<motion.div
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className={styles.fadeInLeft}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Anchor className={styles.logo} href="/">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<RemnawaveLogo size={32} />
|
||||
<Text
|
||||
className={styles.logoText}
|
||||
ff="Unbounded"
|
||||
fw={700}
|
||||
size="xl"
|
||||
>
|
||||
<Text
|
||||
className={styles.logoTextHighlight}
|
||||
component="span"
|
||||
inherit
|
||||
>
|
||||
Remna
|
||||
</Text>
|
||||
<Text
|
||||
className={styles.logoTextNormal}
|
||||
component="span"
|
||||
inherit
|
||||
>
|
||||
wave
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
className={styles.logoTextNormal}
|
||||
component="span"
|
||||
inherit
|
||||
>
|
||||
{' '}
|
||||
Utils
|
||||
</Text>
|
||||
</Text>
|
||||
</Group>
|
||||
</Anchor>
|
||||
</motion.div>
|
||||
|
||||
<div className={styles.nav}>
|
||||
{NAVIGATION_LINKS.map((link, index) => (
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
key={link.href}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
>
|
||||
<Anchor
|
||||
className={styles.navLink}
|
||||
href={link.href}
|
||||
rel={link.external ? 'noopener noreferrer' : undefined}
|
||||
target={link.external ? '_blank' : undefined}
|
||||
>
|
||||
<Box className={styles.navIcon}>{link.icon}</Box>
|
||||
<Text className={styles.navText}>{link.label}</Text>
|
||||
</Anchor>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Burger hiddenFrom="lg" onClick={toggle} opened={opened} size="md" />
|
||||
</Group>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
<Drawer
|
||||
closeButtonProps={{
|
||||
size: 'xl'
|
||||
}}
|
||||
hiddenFrom="lg"
|
||||
onClose={close}
|
||||
opened={opened}
|
||||
padding="lg"
|
||||
position="right"
|
||||
size="400px"
|
||||
styles={{
|
||||
content: {
|
||||
backdropFilter: 'blur(20px)'
|
||||
},
|
||||
header: {
|
||||
background: 'transparent'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Stack className={styles.mobileNav}>
|
||||
{NAVIGATION_LINKS.map((link) => (
|
||||
<Anchor
|
||||
className={styles.mobileNavLink}
|
||||
href={link.href}
|
||||
key={link.href}
|
||||
onClick={close}
|
||||
rel={link.external ? 'noopener noreferrer' : undefined}
|
||||
target={link.external ? '_blank' : undefined}
|
||||
td="none"
|
||||
>
|
||||
<Box className={styles.mobileNavIcon}>{link.icon}</Box>
|
||||
<Text className={styles.mobileNavText}>{link.label}</Text>
|
||||
</Anchor>
|
||||
))}
|
||||
</Stack>
|
||||
</Drawer>
|
||||
|
||||
<Box h={{ base: 64, sm: 72 }} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
2
src/widgets/Header/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { Header } from './header'
|
||||
|
||||
2
src/widgets/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { Header } from './header'
|
||||
export { UtilityCard } from './utility-card'
|
||||
1
src/widgets/utility-card/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { UtilityCard } from './utility-card'
|
||||
73
src/widgets/utility-card/utility-card.module.css
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
.card {
|
||||
position: relative;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.3s ease,
|
||||
border-color 0.3s ease,
|
||||
box-shadow 0.3s ease;
|
||||
text-decoration: none !important;
|
||||
min-height: 240px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
border-radius: inherit;
|
||||
background: transparent;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: rgba(34, 211, 238, 0.3);
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.icon {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: white;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.card:hover .title {
|
||||
color: var(--mantine-color-cyan-4);
|
||||
}
|
||||
|
||||
.description {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.arrowContainer {
|
||||
margin-top: auto;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.card:hover .arrow {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.arrowIcon {
|
||||
color: var(--mantine-color-cyan-4);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.card:hover .arrowIcon {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
89
src/widgets/utility-card/utility-card.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import type { ReactNode } from 'react'
|
||||
|
||||
import { Badge, Box, Card, Group, Stack, Text, ThemeIcon, ThemeIconProps } from '@mantine/core'
|
||||
import { IconChevronRight, IconExternalLink } from '@tabler/icons-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { motion } from 'motion/react'
|
||||
|
||||
import styles from './utility-card.module.css'
|
||||
|
||||
interface UtilityCardProps {
|
||||
badge?: string
|
||||
badgeColor?: string
|
||||
description: string
|
||||
external?: boolean
|
||||
href: string
|
||||
icon: ReactNode
|
||||
title: string
|
||||
variant?: ThemeIconProps['variant']
|
||||
}
|
||||
|
||||
export function UtilityCard({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
href,
|
||||
badge,
|
||||
badgeColor = 'cyan',
|
||||
external = false,
|
||||
variant = 'gradient-cyan'
|
||||
}: UtilityCardProps) {
|
||||
const cardContent = (
|
||||
<Stack gap="lg">
|
||||
<Group align="flex-start" justify="space-between">
|
||||
<ThemeIcon className={styles.icon} radius="lg" size={56} variant={variant}>
|
||||
{icon}
|
||||
</ThemeIcon>
|
||||
{badge && (
|
||||
<Badge color={badgeColor} size="sm" variant="light">
|
||||
{badge}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Text className={styles.title} fw={700} size="xl">
|
||||
{title}
|
||||
</Text>
|
||||
<Text c="dimmed" className={styles.description} size="sm">
|
||||
{description}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Box className={styles.arrowContainer}>
|
||||
<Group className={styles.arrow} gap="xs">
|
||||
<Text c="cyan.4" fw={600} size="sm">
|
||||
{external ? 'Visit' : 'Open'}
|
||||
</Text>
|
||||
{external ? (
|
||||
<IconExternalLink className={styles.arrowIcon} size={18} />
|
||||
) : (
|
||||
<IconChevronRight className={styles.arrowIcon} size={18} />
|
||||
)}
|
||||
</Group>
|
||||
</Box>
|
||||
</Stack>
|
||||
)
|
||||
|
||||
return (
|
||||
<motion.div transition={{ type: 'spring', stiffness: 300, damping: 20 }}>
|
||||
{external ? (
|
||||
<Card
|
||||
className={styles.card}
|
||||
component="a"
|
||||
href={href}
|
||||
padding="xl"
|
||||
radius="lg"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{cardContent}
|
||||
</Card>
|
||||
) : (
|
||||
<Card className={styles.card} component={Link} padding="xl" radius="lg" to={href}>
|
||||
{cardContent}
|
||||
</Card>
|
||||
)}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
83
tsconfig.json
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"removeComments": true,
|
||||
"target": "ES2020",
|
||||
"lib": [
|
||||
"ESNext",
|
||||
"DOM",
|
||||
"DOM.Iterable",
|
||||
],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
/* ------------------------------------------------------------ */
|
||||
/* Bundler mode */
|
||||
/* ------------------------------------------------------------ */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
/* ------------------------------------------------------------ */
|
||||
/* "useDefineForClassFields": true,
|
||||
/* "esModuleInterop": false,
|
||||
/* "allowSyntheticDefaultImports": true,
|
||||
"allowJs": false,
|
||||
|
||||
/* ------------------------------------------------------------ */
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false, // TURN THIS
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"strictPropertyInitialization": false,
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
/* ------------------------------------------------------------ */
|
||||
/* Paths */
|
||||
/* ------------------------------------------------------------ */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@entities": [
|
||||
"./src/entities"
|
||||
],
|
||||
"@entities/*": [
|
||||
"./src/entities/*"
|
||||
],
|
||||
"@features": [
|
||||
"./src/features"
|
||||
],
|
||||
"@features/*": [
|
||||
"./src/features/*"
|
||||
],
|
||||
"@pages": [
|
||||
"./src/pages"
|
||||
],
|
||||
"@pages/*": [
|
||||
"./src/pages/*"
|
||||
],
|
||||
"@widgets": [
|
||||
"./src/widgets"
|
||||
],
|
||||
"@widgets/*": [
|
||||
"./src/widgets/*"
|
||||
],
|
||||
"@public/*": [
|
||||
"./public/*"
|
||||
],
|
||||
"@shared": [
|
||||
"./src/shared"
|
||||
],
|
||||
"@shared/*": [
|
||||
"./src/shared/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src",
|
||||
"@types",
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
13
tsconfig.node.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": [
|
||||
"vite.config.ts"
|
||||
]
|
||||
}
|
||||
54
vite.config.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import removeConsole from 'vite-plugin-remove-console'
|
||||
import webfontDownload from 'vite-plugin-webfont-dl'
|
||||
import tsconfigPaths from 'vite-tsconfig-paths'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import react from '@vitejs/plugin-react-swc'
|
||||
import { defineConfig } from 'vite'
|
||||
import * as dotenv from 'dotenv'
|
||||
|
||||
dotenv.config({ path: `${__dirname}/.env` })
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tsconfigPaths(), removeConsole(), webfontDownload()],
|
||||
build: {
|
||||
target: 'esNext',
|
||||
outDir: 'landing',
|
||||
assetsDir: 'landing-assets',
|
||||
chunkSizeWarningLimit: 1000000
|
||||
// minify: 'terser'
|
||||
},
|
||||
define: {
|
||||
__DOMAIN_BACKEND__: JSON.stringify(process.env.DOMAIN_BACKEND || 'example.com').trim(),
|
||||
__NODE_ENV__: JSON.stringify(process.env.NODE_ENV).trim(),
|
||||
__DOMAIN_OVERRIDE__: JSON.stringify(process.env.DOMAIN_OVERRIDE || '0').trim()
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 80,
|
||||
cors: false,
|
||||
strictPort: true,
|
||||
allowedHosts: true
|
||||
},
|
||||
resolve: {
|
||||
alias: [
|
||||
{
|
||||
find: '@entities',
|
||||
replacement: fileURLToPath(new URL('./src/entities', import.meta.url))
|
||||
},
|
||||
{
|
||||
find: '@features',
|
||||
replacement: fileURLToPath(new URL('./src/features', import.meta.url))
|
||||
},
|
||||
{ find: '@pages', replacement: fileURLToPath(new URL('./src/pages', import.meta.url)) },
|
||||
{
|
||||
find: '@widgets',
|
||||
replacement: fileURLToPath(new URL('./src/widgets', import.meta.url))
|
||||
},
|
||||
{ find: '@public', replacement: fileURLToPath(new URL('./public', import.meta.url)) },
|
||||
{
|
||||
find: '@shared',
|
||||
replacement: fileURLToPath(new URL('./src/shared', import.meta.url))
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||