mirror of
https://github.com/SrIzan10/hctv.git
synced 2026-06-06 00:56:56 +00:00
Compare commits
66 Commits
feat/notif
...
emoji-impo
| Author | SHA1 | Date | |
|---|---|---|---|
| 451952e3f8 | |||
| f236086dba | |||
| d4e09389ad | |||
| 2b078c9c1e | |||
| 74461398c5 | |||
| 41ad53b531 | |||
| 2dc28ae8d9 | |||
| 1fa8c59c33 | |||
| 4237b1b41b | |||
| 54e3d120f6 | |||
| ed5f29824c | |||
| f4e809ae83 | |||
| cb7f6b0676 | |||
| f46fd35980 | |||
| 9b8e83d2e2 | |||
| c89a3bed87 | |||
| ed1fd6a18e | |||
| 79fe992f6c | |||
| 3b684c2cfd | |||
| a0ef59e577 | |||
| ff91e4b8d6 | |||
| 0ef38f0270 | |||
| a1d849092b | |||
| 87961cd18f | |||
| c0ff5912e4 | |||
| 21f7c0fb82 | |||
| a7a046abd8 | |||
| be5576c2c6 | |||
| 78fe930771 | |||
| 28061a83b0 | |||
| 877fd490bc | |||
| 9305b62e2a | |||
| c7d3a4bf1f | |||
| 92429c1390 | |||
| f7ae4cecf4 | |||
| a3d6d587e5 | |||
| cd90281cf9 | |||
| 5366116e26 | |||
| 12daf61c5b | |||
| db01e3bb59 | |||
| 3aa7cd0c87 | |||
| 818566b1c5 | |||
| 338254889a | |||
| 3029de4fae | |||
| 87ba1c0f3a | |||
| d2e2d0ce62 | |||
| 47fdbf66f9 | |||
| 7e26ffb2d1 | |||
| f45d604468 | |||
| 67989a694c | |||
| 10c10b6d2a | |||
| 6218d328cd | |||
| 02ebaa2914 | |||
| 212e0ea8a0 | |||
| 172a0f887f | |||
| 1048c822ac | |||
| 40d4fb7b09 | |||
| c22e653528 | |||
| 26c2a0b320 | |||
| efb84fe1f9 | |||
| 6268e85fce | |||
| e77e750b0a | |||
| ee1ef27be5 | |||
| 87d7e5752b | |||
| ee442f27de | |||
| 752dc641b3 |
37
.github/workflows/docker.yml
vendored
37
.github/workflows/docker.yml
vendored
@@ -29,6 +29,22 @@ jobs:
|
||||
images: srizan10/hclive
|
||||
tags: latest
|
||||
|
||||
- name: Download latest emoji importer
|
||||
run: |
|
||||
RELEASE_URL=$(curl -s https://api.github.com/repos/srizan10/hclive/releases/latest | \
|
||||
grep "browser_download_url.*slack-import-emojis-linux-x86_64" | \
|
||||
cut -d '"' -f 4)
|
||||
|
||||
curl -L -o slack-import-emojis $RELEASE_URL
|
||||
chmod +x slack-import-emojis
|
||||
|
||||
mkdir -p apps/web/src/lib/instrumentation/
|
||||
|
||||
export SLACK_TOKEN=${{ secrets.SLACK_TOKEN }}
|
||||
./slack-import-emojis
|
||||
|
||||
mv emojis.json apps/web/src/lib/instrumentation/
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
@@ -116,10 +132,17 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [frontend, db, chat]
|
||||
steps:
|
||||
- name: Emit a webhook to the server
|
||||
env:
|
||||
AUTH_HEADER: ${{ secrets.WHSERVER_TOKEN }}
|
||||
run: |
|
||||
curl -X POST \
|
||||
-H "Authorization: $AUTH_HEADER" \
|
||||
https://webhooks.srizan.dev/hooks/hctv
|
||||
# source https://github.com/taciturnaxolotl/cachet/blob/main/.github/workflows/deploy.yaml
|
||||
- name: Deploy with Docker
|
||||
uses: appleboy/ssh-action@v1
|
||||
with:
|
||||
host: hackclub.app
|
||||
username: srizan
|
||||
key: ${{ secrets.SSH_KEY }}
|
||||
port: 22
|
||||
script: |
|
||||
cd ~/compose/hctv
|
||||
docker compose pull
|
||||
docker compose up -d --remove-orphans
|
||||
# for some reason, without the restart, the rtmp container stops working
|
||||
docker compose restart
|
||||
67
.github/workflows/emojis.yml
vendored
Normal file
67
.github/workflows/emojis.yml
vendored
Normal file
@@ -0,0 +1,67 @@
|
||||
name: Slack Emoji Importer Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'slack-import-emojis/**'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-release:
|
||||
name: Build and create release
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./slack-import-emojis
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: "./slack-import-emojis -> target"
|
||||
|
||||
- name: Build release binary
|
||||
run: cargo build --release
|
||||
|
||||
- name: Extract version from Cargo.toml
|
||||
id: get_version
|
||||
run: |
|
||||
VERSION=$(grep '^version =' Cargo.toml | head -n 1 | cut -d '"' -f 2)
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
id: create_release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: emoji-importer-v${{ steps.get_version.outputs.version }}
|
||||
name: Slack Emoji Importer v${{ steps.get_version.outputs.version }}
|
||||
body: |
|
||||
Slack Emoji Importer v${{ steps.get_version.outputs.version }}
|
||||
|
||||
Fetches emojis from the Slack workspace and exports them to JSON.
|
||||
|
||||
## Usage
|
||||
```
|
||||
export SLACK_TOKEN=xoxb-your-token
|
||||
./slack-import-emojis
|
||||
```
|
||||
draft: false
|
||||
prerelease: false
|
||||
files: ./slack-import-emojis/target/release/slack-import-emojis
|
||||
|
||||
- name: Upload Linux binary
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: ./slack-import-emojis/target/release/slack-import-emojis
|
||||
asset_name: slack-import-emojis-linux-x86_64
|
||||
asset_content_type: application/octet-stream
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -42,4 +42,7 @@ dev/redis
|
||||
|
||||
.turbo
|
||||
packages/db/generated/client
|
||||
*dist
|
||||
*dist
|
||||
|
||||
slack-import-emojis/target
|
||||
**/*/emojis.json
|
||||
674
LICENSE
Normal file
674
LICENSE
Normal file
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
@@ -12,6 +12,7 @@
|
||||
"@hctv/hono-ws": "*",
|
||||
"@hono/node-server": "^1.14.0",
|
||||
"@hono/node-ws": "^1.1.0",
|
||||
"@leeoniya/ufuzzy": "^1.0.18",
|
||||
"@oslojs/encoding": "^1.1.0",
|
||||
"hono": "^4.7.5"
|
||||
},
|
||||
|
||||
@@ -5,9 +5,15 @@ import { readFile } from 'node:fs/promises';
|
||||
import { lucia } from '@hctv/auth';
|
||||
import { getCookie } from 'hono/cookie';
|
||||
import { getPersonalChannel } from './utils/personalChannel.js';
|
||||
import { prisma } from '@hctv/db';
|
||||
import { getRedisConnection, prisma, type User } from '@hctv/db';
|
||||
import uFuzzy from '@leeoniya/ufuzzy';
|
||||
import { randomString } from './utils/randomString.js';
|
||||
|
||||
const redis = getRedisConnection();
|
||||
const MESSAGE_HISTORY_SIZE = 15;
|
||||
const MESSAGE_TTL = 60 * 60 * 24;
|
||||
const threed = await readFile('./src/3d.txt', 'utf-8');
|
||||
const uf = new uFuzzy();
|
||||
|
||||
const app = new Hono();
|
||||
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app });
|
||||
@@ -26,47 +32,75 @@ app.get(
|
||||
// https://hono.dev/helpers/websocket
|
||||
async onOpen(evt, ws) {
|
||||
const token = getCookie(c, 'auth_session');
|
||||
if (!token) {
|
||||
const grant = c.req.query('grant');
|
||||
console.log({
|
||||
token,
|
||||
grant,
|
||||
})
|
||||
|
||||
// random checks that actually make sense if you read trust me bro
|
||||
if (!token && !grant) {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
if (!token && grant === 'null') {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const { user } = await lucia.validateSession(token);
|
||||
if (!user) {
|
||||
ws.close();
|
||||
return;
|
||||
let user: User | null = null
|
||||
const dbGrant = await prisma.channel.findFirst({
|
||||
where: {
|
||||
obsChatGrantToken: grant,
|
||||
}
|
||||
});
|
||||
if (token) {
|
||||
user = (await lucia.validateSession(token)).user;
|
||||
const personalChannel = await getPersonalChannel(user!.id);
|
||||
if (!personalChannel) {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
ws.personalChannel = personalChannel;
|
||||
}
|
||||
|
||||
const personalChannel = await getPersonalChannel(user.id);
|
||||
if (!personalChannel) {
|
||||
if (!user && !dbGrant) {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const { username } = c.req.param();
|
||||
if (dbGrant && dbGrant?.name !== username) {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
ws.targetUsername = username;
|
||||
ws.user = user;
|
||||
ws.personalChannel = personalChannel;
|
||||
ws.viewerId = randomString(10);
|
||||
if (ws.raw) {
|
||||
ws.raw.targetUsername = username;
|
||||
// @ts-ignore
|
||||
ws.raw.user = user;
|
||||
ws.raw.personalChannel = personalChannel;
|
||||
ws.raw.personalChannel = ws.personalChannel;
|
||||
}
|
||||
|
||||
await prisma.streamInfo.update({
|
||||
where: {
|
||||
username,
|
||||
},
|
||||
data: {
|
||||
viewers: {
|
||||
increment: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
const redis = getRedisConnection();
|
||||
const channelKey = `chat:history:${username}`;
|
||||
const messages = await redis.zrange(channelKey, 0, MESSAGE_HISTORY_SIZE - 1);
|
||||
|
||||
if (messages.length > 0) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'history',
|
||||
messages: messages.map((msg) => JSON.parse(msg)),
|
||||
})
|
||||
);
|
||||
}
|
||||
},
|
||||
async onClose(evt, ws) {
|
||||
// if prematurely exiting due to authentication issues
|
||||
console.log('client disconnected');
|
||||
if (!ws.targetUsername) return;
|
||||
|
||||
const streamInfo = await prisma.streamInfo.findUnique({
|
||||
where: {
|
||||
username: ws.targetUsername,
|
||||
@@ -78,41 +112,118 @@ app.get(
|
||||
|
||||
if (!streamInfo) return;
|
||||
|
||||
await prisma.streamInfo.update({
|
||||
where: {
|
||||
username: ws.targetUsername,
|
||||
},
|
||||
data: {
|
||||
viewers: streamInfo.viewers === 0 ? { set: 0 } : { decrement: 1 },
|
||||
},
|
||||
});
|
||||
await redis.del(`viewer:${ws.targetUsername}:${ws.viewerId}`);
|
||||
},
|
||||
onMessage(evt, ws) {
|
||||
async onMessage(evt, ws) {
|
||||
const msg = JSON.parse(evt.data.toString());
|
||||
if (msg.type === 'ping') {
|
||||
await redis.setex(`viewer:${ws.targetUsername}:${ws.viewerId}`, 30, '1');
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'pong',
|
||||
})
|
||||
);
|
||||
return;
|
||||
} else if (msg.type === 'message') {
|
||||
}
|
||||
if (msg.type === 'message') {
|
||||
if (!ws.personalChannel) return;
|
||||
const message = (msg.message as string).trim();
|
||||
const msgObj = {
|
||||
user: {
|
||||
id: ws.user.id,
|
||||
username: ws.personalChannel.name,
|
||||
pfpUrl: ws.user.pfpUrl,
|
||||
},
|
||||
message,
|
||||
};
|
||||
|
||||
// Save to Redis without the type field to maintain compatibility
|
||||
const redisObj = {
|
||||
user: msgObj.user,
|
||||
message: msgObj.message,
|
||||
type: 'message',
|
||||
};
|
||||
const redisStr = JSON.stringify(redisObj);
|
||||
const msgStr = JSON.stringify(msgObj);
|
||||
|
||||
const channelKey = `chat:history:${ws.targetUsername}`;
|
||||
await redis.zadd(channelKey, Date.now(), redisStr);
|
||||
await redis.zremrangebyrank(channelKey, 0, -MESSAGE_HISTORY_SIZE - 1);
|
||||
await redis.expire(channelKey, MESSAGE_TTL);
|
||||
|
||||
ws.wss.clients.forEach((c) => {
|
||||
const client = c as ModifiedWebSocket;
|
||||
if (client.readyState === client.OPEN && client.targetUsername === ws.targetUsername) {
|
||||
c.send(
|
||||
JSON.stringify({
|
||||
user: {
|
||||
id: ws.user.id,
|
||||
username: ws.personalChannel.name,
|
||||
pfpUrl: ws.user.pfpUrl,
|
||||
},
|
||||
message: msg.message,
|
||||
})
|
||||
);
|
||||
c.send(msgStr);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (msg.type === 'emojiMsg') {
|
||||
const emojis = msg.emojis as string[];
|
||||
const emojiMap: Record<string, string> = {};
|
||||
|
||||
await Promise.all(
|
||||
emojis.map(async (emoji) => {
|
||||
let url = await redis.hget('emojis', emoji);
|
||||
|
||||
if (!url) {
|
||||
url = await redis.hget(`emojis:${emoji}`, 'url');
|
||||
}
|
||||
if (!url) {
|
||||
url = await redis.hget(`emoji:${emoji}`, 'url');
|
||||
}
|
||||
|
||||
emojiMap[emoji] = url ?? '';
|
||||
})
|
||||
);
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'emojiMsgResponse',
|
||||
emojis: emojiMap,
|
||||
})
|
||||
);
|
||||
}
|
||||
if (msg.type === 'emojiSearch') {
|
||||
console.log('emoji search request:', msg);
|
||||
const searchTerm = msg.searchTerm as string;
|
||||
|
||||
const emojis = await redis.hgetall('emojis');
|
||||
const emojiKeys = Object.keys(emojis);
|
||||
const idxs = uf.filter(emojiKeys, searchTerm);
|
||||
console.log(`Emoji search for "${searchTerm}" found ${idxs?.length || 0} results.`);
|
||||
|
||||
if (idxs && idxs.length > 0) {
|
||||
const results: string[] = [];
|
||||
|
||||
if (idxs.length <= 150) {
|
||||
const info = uf.info(idxs, emojiKeys, searchTerm);
|
||||
const order = uf.sort(info, emojiKeys, searchTerm);
|
||||
for (let i = 0; i < order.length && i < 10; i++) {
|
||||
results.push(emojiKeys[idxs[order[i]]]);
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < idxs.length && i < 10; i++) {
|
||||
results.push(emojiKeys[idxs[i]]);
|
||||
}
|
||||
}
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'emojiSearchResponse',
|
||||
results: results,
|
||||
})
|
||||
);
|
||||
console.log(`Sending emoji search results: ${results.join(', ')}`);
|
||||
} else {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'emojiSearchResponse',
|
||||
results: [],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
}))
|
||||
);
|
||||
|
||||
8
apps/chat/src/utils/randomString.ts
Normal file
8
apps/chat/src/utils/randomString.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export function randomString(length: number): string {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -2,13 +2,16 @@ FROM node:lts-alpine AS base
|
||||
|
||||
FROM base AS builder
|
||||
RUN apk update
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN apk add --no-cache libc6-compat git
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
# Replace <your-major-version> with the major version installed in your repository. For example:
|
||||
# RUN yarn global add turbo@^2
|
||||
RUN yarn global add turbo@^2
|
||||
COPY . .
|
||||
|
||||
# Get the git commit hash before pruning (since .git might be removed)
|
||||
RUN git rev-parse --short HEAD > /tmp/commit_hash || echo "unknown" > /tmp/commit_hash
|
||||
|
||||
# Generate a partial monorepo with a pruned lockfile for a target workspace.
|
||||
# Assuming "web" is the name entered in the project's package.json: { name: "web" }
|
||||
@@ -17,22 +20,42 @@ RUN turbo prune @hctv/web --docker
|
||||
# Add lockfile and package.json's of isolated subworkspace
|
||||
FROM base AS installer
|
||||
RUN apk update
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN apk add --no-cache libc6-compat git
|
||||
# Get the commit hash from the builder stage
|
||||
COPY --from=builder /tmp/commit_hash /tmp/commit_hash
|
||||
# Read commit hash and set as build arg
|
||||
ARG COMMIT_HASH_FILE=/tmp/commit_hash
|
||||
RUN COMMIT_HASH=$(cat /tmp/commit_hash 2>/dev/null || echo "unknown") && \
|
||||
echo "COMMIT_HASH=$COMMIT_HASH" > /tmp/build_env
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
# First install the dependencies (as they change less often)
|
||||
COPY --from=builder /app/out/json/ .
|
||||
RUN yarn install --frozen-lockfile
|
||||
|
||||
COPY --from=builder /app/out/full/ .
|
||||
RUN --mount=type=secret,id=TURBO_TOKEN --mount=type=secret,id=TURBO_TEAM TURBO_TOKEN=$(cat /run/secrets/TURBO_TOKEN) TURBO_TEAM=$(cat /run/secrets/TURBO_TEAM) yarn turbo run build
|
||||
RUN --mount=type=secret,id=TURBO_TOKEN --mount=type=secret,id=TURBO_TEAM \
|
||||
. /tmp/build_env && \
|
||||
export commit=$COMMIT_HASH && \
|
||||
TURBO_TOKEN=$(cat /run/secrets/TURBO_TOKEN) TURBO_TEAM=$(cat /run/secrets/TURBO_TEAM) yarn turbo run build --env-mode=loose
|
||||
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
RUN apk add --no-cache ffmpeg
|
||||
|
||||
# Don't run production as root
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
# Get the commit hash from the installer stage and create a startup script
|
||||
COPY --from=installer /tmp/commit_hash /tmp/commit_hash
|
||||
RUN COMMIT_VALUE=$(cat /tmp/commit_hash 2>/dev/null || echo "unknown") && \
|
||||
echo "#!/bin/sh" > /usr/local/bin/start.sh && \
|
||||
echo "export commit=$COMMIT_VALUE" >> /usr/local/bin/start.sh && \
|
||||
echo "exec node apps/web/server.js" >> /usr/local/bin/start.sh && \
|
||||
chmod +x /usr/local/bin/start.sh
|
||||
|
||||
USER nextjs
|
||||
|
||||
# Automatically leverage output traces to reduce image size
|
||||
@@ -41,4 +64,4 @@ COPY --from=installer --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
|
||||
COPY --from=installer --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
|
||||
COPY --from=installer --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
|
||||
|
||||
CMD node apps/web/server.js
|
||||
CMD ["/usr/local/bin/start.sh"]
|
||||
@@ -1,3 +1,5 @@
|
||||
# quick and dirty ai generated benchmark i created for hls streams
|
||||
|
||||
#!/usr/bin/env python3
|
||||
import asyncio
|
||||
import aiohttp
|
||||
|
||||
Binary file not shown.
@@ -1,5 +1,8 @@
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
@@ -7,6 +10,11 @@ const LIVE_SERVER_URL =
|
||||
process.env.NODE_ENV === 'production'
|
||||
? 'https://backend.hctv.srizan.dev'
|
||||
: 'http://localhost:8888';
|
||||
|
||||
const packageJson = JSON.parse(readFileSync('./package.json', 'utf8'));
|
||||
const { version } = packageJson;
|
||||
const commit = process.env.commit || execSync('git rev-parse --short HEAD')
|
||||
.toString().trim();
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
images: {
|
||||
@@ -17,10 +25,16 @@ const nextConfig = {
|
||||
{
|
||||
hostname: 'secure.gravatar.com',
|
||||
},
|
||||
{
|
||||
hostname: 'emoji.slack-edge.com',
|
||||
}
|
||||
],
|
||||
minimumCacheTTL: 120,
|
||||
},
|
||||
env: {
|
||||
LIVE_SERVER_URL,
|
||||
commit,
|
||||
version,
|
||||
},
|
||||
reactStrictMode: false,
|
||||
output: 'standalone',
|
||||
@@ -32,7 +46,7 @@ const nextConfig = {
|
||||
destination: `http://${process.env.NODE_ENV === 'production' ? 'chat' : 'localhost'}:8000/:path*`,
|
||||
},
|
||||
];
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hctv/web",
|
||||
"version": "0.1.0",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -21,18 +21,22 @@
|
||||
"@lucia-auth/adapter-prisma": "^4.0.1",
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@radix-ui/react-avatar": "^1.0.4",
|
||||
"@radix-ui/react-checkbox": "^1.1.4",
|
||||
"@radix-ui/react-dialog": "^1.1.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||
"@radix-ui/react-hover-card": "^1.1.14",
|
||||
"@radix-ui/react-label": "^2.1.1",
|
||||
"@radix-ui/react-popover": "^1.1.5",
|
||||
"@radix-ui/react-select": "^2.1.5",
|
||||
"@radix-ui/react-separator": "^1.1.1",
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@radix-ui/react-switch": "^1.1.3",
|
||||
"@radix-ui/react-tooltip": "^1.1.6",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@slack/web-api": "^7.9.1",
|
||||
"@uidotdev/usehooks": "^2.4.1",
|
||||
"arctic": "^3.1.1",
|
||||
"@uploadthing/react": "^7.3.1",
|
||||
"arctic": "^3.7.0",
|
||||
"bullmq": "^5.45.2",
|
||||
"cheerio": "^1.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -45,17 +49,27 @@
|
||||
"lucia": "^3.2.2",
|
||||
"lucide-react": "^0.473.0",
|
||||
"media-chrome": "^4.8.0",
|
||||
"next": "^15.2.3",
|
||||
"next": "^15.3.4",
|
||||
"next-themes": "^0.4.4",
|
||||
"node-cron": "^3.0.3",
|
||||
"nuqs": "^2.4.3",
|
||||
"pg": "^8.14.1",
|
||||
"pg-boss": "^10.1.6",
|
||||
"react": "19",
|
||||
"react-dom": "19",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-react": "^8.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-rehype": "^11.1.2",
|
||||
"sharp": "^0.34.2",
|
||||
"sonner": "^1.4.41",
|
||||
"swr": "^2.3.0",
|
||||
"tailwind-merge": "^2.2.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"unified": "^11.0.5",
|
||||
"uploadthing": "^7.7.2",
|
||||
"util-utils": "^1.0.3",
|
||||
"valtio": "^2.1.2",
|
||||
"ws": "^8.18.1",
|
||||
@@ -63,13 +77,14 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@types/ws": "^8.18.0",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "15.1.3",
|
||||
"postcss": "^8",
|
||||
"shadcn": "^2.1.8",
|
||||
"shadcn": "^2.7.0",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5"
|
||||
}
|
||||
|
||||
18
apps/web/src/app/(other)/chat/[username]/page.tsx
Normal file
18
apps/web/src/app/(other)/chat/[username]/page.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import ChatPanel from "@/components/app/ChatPanel/ChatPanel";
|
||||
import { prisma } from '@hctv/db';
|
||||
|
||||
export default async function Page({ params }: { params: Promise<{ username: string }> }) {
|
||||
const { username } = await params;
|
||||
const streamInfo = await prisma.streamInfo.findUnique({
|
||||
where: { username },
|
||||
include: { channel: true },
|
||||
});
|
||||
if (!streamInfo) {
|
||||
return <div>Stream not found</div>;
|
||||
}
|
||||
return (
|
||||
<div className="bg-transparent h-screen">
|
||||
<ChatPanel isObsPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { validateRequest } from '@/lib/auth/validate';
|
||||
import { prisma } from '@hctv/db';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const shouldGetOwned = searchParams.get('owned') === 'true';
|
||||
const { user } = await validateRequest();
|
||||
/* const db = await prisma.streamInfo.findMany({
|
||||
include: { ownedBy: true },
|
||||
}); */
|
||||
|
||||
if (shouldGetOwned) {
|
||||
if (!user) {
|
||||
return new Response('No user found in cookies', { status: 401 });
|
||||
}
|
||||
|
||||
const db = await prisma.streamInfo.findMany({
|
||||
where: {
|
||||
channel: {
|
||||
ownerId: user.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
return Response.json(db);
|
||||
} else {
|
||||
const db = await prisma.streamInfo.findMany({
|
||||
include: {
|
||||
channel: true,
|
||||
}
|
||||
});
|
||||
return Response.json(db);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ export default async function Page({ params }: { params: Promise<{ username: str
|
||||
const { username } = await params;
|
||||
const streamInfo = await prisma.streamInfo.findUnique({
|
||||
where: { username },
|
||||
include: { ownedBy: true },
|
||||
include: { channel: true },
|
||||
});
|
||||
if (!streamInfo) {
|
||||
return <div>Stream not found</div>;
|
||||
6
apps/web/src/app/(ui)/(protected)/api/commit/route.ts
Normal file
6
apps/web/src/app/(ui)/(protected)/api/commit/route.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export function GET() {
|
||||
return Response.json({
|
||||
version: process.env.version,
|
||||
commit: process.env.commit,
|
||||
});
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { validateRequest } from '@/lib/auth/validate';
|
||||
import fsP from 'fs/promises';
|
||||
import fs from 'fs';
|
||||
import { getRedisConnection } from '@hctv/db';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ path: string }> }) {
|
||||
const { path } = await params;
|
||||
const { user } = await validateRequest();
|
||||
if (!user) {
|
||||
const c = await cookies();
|
||||
if (!getRedisConnection().exists(`sessions:${c.get('auth_session')?.value}`)) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
if (path.includes('..')) {
|
||||
55
apps/web/src/app/(ui)/(protected)/api/stream/info/route.ts
Normal file
55
apps/web/src/app/(ui)/(protected)/api/stream/info/route.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
// FIXME: THIS EFFING SUCKS OH MY GOD
|
||||
|
||||
import { validateRequest } from '@/lib/auth/validate';
|
||||
import { Prisma, prisma } from '@hctv/db';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const shouldGetOwned = searchParams.get('owned') === 'true';
|
||||
const allPersonalChannels = searchParams.get('personal') === 'true';
|
||||
const isLive = searchParams.get('live') === 'true';
|
||||
const { user } = await validateRequest();
|
||||
|
||||
if ((shouldGetOwned || allPersonalChannels) && !user) {
|
||||
return new Response('No user found in cookies', { status: 401 });
|
||||
}
|
||||
|
||||
const where: Prisma.StreamInfoWhereInput = {};
|
||||
const channelConditions: Prisma.ChannelWhereInput[] = [];
|
||||
|
||||
if (shouldGetOwned && user) {
|
||||
channelConditions.push({ ownerId: user.id });
|
||||
}
|
||||
|
||||
if (allPersonalChannels) {
|
||||
channelConditions.push({
|
||||
personalFor: {
|
||||
isNot: null
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (isLive) {
|
||||
where.isLive = true;
|
||||
}
|
||||
|
||||
if (channelConditions.length > 0) {
|
||||
where.channel = channelConditions.length === 1
|
||||
? channelConditions[0]
|
||||
: { OR: channelConditions };
|
||||
}
|
||||
|
||||
const db = await prisma.streamInfo.findMany({
|
||||
where,
|
||||
include: {
|
||||
channel: {
|
||||
include: {
|
||||
personalFor: true,
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return Response.json(db);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { validateRequest } from '@/lib/auth/validate';
|
||||
import fsP from 'fs/promises';
|
||||
import fs from 'fs';
|
||||
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ username: string }> }) {
|
||||
const { username } = await params;
|
||||
const { user } = await validateRequest();
|
||||
if (!user) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
if (username.includes('..')) {
|
||||
return new Response("nuh uh", { status: 403 });
|
||||
}
|
||||
|
||||
const filePath = `/dev/shm/hctv-thumb/${username}.webp`;
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const fileContent = await fsP.readFile(filePath);
|
||||
return new Response(fileContent, {
|
||||
headers: {
|
||||
'Content-Type': 'image/webp',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createRouteHandler } from "uploadthing/next";
|
||||
|
||||
import { ourFileRouter } from "@/lib/services/uploadthing/fileRouter";
|
||||
|
||||
// Export routes for Next App Router
|
||||
export const { GET, POST } = createRouteHandler({
|
||||
router: ourFileRouter,
|
||||
});
|
||||
52
apps/web/src/app/(ui)/(protected)/create/page.tsx
Normal file
52
apps/web/src/app/(ui)/(protected)/create/page.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
'use client'
|
||||
|
||||
import { UniversalForm } from "@/components/app/UniversalForm/UniversalForm";
|
||||
import { createChannel } from "@/lib/form/actions";
|
||||
import { Hash } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
function CreateChannelPage() {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="flex h-full w-full flex-col items-center justify-center px-4 py-12">
|
||||
<div className="mb-8 text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary shadow-lg">
|
||||
<Hash className="h-8 w-8 text-primary-foreground" />
|
||||
</div>
|
||||
<h1 className="mb-2 text-3xl font-bold text-foreground">
|
||||
Create New Channel
|
||||
</h1>
|
||||
<p className="text-muted-foreground max-w-xl">
|
||||
IRL hackathons, game streams, and more are allowed! Create another channel apart from your personal one to diversify your content.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="w-full max-w-md bg-card rounded-xl shadow-xl p-8 border border-border">
|
||||
<UniversalForm
|
||||
fields={[
|
||||
{ label: "Channel Name", name: "name", type: "text", placeholder: "Enter channel name" },
|
||||
]}
|
||||
schemaName="createChannel"
|
||||
action={createChannel}
|
||||
onActionComplete={(r) => {
|
||||
// @ts-expect-error
|
||||
const channelName = r?.channel;
|
||||
if (channelName) {
|
||||
router.push(`/${channelName}`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-sm text-muted-foreground text-center max-w-md">
|
||||
Your channel will be ready to go live immediately after creation. You can always customize settings later.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreateChannelPage;
|
||||
@@ -0,0 +1,700 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
Settings,
|
||||
Users,
|
||||
Key,
|
||||
Bell,
|
||||
Trash2,
|
||||
Shield,
|
||||
UserPlus,
|
||||
UserMinus,
|
||||
Copy,
|
||||
Check,
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
import { UniversalForm } from '@/components/app/UniversalForm/UniversalForm';
|
||||
import {
|
||||
updateChannelSettings,
|
||||
addChannelManager,
|
||||
removeChannelManager,
|
||||
deleteChannel,
|
||||
toggleGlobalChannelNotifs,
|
||||
editStreamInfo,
|
||||
} from '@/lib/form/actions';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { toast } from 'sonner';
|
||||
import type { Channel, User, StreamInfo, StreamKey, Follow } from '@hctv/db';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { UserCombobox } from '@/components/app/UserCombobox/UserCombobox';
|
||||
import { parseAsString, useQueryState } from 'nuqs';
|
||||
import { Write } from '@/components/ui/channel-desc-fancy-area/write';
|
||||
import { Preview } from '@/components/ui/channel-desc-fancy-area/preview';
|
||||
import { UploadButton } from '@/lib/uploadthing';
|
||||
import { useOwnedChannels } from '@/lib/hooks/useUserList';
|
||||
import { ChannelSelect } from '@/components/app/ChannelSelect/ChannelSelect';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface ChannelSettingsClientProps {
|
||||
channel: Channel & {
|
||||
owner: User;
|
||||
ownerPersonalChannel: Channel | null;
|
||||
managers: User[];
|
||||
managerPersonalChannels: (Channel | null)[];
|
||||
streamInfo: StreamInfo[];
|
||||
streamKey: StreamKey | null;
|
||||
followers: (Follow & { user: { id: string; slack_id: string } })[];
|
||||
followerPersonalChannels: (Channel | null)[];
|
||||
};
|
||||
isOwner: boolean;
|
||||
currentUser: User;
|
||||
isPersonal: boolean;
|
||||
}
|
||||
|
||||
export default function ChannelSettingsClient({
|
||||
channel,
|
||||
isOwner,
|
||||
currentUser,
|
||||
isPersonal,
|
||||
}: ChannelSettingsClientProps) {
|
||||
const [streamKey, setStreamKey] = useState(channel.streamKey?.key || '');
|
||||
const [keyVisible, setKeyVisible] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [selTab, setSelTab] = useQueryState('tab', parseAsString.withDefault('general'));
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const channelList = useOwnedChannels();
|
||||
const router = useRouter();
|
||||
|
||||
const handleStreamInfoActionComplete = useCallback((result: any) => {
|
||||
if (result?.success) {
|
||||
toast.success('Stream information updated');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleChannelSettingsActionComplete = useCallback((result: any) => {
|
||||
if (result?.success) {
|
||||
toast.success('Channel settings updated successfully');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const copyStreamKey = async () => {
|
||||
if (streamKey) {
|
||||
await navigator.clipboard.writeText(streamKey);
|
||||
setCopied(true);
|
||||
toast.success('Stream key copied to clipboard');
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const regenerateStreamKey = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/rtmp/streamKey', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ channel: channel.name }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setStreamKey(data.key);
|
||||
toast.success('Stream key regenerated successfully');
|
||||
} else {
|
||||
toast.error('Failed to regenerate stream key');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to regenerate stream key');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container max-w-4xl mx-auto py-6 px-4">
|
||||
<div className="mb-6 flex">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<Avatar className="h-16 w-16">
|
||||
<AvatarImage src={channel.pfpUrl} alt={channel.name} />
|
||||
<AvatarFallback>{channel.name[0]?.toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">{channel.name}</h1>
|
||||
<p className="text-mantle-foreground">Channel Settings</p>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Badge variant="secondary">
|
||||
{channel.followers.length} follower{channel.followers.length !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
{isOwner && <Badge variant="outline">Owner</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex-1' />
|
||||
<div>
|
||||
<ChannelSelect
|
||||
channelList={channelList.channels.map(c => c.channel)}
|
||||
value={channel.name}
|
||||
onSelect={(value) => {
|
||||
if (value === 'create') {
|
||||
router.push(`/create`);
|
||||
} else {
|
||||
router.push(`/settings/channel/${value}?tab=${selTab}`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs className="w-full" value={selTab} onValueChange={setSelTab}>
|
||||
<TabsList className={`grid w-full ${isPersonal ? 'grid-cols-4' : 'grid-cols-5'}`}>
|
||||
<TabsTrigger value="general" className="flex items-center gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
General
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="stream" className="flex items-center gap-2">
|
||||
<Key className="h-4 w-4" />
|
||||
Streaming
|
||||
</TabsTrigger>
|
||||
{!isPersonal && (
|
||||
<TabsTrigger value="managers" className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4" />
|
||||
Managers
|
||||
</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger value="notifications" className="flex items-center gap-2">
|
||||
<Bell className="h-4 w-4" />
|
||||
Notifications
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="utilities" className="flex items-center gap-2">
|
||||
<Wrench className='size-4' />
|
||||
Utilities
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="general">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>General Settings</CardTitle>
|
||||
<CardDescription>
|
||||
Manage your channel's basic information and settings.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<UniversalForm
|
||||
fields={[
|
||||
{ name: 'channelId', type: 'hidden', value: channel.id, label: 'Channel ID' },
|
||||
{
|
||||
name: 'pfpUrl',
|
||||
label: 'Profile Picture',
|
||||
type: 'url',
|
||||
value: channel.pfpUrl,
|
||||
component: ({ field }) => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<input type="hidden" {...field} />
|
||||
|
||||
{field.value && (
|
||||
<div className="flex items-center space-x-4">
|
||||
<Avatar className="h-16 w-16">
|
||||
<AvatarImage src={field.value} alt="Current profile picture" />
|
||||
<AvatarFallback>{channel.name[0]?.toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">Current profile picture</p>
|
||||
<p className="text-xs text-muted-foreground">Click "Upload new image" to replace</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
field.onChange('');
|
||||
setUploadError(null);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<UploadButton
|
||||
endpoint="pfpUpload"
|
||||
className="mt-2 ut-button:bg-mantle ut-button:text-mantle-foreground ut-allowed-content:text-muted-foreground/70"
|
||||
content={{
|
||||
button: field.value ? "Upload new image" : "Upload profile picture",
|
||||
allowedContent: "Image (1MB max)"
|
||||
}}
|
||||
onUploadBegin={() => {
|
||||
setIsUploading(true);
|
||||
setUploadError(null);
|
||||
}}
|
||||
onClientUploadComplete={(res) => {
|
||||
setIsUploading(false);
|
||||
if (res && res[0]) {
|
||||
field.onChange(res[0].ufsUrl);
|
||||
toast.success('Profile picture uploaded successfully!');
|
||||
}
|
||||
}}
|
||||
onUploadError={(error) => {
|
||||
setIsUploading(false);
|
||||
setUploadError(error.message);
|
||||
toast.error(`Upload failed: ${error.message}`);
|
||||
}}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
|
||||
{isUploading && (
|
||||
<p className="mt-2 text-sm text-primary">
|
||||
Uploading...
|
||||
</p>
|
||||
)}
|
||||
|
||||
{uploadError && (
|
||||
<p className="mt-2 text-sm text-red-600">
|
||||
{uploadError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!field.value && !isUploading && !uploadError && (
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Upload a profile picture for your channel.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
label: 'Channel Description',
|
||||
value: channel.description,
|
||||
component: ({ field }) => (
|
||||
<div>
|
||||
<input type="hidden" {...field} />
|
||||
<Tabs defaultValue="write" className="w-full">
|
||||
<TabsList>
|
||||
<TabsTrigger value="write">Write</TabsTrigger>
|
||||
<TabsTrigger value="preview">Preview</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="write">
|
||||
<Write
|
||||
textValue={field.value || ''}
|
||||
setTextValue={(value) => {
|
||||
field.onChange(value);
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="preview">
|
||||
<Preview textValue={field.value || ''} className="h-[159.5px]" />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
schemaName="updateChannelSettings"
|
||||
action={updateChannelSettings}
|
||||
submitText="Save Changes"
|
||||
onActionComplete={handleChannelSettingsActionComplete}
|
||||
/>
|
||||
|
||||
{false && isOwner && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-destructive">Danger Zone</h3>
|
||||
<Card className="border-destructive">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">Delete Channel</CardTitle>
|
||||
<CardDescription>
|
||||
Permanently delete this channel. This action cannot be undone.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm(
|
||||
'Are you sure you want to delete this channel? This action cannot be undone.'
|
||||
)
|
||||
) {
|
||||
deleteChannel(channel.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete Channel
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="stream">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Streaming Settings</CardTitle>
|
||||
<CardDescription>Manage your stream key and streaming configuration.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">Stream Key</h3>
|
||||
<p className="text-sm text-mantle-foreground mb-4">
|
||||
Use this key to start streaming to your channel. Keep it secure!
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
Need help getting started? Check out our{' '}
|
||||
<Link
|
||||
href="https://gist.github.com/SrIzan10/ebd89ced6b21b016d4d389e6711a94e9"
|
||||
className="text-primary hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
streaming guide
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type={keyVisible ? 'text' : 'password'}
|
||||
value={streamKey}
|
||||
readOnly
|
||||
className="w-full px-3 py-2 border rounded-md bg-mantle font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setKeyVisible(!keyVisible)}>
|
||||
{keyVisible ? 'Hide' : 'Show'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={copyStreamKey}
|
||||
disabled={!streamKey}
|
||||
>
|
||||
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={regenerateStreamKey} variant="outline">
|
||||
<Key className="h-4 w-4 mr-2" />
|
||||
Regenerate Stream Key
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Stream Information</h3>
|
||||
{channel.streamInfo.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{channel.streamInfo.map((stream, index) => (
|
||||
<UniversalForm
|
||||
key={stream.id}
|
||||
fields={[
|
||||
{
|
||||
name: 'username',
|
||||
type: 'hidden',
|
||||
value: stream.username,
|
||||
label: 'Username',
|
||||
},
|
||||
{
|
||||
name: 'title',
|
||||
label: 'Stream Title',
|
||||
type: 'text',
|
||||
value: stream.title,
|
||||
},
|
||||
{
|
||||
name: 'category',
|
||||
label: 'Category',
|
||||
type: 'text',
|
||||
value: stream.category,
|
||||
},
|
||||
]}
|
||||
schemaName="streamInfoEdit"
|
||||
action={editStreamInfo}
|
||||
submitText="Update Stream Info"
|
||||
onActionComplete={handleStreamInfoActionComplete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-mantle-foreground">No stream information available.</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{!isPersonal && (
|
||||
<TabsContent value="managers">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Channel Managers</CardTitle>
|
||||
<CardDescription>
|
||||
Manage who can help moderate and stream to this channel.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Current Managers</h3>
|
||||
{isOwner && (
|
||||
<AddManagerDialog
|
||||
channelId={channel.id}
|
||||
existingManagers={[...channel.managers.map((m) => m.id), channel.owner.id]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Owner */}
|
||||
<div className="flex items-center justify-between p-3 border rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarImage src={channel.owner.pfpUrl} />
|
||||
<AvatarFallback>
|
||||
{channel.owner.slack_id[0]?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="font-medium">{channel.ownerPersonalChannel?.name}</p>
|
||||
<p className="text-sm text-mantle-foreground">Channel Owner</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="default">
|
||||
<Shield className="h-3 w-3 mr-1" />
|
||||
Owner
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Managers */}
|
||||
{channel.managers.map((manager) => {
|
||||
const personalChannel = channel.managerPersonalChannels.find(
|
||||
(c) => c?.ownerId === manager.id
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={manager.id}
|
||||
className="flex items-center justify-between p-3 border rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarImage src={manager.pfpUrl} />
|
||||
<AvatarFallback>{personalChannel?.name}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="font-medium">{personalChannel?.name}</p>
|
||||
<p className="text-sm text-mantle-foreground">Manager</p>
|
||||
</div>
|
||||
</div>
|
||||
{isOwner && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (confirm('Remove this manager?')) {
|
||||
removeChannelManager(channel.id, manager.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<UserMinus className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{channel.managers.length === 0 && (
|
||||
<p className="text-mantle-foreground text-center py-8">
|
||||
No managers added yet.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
<TabsContent value="notifications">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Notification Settings</CardTitle>
|
||||
<CardDescription>
|
||||
Configure when and how followers are notified about your streams.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium">Stream Notifications</h3>
|
||||
<p className="text-sm text-mantle-foreground">
|
||||
Send notifications to followers when you go live
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={channel.streamInfo[0]?.enableNotifications ?? true}
|
||||
onCheckedChange={(checked) => {
|
||||
toast.promise(toggleGlobalChannelNotifs(channel.id), {
|
||||
loading: 'Updating notifications...',
|
||||
success(data) {
|
||||
return `${
|
||||
data.toggle ? 'Enabled' : 'Disabled'
|
||||
} global notifications for this channel.`;
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h3 className="font-medium mb-3">Followers ({channel.followers.length})</h3>
|
||||
<div className="space-y-2 max-h-60 overflow-y-auto">
|
||||
{channel.followers.map((follower) => {
|
||||
const personalChannel = channel.followerPersonalChannels.find(
|
||||
(c) => c?.ownerId === follower.user.id
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={follower.id}
|
||||
className="flex items-center justify-between p-2 border rounded"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarFallback>{personalChannel?.name}</AvatarFallback>
|
||||
<AvatarImage
|
||||
src={personalChannel?.pfpUrl}
|
||||
alt={personalChannel?.name}
|
||||
/>
|
||||
</Avatar>
|
||||
<span className="text-sm">{personalChannel?.name}</span>
|
||||
</div>
|
||||
<Badge variant={follower.notifyStream ? 'default' : 'secondary'}>
|
||||
{follower.notifyStream ? 'Notifications On' : 'Notifications Off'}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{channel.followers.length === 0 && (
|
||||
<p className="text-mantle-foreground text-center py-4">No followers yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="utilities">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Utilities</CardTitle>
|
||||
<CardDescription>OBS overlays, APIs... everything in one neat place!</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">Chat overlay</h3>
|
||||
<p className="text-sm text-mantle-foreground mb-4">
|
||||
Add a 300x600 browser source with this and enjoy!
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type={keyVisible ? 'text' : 'password'}
|
||||
value={`https://hctv.srizan.dev/chat/${channel.name}?grant=${channel.obsChatGrantToken}`}
|
||||
readOnly
|
||||
className="w-full px-3 py-2 border rounded-md bg-mantle font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setKeyVisible(!keyVisible)}>
|
||||
{keyVisible ? 'Hide' : 'Show'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddManagerDialog({
|
||||
channelId,
|
||||
existingManagers,
|
||||
}: {
|
||||
channelId: string;
|
||||
existingManagers: string[];
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [channel, setChannel] = useState('');
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">
|
||||
<UserPlus className="h-4 w-4 mr-2" />
|
||||
Add Manager
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add channel manager</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a channel manager to help manage your channel during big events or projects.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<UserCombobox
|
||||
onValueChange={(value) => {
|
||||
setChannel(value);
|
||||
}}
|
||||
filter={existingManagers}
|
||||
value={channel}
|
||||
modal
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
toast.promise(addChannelManager(channelId, channel), {
|
||||
loading: 'Adding manager...',
|
||||
success: 'Manager added successfully',
|
||||
error: 'Failed to add manager',
|
||||
});
|
||||
setOpen(false);
|
||||
setChannel('');
|
||||
}}
|
||||
>
|
||||
Add Manager
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { validateRequest } from '@/lib/auth/validate';
|
||||
import { prisma } from '@hctv/db';
|
||||
import { redirect } from 'next/navigation';
|
||||
import ChannelSettingsClient from './page.client';
|
||||
import { resolvePersonalChannel } from '@/lib/auth/resolve';
|
||||
|
||||
export default async function ChannelSettingsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ channelName: string }>;
|
||||
}) {
|
||||
const { channelName } = await params;
|
||||
const { user } = await validateRequest();
|
||||
|
||||
if (!user) {
|
||||
redirect('/auth/slack');
|
||||
}
|
||||
|
||||
const channel = await prisma.channel.findUnique({
|
||||
where: { name: channelName },
|
||||
include: {
|
||||
owner: true,
|
||||
managers: true,
|
||||
streamInfo: true,
|
||||
streamKey: true,
|
||||
followers: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
slack_id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
personalFor: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!channel) {
|
||||
redirect('/');
|
||||
}
|
||||
|
||||
const isOwner = channel.ownerId === user.id;
|
||||
const isManager = channel.managers.some((manager) => manager.id === user.id);
|
||||
|
||||
if (!isOwner && !isManager) {
|
||||
redirect('/');
|
||||
}
|
||||
|
||||
const ownerPersonalChannel = await resolvePersonalChannel(channel.ownerId);
|
||||
const managerPersonalChannels = await Promise.all(
|
||||
channel.managers.map((manager) => resolvePersonalChannel(manager.id))
|
||||
);
|
||||
const followerPersonalChannels = await Promise.all(
|
||||
channel.followers.map((follower) => resolvePersonalChannel(follower.user.id))
|
||||
);
|
||||
|
||||
return (
|
||||
<ChannelSettingsClient
|
||||
channel={{
|
||||
...channel,
|
||||
ownerPersonalChannel,
|
||||
managerPersonalChannels,
|
||||
followerPersonalChannels,
|
||||
}}
|
||||
isOwner={isOwner}
|
||||
currentUser={user}
|
||||
isPersonal={channel.personalFor !== null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
18
apps/web/src/app/(ui)/(protected)/settings/channel/page.tsx
Normal file
18
apps/web/src/app/(ui)/(protected)/settings/channel/page.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { resolvePersonalChannel } from '@/lib/auth/resolve';
|
||||
import { loadSearchParams } from '@/lib/nuqs/channelSettings';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default async function ChannelSettingsRedirector({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
}) {
|
||||
const { tab } = await loadSearchParams(searchParams);
|
||||
const personalChannel = await resolvePersonalChannel();
|
||||
if (personalChannel) {
|
||||
return redirect(`/settings/channel/${personalChannel.name}?tab=${tab}`);
|
||||
}
|
||||
|
||||
// lil easter egg i doubt anyone will see
|
||||
return <p>erm</p>;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { cookies as nextCookies } from 'next/headers';
|
||||
import { decodeIdToken, OAuth2RequestError } from 'arctic';
|
||||
import { generateIdFromEntropySize } from 'lucia';
|
||||
import { prisma } from '@hctv/db';
|
||||
import { getRedisConnection } from '@hctv/db';
|
||||
|
||||
export async function GET(request: Request): Promise<Response> {
|
||||
const cookies = await nextCookies();
|
||||
@@ -36,6 +37,7 @@ export async function GET(request: Request): Promise<Response> {
|
||||
if (existingUser) {
|
||||
const session = await lucia.createSession(existingUser.id, {});
|
||||
const sessionCookie = lucia.createSessionCookie(session.id);
|
||||
await getRedisConnection().set(`sessions:${session.id}`, '');
|
||||
cookies.set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
@@ -51,13 +53,14 @@ export async function GET(request: Request): Promise<Response> {
|
||||
data: {
|
||||
id: userId,
|
||||
slack_id: slackUser.sub,
|
||||
pfpUrl: slackUser.picture,
|
||||
pfpUrl: `https://cachet.dunkirk.sh/users/${slackUser.sub}/r`,
|
||||
hasOnboarded: false,
|
||||
},
|
||||
});
|
||||
|
||||
const session = await lucia.createSession(userId, {});
|
||||
const sessionCookie = lucia.createSessionCookie(session.id);
|
||||
await getRedisConnection().set(`sessions:${session.id}`, '');
|
||||
cookies.set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
@@ -4,11 +4,9 @@ import { UniversalForm } from '@/components/app/UniversalForm/UniversalForm';
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
|
||||
import { onboard } from '@/lib/form/actions';
|
||||
import { useSession } from '@/lib/providers/SessionProvider';
|
||||
import { redirect, useRouter } from 'next/navigation';
|
||||
|
||||
export default function OnboardingClient() {
|
||||
const { user } = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Card className="mx-auto max-w-sm border-0 shadow-none">
|
||||
@@ -27,7 +27,7 @@ export default async function Home() {
|
||||
if (!streams.length) {
|
||||
return (
|
||||
<div className="flex justify-center items-center text-center flex-col pt-4 gap-2">
|
||||
<h2>No streams found!!</h2>
|
||||
<h2>No streams found!</h2>
|
||||
<p>...maybe start one?</p>
|
||||
<ConfusedDino className='w-40 h-40' />
|
||||
</div>
|
||||
@@ -43,7 +43,7 @@ export default async function Home() {
|
||||
<CardContent className="p-0">
|
||||
<div className="relative">
|
||||
<Image
|
||||
src={stream.channel.pfpUrl || '/placeholder.svg'}
|
||||
src={`/api/stream/thumb/${stream.channel.name}`}
|
||||
width={512}
|
||||
height={512}
|
||||
alt={stream.title}
|
||||
66
apps/web/src/app/(ui)/layout.tsx
Normal file
66
apps/web/src/app/(ui)/layout.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
import '../globals.css';
|
||||
import Navbar from '@/components/app/NavBar/NavBar';
|
||||
import { SessionProvider } from '@/lib/providers/SessionProvider';
|
||||
import { validateRequest } from '@/lib/auth/validate';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import { ThemeProvider } from '@/lib/providers/ThemeProvider';
|
||||
import { SidebarProvider } from '@/components/ui/sidebar';
|
||||
import Sidebar from '@/components/app/Sidebar/Sidebar';
|
||||
import { cn } from '@/lib/utils';
|
||||
import EditLivestream from '@/components/app/EditLivestream/EditLivestream';
|
||||
import { StreamInfoProvider } from '@/lib/providers/StreamInfoProvider';
|
||||
import { NextSSRPlugin } from "@uploadthing/react/next-ssr-plugin";
|
||||
import { extractRouterConfig } from 'uploadthing/server';
|
||||
import { ourFileRouter } from '@/lib/services/uploadthing/fileRouter';
|
||||
import { NuqsAdapter } from 'nuqs/adapters/next/app'
|
||||
import SonnerNewVersion from '@/components/app/SonnerNewVersion/SonnerNewVersion';
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'hackclub.tv',
|
||||
description: "Hack Club's livestreaming platform",
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const sessionData = await validateRequest();
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={cn('flex flex-col h-screen', inter.className)}>
|
||||
<SessionProvider value={sessionData}>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<SonnerNewVersion />
|
||||
<NextSSRPlugin
|
||||
routerConfig={extractRouterConfig(ourFileRouter)}
|
||||
/>
|
||||
<NuqsAdapter>
|
||||
<SidebarProvider>
|
||||
<StreamInfoProvider>
|
||||
{/* this promise is ugly but i'm lazy to fix the type errors */}
|
||||
<Navbar editLivestream={Promise.resolve(<EditLivestream />)} />
|
||||
<div className="flex flex-1 pt-16">
|
||||
{/* pt-16 for navbar height */}
|
||||
<Sidebar className="pt-16" />
|
||||
<main className="flex-1 overflow-auto">{children}</main>
|
||||
</div>
|
||||
<Toaster />
|
||||
</StreamInfoProvider>
|
||||
</SidebarProvider>
|
||||
</NuqsAdapter>
|
||||
</ThemeProvider>
|
||||
</SessionProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -119,6 +119,8 @@
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
.scrollbar-hide::-webkit-scrollbar { display: none; }
|
||||
.scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }
|
||||
}
|
||||
h1 {
|
||||
@apply scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl;
|
||||
|
||||
@@ -1,55 +1,14 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
import { NuqsAdapter } from 'nuqs/adapters/next/app';
|
||||
import './globals.css';
|
||||
import Navbar from '@/components/app/NavBar/NavBar';
|
||||
import { SessionProvider } from '@/lib/providers/SessionProvider';
|
||||
import { validateRequest } from '@/lib/auth/validate';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import { ThemeProvider } from '@/lib/providers/ThemeProvider';
|
||||
import { SidebarProvider } from '@/components/ui/sidebar';
|
||||
import Sidebar from '@/components/app/Sidebar/Sidebar';
|
||||
import { cn } from '@/lib/utils';
|
||||
import EditLivestream from '@/components/app/EditLivestream/EditLivestream';
|
||||
import { StreamInfoProvider } from '@/lib/providers/StreamInfoProvider';
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'hackclub.tv',
|
||||
description: "Hack Club's livestreaming platform",
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const sessionData = await validateRequest();
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={cn('flex flex-col h-screen', inter.className)}>
|
||||
<SessionProvider value={sessionData}>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<SidebarProvider>
|
||||
<StreamInfoProvider>
|
||||
{/* this promise is ugly but i'm lazy to fix the type errors */}
|
||||
<Navbar editLivestream={Promise.resolve(<EditLivestream />)} />
|
||||
<div className="flex flex-1 pt-16">
|
||||
{/* pt-16 for navbar height */}
|
||||
<Sidebar className="pt-16" />
|
||||
<main className="flex-1 overflow-auto">{children}</main>
|
||||
</div>
|
||||
<Toaster />
|
||||
</StreamInfoProvider>
|
||||
</SidebarProvider>
|
||||
</ThemeProvider>
|
||||
</SessionProvider>
|
||||
</body>
|
||||
<NuqsAdapter>
|
||||
<body>
|
||||
<main>{children}</main>
|
||||
</body>
|
||||
</NuqsAdapter>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
46
apps/web/src/components/app/ChannelSelect/ChannelSelect.tsx
Normal file
46
apps/web/src/components/app/ChannelSelect/ChannelSelect.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
'use client'
|
||||
|
||||
import type { Channel } from "@hctv/db";
|
||||
import * as React from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
|
||||
export function ChannelSelect(props: Props) {
|
||||
const { channelList } = props;
|
||||
return (
|
||||
<Select onValueChange={props.onSelect} value={props.value}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Channel" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{channelList.map((channel) => (
|
||||
<SelectItem key={channel.id} value={channel.name}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={channel.pfpUrl} alt={channel.name} />
|
||||
<AvatarFallback>{channel.name[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="font-medium">{channel.name}</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem key="create" value="create" icon={<Plus className="h-4 w-4" />} className='h-11'>
|
||||
Create Channel
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
channelList: Channel[];
|
||||
value?: string;
|
||||
onSelect: (value: string) => void;
|
||||
}
|
||||
@@ -2,22 +2,32 @@
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Send } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { Message } from './message';
|
||||
import { useMap } from '@uidotdev/usehooks';
|
||||
import { EmojiSearch } from './EmojiSearch';
|
||||
import { useQueryState } from 'nuqs';
|
||||
|
||||
export default function ChatPanel() {
|
||||
export default function ChatPanel(props: Props) {
|
||||
const { username } = useParams();
|
||||
const [grant, setGrant] = useQueryState('grant');
|
||||
const [message, setMessage] = useState('');
|
||||
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const socketRef = useRef<WebSocket | null>(null);
|
||||
const emojiMap = useMap() as Map<string, string>;
|
||||
const [emojisToReq, setEmojisToReq] = useState<string[]>([]);
|
||||
const [cursorPosition, setCursorPosition] = useState(0);
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('Initializing WebSocket connection for user:', username);
|
||||
const socket = new WebSocket(
|
||||
`ws${window.location.protocol === 'https:' ? 's' : ''}://${
|
||||
window.location.host
|
||||
}/api/stream/chat/ws/${username}`
|
||||
}/api/stream/chat/ws/${username}?grant=${grant}`
|
||||
);
|
||||
socketRef.current = socket;
|
||||
|
||||
@@ -28,10 +38,30 @@ export default function ChatPanel() {
|
||||
socket.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === 'ping' || data.type === 'pong' || !data.user) return;
|
||||
setChatMessages((prev) => [...prev, data]);
|
||||
console.log('Received websocket message:', data);
|
||||
|
||||
if (data.type === 'history') {
|
||||
const messages = data.messages as ChatMessage[];
|
||||
setChatMessages((prev) => [
|
||||
...prev,
|
||||
...messages,
|
||||
{ message: 'Welcome to the chat!', type: 'systemMsg' },
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === 'message') {
|
||||
console.log('Adding new chat message:', data);
|
||||
setChatMessages((prev) => [...prev, data]);
|
||||
}
|
||||
|
||||
if (!data.type && data.message && data.user) {
|
||||
console.log('Adding legacy chat message format:', data);
|
||||
setChatMessages((prev) => [...prev, { ...data, type: 'message' }]);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Received message confirmation:', event.data);
|
||||
console.error('Error processing message:', e);
|
||||
console.log('Raw message data:', event.data);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -42,7 +72,7 @@ export default function ChatPanel() {
|
||||
return () => {
|
||||
socket.close();
|
||||
};
|
||||
}, []);
|
||||
}, [username]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
@@ -63,7 +93,7 @@ export default function ChatPanel() {
|
||||
const socket = new WebSocket(
|
||||
`ws${window.location.protocol === 'https:' ? 's' : ''}://${
|
||||
window.location.host
|
||||
}/api/stream/chat/ws/${username}`
|
||||
}/api/stream/chat/ws/${username}?grant=${grant}`
|
||||
);
|
||||
socket.onopen = () => {
|
||||
socket.send(JSON.stringify({ type: 'message', message }));
|
||||
@@ -81,54 +111,210 @@ export default function ChatPanel() {
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// emoji message collector
|
||||
useEffect(() => {
|
||||
if (chatMessages.length === 0) return;
|
||||
|
||||
const emojiPattern = /:[\w\-+]+:/g;
|
||||
const newEmojis = chatMessages
|
||||
.filter((msg) => msg.type === 'message')
|
||||
.flatMap((msg) => {
|
||||
if (!msg.message) return [];
|
||||
const message = String(msg.message);
|
||||
const matches = [...message.matchAll(emojiPattern)].map((m) => m[0]);
|
||||
return matches;
|
||||
})
|
||||
.filter((emoji) => {
|
||||
const emojiName = emoji.replaceAll(':', '');
|
||||
return !emojiMap.has(emojiName) && emojiName.length > 0;
|
||||
});
|
||||
|
||||
if (newEmojis.length > 0) {
|
||||
console.log(`Found ${newEmojis.length} new emojis to request: ${newEmojis.join(', ')}`);
|
||||
setEmojisToReq((prev) => [...new Set([...prev, ...newEmojis])]);
|
||||
}
|
||||
}, [chatMessages, emojiMap]);
|
||||
|
||||
// emoji requester
|
||||
useEffect(() => {
|
||||
if (emojisToReq.length === 0) return;
|
||||
|
||||
console.log('Requesting emojis:', emojisToReq);
|
||||
|
||||
// Ensure websocket is connected
|
||||
if (!socketRef.current || socketRef.current.readyState !== WebSocket.OPEN) {
|
||||
const socket = new WebSocket(
|
||||
`ws${window.location.protocol === 'https:' ? 's' : ''}://${
|
||||
window.location.host
|
||||
}/api/stream/chat/ws/${username}?grant=${grant}`
|
||||
);
|
||||
|
||||
socket.onopen = () => {
|
||||
socketRef.current = socket;
|
||||
sendEmojiRequest();
|
||||
};
|
||||
|
||||
return () => {
|
||||
socket.close();
|
||||
};
|
||||
} else {
|
||||
sendEmojiRequest();
|
||||
}
|
||||
|
||||
function sendEmojiRequest() {
|
||||
const handleEmojiResponse = (event: MessageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'emojiMsgResponse') {
|
||||
const emojis = data.emojis as Record<string, string>;
|
||||
|
||||
let validEmojiCount = 0;
|
||||
Object.entries(emojis).forEach(([name, url]) => {
|
||||
if (url) {
|
||||
emojiMap.set(name, url);
|
||||
validEmojiCount++;
|
||||
} else {
|
||||
console.log(`No URL found for emoji: ${name}`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`added ${validEmojiCount} valid emojis to map.`);
|
||||
|
||||
if (validEmojiCount > 0) {
|
||||
const sampleName = Object.entries(emojis).find(([_, url]) => url)?.[0];
|
||||
if (sampleName) {
|
||||
}
|
||||
} else {
|
||||
console.warn('No valid emoji URLs received');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('error processing emoji response:', e);
|
||||
}
|
||||
};
|
||||
|
||||
socketRef.current?.addEventListener('message', handleEmojiResponse);
|
||||
|
||||
const emojiRequest = {
|
||||
type: 'emojiMsg',
|
||||
emojis: emojisToReq.map((e) => e.replaceAll(':', '')),
|
||||
};
|
||||
|
||||
console.log('sending emoji request:', emojiRequest);
|
||||
socketRef.current?.send(JSON.stringify(emojiRequest));
|
||||
|
||||
return () => {
|
||||
socketRef.current?.removeEventListener('message', handleEmojiResponse);
|
||||
setEmojisToReq([]);
|
||||
};
|
||||
}
|
||||
}, [emojisToReq, emojiMap, username]);
|
||||
|
||||
const handleEmojiSelect = (emojiName: string) => {
|
||||
if (!textareaRef.current) return;
|
||||
|
||||
const textarea = textareaRef.current;
|
||||
const beforeCursor = message.substring(0, cursorPosition);
|
||||
const afterCursor = message.substring(cursorPosition);
|
||||
|
||||
const match = beforeCursor.match(/:[\w\-+]*$/);
|
||||
if (!match) return;
|
||||
|
||||
const startPos = beforeCursor.lastIndexOf(match[0]);
|
||||
const newBeforeCursor = beforeCursor.substring(0, startPos);
|
||||
|
||||
const newMessage = `${newBeforeCursor}:${emojiName}: ${afterCursor}`;
|
||||
setMessage(newMessage);
|
||||
|
||||
// 3 for colons and space
|
||||
const newCursorPos = newBeforeCursor.length + emojiName.length + 3;
|
||||
|
||||
setTimeout(() => {
|
||||
textarea.focus();
|
||||
textarea.selectionStart = newCursorPos;
|
||||
textarea.selectionEnd = newCursorPos;
|
||||
setCursorPosition(newCursorPos);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const isEmojiSearchOpen = () => {
|
||||
const beforeCursor = message.substring(0, cursorPosition);
|
||||
const match = beforeCursor.match(/:[\w\-+]*$/);
|
||||
return match !== null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="md:border flex flex-col w-full min-w-[350px] h-full bg-mantle">
|
||||
<div ref={scrollRef} className="flex-1 p-4 overflow-y-auto flex flex-col">
|
||||
<div className={`${props.isObsPanel ? 'w-full text-white' : 'md:border bg-mantle w-[350px] max-w-[350px]'} flex flex-col h-full`}>
|
||||
<div ref={scrollRef} className={`flex-1 p-4 ${props.isObsPanel ? 'scrollbar-hide' : ''} overflow-y-auto flex flex-col`}>
|
||||
<div className="space-y-4 flex-1">
|
||||
{chatMessages.map((msg, i) => (
|
||||
<div key={i} className="flex space-x-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="font-bold shrink-0">{msg.user.username}</div>
|
||||
</div>
|
||||
<div
|
||||
lang="en"
|
||||
className="max-w-[calc(100%-4rem)] break-all whitespace-pre-wrap hyphens-auto"
|
||||
>
|
||||
{msg.message}
|
||||
</div>
|
||||
</div>
|
||||
<Message
|
||||
key={i}
|
||||
user={msg.user}
|
||||
message={msg.message}
|
||||
type={msg.type}
|
||||
emojiMap={emojiMap}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 border-t">
|
||||
<div className="flex space-x-2">
|
||||
<Input
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
sendMessage();
|
||||
}
|
||||
}}
|
||||
placeholder="Type a message"
|
||||
className="flex-1 bg-transparent focus-visible:ring-offset-0"
|
||||
{!props.isObsPanel && (
|
||||
<div className="p-4 border-t relative">
|
||||
<div className="flex space-x-2">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={message}
|
||||
onChange={(e) => {
|
||||
setMessage(e.target.value);
|
||||
setCursorPosition(e.target.selectionStart || 0);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !isEmojiSearchOpen()) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
}}
|
||||
onKeyUp={(e) => {
|
||||
setCursorPosition(e.currentTarget.selectionStart || 0);
|
||||
}}
|
||||
onClick={(e) => {
|
||||
setCursorPosition(e.currentTarget.selectionStart || 0);
|
||||
}}
|
||||
placeholder="Type a message"
|
||||
className="flex-1 bg-transparent focus-visible:ring-offset-0 min-h-[40px] max-h-[120px] resize-none py-2"
|
||||
rows={1}
|
||||
/>
|
||||
<Button size="icon" className="text-black transition-colors" onClick={sendMessage}>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<EmojiSearch
|
||||
message={message}
|
||||
cursorPosition={cursorPosition}
|
||||
onSelect={handleEmojiSelect}
|
||||
socket={socketRef.current}
|
||||
emojiMap={emojiMap}
|
||||
textareaRef={textareaRef}
|
||||
/>
|
||||
<Button size="icon" className="text-black transition-colors" onClick={sendMessage}>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface User {
|
||||
export interface ChatMessage {
|
||||
user?: User;
|
||||
message: string;
|
||||
type: 'message' | 'systemMsg';
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
pfpUrl: string;
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
user: User;
|
||||
message: string;
|
||||
interface Props {
|
||||
isObsPanel?: boolean;
|
||||
}
|
||||
|
||||
178
apps/web/src/components/app/ChatPanel/EmojiSearch.tsx
Normal file
178
apps/web/src/components/app/ChatPanel/EmojiSearch.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
// source: ai
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
|
||||
interface EmojiSearchProps {
|
||||
message: string;
|
||||
cursorPosition: number;
|
||||
onSelect: (emoji: string) => void;
|
||||
socket: WebSocket | null;
|
||||
emojiMap: Map<string, string>;
|
||||
textareaRef: React.RefObject<HTMLTextAreaElement>;
|
||||
}
|
||||
|
||||
export function EmojiSearch({
|
||||
message,
|
||||
cursorPosition,
|
||||
onSelect,
|
||||
socket,
|
||||
emojiMap,
|
||||
textareaRef,
|
||||
}: EmojiSearchProps) {
|
||||
const [searchTerm, setSearchTerm] = useState<string | null>(null);
|
||||
const [searchResults, setSearchResults] = useState<string[]>([]);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const resultsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const beforeCursor = message.substring(0, cursorPosition);
|
||||
const match = beforeCursor.match(/:[\w\-+]*$/);
|
||||
|
||||
if (match) {
|
||||
const term = match[0].substring(1);
|
||||
setSearchTerm(term);
|
||||
|
||||
if (term.length > 0) {
|
||||
const localResults = Array.from(emojiMap.keys())
|
||||
.filter(name => name.toLowerCase().includes(term.toLowerCase()))
|
||||
.slice(0, 5);
|
||||
|
||||
if (localResults.length > 0) {
|
||||
setSearchResults(localResults);
|
||||
}
|
||||
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({
|
||||
type: 'emojiSearch',
|
||||
searchTerm: term
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
setSearchResults([]);
|
||||
}
|
||||
} else {
|
||||
setSearchTerm(null);
|
||||
setSearchResults([]);
|
||||
}
|
||||
}, [message, cursorPosition, socket, emojiMap]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
const handleEmojiSearchResponse = (event: MessageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'emojiSearchResponse') {
|
||||
const serverResults = data.results || [];
|
||||
|
||||
const localResults = Array.from(emojiMap.keys())
|
||||
.filter(name => searchTerm && name.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
.slice(0, 5);
|
||||
|
||||
const combinedResults = [...serverResults];
|
||||
|
||||
localResults.forEach(name => {
|
||||
if (!combinedResults.includes(name)) {
|
||||
combinedResults.push(name);
|
||||
}
|
||||
});
|
||||
|
||||
setSearchResults(combinedResults.slice(0, 10));
|
||||
setSelectedIndex(0);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('error processing emoji search response:', e);
|
||||
}
|
||||
};
|
||||
|
||||
socket.addEventListener('message', handleEmojiSearchResponse);
|
||||
return () => {
|
||||
socket.removeEventListener('message', handleEmojiSearchResponse);
|
||||
};
|
||||
}, [socket, searchTerm, emojiMap]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!textareaRef.current) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!searchTerm || searchResults.length === 0) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => (prev + 1) % searchResults.length);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => (prev - 1 + searchResults.length) % searchResults.length);
|
||||
break;
|
||||
case 'Enter':
|
||||
if (searchResults[selectedIndex]) {
|
||||
e.preventDefault();
|
||||
onSelect(searchResults[selectedIndex]);
|
||||
}
|
||||
break;
|
||||
case 'Tab':
|
||||
if (searchResults[selectedIndex]) {
|
||||
e.preventDefault();
|
||||
onSelect(searchResults[selectedIndex]);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
setSearchTerm(null);
|
||||
setSearchResults([]);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const textarea = textareaRef.current;
|
||||
textarea.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
textarea.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [searchTerm, searchResults, selectedIndex, onSelect, textareaRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (resultsRef.current) {
|
||||
const selectedElement = resultsRef.current.children[selectedIndex] as HTMLElement;
|
||||
if (selectedElement) {
|
||||
selectedElement.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
|
||||
if (!searchTerm || searchResults.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-16 left-4 bg-mantle border rounded-md shadow-lg max-h-60 overflow-y-auto z-10 min-w-[200px] max-w-[300px]">
|
||||
<div ref={resultsRef} className="py-1">
|
||||
{searchResults.map((emojiName, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
const emojiUrl = emojiMap.get(emojiName);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={emojiName}
|
||||
className={`px-3 py-1.5 flex items-center gap-2 cursor-pointer ${
|
||||
isSelected ? 'bg-primary/10' : 'hover:bg-primary/5'
|
||||
}`}
|
||||
onClick={() => onSelect(emojiName)}
|
||||
>
|
||||
{emojiUrl && (
|
||||
<Image src={emojiUrl} alt={emojiName} width={20} height={20} className="w-5 h-5" />
|
||||
)}
|
||||
<span className="flex-grow text-sm">{emojiName}</span>
|
||||
{isSelected && <Check className="h-4 w-4 text-blue-500" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
78
apps/web/src/components/app/ChatPanel/message.tsx
Normal file
78
apps/web/src/components/app/ChatPanel/message.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { User } from './ChatPanel';
|
||||
import React from 'react';
|
||||
import Image from 'next/image';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
|
||||
export function Message({ user, message, type, emojiMap }: MessageProps) {
|
||||
if (type === 'systemMsg') {
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<span className="text-xs text-muted-foreground">{message}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex">
|
||||
<div
|
||||
lang="en"
|
||||
className="max-w-full break-all whitespace-pre-wrap hyphens-auto"
|
||||
>
|
||||
<p>
|
||||
<span className="font-bold mr-2">{user?.username}</span>
|
||||
<EmojiRenderer text={message} emojiMap={emojiMap} />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmojiRenderer({ text, emojiMap }: EmojiRendererProps) {
|
||||
if (!text) return null;
|
||||
|
||||
const parts = text.split(/(:[\w\-+]+:)/g);
|
||||
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, index) => {
|
||||
if (part.match(/^:[\w\-+]+:$/)) {
|
||||
const emojiName = part.replaceAll(':', '');
|
||||
const emojiUrl = emojiMap.get(emojiName);
|
||||
|
||||
if (emojiUrl) {
|
||||
return (
|
||||
<Tooltip key={index} delayDuration={250}>
|
||||
<TooltipTrigger>
|
||||
<span key={index} className="inline-block align-middle" style={{ height: '1.2em' }}>
|
||||
<Image src={emojiUrl} alt={part} width={20} height={20} className="inline-block" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{part}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return <span key={index}>{part}</span>;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface MessageProps {
|
||||
user?: User;
|
||||
message: string;
|
||||
type: 'message' | 'systemMsg';
|
||||
emojiMap: Map<string, string>;
|
||||
}
|
||||
|
||||
interface EmojiRendererProps {
|
||||
text: string;
|
||||
emojiMap: Map<string, string>;
|
||||
}
|
||||
@@ -1,17 +1,16 @@
|
||||
import { validateRequest } from '@/lib/auth/validate';
|
||||
import { prisma } from '@hctv/db';
|
||||
import EditLivestreamDialog from './dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default async function EditLivestream() {
|
||||
const { user } = await validateRequest();
|
||||
if (!user?.hasOnboarded) {
|
||||
return null;
|
||||
}
|
||||
const ownedChannels = await prisma.channel.findMany({
|
||||
where: {
|
||||
OR: [{ ownerId: user.id }, { managers: { some: { id: user.id } } }],
|
||||
},
|
||||
});
|
||||
|
||||
return <EditLivestreamDialog ownedChannels={ownedChannels} />;
|
||||
return (
|
||||
<Link href={`/settings/channel?tab=stream`}>
|
||||
<Button variant="outline">Edit Livestream</Button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
'use client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { StreamInfo } from '@hctv/db';
|
||||
import { UniversalForm } from '../UniversalForm/UniversalForm';
|
||||
import { editStreamInfo } from '@/lib/form/actions';
|
||||
import RegenerateKey from '../RegenerateKey/RegenerateKey';
|
||||
import * as React from 'react';
|
||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { Channel } from '@hctv/db';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import useSWR, { Fetcher } from 'swr';
|
||||
import { fetcher } from '@/lib/services/swr';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function EditLivestreamDialog(props: Props) {
|
||||
const [selectedChannel, setSelectedChannel] = React.useState('');
|
||||
const [streamInfo, setStreamInfo] = React.useState<StreamInfo>();
|
||||
|
||||
const { data, error } = useSWR(
|
||||
selectedChannel ? '/api/stream/info?owned=true' : null,
|
||||
fetcher as Fetcher<StreamInfo[]>
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (data && selectedChannel) {
|
||||
const stream = data.find((stream) => stream.username === selectedChannel);
|
||||
setStreamInfo(stream);
|
||||
}
|
||||
}, [data, selectedChannel]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (props.ownedChannels.length > 0 && !selectedChannel) {
|
||||
setSelectedChannel(props.ownedChannels[0].name);
|
||||
}
|
||||
}, [props.ownedChannels, selectedChannel]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
onOpenChange={(op) => {
|
||||
if (op) {
|
||||
setSelectedChannel('');
|
||||
setStreamInfo(undefined);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">Edit Livestream</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit livestream</DialogTitle>
|
||||
<DialogDescription>Regenerate a key or edit your stream metadata</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Link
|
||||
href="https://gist.github.com/SrIzan10/ebd89ced6b21b016d4d389e6711a94e9"
|
||||
target="_blank"
|
||||
>
|
||||
HOW TO STREAM (github gist link)
|
||||
</Link>
|
||||
<ChannelSelect
|
||||
channelList={props.ownedChannels}
|
||||
onSelect={setSelectedChannel}
|
||||
value={selectedChannel}
|
||||
/>
|
||||
{streamInfo && data ? (
|
||||
<Form username={selectedChannel} streamInfo={streamInfo} />
|
||||
) : (
|
||||
<FormSkeleton />
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function Form(props: FormProps) {
|
||||
return (
|
||||
<UniversalForm
|
||||
fields={[
|
||||
{ name: 'username', label: 'Username', value: props.username, type: 'hidden' },
|
||||
{ name: 'title', label: 'Title', type: 'text', value: props.streamInfo?.title },
|
||||
{ name: 'category', label: 'Category', type: 'text', value: props.streamInfo?.category },
|
||||
]}
|
||||
schemaName="streamInfoEdit"
|
||||
action={editStreamInfo}
|
||||
submitButtonDivClassname="float-right"
|
||||
submitText="Save"
|
||||
otherSubmitButton={<RegenerateKey channel={props.username} />}
|
||||
key={props.streamInfo?.id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
function FormSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Title field */}
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-12" /> {/* Label */}
|
||||
<Skeleton className="h-10 w-full" /> {/* Input */}
|
||||
</div>
|
||||
|
||||
{/* Category field */}
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-16" /> {/* Label */}
|
||||
<Skeleton className="h-10 w-full" /> {/* Input */}
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="float-right flex gap-2 py-2">
|
||||
<Skeleton className="h-10 w-32" /> {/* RegenerateKey button */}
|
||||
<Skeleton className="h-10 w-24" /> {/* Save button */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChannelSelect(props: SelectProps) {
|
||||
const { channelList } = props;
|
||||
return (
|
||||
<Select onValueChange={props.onSelect} value={props.value}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Channel" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{channelList.map((channel) => (
|
||||
<SelectItem key={channel.id} value={channel.name}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={channel.pfpUrl} alt={channel.name} />
|
||||
<AvatarFallback>{channel.name[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="font-medium">{channel.name}</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
ownedChannels: Channel[];
|
||||
}
|
||||
interface FormProps {
|
||||
username: string;
|
||||
streamInfo: StreamInfo;
|
||||
}
|
||||
interface SelectProps {
|
||||
channelList: Channel[];
|
||||
value?: string;
|
||||
onSelect: (value: string) => void;
|
||||
}
|
||||
@@ -1,80 +1,175 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Play, Users, MessageCircle, Code, Zap, Heart } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function LandingPage() {
|
||||
return (
|
||||
<>
|
||||
<main className="flex-1">
|
||||
<section className="w-full py-12 md:py-24 lg:py-32 xl:py-48">
|
||||
<div className="container px-4 md:px-6">
|
||||
<div className="flex flex-col items-center space-y-4 text-center">
|
||||
<div className="flex items-center rounded-lg px-3 py-1 text-sm bg-mantle text-red-500">
|
||||
THIS IS A PLACEHOLDER WITH NO "HACK CLUB" VIBE, I'M REALLY BAD AT LANDING PAGES SORRY
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl lg:text-6xl/none">
|
||||
This is hackclub.tv
|
||||
</h1>
|
||||
<p className="mx-auto max-w-[700px] text-gray-500 md:text-xl dark:text-gray-400">
|
||||
The streaming service made by and for Hack Clubbers. Share your coding sessions, workshops, and hackathon moments live!
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-x-4">
|
||||
<Link href="/login">
|
||||
<Button>Start Streaming Now!</Button>
|
||||
</Link>
|
||||
</div>
|
||||
{/* Hero Section */}
|
||||
<section className="relative w-full py-20 md:py-32 lg:py-40 xl:py-48 overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/10 via-transparent to-secondary/10"></div>
|
||||
<div className="container px-4 md:px-6 relative">
|
||||
<div className="flex flex-col items-center space-y-8 text-center">
|
||||
<Badge variant="outline" className="px-4 py-2 text-sm font-medium bg-primary/10 text-primary border-primary/20">
|
||||
<Heart className="w-4 h-4 mr-2" />
|
||||
Made with ❤️ by Hack Clubbers
|
||||
</Badge>
|
||||
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl md:text-6xl lg:text-7xl bg-gradient-to-r from-primary via-primary/80 to-primary/60 bg-clip-text text-transparent">
|
||||
hackclub.tv
|
||||
</h1>
|
||||
<p className="mx-auto max-w-[600px] text-lg text-muted-foreground md:text-xl">
|
||||
The streaming platform where Hack Clubbers share their coding journeys, workshops, and hackathon adventures with the world.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row">
|
||||
<Link href="/login" className="space-x-4">
|
||||
<Button size="lg" className="px-8 py-3 text-lg font-semibold">
|
||||
<Play className="w-5 h-5 mr-2" />
|
||||
Start Streaming
|
||||
</Button>
|
||||
<Button variant="outline" size="lg" className="px-8 py-3 text-lg font-semibold">
|
||||
<Users className="w-5 h-5 mr-2" />
|
||||
Watch Streams
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="w-full py-12 md:py-24 lg:py-32 bg-mantle" id="features">
|
||||
<div className="container px-4 md:px-6">
|
||||
<div className="flex flex-col items-center justify-center space-y-4 text-center">
|
||||
<div className="space-y-2">
|
||||
<div className="inline-block rounded-lg bg-gray-100 px-3 py-1 text-sm dark:bg-gray-800">
|
||||
Platform Features
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Section */}
|
||||
<section className="w-full py-16 md:py-24 lg:py-32 bg-muted/30" id="features">
|
||||
<div className="container px-4 md:px-6">
|
||||
<div className="flex flex-col items-center text-center space-y-8 mb-16">
|
||||
<Badge variant="secondary" className="px-4 py-2">
|
||||
Platform Features
|
||||
</Badge>
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl">
|
||||
Built for creators, by creators
|
||||
</h2>
|
||||
<p className="max-w-[700px] text-lg text-muted-foreground">
|
||||
Everything you need to connect, create, and grow within the Hack Club community.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3 max-w-5xl mx-auto">
|
||||
<Card className="border-0 shadow-sm hover:shadow-md transition-shadow">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="w-12 h-12 bg-primary/10 rounded-lg flex items-center justify-center mb-4">
|
||||
<Zap className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold tracking-tighter sm:text-5xl">
|
||||
Built for Hack Clubbers, by Hack Clubbers
|
||||
</h2>
|
||||
<p className="max-w-[900px] text-gray-500 md:text-xl/relaxed lg:text-base/relaxed xl:text-xl/relaxed dark:text-gray-400">
|
||||
Everything you need to have a closer relationship with the Hack Club community!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto max-w-5xl items-center gap-6 py-12 lg:grid-cols-2 lg:gap-12">
|
||||
<div className="flex flex-col justify-center items-center text-center space-y-4">
|
||||
<ul className="grid gap-6">
|
||||
<li>
|
||||
<div className="grid gap-1">
|
||||
<h3 className="text-xl font-bold">Live Streaming</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Stream your coding sessions with low-latency and high quality
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div className="grid gap-1">
|
||||
<h3 className="text-xl font-bold">Chat Integration</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Interact with viewers in real-time through the built-in chat system
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div className="grid gap-1">
|
||||
<h3 className="text-xl font-bold">Community Features</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Follow other streamers and build your network of Hack Club friends!
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle className="text-xl">Low-Latency Streaming</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription className="text-base">
|
||||
Share your coding sessions with ultra-low latency. Your audience stays engaged with real-time interaction.
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 shadow-sm hover:shadow-md transition-shadow">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="w-12 h-12 bg-primary/10 rounded-lg flex items-center justify-center mb-4">
|
||||
<MessageCircle className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">Real-Time Chat</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription className="text-base">
|
||||
Engage with your community through integrated chat. Get instant feedback and build connections.
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 shadow-sm hover:shadow-md transition-shadow">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="w-12 h-12 bg-primary/10 rounded-lg flex items-center justify-center mb-4">
|
||||
<Users className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">Community First</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription className="text-base">
|
||||
Follow your favorite streamers, discover new creators, and be part of the vibrant Hack Club ecosystem.
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 shadow-sm hover:shadow-md transition-shadow">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="w-12 h-12 bg-accent/50 rounded-lg flex items-center justify-center mb-4">
|
||||
<Code className="w-6 h-6 text-accent-foreground" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">Code-Focused</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription className="text-base">
|
||||
Built specifically for developers. Perfect for coding sessions, tutorials, and technical workshops.
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 shadow-sm hover:shadow-md transition-shadow">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="w-12 h-12 bg-secondary/50 rounded-lg flex items-center justify-center mb-4">
|
||||
<Play className="w-6 h-6 text-secondary-foreground" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">Easy to Use</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription className="text-base">
|
||||
Simple, intuitive interface. Start streaming in minutes, not hours. Focus on what you love: coding.
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 shadow-sm hover:shadow-md transition-shadow">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="w-12 h-12 bg-primary/10 rounded-lg flex items-center justify-center mb-4">
|
||||
<Heart className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">Open Source</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription className="text-base">
|
||||
Transparent, community-driven, and built in the open. Contribute, customize, and make it yours.
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="w-full py-16 md:py-24">
|
||||
<div className="container px-4 md:px-6">
|
||||
<div className="flex flex-col items-center text-center space-y-8">
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl">
|
||||
Ready to share your journey?
|
||||
</h2>
|
||||
<p className="max-w-[600px] text-lg text-muted-foreground">
|
||||
Join the community of makers, builders, and dreamers. Start streaming your coding adventures today.
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/login">
|
||||
<Button size="lg" className="px-8 py-3 text-lg font-semibold">
|
||||
<Play className="w-5 h-5 mr-2" />
|
||||
Get Started Now
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import StreamPlayer from '../StreamPlayer/StreamPlayer';
|
||||
import UserInfoCard from '../UserInfoCard/UserInfoCard';
|
||||
import ChatPanel from '../ChatPanel/ChatPanel';
|
||||
import type { StreamInfo, User } from '@hctv/db';
|
||||
import type { StreamInfo, User, Channel } from '@hctv/db';
|
||||
import { useIsMobile } from '@/lib/hooks/useMobile';
|
||||
|
||||
export default function LiveStream(props: Props) {
|
||||
@@ -32,5 +32,5 @@ export default function LiveStream(props: Props) {
|
||||
|
||||
interface Props {
|
||||
username: string;
|
||||
streamInfo: StreamInfo & { ownedBy: User };
|
||||
streamInfo: StreamInfo & { channel: Channel };
|
||||
}
|
||||
@@ -69,6 +69,9 @@ export default function Navbar(props: Props) {
|
||||
<Link href={`/settings/follows`}>
|
||||
<DropdownMenuItem className="cursor-pointer">Follows</DropdownMenuItem>
|
||||
</Link>
|
||||
<Link href={`/create`}>
|
||||
<DropdownMenuItem className="cursor-pointer">Create channel</DropdownMenuItem>
|
||||
</Link>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
@@ -85,6 +88,17 @@ export default function Navbar(props: Props) {
|
||||
<DropdownMenuGroup>
|
||||
<ThemeSwitcher />
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuGroup>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm px-2">
|
||||
v{process.env.version}-{process.env.NODE_ENV === 'development' ? 'dev' : 'prod'}, commit{' '}
|
||||
<Link
|
||||
href={`https://github.com/SrIzan10/hctv/commit/${process.env.commit}`}
|
||||
target="_blank"
|
||||
>
|
||||
{process.env.commit}
|
||||
</Link>
|
||||
</p>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
|
||||
@@ -17,9 +17,10 @@ import {
|
||||
import { StreamInfoResponse, useStreams } from '@/lib/providers/StreamInfoProvider';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useAllChannels } from '@/lib/hooks/useUserList';
|
||||
|
||||
export default function Sidebar({ ...props }: React.ComponentProps<typeof UISidebar>) {
|
||||
const { stream, isLoading } = useStreams();
|
||||
const { channels: stream, isLoading } = useAllChannels(5000);
|
||||
const [followedExpanded, setFollowedExpanded] = React.useState(true);
|
||||
|
||||
if (isLoading) return <SidebarSkeleton />;
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { toast } from 'sonner';
|
||||
import { fetcher } from '@/lib/services/swr';
|
||||
|
||||
|
||||
export default function SonnerNewVersion() {
|
||||
const [initialCommitId, setInitialCommitId] = useState<string | null>(null);
|
||||
const [hasShownToast, setHasShownToast] = useState(false);
|
||||
|
||||
const { data } = useSWR('/api/commit', fetcher, {
|
||||
refreshInterval: 5000,
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.commit && !initialCommitId) {
|
||||
setInitialCommitId(data.commit);
|
||||
}
|
||||
}, [data?.commit, initialCommitId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
initialCommitId &&
|
||||
data?.commit &&
|
||||
data.commit !== initialCommitId &&
|
||||
!hasShownToast
|
||||
) {
|
||||
toast('New version available!', {
|
||||
action: {
|
||||
label: 'Refresh',
|
||||
onClick: () => window.location.reload(),
|
||||
},
|
||||
duration: Infinity,
|
||||
position: 'top-center'
|
||||
});
|
||||
setHasShownToast(true);
|
||||
}
|
||||
}, [data?.commit, initialCommitId, hasShownToast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasShownToast && data?.commit !== initialCommitId) {
|
||||
setInitialCommitId(data.commit);
|
||||
setHasShownToast(false);
|
||||
}
|
||||
}, [data?.commit, initialCommitId, hasShownToast]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -19,11 +19,13 @@ import React from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { onboardSchema, streamInfoEditSchema } from '@/lib/form/zod';
|
||||
import { createChannelSchema, onboardSchema, streamInfoEditSchema, updateChannelSettingsSchema } from '@/lib/form/zod';
|
||||
|
||||
export const schemaDb = [
|
||||
{ name: 'streamInfoEdit', zod: streamInfoEditSchema },
|
||||
{ name: 'onboard', zod: onboardSchema }
|
||||
{ name: 'onboard', zod: onboardSchema },
|
||||
{ name: 'createChannel', zod: createChannelSchema },
|
||||
{ name: 'updateChannelSettings', zod: updateChannelSettingsSchema },
|
||||
] as const;
|
||||
|
||||
export function UniversalForm<T extends z.ZodType>({
|
||||
@@ -80,7 +82,9 @@ export function UniversalForm<T extends z.ZodType>({
|
||||
<FormItem>
|
||||
{field.type !== 'hidden' && <FormLabel>{field.label}</FormLabel>}
|
||||
<FormControl>
|
||||
{field.textArea ? (
|
||||
{field.component ? (
|
||||
field.component({ field: formField, ...field.componentProps })
|
||||
) : field.textArea ? (
|
||||
<Textarea
|
||||
placeholder={field.placeholder}
|
||||
{...formField}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
import { HTMLInputTypeAttribute } from 'react';
|
||||
import { ControllerRenderProps } from 'react-hook-form';
|
||||
import { schemaDb } from './UniversalForm';
|
||||
|
||||
export type FormFieldConfig = {
|
||||
@@ -8,9 +9,11 @@ export type FormFieldConfig = {
|
||||
type?: HTMLInputTypeAttribute;
|
||||
placeholder?: string;
|
||||
description?: string;
|
||||
value?: string;
|
||||
value?: any;
|
||||
textArea?: boolean;
|
||||
textAreaRows?: number;
|
||||
component?: (props: { field: ControllerRenderProps<any, any> } & any) => React.ReactNode;
|
||||
componentProps?: Record<string, any>;
|
||||
};
|
||||
|
||||
export type UniversalFormProps<T extends z.ZodType> = {
|
||||
|
||||
108
apps/web/src/components/app/UserCombobox/UserCombobox.tsx
Normal file
108
apps/web/src/components/app/UserCombobox/UserCombobox.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import useSWR from 'swr';
|
||||
import { fetcher } from '@/lib/services/swr';
|
||||
import { Channel, StreamInfo } from '@hctv/db';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
|
||||
export function UserCombobox(props: Props) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [internalValue, setInternalValue] = React.useState('');
|
||||
|
||||
// Use external value if provided, otherwise use internal state
|
||||
const value = props.value ?? internalValue;
|
||||
const setValue = props.onValueChange ?? setInternalValue;
|
||||
const {
|
||||
data: fetchedUsers,
|
||||
error,
|
||||
isLoading,
|
||||
} = useSWR<APIResponse>(
|
||||
props.users ? null : '/api/stream/info?personal=true',
|
||||
fetcher
|
||||
);
|
||||
|
||||
const users = props.users || fetchedUsers;
|
||||
|
||||
if (!props.users && error) return <div>Error loading users</div>;
|
||||
if (!props.users && isLoading) return <div>Loading...</div>;
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen} modal={props.modal}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-[200px] justify-between"
|
||||
>
|
||||
{value
|
||||
? (
|
||||
<div className='flex items-center gap-2'>
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={users?.find((user) => user.username === value)?.channel.pfpUrl} alt={value} />
|
||||
<AvatarFallback>{value[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span>{users?.find((user) => user.username === value)?.username}</span>
|
||||
</div>
|
||||
)
|
||||
: 'Select user...'}
|
||||
<ChevronsUpDown className="opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search user..." className="h-9" />
|
||||
<CommandList>
|
||||
<CommandEmpty>No user found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{users?.filter(user => !props.filter?.some(filterStr => user.userId === filterStr)).map((user) => (
|
||||
<CommandItem
|
||||
key={user.channelId}
|
||||
value={user.username}
|
||||
onSelect={(currentValue) => {
|
||||
setValue(currentValue === value ? '' : currentValue);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={user.channel.pfpUrl} alt={user.username} />
|
||||
<AvatarFallback>{user.username[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
{user.username}
|
||||
<Check
|
||||
className={cn(
|
||||
'ml-auto',
|
||||
value === user.username ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
type APIResponse = (StreamInfo & { channel: Channel })[];
|
||||
type Props = {
|
||||
users?: APIResponse;
|
||||
value?: string;
|
||||
filter?: string[];
|
||||
modal?: boolean;
|
||||
onValueChange?: (value: string) => void;
|
||||
}
|
||||
@@ -1,16 +1,17 @@
|
||||
import { Avatar, AvatarImage } from '@/components/ui/avatar';
|
||||
import type { StreamInfo, User } from '@hctv/db';
|
||||
import type { StreamInfo, Channel } from '@hctv/db';
|
||||
import FollowButton from './follow';
|
||||
import FollowCountText from './followCount';
|
||||
import ViewerCount from './viewerCount';
|
||||
import { Preview } from '@/components/ui/channel-desc-fancy-area/preview';
|
||||
|
||||
export default function UserInfoCard(props: Props) {
|
||||
return (
|
||||
<div className="bg-mantle p-4 border-b">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="bg-mantle p-4 border-b h-48 flex flex-col">
|
||||
<div className="flex items-start justify-between mb-4 flex-shrink-0">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Avatar className="h-16 w-16">
|
||||
<AvatarImage src={props.streamInfo.ownedBy.pfpUrl} alt={props.streamInfo.username} />
|
||||
<AvatarImage src={props.streamInfo.channel.pfpUrl} alt={props.streamInfo.username} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{props.streamInfo.title}</h1>
|
||||
@@ -23,11 +24,13 @@ export default function UserInfoCard(props: Props) {
|
||||
<FollowButton channel={props.streamInfo.username} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="mb-4">markdown description here</p>
|
||||
<div className="max-h-32 overflow-y-auto">
|
||||
<Preview textValue={props.streamInfo.channel.description} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
streamInfo: StreamInfo & { ownedBy: User };
|
||||
streamInfo: StreamInfo & { channel: Channel };
|
||||
}
|
||||
@@ -22,13 +22,13 @@ export default function FollowButton(props: Props) {
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (followingData) {
|
||||
if (!isLoadingFollowing && followingData) {
|
||||
setFollowing(followingData.following);
|
||||
}
|
||||
if (followingData === undefined) {
|
||||
if (!isLoadingFollowing && followingData === undefined) {
|
||||
setBye(true);
|
||||
}
|
||||
}, [followingData]);
|
||||
}, [followingData, isLoadingFollowing]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (data) {
|
||||
|
||||
298
apps/web/src/components/examples/ChannelListExample.tsx
Normal file
298
apps/web/src/components/examples/ChannelListExample.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
// Example component demonstrating production-ready useUserList hook
|
||||
// filepath: /home/srizan/Documents/Development/hclive/apps/web/src/components/examples/ChannelListExample.tsx
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import { useUserList, useLiveChannels, channelCacheUtils, type StreamInfoError } from '@/lib/hooks/useUserList'
|
||||
|
||||
// Error display component
|
||||
function ErrorDisplay({ error, onRetry }: { error: StreamInfoError; onRetry: () => void }) {
|
||||
const getErrorMessage = (error: StreamInfoError) => {
|
||||
if (error.status === 401) {
|
||||
return 'Authentication required. Please log in.'
|
||||
}
|
||||
if (error.status === 403) {
|
||||
return 'Access denied. You don\'t have permission to view this content.'
|
||||
}
|
||||
if (error.status && error.status >= 500) {
|
||||
return 'Server error. Please try again later.'
|
||||
}
|
||||
return error.message || 'An unknown error occurred.'
|
||||
}
|
||||
|
||||
const shouldShowRetry = error.status !== 401 && error.status !== 403
|
||||
|
||||
return (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-red-800 font-medium">Error Loading Channels</h3>
|
||||
<p className="text-red-600 text-sm mt-1">{getErrorMessage(error)}</p>
|
||||
{error.status && (
|
||||
<p className="text-red-500 text-xs mt-1">Status: {error.status}</p>
|
||||
)}
|
||||
</div>
|
||||
{shouldShowRetry && (
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="px-3 py-1 text-sm bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Loading skeleton component
|
||||
function ChannelSkeleton() {
|
||||
return (
|
||||
<div className="animate-pulse">
|
||||
<div className="bg-gray-200 h-4 rounded mb-2"></div>
|
||||
<div className="bg-gray-200 h-3 rounded w-3/4 mb-2"></div>
|
||||
<div className="bg-gray-200 h-3 rounded w-1/2"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Individual channel card component
|
||||
function ChannelCard({ channel }: { channel: any }) {
|
||||
return (
|
||||
<div className="border rounded-lg p-4 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="font-medium text-gray-900">{channel.username}</h3>
|
||||
{channel.isLive && (
|
||||
<span className="px-2 py-1 text-xs bg-red-500 text-white rounded-full">
|
||||
LIVE
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm mb-2">{channel.title}</p>
|
||||
<div className="flex items-center justify-between text-xs text-gray-500">
|
||||
<span>{channel.category}</span>
|
||||
{channel.isLive && (
|
||||
<span>{channel.viewers} viewers</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Main channel list component with comprehensive error handling and loading states
|
||||
export function ChannelListExample() {
|
||||
const [filter, setFilter] = useState<'all' | 'live' | 'owned'>('all')
|
||||
|
||||
// Use different hooks based on filter
|
||||
const allChannelsResult = useUserList({
|
||||
refreshInterval: 60000,
|
||||
revalidateOnFocus: true,
|
||||
isPaused: filter !== 'all'
|
||||
})
|
||||
|
||||
const liveChannelsResult = useLiveChannels()
|
||||
const ownedChannelsResult = useUserList({
|
||||
owned: true,
|
||||
isPaused: filter !== 'owned'
|
||||
})
|
||||
|
||||
// Select the appropriate result based on filter
|
||||
const result = filter === 'live'
|
||||
? liveChannelsResult
|
||||
: filter === 'owned'
|
||||
? ownedChannelsResult
|
||||
: allChannelsResult
|
||||
|
||||
const {
|
||||
channels,
|
||||
isLoading,
|
||||
error,
|
||||
isValidating,
|
||||
isBackgroundFetching,
|
||||
totalChannels,
|
||||
liveChannels,
|
||||
lastUpdated,
|
||||
refresh,
|
||||
clearCache
|
||||
} = result
|
||||
|
||||
// Cache management handlers
|
||||
const handleClearCache = useCallback(async () => {
|
||||
try {
|
||||
await clearCache()
|
||||
console.log('Cache cleared successfully')
|
||||
} catch (error) {
|
||||
console.error('Failed to clear cache:', error)
|
||||
}
|
||||
}, [clearCache])
|
||||
|
||||
const handleInvalidateLive = useCallback(async () => {
|
||||
try {
|
||||
await channelCacheUtils.invalidateLive()
|
||||
console.log('Live channels cache invalidated')
|
||||
} catch (error) {
|
||||
console.error('Failed to invalidate live cache:', error)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleWarmUpCache = useCallback(async () => {
|
||||
try {
|
||||
await channelCacheUtils.warmUp()
|
||||
console.log('Cache warmed up successfully')
|
||||
} catch (error) {
|
||||
console.error('Failed to warm up cache:', error)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Render loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">Loading Channels...</h2>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<ChannelSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Render error state
|
||||
if (error) {
|
||||
return <ErrorDisplay error={error} onRetry={refresh} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header with stats and controls */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">
|
||||
Channels
|
||||
{isBackgroundFetching && (
|
||||
<span className="ml-2 text-blue-500 animate-spin">🔄</span>
|
||||
)}
|
||||
</h2>
|
||||
<div className="text-sm text-gray-600 mt-1">
|
||||
{totalChannels} total • {liveChannels} live
|
||||
{lastUpdated && (
|
||||
<span className="ml-2">
|
||||
• Updated {lastUpdated.toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={refresh}
|
||||
disabled={isValidating}
|
||||
className="px-3 py-1 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isValidating ? 'Refreshing...' : 'Refresh'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleClearCache}
|
||||
className="px-3 py-1 text-sm bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
Clear Cache
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter tabs */}
|
||||
<div className="flex gap-2 border-b">
|
||||
{[
|
||||
{ key: 'all', label: 'All Channels' },
|
||||
{ key: 'live', label: 'Live' },
|
||||
{ key: 'owned', label: 'My Channels' }
|
||||
].map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setFilter(key as any)}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
filter === key
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Cache management tools (development only) */}
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div className="bg-gray-50 p-3 rounded-lg">
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-2">
|
||||
Cache Management (Dev Tools)
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleInvalidateLive}
|
||||
className="px-2 py-1 text-xs bg-yellow-500 text-white rounded hover:bg-yellow-600"
|
||||
>
|
||||
Invalidate Live
|
||||
</button>
|
||||
<button
|
||||
onClick={handleWarmUpCache}
|
||||
className="px-2 py-1 text-xs bg-green-500 text-white rounded hover:bg-green-600"
|
||||
>
|
||||
Warm Up Cache
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Channel grid */}
|
||||
{channels.length > 0 ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{channels.map((channel) => (
|
||||
<ChannelCard key={channel.id} channel={channel} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<p>No channels found.</p>
|
||||
{filter !== 'all' && (
|
||||
<button
|
||||
onClick={() => setFilter('all')}
|
||||
className="mt-2 text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
View all channels
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Debug info in development */}
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<details className="bg-gray-50 p-3 rounded-lg">
|
||||
<summary className="text-sm font-medium text-gray-700 cursor-pointer">
|
||||
Debug Information
|
||||
</summary>
|
||||
<pre className="mt-2 text-xs text-gray-600 overflow-auto">
|
||||
{JSON.stringify({
|
||||
filter,
|
||||
totalChannels,
|
||||
liveChannels,
|
||||
isLoading,
|
||||
isValidating,
|
||||
isBackgroundFetching,
|
||||
cacheKey: result.cacheKey,
|
||||
lastUpdated: lastUpdated?.toISOString(),
|
||||
hasError: !!error
|
||||
}, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChannelListExample
|
||||
36
apps/web/src/components/ui/badge.tsx
Normal file
36
apps/web/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -0,0 +1,3 @@
|
||||
source: https://craft.mxkaske.dev/post/fancy-area
|
||||
|
||||
currently only used for the description input.
|
||||
47
apps/web/src/components/ui/channel-desc-fancy-area/data.ts
Normal file
47
apps/web/src/components/ui/channel-desc-fancy-area/data.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
// REMINDER: currently a static file!
|
||||
export const people = [
|
||||
{
|
||||
username: "@shadcn",
|
||||
profileImg:
|
||||
"https://pbs.twimg.com/profile_images/1593304942210478080/TUYae5z7_400x400.jpg",
|
||||
description:
|
||||
"Design · Code · Open Source · I tweet about building products with Next.js",
|
||||
joined: "April 2009",
|
||||
},
|
||||
{
|
||||
username: "@mxkaske",
|
||||
profileImg:
|
||||
"https://pbs.twimg.com/profile_images/1559935773151051778/0O_Bf4LY_400x400.jpg",
|
||||
description: "Developer · Climber · Just call me Max",
|
||||
joined: "January 2017",
|
||||
},
|
||||
{
|
||||
username: "@chronark_",
|
||||
profileImg:
|
||||
"https://pbs.twimg.com/profile_images/1437670380957835264/gu8S0olw_400x400.jpg",
|
||||
description: "Building open source and serverless solutions @upstash",
|
||||
joined: "June 2019",
|
||||
},
|
||||
{
|
||||
username: "@rauchg",
|
||||
profileImg:
|
||||
"https://pbs.twimg.com/profile_images/1576257734810312704/ucxb4lHy_400x400.jpg",
|
||||
description: "@vercel CEO",
|
||||
joined: "June 2008",
|
||||
},
|
||||
{
|
||||
username: "@steventey",
|
||||
profileImg:
|
||||
"https://pbs.twimg.com/profile_images/1506792347840888834/dS-r50Je_400x400.jpg",
|
||||
description:
|
||||
"building the future of the web ▲ @vercel | @dubdotsh | http://oneword.domains",
|
||||
joined: "July 2011",
|
||||
},
|
||||
{
|
||||
username: "@raunofreiberg",
|
||||
profileImg:
|
||||
"https://pbs.twimg.com/profile_images/1525606643794427905/17-x8e9o_400x400.jpg",
|
||||
description: "Devoured by details.",
|
||||
joined: "December 2018",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../tabs";
|
||||
import { Write } from "./write";
|
||||
import { Preview } from "./preview";
|
||||
// TODO: TabsList has an interesting tab focus. Need to investigate on it
|
||||
|
||||
const defaultText = `Build by @mxkaske, _powered by_ @shadcn **ui**.\n\nSupports raw <code>html</code>.`;
|
||||
|
||||
export function FancyArea() {
|
||||
const [textValue, setTextValue] = React.useState(defaultText);
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="write" className="w-full">
|
||||
<TabsList>
|
||||
<TabsTrigger value="write">Write</TabsTrigger>
|
||||
<TabsTrigger value="preview">Preview</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="write">
|
||||
<Write {...{ textValue, setTextValue }} />
|
||||
</TabsContent>
|
||||
<TabsContent value="preview">
|
||||
<Preview {...{ textValue }} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { CalendarDays } from "lucide-react";
|
||||
import React from "react";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import { HoverCardPortal } from "@radix-ui/react-hover-card";
|
||||
import { useAllChannels } from "@/lib/hooks/useUserList";
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
handle: string;
|
||||
}
|
||||
|
||||
export function Mention({ children, handle }: Props) {
|
||||
const { channels, isLoading, error } = useAllChannels();
|
||||
|
||||
if (isLoading) return null;
|
||||
if (error) {
|
||||
console.error("Error fetching channels:", error);
|
||||
return null;
|
||||
}
|
||||
|
||||
const isString = typeof children === "string";
|
||||
// REMINDER: children has other children - return early
|
||||
if (!isString) {
|
||||
return children;
|
||||
}
|
||||
|
||||
// Find channel by name (handle without @)
|
||||
const channel = channels.find((ch) => ch.username.toLowerCase() === handle.toLowerCase());
|
||||
// REMINDER: only allowed users are rendered with HoverCard
|
||||
if (!channel) {
|
||||
return children;
|
||||
}
|
||||
|
||||
const fallback = handle.substring(0, 2).toUpperCase();
|
||||
const url = `https://hctv.srizan.dev/${handle}`;
|
||||
|
||||
return (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<a href={url} target="_blank">
|
||||
{children}
|
||||
</a>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardPortal>
|
||||
<HoverCardContent className="max-w-xs w-auto">
|
||||
<div className="flex justify-between space-x-4">
|
||||
<Avatar>
|
||||
<AvatarImage src={channel.channel.pfpUrl} />
|
||||
<AvatarFallback>{fallback}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-sm font-semibold">{channel.channel.name}</h4>
|
||||
<p className="text-sm">{channel.channel.description}</p>
|
||||
<div className="flex items-center pt-2">
|
||||
<CalendarDays className="mr-2 h-4 w-4 opacity-70" />{" "}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Joined {new Date(channel.channel.createdAt).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCardPortal>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useProcessor } from "./use-processor";
|
||||
|
||||
interface Props {
|
||||
textValue: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Preview({ textValue, className }: Props) {
|
||||
const Component = useProcessor(textValue);
|
||||
return (
|
||||
<div className={cn("w-full overflow-auto prose dark:prose-invert prose-sm px-1 border border-transparent prose-headings:font-cal", className)}>
|
||||
{Component}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { unified } from "unified";
|
||||
import remarkParse from "remark-parse";
|
||||
import remarkRehype from "remark-rehype";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import rehypeReact from "rehype-react";
|
||||
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
|
||||
import { createElement, Fragment, useEffect, useState } from "react";
|
||||
import { Mention } from "./mention";
|
||||
import { jsx, jsxs } from "react/jsx-runtime";
|
||||
|
||||
export function useProcessor(md: string) {
|
||||
const [content, setContent] = useState<React.ReactNode>(null);
|
||||
const mentionRegex = /@(\w+)/g;
|
||||
const text = md.replace(mentionRegex, '<mention handle="$1">@$1</mention>');
|
||||
|
||||
useEffect(() => {
|
||||
unified()
|
||||
.use(remarkParse)
|
||||
.use(remarkRehype, { allowDangerousHtml: true })
|
||||
.use(rehypeRaw)
|
||||
.use(rehypeSanitize, {
|
||||
...defaultSchema,
|
||||
tagNames: [...defaultSchema.tagNames!, "mention"],
|
||||
attributes: {
|
||||
...defaultSchema.attributes,
|
||||
mention: ["handle"],
|
||||
},
|
||||
})
|
||||
// @ts-expect-error because mention is not valid html-tag
|
||||
.use(rehypeReact, {
|
||||
createElement,
|
||||
Fragment,
|
||||
jsx,
|
||||
jsxs,
|
||||
components: {
|
||||
mention: Mention,
|
||||
},
|
||||
})
|
||||
.process(text)
|
||||
.then((file) => {
|
||||
setContent(file.result);
|
||||
});
|
||||
}, [text]);
|
||||
|
||||
return content;
|
||||
}
|
||||
220
apps/web/src/components/ui/channel-desc-fancy-area/utils.ts
Normal file
220
apps/web/src/components/ui/channel-desc-fancy-area/utils.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
// https://github.com/component/textarea-caret-position
|
||||
|
||||
// We'll copy the properties below into the mirror div.
|
||||
// Note that some browsers, such as Firefox, do not concatenate properties
|
||||
// into their shorthand (e.g. padding-top, padding-bottom etc. -> padding),
|
||||
// so we have to list every single property explicitly.
|
||||
const properties = [
|
||||
"direction", // RTL support
|
||||
"boxSizing",
|
||||
"width", // on Chrome and IE, exclude the scrollbar, so the mirror div wraps exactly as the textarea does
|
||||
"height",
|
||||
"overflowX",
|
||||
"overflowY", // copy the scrollbar for IE
|
||||
|
||||
"borderTopWidth",
|
||||
"borderRightWidth",
|
||||
"borderBottomWidth",
|
||||
"borderLeftWidth",
|
||||
"borderStyle",
|
||||
|
||||
"paddingTop",
|
||||
"paddingRight",
|
||||
"paddingBottom",
|
||||
"paddingLeft",
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/CSS/font
|
||||
"fontStyle",
|
||||
"fontVariant",
|
||||
"fontWeight",
|
||||
"fontStretch",
|
||||
"fontSize",
|
||||
"fontSizeAdjust",
|
||||
"lineHeight",
|
||||
"fontFamily",
|
||||
|
||||
"textAlign",
|
||||
"textTransform",
|
||||
"textIndent",
|
||||
"textDecoration", // might not make a difference, but better be safe
|
||||
|
||||
"letterSpacing",
|
||||
"wordSpacing",
|
||||
|
||||
"tabSize",
|
||||
"MozTabSize",
|
||||
] as const;
|
||||
|
||||
const isBrowser = typeof window !== "undefined";
|
||||
// @ts-expect-error
|
||||
const isFirefox = isBrowser && window.mozInnerScreenX != null;
|
||||
|
||||
export function getCaretPosition(element: HTMLTextAreaElement) {
|
||||
return {
|
||||
caretStartIndex: element.selectionStart || 0,
|
||||
caretEndIndex: element.selectionEnd || 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function getCurrentWord(element: HTMLTextAreaElement) {
|
||||
const text = element.value;
|
||||
const { caretStartIndex } = getCaretPosition(element);
|
||||
|
||||
// Find the start position of the word
|
||||
let start = caretStartIndex;
|
||||
while (start > 0 && text[start - 1].match(/\S/)) {
|
||||
start--;
|
||||
}
|
||||
|
||||
// Find the end position of the word
|
||||
let end = caretStartIndex;
|
||||
while (end < text.length && text[end].match(/\S/)) {
|
||||
end++;
|
||||
}
|
||||
|
||||
const w = text.substring(start, end);
|
||||
|
||||
return w;
|
||||
}
|
||||
|
||||
export function replaceWord(element: HTMLTextAreaElement, value: string) {
|
||||
const text = element.value;
|
||||
const caretPos = element.selectionStart;
|
||||
|
||||
// Find the word that needs to be replaced
|
||||
const wordRegex = /[\w@#]+/g;
|
||||
let match;
|
||||
let startIndex;
|
||||
let endIndex;
|
||||
|
||||
while ((match = wordRegex.exec(text)) !== null) {
|
||||
startIndex = match.index;
|
||||
endIndex = startIndex + match[0].length;
|
||||
|
||||
if (caretPos >= startIndex && caretPos <= endIndex) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Replace the word with a new word using document.execCommand
|
||||
if (startIndex !== undefined && endIndex !== undefined) {
|
||||
// Preserve the current selection range
|
||||
const selectionStart = element.selectionStart;
|
||||
const selectionEnd = element.selectionEnd;
|
||||
|
||||
// Modify the selected range to encompass the word to be replaced
|
||||
element.setSelectionRange(startIndex, endIndex);
|
||||
|
||||
// REMINDER: Fastest way to include CMD + Z compatibility
|
||||
// Execute the command to replace the selected text with the new word
|
||||
document.execCommand("insertText", false, value);
|
||||
|
||||
// Restore the original selection range
|
||||
element.setSelectionRange(
|
||||
selectionStart - (endIndex - startIndex) + value.length,
|
||||
selectionEnd - (endIndex - startIndex) + value.length,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function getCaretCoordinates(
|
||||
element: HTMLTextAreaElement,
|
||||
position: number,
|
||||
options?: { debug: boolean },
|
||||
) {
|
||||
if (!isBrowser) {
|
||||
throw new Error(
|
||||
"textarea-caret-position#getCaretCoordinates should only be called in a browser",
|
||||
);
|
||||
}
|
||||
|
||||
var debug = (options && options.debug) || false;
|
||||
if (debug) {
|
||||
var el = document.querySelector(
|
||||
"#input-textarea-caret-position-mirror-div",
|
||||
);
|
||||
if (el) el?.parentNode?.removeChild(el);
|
||||
}
|
||||
|
||||
// The mirror div will replicate the textarea's style
|
||||
var div = document.createElement("div");
|
||||
div.id = "input-textarea-caret-position-mirror-div";
|
||||
document.body.appendChild(div);
|
||||
|
||||
var style = div.style;
|
||||
var computed = window.getComputedStyle(element);
|
||||
var isInput = element.nodeName === "INPUT";
|
||||
|
||||
// Default textarea styles
|
||||
style.whiteSpace = "pre-wrap";
|
||||
if (!isInput) style.wordWrap = "break-word"; // only for textarea-s
|
||||
|
||||
// Position off-screen
|
||||
style.position = "absolute"; // required to return coordinates properly
|
||||
if (!debug) style.visibility = "hidden"; // not 'display: none' because we want rendering
|
||||
|
||||
// Transfer the element's properties to the div
|
||||
properties.forEach(function (prop) {
|
||||
if (isInput && prop === "lineHeight") {
|
||||
// Special case for <input>s because text is rendered centered and line height may be != height
|
||||
if (computed.boxSizing === "border-box") {
|
||||
var height = parseInt(computed.height);
|
||||
var outerHeight =
|
||||
parseInt(computed.paddingTop) +
|
||||
parseInt(computed.paddingBottom) +
|
||||
parseInt(computed.borderTopWidth) +
|
||||
parseInt(computed.borderBottomWidth);
|
||||
var targetHeight = outerHeight + parseInt(computed.lineHeight);
|
||||
if (height > targetHeight) {
|
||||
style.lineHeight = height - outerHeight + "px";
|
||||
} else if (height === targetHeight) {
|
||||
style.lineHeight = computed.lineHeight;
|
||||
} else {
|
||||
style.lineHeight = 0 + "px";
|
||||
}
|
||||
} else {
|
||||
style.lineHeight = computed.height;
|
||||
}
|
||||
} else {
|
||||
// @ts-expect-error
|
||||
style[prop] = computed[prop];
|
||||
}
|
||||
});
|
||||
|
||||
if (isFirefox) {
|
||||
// Firefox lies about the overflow property for textareas: https://bugzilla.mozilla.org/show_bug.cgi?id=984275
|
||||
if (element.scrollHeight > parseInt(computed.height))
|
||||
style.overflowY = "scroll";
|
||||
} else {
|
||||
style.overflow = "hidden"; // for Chrome to not render a scrollbar; IE keeps overflowY = 'scroll'
|
||||
}
|
||||
|
||||
div.textContent = element.value.substring(0, position);
|
||||
// The second special handling for input type="text" vs textarea:
|
||||
// spaces need to be replaced with non-breaking spaces - http://stackoverflow.com/a/13402035/1269037
|
||||
if (isInput) div.textContent = div.textContent.replace(/\s/g, "\u00a0");
|
||||
|
||||
var span = document.createElement("span");
|
||||
// Wrapping must be replicated *exactly*, including when a long word gets
|
||||
// onto the next line, with whitespace at the end of the line before (#7).
|
||||
// The *only* reliable way to do that is to copy the *entire* rest of the
|
||||
// textarea's content into the <span> created at the caret position.
|
||||
// For inputs, just '.' would be enough, but no need to bother.
|
||||
// REMINDER: changed it from "." to empty string ""...
|
||||
span.textContent = element.value.substring(position) || ""; // || because a completely empty faux span doesn't render at all
|
||||
div.appendChild(span);
|
||||
|
||||
var coordinates = {
|
||||
top: span.offsetTop + parseInt(computed["borderTopWidth"]),
|
||||
left: span.offsetLeft + parseInt(computed["borderLeftWidth"]),
|
||||
height: parseInt(computed["lineHeight"]),
|
||||
};
|
||||
|
||||
if (debug) {
|
||||
span.style.backgroundColor = "#aaa";
|
||||
} else {
|
||||
document.body.removeChild(div);
|
||||
}
|
||||
|
||||
return coordinates;
|
||||
}
|
||||
191
apps/web/src/components/ui/channel-desc-fancy-area/write.tsx
Normal file
191
apps/web/src/components/ui/channel-desc-fancy-area/write.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
"use client";
|
||||
|
||||
import React, { useRef, useState, useEffect, useCallback } from "react";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Command,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getCaretCoordinates, getCurrentWord, replaceWord } from "./utils";
|
||||
import { useAllChannels } from "@/lib/hooks/useUserList";
|
||||
|
||||
interface Props {
|
||||
textValue: string;
|
||||
setTextValue: React.Dispatch<React.SetStateAction<string>>;
|
||||
}
|
||||
|
||||
export function Write({ textValue, setTextValue }: Props) {
|
||||
const { channels, isLoading, error } = useAllChannels();
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [commandValue, setCommandValue] = useState("");
|
||||
|
||||
// TODO: check if this is possible?!?
|
||||
// const texarea = textareaRef.current;
|
||||
// const dropdown = dropdownRef.current;
|
||||
|
||||
const handleBlur = useCallback((e: Event) => {
|
||||
const dropdown = dropdownRef.current;
|
||||
if (dropdown) {
|
||||
dropdown.classList.add("hidden");
|
||||
setCommandValue("");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
const textarea = textareaRef.current;
|
||||
const input = inputRef.current;
|
||||
const dropdown = dropdownRef.current;
|
||||
if (textarea && input && dropdown) {
|
||||
const currentWord = getCurrentWord(textarea);
|
||||
const isDropdownHidden = dropdown.classList.contains("hidden");
|
||||
if (currentWord.startsWith("@") && !isDropdownHidden) {
|
||||
// FIXME: handle Escape
|
||||
if (
|
||||
e.key === "ArrowUp" ||
|
||||
e.keyCode === 38 ||
|
||||
e.key === "ArrowDown" ||
|
||||
e.keyCode === 40 ||
|
||||
e.key === "Enter" ||
|
||||
e.keyCode === 13 ||
|
||||
e.key === "Escape" ||
|
||||
e.keyCode === 27
|
||||
) {
|
||||
e.preventDefault();
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onTextValueChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const text = e.target.value;
|
||||
const textarea = textareaRef.current;
|
||||
const dropdown = dropdownRef.current;
|
||||
|
||||
if (textarea && dropdown) {
|
||||
const caret = getCaretCoordinates(textarea, textarea.selectionEnd);
|
||||
const currentWord = getCurrentWord(textarea);
|
||||
setTextValue(text);
|
||||
|
||||
if (currentWord.startsWith("@")) {
|
||||
// Remove the @ symbol for the Command component filtering
|
||||
const searchTerm = currentWord.slice(1);
|
||||
setCommandValue(searchTerm);
|
||||
dropdown.style.left = caret.left + "px";
|
||||
dropdown.style.top = caret.top + caret.height + "px";
|
||||
dropdown.classList.remove("hidden");
|
||||
} else {
|
||||
// REMINDER: apparently, we need it when deleting
|
||||
if (commandValue !== "") {
|
||||
setCommandValue("");
|
||||
dropdown.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[setTextValue, commandValue],
|
||||
);
|
||||
|
||||
const onCommandSelect = useCallback((value: string) => {
|
||||
const textarea = textareaRef.current;
|
||||
const dropdown = dropdownRef.current;
|
||||
if (textarea && dropdown) {
|
||||
// Add the @ symbol back when replacing
|
||||
replaceWord(textarea, `@${value}`);
|
||||
setCommandValue("");
|
||||
dropdown.classList.add("hidden");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleMouseDown = useCallback((e: Event) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleSectionChange = useCallback(
|
||||
(e: Event) => {
|
||||
const textarea = textareaRef.current;
|
||||
const dropdown = dropdownRef.current;
|
||||
if (textarea && dropdown) {
|
||||
const currentWord = getCurrentWord(textarea);
|
||||
if (!currentWord.startsWith("@") && commandValue !== "") {
|
||||
setCommandValue("");
|
||||
dropdown.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
},
|
||||
[commandValue],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const textarea = textareaRef.current;
|
||||
const dropdown = dropdownRef.current;
|
||||
textarea?.addEventListener("keydown", handleKeyDown);
|
||||
textarea?.addEventListener("blur", handleBlur);
|
||||
document?.addEventListener("selectionchange", handleSectionChange);
|
||||
dropdown?.addEventListener("mousedown", handleMouseDown);
|
||||
return () => {
|
||||
textarea?.removeEventListener("keydown", handleKeyDown);
|
||||
textarea?.removeEventListener("blur", handleBlur);
|
||||
document?.removeEventListener("selectionchange", handleSectionChange);
|
||||
dropdown?.removeEventListener("mousedown", handleMouseDown);
|
||||
};
|
||||
}, [handleBlur, handleKeyDown, handleMouseDown, handleSectionChange]);
|
||||
|
||||
if (isLoading) return null;
|
||||
if (error) {
|
||||
console.error("Error fetching channels:", error);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
className="h-auto resize-none"
|
||||
value={textValue}
|
||||
onChange={onTextValueChange}
|
||||
rows={5}
|
||||
/>
|
||||
<p className="prose-none mt-1 text-sm text-muted-foreground">
|
||||
Supports markdown.
|
||||
</p>
|
||||
<Command
|
||||
ref={dropdownRef}
|
||||
className={cn(
|
||||
"absolute hidden h-auto max-h-32 max-w-min overflow-y-scroll border border-popover shadow",
|
||||
)}
|
||||
>
|
||||
<div className="hidden">
|
||||
{/* REMINDER: className="hidden" won't hide the SearchIcon and border */}
|
||||
<CommandInput ref={inputRef} value={commandValue} />
|
||||
</div>
|
||||
<CommandList>
|
||||
<CommandGroup className="max-w-min overflow-auto">
|
||||
{channels.map((c) => {
|
||||
return (
|
||||
<CommandItem
|
||||
key={c.id}
|
||||
value={c.username}
|
||||
onSelect={onCommandSelect}
|
||||
>
|
||||
{c.username}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
apps/web/src/components/ui/checkbox.tsx
Normal file
30
apps/web/src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { Check } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn("flex items-center justify-center text-current")}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
|
||||
export { Checkbox }
|
||||
29
apps/web/src/components/ui/hover-card.tsx
Normal file
29
apps/web/src/components/ui/hover-card.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const HoverCard = HoverCardPrimitive.Root
|
||||
|
||||
const HoverCardTrigger = HoverCardPrimitive.Trigger
|
||||
|
||||
const HoverCardContent = React.forwardRef<
|
||||
React.ElementRef<typeof HoverCardPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<HoverCardPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-hover-card-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||
@@ -113,8 +113,10 @@ SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> & {
|
||||
icon?: React.ReactNode
|
||||
}
|
||||
>(({ className, children, icon, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
@@ -124,9 +126,11 @@ const SelectItem = React.forwardRef<
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
{icon ? icon : (
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
|
||||
66
apps/web/src/components/ui/tabs.tsx
Normal file
66
apps/web/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"bg-mantle text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"data-[state=active]:bg-background/50 dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-xs [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
@@ -1,12 +1,43 @@
|
||||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
||||
await (await import('@/lib/instrumentation/streamInfo')).default();
|
||||
await (await import('@/lib/instrumentation/writeSessions')).default();
|
||||
}
|
||||
|
||||
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
||||
const { registerWorkers } = await import('@/lib/workers/register');
|
||||
|
||||
await registerWorkers();
|
||||
|
||||
console.log('bullmq workers registered');
|
||||
}
|
||||
|
||||
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
||||
const cron = (await import('node-cron')).default;
|
||||
|
||||
const getLiveThumb = (await import('@/lib/instrumentation/getLiveThumb')).default;
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
console.log('running production cron job scheduling')
|
||||
cron.schedule('*/3 * * * *', async () => {
|
||||
await getLiveThumb();
|
||||
});
|
||||
} else {
|
||||
console.log('running local cron job scheduling')
|
||||
setInterval(async () => {
|
||||
await getLiveThumb();
|
||||
}, 5000);
|
||||
}
|
||||
console.log('cron stuff registered');
|
||||
}
|
||||
|
||||
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
||||
const { emojisWriteRedis } = await import('@/lib/instrumentation/emojisWriteRedis');
|
||||
await emojisWriteRedis();
|
||||
}
|
||||
|
||||
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
||||
const { viewerCountSync } = await import('@/lib/instrumentation/viewerCountSync');
|
||||
setInterval(async () => {
|
||||
await viewerCountSync();
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,13 @@ import { cookies } from 'next/headers';
|
||||
import { lucia } from '@hctv/auth';
|
||||
import { validateRequest } from '@/lib/auth/validate';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getRedisConnection } from '@hctv/db';
|
||||
|
||||
export async function logout() {
|
||||
const { session } = await validateRequest();
|
||||
await getRedisConnection().del(`sessions:${session!.id}`);
|
||||
await lucia.invalidateSession(session!.id);
|
||||
const sessionCookie = lucia.createBlankSessionCookie();
|
||||
(await cookies()).set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
|
||||
return redirect('/auth/login');
|
||||
return redirect('/login');
|
||||
}
|
||||
|
||||
@@ -43,4 +43,47 @@ export async function resolveFollowedChannels(id?: string) {
|
||||
return null;
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
export async function resolvePersonalChannel(id?: string) {
|
||||
const { user } = await validateRequest();
|
||||
const db = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: id ?? user?.id,
|
||||
},
|
||||
select: {
|
||||
personalChannel: true,
|
||||
},
|
||||
});
|
||||
if (!db) {
|
||||
return null;
|
||||
}
|
||||
return db.personalChannel;
|
||||
}
|
||||
|
||||
export async function resolveUserFromPersonalChannelName(channelName: string) {
|
||||
const db = await prisma.channel.findUnique({
|
||||
where: {
|
||||
name: channelName,
|
||||
},
|
||||
select: {
|
||||
personalFor: true,
|
||||
},
|
||||
});
|
||||
if (!db) {
|
||||
return null;
|
||||
}
|
||||
return db.personalFor;
|
||||
}
|
||||
|
||||
export async function resolveStreamInfo(channelId: string) {
|
||||
const db = await prisma.streamInfo.findFirst({
|
||||
where: {
|
||||
channelId,
|
||||
},
|
||||
});
|
||||
if (!db) {
|
||||
return null;
|
||||
}
|
||||
return db;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { cache } from "react";
|
||||
import { lucia } from '@hctv/auth';
|
||||
import { getRedisConnection } from "@hctv/db";
|
||||
|
||||
export const validateRequest = cache(async () => {
|
||||
const sessionId = (await cookies()).get(lucia.sessionCookieName)?.value ?? null;
|
||||
@@ -15,6 +16,7 @@ export const validateRequest = cache(async () => {
|
||||
try {
|
||||
if (session && session.fresh) {
|
||||
const sessionCookie = lucia.createSessionCookie(session.id);
|
||||
await getRedisConnection().set(`sessions:${session.id}`, '');
|
||||
(await cookies()).set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
|
||||
}
|
||||
if (!session) {
|
||||
|
||||
@@ -4,9 +4,10 @@ import { revalidatePath } from 'next/cache';
|
||||
import { validateRequest } from '@/lib/auth/validate';
|
||||
import { prisma } from '@hctv/db';
|
||||
import zodVerify from '../zodVerify';
|
||||
import { onboardSchema, streamInfoEditSchema } from './zod';
|
||||
import { createChannelSchema, onboardSchema, streamInfoEditSchema, updateChannelSettingsSchema } from './zod';
|
||||
import { initializeStreamInfo } from '../instrumentation/streamInfo';
|
||||
import { resolveFollowedChannels } from '../auth/resolve';
|
||||
import { resolveFollowedChannels, resolveStreamInfo, resolveUserFromPersonalChannelName } from '../auth/resolve';
|
||||
import { genIdenticonUpload } from '../utils/genIdenticonUpload';
|
||||
|
||||
export async function editStreamInfo(prev: any, formData: FormData) {
|
||||
const { user } = await validateRequest();
|
||||
@@ -90,12 +91,14 @@ export async function onboard(prev: any, formData: FormData) {
|
||||
});
|
||||
await initializeStreamInfo(createdChannel.id);
|
||||
|
||||
await fetch(process.env.WELCOME_WORKFLOW_URL!, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
username: zod.data.username,
|
||||
}),
|
||||
})
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
await fetch(process.env.WELCOME_WORKFLOW_URL!, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
username: zod.data.username,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -121,4 +124,232 @@ export async function notifyStreamToggle(channelName: string) {
|
||||
});
|
||||
|
||||
return { success: true, toggle: !channel.notifyStream };
|
||||
}
|
||||
|
||||
export async function createChannel(prev: any, formData: FormData) {
|
||||
const { user } = await validateRequest();
|
||||
if (!user) {
|
||||
return { success: false, error: 'Unauthorized' };
|
||||
}
|
||||
|
||||
const zod = await zodVerify(createChannelSchema, formData);
|
||||
if (!zod.success) {
|
||||
return zod;
|
||||
}
|
||||
|
||||
const channelExists = await prisma.channel.findFirst({
|
||||
where: { name: zod.data.name },
|
||||
});
|
||||
if (channelExists) {
|
||||
return { success: false, error: 'Channel name already exists' };
|
||||
}
|
||||
|
||||
const identicon = await genIdenticonUpload(zod.data.name, 'pfp');
|
||||
const createdChannel = await prisma.channel.create({
|
||||
data: {
|
||||
name: zod.data.name,
|
||||
ownerId: user.id,
|
||||
pfpUrl: identicon,
|
||||
}
|
||||
});
|
||||
|
||||
await initializeStreamInfo(createdChannel.id);
|
||||
|
||||
return { success: true, channel: createdChannel.name };
|
||||
}
|
||||
|
||||
export async function updateChannelSettings(prev: any, formData: FormData) {
|
||||
const { user } = await validateRequest();
|
||||
if (!user) {
|
||||
return { success: false, error: 'Unauthorized' };
|
||||
}
|
||||
|
||||
const zod = await zodVerify(updateChannelSettingsSchema, formData);
|
||||
const urlRegex = /(?:http[s]?:\/\/.)?(?:www\.)?[-a-zA-Z0-9@%._\+~#=]{2,256}\.[a-z]{2,6}\b(?:[-a-zA-Z0-9@:%_\+.~#?&\/\/=]*)/gm;
|
||||
if (!zod.success) {
|
||||
return zod;
|
||||
}
|
||||
if (zod.data.pfpUrl && !urlRegex.test(zod.data.pfpUrl)) {
|
||||
return { success: false, error: 'Invalid URL for profile picture' };
|
||||
}
|
||||
|
||||
const channel = await prisma.channel.findUnique({
|
||||
where: { id: zod.data.channelId },
|
||||
include: {
|
||||
owner: true,
|
||||
managers: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!channel) {
|
||||
return { success: false, error: 'Channel not found' };
|
||||
}
|
||||
|
||||
const isOwner = channel.ownerId === user.id;
|
||||
const isManager = channel.managers.some(manager => manager.id === user.id);
|
||||
|
||||
if (!isOwner && !isManager) {
|
||||
return { success: false, error: 'Unauthorized' };
|
||||
}
|
||||
|
||||
if (zod.data.pfpUrl === '') {
|
||||
const identicon = await genIdenticonUpload(channel.name, 'pfp');
|
||||
zod.data.pfpUrl = identicon;
|
||||
}
|
||||
|
||||
await prisma.channel.update({
|
||||
where: { id: zod.data.channelId },
|
||||
data: {
|
||||
description: zod.data.description || undefined,
|
||||
pfpUrl: zod.data.pfpUrl,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath(`/settings/channel/${channel.name}`);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function addChannelManager(channelId: string, userChannel: string) {
|
||||
const { user } = await validateRequest();
|
||||
if (!user) {
|
||||
return { success: false, error: 'Unauthorized' };
|
||||
}
|
||||
|
||||
const channel = await prisma.channel.findUnique({
|
||||
where: { id: channelId, personalFor: null },
|
||||
include: { owner: true, managers: true },
|
||||
});
|
||||
|
||||
if (!channel) {
|
||||
return { success: false, error: 'Channel not found OR is personal.' };
|
||||
}
|
||||
|
||||
if (channel.ownerId !== user.id) {
|
||||
return { success: false, error: 'Only channel owners can add managers' };
|
||||
}
|
||||
|
||||
if (channel.ownerId === userChannel) {
|
||||
return { success: false, error: 'Owner can\'t add themselves as managers' };
|
||||
}
|
||||
|
||||
const userDb = await resolveUserFromPersonalChannelName(userChannel);
|
||||
if (!userDb) {
|
||||
return { success: false, error: 'User not found' };
|
||||
}
|
||||
if (channel.managers.some((m) => m.id === userDb.id)) {
|
||||
return { success: false, error: 'User is already a manager' };
|
||||
}
|
||||
|
||||
await prisma.channel.update({
|
||||
where: { id: channelId },
|
||||
data: {
|
||||
managers: {
|
||||
connect: { id: userDb.id },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath(`/settings/channel/${channel.name}`);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function removeChannelManager(channelId: string, userId: string) {
|
||||
const { user } = await validateRequest();
|
||||
if (!user) {
|
||||
return { success: false, error: 'Unauthorized' };
|
||||
}
|
||||
|
||||
const channel = await prisma.channel.findUnique({
|
||||
where: { id: channelId },
|
||||
include: { owner: true },
|
||||
});
|
||||
|
||||
if (!channel) {
|
||||
return { success: false, error: 'Channel not found' };
|
||||
}
|
||||
|
||||
if (channel.ownerId !== user.id) {
|
||||
return { success: false, error: 'Only channel owners can remove managers' };
|
||||
}
|
||||
|
||||
await prisma.channel.update({
|
||||
where: { id: channelId },
|
||||
data: {
|
||||
managers: {
|
||||
disconnect: { id: userId },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath(`/settings/channel/${channel.name}`);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function toggleGlobalChannelNotifs(channelId: string) {
|
||||
const { user } = await validateRequest();
|
||||
if (!user) {
|
||||
return { success: false, error: 'Unauthorized' };
|
||||
}
|
||||
|
||||
const channel = await prisma.channel.findUnique({
|
||||
where: { id: channelId },
|
||||
include: { followers: true },
|
||||
});
|
||||
|
||||
if (!channel) {
|
||||
return { success: false, error: 'Channel not found' };
|
||||
}
|
||||
|
||||
const streamInfo = await resolveStreamInfo(channelId);
|
||||
if (!streamInfo) {
|
||||
return { success: false, error: 'Stream info not found' };
|
||||
}
|
||||
|
||||
await prisma.streamInfo.update({
|
||||
where: {
|
||||
id: streamInfo.id,
|
||||
},
|
||||
data: {
|
||||
enableNotifications: !streamInfo.enableNotifications,
|
||||
}
|
||||
})
|
||||
|
||||
revalidatePath(`/settings/channel/${channel.name}`);
|
||||
|
||||
return { success: true, toggle: !streamInfo.enableNotifications };
|
||||
}
|
||||
|
||||
export async function deleteChannel(channelId: string) {
|
||||
return { success: false, error: 'disabled atm. dm @eth0 if you want to request a deletion.' }
|
||||
/* const { user } = await validateRequest();
|
||||
if (!user) {
|
||||
return { success: false, error: 'Unauthorized' };
|
||||
}
|
||||
|
||||
const channel = await prisma.channel.findUnique({
|
||||
where: { id: channelId },
|
||||
include: {
|
||||
owner: true,
|
||||
personalFor: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!channel) {
|
||||
return { success: false, error: 'Channel not found' };
|
||||
}
|
||||
|
||||
if (channel.ownerId !== user.id) {
|
||||
return { success: false, error: 'Only channel owners can delete channels' };
|
||||
}
|
||||
|
||||
// Prevent deletion of personal channels
|
||||
if (channel.personalFor) {
|
||||
return { success: false, error: 'Cannot delete personal channels' };
|
||||
}
|
||||
|
||||
await prisma.channel.delete({
|
||||
where: { id: channelId },
|
||||
});
|
||||
|
||||
return { success: true }; */
|
||||
}
|
||||
@@ -15,3 +15,13 @@ export const onboardSchema = z.object({
|
||||
userId: z.string().min(1),
|
||||
username: username,
|
||||
});
|
||||
|
||||
export const createChannelSchema = z.object({
|
||||
name: username,
|
||||
});
|
||||
|
||||
export const updateChannelSettingsSchema = z.object({
|
||||
channelId: z.string().min(1),
|
||||
pfpUrl: z.string(),
|
||||
description: z.string().min(1).max(500),
|
||||
});
|
||||
486
apps/web/src/lib/hooks/useUserList.md
Normal file
486
apps/web/src/lib/hooks/useUserList.md
Normal file
@@ -0,0 +1,486 @@
|
||||
# useUserList Hook - Production Documentation
|
||||
|
||||
note by @SrIzan10: this was made by claude 4 sonnet to speed up development. i'm really lazy to make
|
||||
a good hook that is production ready, plus it's going to be used in lots of places.
|
||||
|
||||
## Overview
|
||||
|
||||
The `useUserList` hook provides a robust, production-ready interface for fetching and managing stream information data. Built on top of SWR, it includes comprehensive error handling, caching strategies, and performance optimizations.
|
||||
|
||||
## Features
|
||||
|
||||
### ✅ Production-Ready Enhancements
|
||||
|
||||
- **Enhanced Error Handling**: Custom error types, retry logic, and proper error boundaries
|
||||
- **Type Safety**: Comprehensive TypeScript interfaces and type guards
|
||||
- **Performance Optimization**: Memoized values, efficient re-renders, and smart caching
|
||||
- **Robust Retry Logic**: Exponential backoff with jitter, configurable retry strategies
|
||||
- **Background Fetching**: Non-blocking updates with loading state management
|
||||
- **Cache Management**: Global cache utilities with invalidation strategies
|
||||
- **Memory Optimization**: Proper cleanup and memoization patterns
|
||||
- **Development Tools**: Debug logging and development-specific features
|
||||
|
||||
### 🚀 Key Improvements Over Original
|
||||
|
||||
1. **Error Handling**:
|
||||
- Custom `StreamInfoError` type with status codes
|
||||
- Smart retry logic that doesn't retry 4xx errors
|
||||
- Exponential backoff with jitter to prevent thundering herd
|
||||
|
||||
2. **Performance**:
|
||||
- Memoized computed values to prevent unnecessary re-renders
|
||||
- Callback memoization for stable function references
|
||||
- Efficient parameter serialization
|
||||
|
||||
3. **Reliability**:
|
||||
- Proper fallback data handling
|
||||
- Network-aware fetching options
|
||||
- Configurable pause/resume functionality
|
||||
|
||||
4. **Developer Experience**:
|
||||
- Comprehensive TypeScript types
|
||||
- Debug logging in development
|
||||
- Clear error messages and documentation
|
||||
|
||||
## API Reference
|
||||
|
||||
### Main Hook
|
||||
|
||||
```typescript
|
||||
function useUserList(options?: UseUserListOptions): UseUserListReturn
|
||||
```
|
||||
|
||||
### Options Interface
|
||||
|
||||
```typescript
|
||||
interface UseUserListOptions {
|
||||
// Data filtering
|
||||
owned?: boolean // Only fetch user's owned channels
|
||||
personal?: boolean // Include personal channels
|
||||
live?: boolean // Only fetch live channels
|
||||
|
||||
// Caching & Performance
|
||||
refreshInterval?: number // Auto-refresh interval (ms)
|
||||
cacheTTL?: number // Cache time-to-live (ms)
|
||||
dedupingInterval?: number // Request deduplication window (ms)
|
||||
|
||||
// Revalidation Behavior
|
||||
revalidateOnFocus?: boolean // Revalidate when window gains focus
|
||||
revalidateOnReconnect?: boolean // Revalidate when network reconnects
|
||||
revalidateIfStale?: boolean // Background revalidation of stale data
|
||||
refreshWhenHidden?: boolean // Continue refreshing when tab hidden
|
||||
refreshWhenOffline?: boolean // Continue refreshing when offline
|
||||
|
||||
// Error Handling
|
||||
errorRetryCount?: number // Number of retry attempts
|
||||
errorRetryInterval?: number // Base retry interval (ms)
|
||||
|
||||
// Control
|
||||
isPaused?: boolean // Pause all fetching
|
||||
}
|
||||
```
|
||||
|
||||
### Return Interface
|
||||
|
||||
```typescript
|
||||
interface UseUserListReturn {
|
||||
// Data
|
||||
channels: StreamInfoResponse // Array of stream info objects
|
||||
totalChannels: number // Total number of channels
|
||||
liveChannels: number // Number of currently live channels
|
||||
lastUpdated: Date | null // Last successful update timestamp
|
||||
|
||||
// Loading States
|
||||
isLoading: boolean // Initial loading state
|
||||
isValidating: boolean // Validation/revalidation in progress
|
||||
isBackgroundFetching: boolean // Background update in progress
|
||||
|
||||
// Error Handling
|
||||
error?: StreamInfoError // Current error state
|
||||
|
||||
// Actions
|
||||
refresh: () => Promise<StreamInfoResponse | undefined> // Manual refresh
|
||||
clearCache: () => Promise<void> // Clear cache
|
||||
prefetch: () => Promise<StreamInfoResponse | undefined> // Prefetch data
|
||||
|
||||
// Metadata
|
||||
cacheKey: string // Cache key for this query
|
||||
}
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```typescript
|
||||
import { useUserList } from '@/lib/hooks/useUserList'
|
||||
|
||||
function ChannelList() {
|
||||
const {
|
||||
channels,
|
||||
isLoading,
|
||||
error,
|
||||
totalChannels,
|
||||
refresh
|
||||
} = useUserList()
|
||||
|
||||
if (isLoading) return <div>Loading...</div>
|
||||
if (error) return <div>Error: {error.message}</div>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Channels ({totalChannels})</h2>
|
||||
<button onClick={refresh}>Refresh</button>
|
||||
{channels.map(channel => (
|
||||
<ChannelCard key={channel.id} channel={channel} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Live Channels with Custom Configuration
|
||||
|
||||
```typescript
|
||||
function LiveChannels() {
|
||||
const {
|
||||
channels,
|
||||
liveChannels,
|
||||
isBackgroundFetching,
|
||||
error
|
||||
} = useUserList({
|
||||
live: true,
|
||||
refreshInterval: 5000, // Refresh every 5 seconds
|
||||
revalidateOnFocus: true, // Refresh when user returns to tab
|
||||
errorRetryCount: 5, // Retry failed requests 5 times
|
||||
refreshWhenHidden: false // Stop refreshing when tab is hidden
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>
|
||||
Live Channels ({liveChannels})
|
||||
{isBackgroundFetching && <span>🔄</span>}
|
||||
</h2>
|
||||
{error && (
|
||||
<div className="error">
|
||||
Failed to load live channels: {error.message}
|
||||
{error.status && ` (${error.status})`}
|
||||
</div>
|
||||
)}
|
||||
{channels.map(channel => (
|
||||
<LiveChannelCard key={channel.id} channel={channel} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Convenience Hooks
|
||||
|
||||
```typescript
|
||||
// Optimized for different use cases
|
||||
const { channels } = useAllChannels() // All channels, less frequent updates
|
||||
const { channels } = useOwnedChannels() // User's channels, focus-aware
|
||||
const { channels } = useLiveChannels() // Live channels, frequent updates
|
||||
const { channels } = usePersonalChannels() // Personal channels
|
||||
```
|
||||
|
||||
### Cache Management
|
||||
|
||||
```typescript
|
||||
import { channelCacheUtils } from '@/lib/hooks/useUserList'
|
||||
|
||||
// Clear all channel caches
|
||||
await channelCacheUtils.clearAll()
|
||||
|
||||
// Invalidate live channels when stream status changes
|
||||
await channelCacheUtils.invalidateLive()
|
||||
|
||||
// Invalidate specific cache
|
||||
await channelCacheUtils.invalidateByOptions({ live: true, owned: true })
|
||||
|
||||
// Warm up cache on app start
|
||||
await channelCacheUtils.warmUp()
|
||||
|
||||
// Get cache statistics for debugging
|
||||
const stats = channelCacheUtils.getStats()
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
The hook provides sophisticated error handling with custom error types:
|
||||
|
||||
```typescript
|
||||
interface StreamInfoError extends Error {
|
||||
status?: number // HTTP status code
|
||||
statusText?: string // HTTP status text
|
||||
info?: any // Additional error details
|
||||
}
|
||||
```
|
||||
|
||||
### Error Scenarios
|
||||
|
||||
1. **Network Errors**: Automatic retry with exponential backoff
|
||||
2. **HTTP 4xx Errors**: No retry (client errors)
|
||||
3. **HTTP 5xx Errors**: Retry with backoff
|
||||
4. **Parsing Errors**: Immediate failure with clear error message
|
||||
|
||||
### Custom Error Handling
|
||||
|
||||
```typescript
|
||||
const { error, refresh } = useUserList()
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
if (error.status === 401) {
|
||||
// Handle authentication errors
|
||||
redirectToLogin()
|
||||
} else if (error.status && error.status >= 500) {
|
||||
// Handle server errors
|
||||
showNotification('Server error, please try again later')
|
||||
} else {
|
||||
// Handle other errors
|
||||
console.error('Unexpected error:', error)
|
||||
}
|
||||
}
|
||||
}, [error])
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Caching Strategy
|
||||
|
||||
- **All Channels**: 10-minute cache, 60-second refresh
|
||||
- **Owned Channels**: 5-minute cache, 30-second refresh
|
||||
- **Live Channels**: 30-second cache, 10-second refresh
|
||||
- **Personal Channels**: 7-minute cache, 45-second refresh
|
||||
|
||||
### Memory Optimization
|
||||
|
||||
- Memoized computed values prevent unnecessary re-renders
|
||||
- Callback functions are memoized for stable references
|
||||
- Automatic cleanup when component unmounts
|
||||
|
||||
### Network Optimization
|
||||
|
||||
- Request deduplication prevents duplicate API calls
|
||||
- Background revalidation keeps data fresh without blocking UI
|
||||
- Smart retry logic prevents unnecessary network load
|
||||
|
||||
## Monitoring & Debugging
|
||||
|
||||
### Development Mode
|
||||
|
||||
```typescript
|
||||
// Enable debug logging
|
||||
console.debug('[useUserList] Successfully fetched 5 channels for key: stream-info:live')
|
||||
console.error('[useUserList] Error fetching data for key stream-info:all:', error)
|
||||
```
|
||||
|
||||
### Production Monitoring
|
||||
|
||||
The hook includes hooks for adding production monitoring:
|
||||
|
||||
```typescript
|
||||
// In onError callback
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
// Send to error tracking service
|
||||
Sentry.captureException(error)
|
||||
|
||||
// Send custom metrics
|
||||
analytics.track('stream_fetch_error', {
|
||||
url: key,
|
||||
error: error.message,
|
||||
status: error.status
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Migration from Original Hook
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
1. **Return Type**: Now returns `UseUserListReturn` interface instead of object
|
||||
2. **Error Type**: Errors are now `StreamInfoError` instead of generic `Error`
|
||||
3. **Cache Keys**: Updated cache key format for better organization
|
||||
|
||||
### Migration Steps
|
||||
|
||||
1. Update import statements if using TypeScript
|
||||
2. Update error handling to use new `StreamInfoError` type
|
||||
3. Replace any direct cache key usage with new format
|
||||
4. Update any custom retry logic to use new options
|
||||
|
||||
### Before
|
||||
|
||||
```typescript
|
||||
const { channels, error, refresh } = useUserList({ live: true })
|
||||
|
||||
// Error handling
|
||||
if (error) {
|
||||
console.error('Error:', error)
|
||||
}
|
||||
```
|
||||
|
||||
### After
|
||||
|
||||
```typescript
|
||||
const { channels, error, refresh } = useUserList({ live: true })
|
||||
|
||||
// Enhanced error handling
|
||||
if (error) {
|
||||
console.error('Error:', error.message)
|
||||
if (error.status) {
|
||||
console.error('Status:', error.status)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Use Appropriate Convenience Hooks
|
||||
|
||||
```typescript
|
||||
// ✅ Good - Use specific hooks for better defaults
|
||||
const { channels } = useLiveChannels()
|
||||
|
||||
// ❌ Avoid - Manual configuration when convenience hook exists
|
||||
const { channels } = useUserList({
|
||||
live: true,
|
||||
refreshInterval: 10000,
|
||||
cacheTTL: 30000,
|
||||
revalidateOnFocus: true
|
||||
})
|
||||
```
|
||||
|
||||
### 2. Handle Loading States Properly
|
||||
|
||||
```typescript
|
||||
// ✅ Good - Distinguish between initial load and background updates
|
||||
const { isLoading, isBackgroundFetching, channels } = useUserList()
|
||||
|
||||
if (isLoading) {
|
||||
return <FullPageLoader />
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{isBackgroundFetching && <RefreshIndicator />}
|
||||
<ChannelList channels={channels} />
|
||||
</div>
|
||||
)
|
||||
```
|
||||
|
||||
### 3. Implement Proper Error Boundaries
|
||||
|
||||
```typescript
|
||||
// ✅ Good - Comprehensive error handling
|
||||
function ChannelListWithErrorBoundary() {
|
||||
const { channels, error, refresh, isLoading } = useUserList()
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorState
|
||||
error={error}
|
||||
onRetry={refresh}
|
||||
showRetry={error.status !== 401}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <ChannelList channels={channels} isLoading={isLoading} />
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Use Cache Management Appropriately
|
||||
|
||||
```typescript
|
||||
// ✅ Good - Clear caches when user logs out
|
||||
function useLogout() {
|
||||
const logout = async () => {
|
||||
await authService.logout()
|
||||
await channelCacheUtils.clearAll()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
return logout
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Testing
|
||||
|
||||
```typescript
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { useUserList } from '@/lib/hooks/useUserList'
|
||||
|
||||
describe('useUserList', () => {
|
||||
it('fetches channels successfully', async () => {
|
||||
const { result } = renderHook(() => useUserList())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
expect(result.current.channels).toHaveLength(5)
|
||||
expect(result.current.error).toBeUndefined()
|
||||
})
|
||||
|
||||
it('handles errors gracefully', async () => {
|
||||
// Mock fetch to return error
|
||||
fetchMock.mockRejectOnce(new Error('Network error'))
|
||||
|
||||
const { result } = renderHook(() => useUserList())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBeDefined()
|
||||
})
|
||||
|
||||
expect(result.current.error?.message).toBe('Network error')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Integration Testing
|
||||
|
||||
```typescript
|
||||
describe('useUserList integration', () => {
|
||||
it('refreshes data when stream goes live', async () => {
|
||||
const { result } = renderHook(() => useLiveChannels())
|
||||
|
||||
// Simulate stream going live
|
||||
await act(async () => {
|
||||
await channelCacheUtils.invalidateLive()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.liveChannels).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **High Memory Usage**: Check if components are properly memoized
|
||||
2. **Too Many Requests**: Verify deduplication settings and refresh intervals
|
||||
3. **Stale Data**: Check cache TTL and revalidation settings
|
||||
4. **Authentication Errors**: Implement proper 401 error handling
|
||||
|
||||
### Debug Checklist
|
||||
|
||||
- [ ] Check network tab for actual API calls
|
||||
- [ ] Verify cache keys are unique per query combination
|
||||
- [ ] Ensure proper error boundaries are in place
|
||||
- [ ] Check refresh intervals are appropriate for use case
|
||||
- [ ] Verify retry logic is working as expected
|
||||
|
||||
## Conclusion
|
||||
|
||||
This production-ready implementation of `useUserList` provides a robust foundation for managing stream information data in your application. It includes comprehensive error handling, performance optimizations, and developer-friendly features while maintaining backward compatibility with existing code.
|
||||
|
||||
The hook is designed to scale with your application's needs and provides the flexibility to handle various use cases while maintaining optimal performance and user experience.
|
||||
434
apps/web/src/lib/hooks/useUserList.tsx
Normal file
434
apps/web/src/lib/hooks/useUserList.tsx
Normal file
@@ -0,0 +1,434 @@
|
||||
|
||||
'use client'
|
||||
|
||||
import useSWR, { mutate as globalMutate } from 'swr'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import type { StreamInfoResponse } from '@/lib/providers/StreamInfoProvider'
|
||||
import { fetcher } from '@/lib/services/swr'
|
||||
|
||||
// Cache utility functions
|
||||
const CACHE_KEYS = {
|
||||
ALL_CHANNELS: 'stream-info:all',
|
||||
OWNED_CHANNELS: 'stream-info:owned',
|
||||
LIVE_CHANNELS: 'stream-info:live',
|
||||
PERSONAL_CHANNELS: 'stream-info:personal',
|
||||
} as const
|
||||
|
||||
// Error types for better error handling
|
||||
export interface StreamInfoError extends Error {
|
||||
status?: number
|
||||
statusText?: string
|
||||
info?: any
|
||||
}
|
||||
|
||||
// Create a cache key based on options
|
||||
function createCacheKey(options: UseUserListOptions): string {
|
||||
const params = []
|
||||
if (options.owned) params.push('owned')
|
||||
if (options.personal) params.push('personal')
|
||||
if (options.live) params.push('live')
|
||||
|
||||
return params.length > 0
|
||||
? `stream-info:${params.join('-')}`
|
||||
: CACHE_KEYS.ALL_CHANNELS
|
||||
}
|
||||
|
||||
// Enhanced fetcher with proper error handling
|
||||
async function enhancedFetcher(url: string): Promise<StreamInfoResponse> {
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
|
||||
if (!response.ok) {
|
||||
const error = new Error(`HTTP ${response.status}: ${response.statusText}`) as StreamInfoError
|
||||
error.status = response.status
|
||||
error.statusText = response.statusText
|
||||
|
||||
// Try to get error details from response
|
||||
try {
|
||||
error.info = await response.json()
|
||||
} catch {
|
||||
// If response is not JSON, that's fine
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Validate that response is an array
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error('Invalid response format: expected array')
|
||||
}
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw error
|
||||
}
|
||||
throw new Error('Unknown error occurred while fetching stream info')
|
||||
}
|
||||
}
|
||||
|
||||
export interface UseUserListOptions {
|
||||
/** Only fetch channels owned by the current user */
|
||||
owned?: boolean
|
||||
/** Include personal channels */
|
||||
personal?: boolean
|
||||
/** Only fetch live channels */
|
||||
live?: boolean
|
||||
/** Refresh interval in milliseconds */
|
||||
refreshInterval?: number
|
||||
/** Cache time to live in milliseconds (default: 5 minutes) */
|
||||
cacheTTL?: number
|
||||
/** Whether to revalidate on focus (default: false) */
|
||||
revalidateOnFocus?: boolean
|
||||
/** Whether to revalidate on reconnect (default: true) */
|
||||
revalidateOnReconnect?: boolean
|
||||
/** Whether to dedupe requests (default: true) */
|
||||
dedupingInterval?: number
|
||||
/** Whether to use background revalidation (default: true) */
|
||||
revalidateIfStale?: boolean
|
||||
/** Whether to pause fetching (default: false) */
|
||||
isPaused?: boolean
|
||||
/** Custom error retry count (default: 3) */
|
||||
errorRetryCount?: number
|
||||
/** Custom error retry interval in milliseconds (default: 5000) */
|
||||
errorRetryInterval?: number
|
||||
/** Whether to refresh when hidden (default: false) */
|
||||
refreshWhenHidden?: boolean
|
||||
/** Whether to refresh when offline (default: false) */
|
||||
refreshWhenOffline?: boolean
|
||||
}
|
||||
|
||||
export interface UseUserListReturn {
|
||||
/** Array of channels/streams */
|
||||
channels: StreamInfoResponse
|
||||
/** Loading state */
|
||||
isLoading: boolean
|
||||
/** Error state */
|
||||
error?: StreamInfoError
|
||||
/** Whether this is the first load */
|
||||
isValidating: boolean
|
||||
/** Manually refresh the data */
|
||||
refresh: () => Promise<StreamInfoResponse | undefined>
|
||||
/** Clear the cache for this query */
|
||||
clearCache: () => Promise<void>
|
||||
/** Prefetch data without triggering rerender */
|
||||
prefetch: () => Promise<StreamInfoResponse | undefined>
|
||||
/** Total number of channels */
|
||||
totalChannels: number
|
||||
/** Number of live channels */
|
||||
liveChannels: number
|
||||
/** Cache key for this query */
|
||||
cacheKey: string
|
||||
/** Last updated timestamp */
|
||||
lastUpdated: Date | null
|
||||
/** Whether data is being fetched in background */
|
||||
isBackgroundFetching: boolean
|
||||
}
|
||||
|
||||
export function useUserList(options: UseUserListOptions = {}): UseUserListReturn {
|
||||
const {
|
||||
owned = false,
|
||||
personal = false,
|
||||
live = false,
|
||||
refreshInterval = 30000,
|
||||
cacheTTL = 5 * 60 * 1000, // 5 minutes
|
||||
revalidateOnFocus = false,
|
||||
revalidateOnReconnect = true,
|
||||
dedupingInterval = 2000, // 2 seconds
|
||||
revalidateIfStale = true,
|
||||
isPaused = false,
|
||||
errorRetryCount = 3,
|
||||
errorRetryInterval = 5000,
|
||||
refreshWhenHidden = false,
|
||||
refreshWhenOffline = false,
|
||||
} = options
|
||||
|
||||
// Build query parameters
|
||||
const params = useMemo(() => {
|
||||
const searchParams = new URLSearchParams()
|
||||
if (owned) searchParams.set('owned', 'true')
|
||||
if (personal) searchParams.set('personal', 'true')
|
||||
if (live) searchParams.set('live', 'true')
|
||||
return searchParams
|
||||
}, [owned, personal, live])
|
||||
|
||||
const queryString = params.toString()
|
||||
const url = `/api/stream/info${queryString ? `?${queryString}` : ''}`
|
||||
const cacheKey = createCacheKey(options)
|
||||
|
||||
// SWR configuration with enhanced error handling
|
||||
const swrConfig = useMemo(() => ({
|
||||
refreshInterval: isPaused ? 0 : refreshInterval,
|
||||
revalidateOnFocus,
|
||||
revalidateOnReconnect,
|
||||
dedupingInterval,
|
||||
revalidateIfStale,
|
||||
errorRetryCount,
|
||||
errorRetryInterval,
|
||||
refreshWhenHidden,
|
||||
refreshWhenOffline,
|
||||
keepPreviousData: true,
|
||||
fallbackData: [] as StreamInfoResponse,
|
||||
|
||||
// Custom error retry logic
|
||||
onErrorRetry: (error: StreamInfoError, key: string, config: any, revalidate: any, { retryCount }: any) => {
|
||||
// Don't retry on 4xx errors (client errors)
|
||||
if (error.status && error.status >= 400 && error.status < 500) {
|
||||
return
|
||||
}
|
||||
|
||||
// Don't retry on network errors after max attempts
|
||||
if (retryCount >= errorRetryCount) {
|
||||
return
|
||||
}
|
||||
|
||||
// Exponential backoff with jitter
|
||||
const timeout = Math.min(errorRetryInterval * Math.pow(2, retryCount), 30000)
|
||||
const jitter = Math.random() * 1000
|
||||
|
||||
setTimeout(() => revalidate({ retryCount }), timeout + jitter)
|
||||
},
|
||||
|
||||
// Success callback for metrics/logging
|
||||
onSuccess: (data: StreamInfoResponse, key: string, config: any) => {
|
||||
// Could add analytics/metrics here
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.debug(`[useUserList] Successfully fetched ${data.length} channels for key: ${key}`)
|
||||
}
|
||||
},
|
||||
|
||||
// Error callback for logging
|
||||
onError: (error: StreamInfoError, key: string, config: any) => {
|
||||
// Log errors for monitoring
|
||||
console.error(`[useUserList] Error fetching data for key ${key}:`, error)
|
||||
|
||||
// Could send to error tracking service here
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
// e.g., Sentry.captureException(error)
|
||||
}
|
||||
},
|
||||
}), [
|
||||
isPaused,
|
||||
refreshInterval,
|
||||
revalidateOnFocus,
|
||||
revalidateOnReconnect,
|
||||
dedupingInterval,
|
||||
revalidateIfStale,
|
||||
errorRetryCount,
|
||||
errorRetryInterval,
|
||||
refreshWhenHidden,
|
||||
refreshWhenOffline,
|
||||
])
|
||||
|
||||
const { data, error, isLoading, isValidating, mutate } = useSWR<StreamInfoResponse, StreamInfoError>(
|
||||
isPaused ? null : url,
|
||||
enhancedFetcher,
|
||||
swrConfig
|
||||
)
|
||||
|
||||
// Memoized computed values
|
||||
const computedValues = useMemo(() => {
|
||||
const channels = data || []
|
||||
return {
|
||||
totalChannels: channels.length,
|
||||
liveChannels: channels.filter(stream => stream.isLive).length,
|
||||
lastUpdated: channels.length > 0 ? new Date() : null,
|
||||
}
|
||||
}, [data])
|
||||
|
||||
// Memoized action functions
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
return await mutate()
|
||||
} catch (error) {
|
||||
console.error('[useUserList] Error during manual refresh:', error)
|
||||
throw error
|
||||
}
|
||||
}, [mutate])
|
||||
|
||||
const clearCache = useCallback(async () => {
|
||||
try {
|
||||
await mutate(undefined, { revalidate: false })
|
||||
// Also clear from global cache if needed
|
||||
await globalMutate(
|
||||
key => typeof key === 'string' && key.startsWith('/api/stream/info'),
|
||||
undefined,
|
||||
{ revalidate: false }
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('[useUserList] Error during cache clear:', error)
|
||||
throw error
|
||||
}
|
||||
}, [mutate])
|
||||
|
||||
const prefetch = useCallback(async () => {
|
||||
try {
|
||||
return await mutate()
|
||||
} catch (error) {
|
||||
console.error('[useUserList] Error during prefetch:', error)
|
||||
throw error
|
||||
}
|
||||
}, [mutate])
|
||||
|
||||
return {
|
||||
channels: data || [],
|
||||
isLoading,
|
||||
error,
|
||||
isValidating,
|
||||
refresh,
|
||||
clearCache,
|
||||
prefetch,
|
||||
cacheKey,
|
||||
isBackgroundFetching: isValidating && !isLoading,
|
||||
...computedValues,
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience hooks for common use cases with optimized caching
|
||||
export function useAllChannels(refreshInterval?: number): UseUserListReturn {
|
||||
return useUserList({
|
||||
refreshInterval: refreshInterval ?? 60000, // Less frequent updates for all channels
|
||||
cacheTTL: 10 * 60 * 1000, // 10 minutes cache
|
||||
refreshWhenHidden: false,
|
||||
errorRetryCount: 2, // Fewer retries for non-critical data
|
||||
})
|
||||
}
|
||||
|
||||
export function useOwnedChannels(refreshInterval?: number): UseUserListReturn {
|
||||
return useUserList({
|
||||
owned: true,
|
||||
refreshInterval: refreshInterval ?? 30000,
|
||||
cacheTTL: 5 * 60 * 1000, // 5 minutes cache
|
||||
revalidateOnFocus: true, // User's own channels are more important
|
||||
})
|
||||
}
|
||||
|
||||
export function useLiveChannels(refreshInterval?: number): UseUserListReturn {
|
||||
return useUserList({
|
||||
live: true,
|
||||
refreshInterval: refreshInterval ?? 10000, // More frequent updates for live channels
|
||||
cacheTTL: 30 * 1000, // 30 seconds cache for live data
|
||||
revalidateOnFocus: true, // Revalidate when user focuses tab
|
||||
refreshWhenHidden: false, // Don't waste resources when hidden
|
||||
errorRetryCount: 5, // More retries for critical live data
|
||||
})
|
||||
}
|
||||
|
||||
export function usePersonalChannels(refreshInterval?: number): UseUserListReturn {
|
||||
return useUserList({
|
||||
personal: true,
|
||||
refreshInterval: refreshInterval ?? 45000,
|
||||
cacheTTL: 7 * 60 * 1000, // 7 minutes cache
|
||||
revalidateOnFocus: true,
|
||||
})
|
||||
}
|
||||
|
||||
// Cache management utilities with proper error handling
|
||||
export const channelCacheUtils = {
|
||||
/** Clear all channel caches */
|
||||
clearAll: async (): Promise<void> => {
|
||||
try {
|
||||
await Promise.all([
|
||||
globalMutate(
|
||||
key => typeof key === 'string' && key.includes('/api/stream/info'),
|
||||
undefined,
|
||||
{ revalidate: false }
|
||||
),
|
||||
// Clear specific cache keys
|
||||
...Object.values(CACHE_KEYS).map(key =>
|
||||
globalMutate(key, undefined, { revalidate: false })
|
||||
)
|
||||
])
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.debug('[channelCacheUtils] All caches cleared successfully')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[channelCacheUtils] Error clearing caches:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
/** Invalidate live channels cache (useful when stream status changes) */
|
||||
invalidateLive: async (): Promise<void> => {
|
||||
try {
|
||||
await globalMutate(
|
||||
key => typeof key === 'string' && (
|
||||
key.includes('/api/stream/info?live=true') ||
|
||||
key === CACHE_KEYS.LIVE_CHANNELS
|
||||
),
|
||||
undefined,
|
||||
{ revalidate: true }
|
||||
)
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.debug('[channelCacheUtils] Live channels cache invalidated')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[channelCacheUtils] Error invalidating live cache:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
/** Invalidate specific cache by options */
|
||||
invalidateByOptions: async (options: UseUserListOptions): Promise<void> => {
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (options.owned) params.set('owned', 'true')
|
||||
if (options.personal) params.set('personal', 'true')
|
||||
if (options.live) params.set('live', 'true')
|
||||
|
||||
const queryString = params.toString()
|
||||
const url = `/api/stream/info${queryString ? `?${queryString}` : ''}`
|
||||
|
||||
await globalMutate(url, undefined, { revalidate: true })
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.debug(`[channelCacheUtils] Cache invalidated for: ${url}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[channelCacheUtils] Error invalidating specific cache:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
/** Warm up cache by prefetching common queries */
|
||||
warmUp: async (): Promise<void> => {
|
||||
try {
|
||||
const queries = [
|
||||
'/api/stream/info',
|
||||
'/api/stream/info?live=true',
|
||||
'/api/stream/info?owned=true'
|
||||
]
|
||||
|
||||
// Prefetch in parallel but don't fail if some requests fail
|
||||
const results = await Promise.allSettled(
|
||||
queries.map(url => enhancedFetcher(url))
|
||||
)
|
||||
|
||||
const failed = results.filter(result => result.status === 'rejected')
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.debug(`[channelCacheUtils] Cache warmed up. ${results.length - failed.length}/${results.length} succeeded`)
|
||||
}
|
||||
|
||||
if (failed.length > 0) {
|
||||
console.warn(`[channelCacheUtils] ${failed.length} cache warm-up requests failed`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[channelCacheUtils] Error during cache warm-up:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
/** Get cache statistics for debugging */
|
||||
getStats: (): { cacheKeys: string[] } => {
|
||||
// This is a simplified version - in a real implementation you might
|
||||
// want to integrate with SWR's cache inspector or build custom monitoring
|
||||
return {
|
||||
cacheKeys: Object.values(CACHE_KEYS)
|
||||
}
|
||||
}
|
||||
}
|
||||
44
apps/web/src/lib/instrumentation/emojisWriteRedis.ts
Normal file
44
apps/web/src/lib/instrumentation/emojisWriteRedis.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { getRedisConnection } from '@hctv/db';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { existsSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export async function emojisWriteRedis() {
|
||||
const startTime = performance.now();
|
||||
const fileLocation = getPath();
|
||||
const redis = getRedisConnection();
|
||||
|
||||
const emojisFile = await readFile(fileLocation, 'utf-8');
|
||||
const emojis = JSON.parse(emojisFile) as Record<string, string>;
|
||||
|
||||
// janky way to remove not existing emojis,
|
||||
// just obliterate the emojis hash and rewrite it lol help
|
||||
await redis.del('emojis');
|
||||
for (const [name, url] of Object.entries(emojis)) {
|
||||
await redis.hset('emojis', name, url);
|
||||
}
|
||||
|
||||
const endTime = performance.now();
|
||||
console.log(`Finished writing emojis to Redis in ${(endTime - startTime).toFixed(2)}ms`);
|
||||
}
|
||||
|
||||
function getPath() {
|
||||
const possiblePaths = [
|
||||
// original
|
||||
'src/lib/instrumentation/emojis.json',
|
||||
// relative
|
||||
path.join(__dirname, 'emojis.json'),
|
||||
// standalone nextjs
|
||||
path.join(process.cwd(), 'src/lib/instrumentation/emojis.json')
|
||||
];
|
||||
console.log('Writing emojis to Redis...');
|
||||
|
||||
for (const p of possiblePaths) {
|
||||
if (existsSync(p)) {
|
||||
console.log(`Found emojis at: ${p}`);
|
||||
return p;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('emojis json not found anywhere');
|
||||
}
|
||||
21
apps/web/src/lib/instrumentation/getLiveThumb.ts
Normal file
21
apps/web/src/lib/instrumentation/getLiveThumb.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { prisma } from "@hctv/db";
|
||||
import { getThumbnailQueue } from "../workers";
|
||||
|
||||
export default async function getLiveThumb() {
|
||||
const liveChannels = await prisma.streamInfo.findMany({
|
||||
where: {
|
||||
isLive: true,
|
||||
},
|
||||
include: {
|
||||
channel: true,
|
||||
}
|
||||
});
|
||||
const liveChannelNames = liveChannels.map((channel) => channel.channel.name);
|
||||
|
||||
const thumbQueue = getThumbnailQueue();
|
||||
for (const channel of liveChannelNames) {
|
||||
await thumbQueue.add("getLiveThumb", {
|
||||
name: channel,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -130,13 +130,14 @@ export async function syncStream() {
|
||||
channel: process.env.NOTIFICATION_CHANNEL_ID!,
|
||||
unfurl_links: true,
|
||||
});
|
||||
|
||||
for (const follower of subscribedFollowers) {
|
||||
queue.add(`streamStartDm:${follower.user.id}`, {
|
||||
text: `${existingStream.username} is now *live*, streaming *${existingStream.title}* (${existingStream.category})!\n<https://hctv.srizan.dev/${existingStream.username}|Go check them out>\n_Stream notifications are enabled for this user. If you want to disable them, you can do so in \`Profile > Follows\`._`,
|
||||
channel: follower.user.slack_id,
|
||||
unfurl_links: true,
|
||||
});
|
||||
if (existingStream.enableNotifications) {
|
||||
for (const follower of subscribedFollowers) {
|
||||
queue.add(`streamStartDm:${follower.user.id}`, {
|
||||
text: `${existingStream.username} is now *live*, streaming *${existingStream.title}* (${existingStream.category})!\n<https://hctv.srizan.dev/${existingStream.username}|Go check them out>\n_Stream notifications are enabled for this user. If you want to disable them, you can do so in \`Profile > Follows\`._`,
|
||||
channel: follower.user.slack_id,
|
||||
unfurl_links: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
22
apps/web/src/lib/instrumentation/viewerCountSync.ts
Normal file
22
apps/web/src/lib/instrumentation/viewerCountSync.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { getRedisConnection, prisma } from "@hctv/db";
|
||||
|
||||
export async function viewerCountSync() {
|
||||
const streams = await prisma.streamInfo.findMany({
|
||||
include: {
|
||||
channel: true
|
||||
}
|
||||
})
|
||||
const redis = getRedisConnection();
|
||||
|
||||
for (const stream of streams) {
|
||||
const viewerCount = await redis.keys(`viewer:${stream.channel.name}:*`);
|
||||
await prisma.streamInfo.update({
|
||||
where: {
|
||||
username: stream.username,
|
||||
},
|
||||
data: {
|
||||
viewers: viewerCount.length,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
17
apps/web/src/lib/instrumentation/writeSessions.ts
Normal file
17
apps/web/src/lib/instrumentation/writeSessions.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { prisma } from "@hctv/db";
|
||||
import { getRedisConnection } from "@hctv/db";
|
||||
|
||||
export default async function writeSessions() {
|
||||
const sessions = await prisma.session.findMany();
|
||||
const sessionIds = sessions.map((session) => session.id);
|
||||
|
||||
const redis = getRedisConnection();
|
||||
const multi = redis.multi();
|
||||
multi.del('sessions:*')
|
||||
for (const sessionId of sessionIds) {
|
||||
multi.set(`sessions:${sessionId}`, '');
|
||||
}
|
||||
await multi.exec();
|
||||
|
||||
console.log("Sessions written to Redis");
|
||||
}
|
||||
7
apps/web/src/lib/nuqs/channelSettings.ts
Normal file
7
apps/web/src/lib/nuqs/channelSettings.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createLoader, parseAsString } from 'nuqs/server'
|
||||
|
||||
export const searchParams = {
|
||||
tab: parseAsString.withDefault('general'),
|
||||
}
|
||||
|
||||
export const loadSearchParams = createLoader(searchParams)
|
||||
47
apps/web/src/lib/services/uploadthing/fileRouter.ts
Normal file
47
apps/web/src/lib/services/uploadthing/fileRouter.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { createUploadthing, type FileRouter } from "uploadthing/next";
|
||||
import { UploadThingError } from "uploadthing/server";
|
||||
import { validateRequest } from "../../auth/validate";
|
||||
|
||||
const f = createUploadthing();
|
||||
|
||||
const auth = async () => {
|
||||
const req = await validateRequest();
|
||||
return req.user;
|
||||
};
|
||||
|
||||
// FileRouter for your app, can contain multiple FileRoutes
|
||||
export const ourFileRouter = {
|
||||
// Define as many FileRoutes as you like, each with a unique routeSlug
|
||||
pfpUpload: f({
|
||||
image: {
|
||||
/**
|
||||
* For full list of options and defaults, see the File Route API reference
|
||||
* @see https://docs.uploadthing.com/file-routes#route-config
|
||||
*/
|
||||
maxFileSize: "1MB",
|
||||
maxFileCount: 1,
|
||||
},
|
||||
})
|
||||
// Set permissions and file types for this FileRoute
|
||||
.middleware(async () => {
|
||||
// This code runs on your server before upload
|
||||
const user = await auth();
|
||||
|
||||
// If you throw, the user will not be able to upload
|
||||
if (!user) throw new UploadThingError("Unauthorized");
|
||||
|
||||
// Whatever is returned here is accessible in onUploadComplete as `metadata`
|
||||
return { userId: user.id };
|
||||
})
|
||||
.onUploadComplete(async ({ metadata, file }) => {
|
||||
// This code RUNS ON YOUR SERVER after upload
|
||||
console.log("Upload complete for userId:", metadata.userId);
|
||||
|
||||
console.log("file url", file.ufsUrl);
|
||||
|
||||
// !!! Whatever is returned here is sent to the clientside `onClientUploadComplete` callback
|
||||
return { uploadedBy: metadata.userId };
|
||||
}),
|
||||
} satisfies FileRouter;
|
||||
|
||||
export type OurFileRouter = typeof ourFileRouter;
|
||||
3
apps/web/src/lib/services/uploadthing/server.ts
Normal file
3
apps/web/src/lib/services/uploadthing/server.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { UTApi } from "uploadthing/server";
|
||||
|
||||
export const utapi = new UTApi();
|
||||
9
apps/web/src/lib/uploadthing.ts
Normal file
9
apps/web/src/lib/uploadthing.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import {
|
||||
generateUploadButton,
|
||||
generateUploadDropzone,
|
||||
} from "@uploadthing/react";
|
||||
|
||||
import type { OurFileRouter } from "@/lib/services/uploadthing/fileRouter";
|
||||
|
||||
export const UploadButton = generateUploadButton<OurFileRouter>();
|
||||
export const UploadDropzone = generateUploadDropzone<OurFileRouter>();
|
||||
19
apps/web/src/lib/utils/genIdenticonUpload.ts
Normal file
19
apps/web/src/lib/utils/genIdenticonUpload.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { utapi } from "../services/uploadthing/server";
|
||||
import sharp from "sharp";
|
||||
|
||||
export async function genIdenticonUpload(str: string, type?: string) {
|
||||
const identicon = await fetch(`https://api.dicebear.com/9.x/identicon/svg?seed=${str}&size=256`);
|
||||
const webpBuffer = await sharp(await identicon.arrayBuffer())
|
||||
.webp({ quality: 80 })
|
||||
.toBuffer();
|
||||
|
||||
const file = new File([webpBuffer], `${str}${type ? `-${type}` : ""}.webp`, {
|
||||
type: "image/webp",
|
||||
});
|
||||
const ul = await utapi.uploadFiles(file);
|
||||
if (ul.error) {
|
||||
throw new Error("Failed to upload identicon: " + ul.error);
|
||||
}
|
||||
|
||||
return ul.data?.ufsUrl
|
||||
}
|
||||
@@ -1,19 +1,19 @@
|
||||
import { Queue, Worker } from 'bullmq';
|
||||
import { getRedisConnection } from '@/lib/services/redis';
|
||||
import { getRedisConnection } from '@hctv/db';
|
||||
|
||||
// Singleton instances for notifier
|
||||
const globalForNotifier = global as unknown as {
|
||||
notificationQueue: Queue | null;
|
||||
notificationWorker: Worker | null;
|
||||
|
||||
thumbnailQueue: Queue | null;
|
||||
thumbnailWorker: Worker | null;
|
||||
};
|
||||
|
||||
// Initialize if they don't exist
|
||||
if (!globalForNotifier.notificationQueue) {
|
||||
globalForNotifier.notificationQueue = null;
|
||||
globalForNotifier.notificationWorker = null;
|
||||
}
|
||||
|
||||
// Get or create the notification queue
|
||||
export function getNotificationQueue(): Queue {
|
||||
if (!globalForNotifier.notificationQueue) {
|
||||
globalForNotifier.notificationQueue = new Queue('notifications', {
|
||||
@@ -28,4 +28,20 @@ export function getNotificationQueue(): Queue {
|
||||
});
|
||||
}
|
||||
return globalForNotifier.notificationQueue;
|
||||
}
|
||||
|
||||
export function getThumbnailQueue(): Queue {
|
||||
if (!globalForNotifier.thumbnailQueue) {
|
||||
globalForNotifier.thumbnailQueue = new Queue('thumbnails', {
|
||||
connection: getRedisConnection(),
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
backoff: {
|
||||
type: 'exponential',
|
||||
delay: 5000,
|
||||
},
|
||||
}
|
||||
});
|
||||
}
|
||||
return globalForNotifier.thumbnailQueue;
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { registerNotificationWorker } from './worker/notification';
|
||||
import { registerThumbnailWorker } from './worker/thumbnails';
|
||||
|
||||
export async function registerWorkers(): Promise<void> {
|
||||
await registerNotificationWorker();
|
||||
await registerThumbnailWorker();
|
||||
console.log('All workers registered successfully');
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Worker } from 'bullmq';
|
||||
import { getRedisConnection } from '@/lib/services/redis';
|
||||
import { getRedisConnection } from '@hctv/db';
|
||||
import snClient from '@/lib/services/slackNotifier';
|
||||
|
||||
const globalForWorker = global as unknown as {
|
||||
|
||||
76
apps/web/src/lib/workers/worker/thumbnails.ts
Normal file
76
apps/web/src/lib/workers/worker/thumbnails.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Worker } from 'bullmq';
|
||||
import { getRedisConnection } from '@hctv/db';
|
||||
import { exec } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { existsSync } from 'node:fs';
|
||||
const pExec = promisify(exec);
|
||||
|
||||
const globalForWorker = global as unknown as {
|
||||
thumbnailWorker: Worker | null;
|
||||
};
|
||||
|
||||
if (!globalForWorker.thumbnailWorker) {
|
||||
globalForWorker.thumbnailWorker = null;
|
||||
}
|
||||
|
||||
export async function registerThumbnailWorker(): Promise<void> {
|
||||
if (globalForWorker.thumbnailWorker) {
|
||||
console.log('Notification worker already registered');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Registering thumbnail worker...');
|
||||
const worker = new Worker(
|
||||
'thumbnails',
|
||||
async (job) => {
|
||||
try {
|
||||
// this is totally unnecessary, but i'll keep it for security purposes.
|
||||
const name = job.data.name.replace(/[^a-zA-Z0-9]/g, '_');
|
||||
const m3u8location = `/dev/shm/hls/${name}.m3u8`;
|
||||
const thumbDir = '/dev/shm/hctv-thumb';
|
||||
|
||||
if (!existsSync(m3u8location)) return;
|
||||
if (!existsSync(thumbDir)) {
|
||||
await pExec(`mkdir -p ${thumbDir}`);
|
||||
}
|
||||
// unnecessary for development, but maybe docker volumes mess with permissions in prod
|
||||
// also ik it's not the best practice to use 777, but it'll be fiiiiiine
|
||||
// await pExec('chown -R 777 /dev/shm/hctv-thumb');
|
||||
|
||||
exec(
|
||||
`ffmpeg -i ${m3u8location} -vframes 1 -an -y -f image2 ${thumbDir}/${name}.webp`,
|
||||
(error) => {
|
||||
if (error) {
|
||||
console.error(`Error: ${error.message}`);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
console.error('Slack notification failed:', e);
|
||||
// @ts-ignore e is unknown
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
},
|
||||
{
|
||||
connection: getRedisConnection(),
|
||||
concurrency: 3,
|
||||
limiter: {
|
||||
max: 50,
|
||||
duration: 30000,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
globalForWorker.thumbnailWorker = worker;
|
||||
}
|
||||
|
||||
// Close the worker
|
||||
export async function closeThumbnailWorker(): Promise<void> {
|
||||
if (globalForWorker.thumbnailWorker) {
|
||||
await globalForWorker.thumbnailWorker.close();
|
||||
globalForWorker.thumbnailWorker = null;
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,11 @@ export default async function zodVerify<T>(schema: ZodType<T>, data: FormData |
|
||||
let obj: any = data;
|
||||
if (data instanceof FormData) {
|
||||
obj = Object.fromEntries(data.entries());
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (value === 'true') obj[key] = true;
|
||||
if (value === 'false') obj[key] = false;
|
||||
}
|
||||
}
|
||||
|
||||
const zod = schema.safeParse(obj);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Config } from "tailwindcss"
|
||||
import { withUt } from "uploadthing/tw";
|
||||
|
||||
const config = {
|
||||
darkMode: ["class"],
|
||||
@@ -104,4 +105,4 @@ const config = {
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
} satisfies Config
|
||||
|
||||
export default config
|
||||
export default withUt(config)
|
||||
@@ -1,7 +1,6 @@
|
||||
services:
|
||||
psql:
|
||||
image: postgres
|
||||
user: 1000:1000
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
# my condolences
|
||||
@@ -29,7 +28,7 @@ services:
|
||||
- ./nginx.conf:/etc/nginx/templates/nginx.conf.template
|
||||
- ./html:/var/www/html
|
||||
- /dev/shm/hls:/dev/shm/hls
|
||||
image: flv-module
|
||||
image: srizan10/flv-module
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
- -c
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"docker:web": "dotenvx run -f .env.docker -- docker buildx build --platform linux/amd64 -f apps/web/Dockerfile . --secret id=TURBO_TOKEN,env=TURBO_TOKEN --secret id=TURBO_TEAM,env=TURBO_TEAM --no-cache",
|
||||
"docker:chat": "dotenvx run -f .env.docker -- docker buildx build --platform linux/amd64 -f apps/chat/Dockerfile . --secret id=TURBO_TOKEN,env=TURBO_TOKEN --secret id=TURBO_TEAM,env=TURBO_TEAM --no-cache",
|
||||
"act": "act --secret-file .env.ci",
|
||||
"db:migrate": "yarn workspace @hctv/db db:migrate"
|
||||
"db:migrate": "yarn workspace @hctv/db db:migrate",
|
||||
"ui:add": "yarn workspace @hctv/web ui:add"
|
||||
},
|
||||
"devDependencies": {
|
||||
"turbo": "^2.4.4"
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc --build"
|
||||
"build": "tsc --build",
|
||||
"dev": "tsc --watch --preserveWatchOutput"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hctv/db": "*",
|
||||
|
||||
@@ -21,6 +21,7 @@ export const lucia = new Lucia(adapter, {
|
||||
slack_id: attributes.slack_id,
|
||||
pfpUrl: attributes.pfpUrl,
|
||||
hasOnboarded: attributes.hasOnboarded,
|
||||
personalChannelId: attributes.personalChannelId,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -36,4 +37,5 @@ interface DatabaseUserAttributes {
|
||||
slack_id: string;
|
||||
pfpUrl: string;
|
||||
hasOnboarded: boolean;
|
||||
personalChannelId: string | null;
|
||||
}
|
||||
|
||||
@@ -11,15 +11,18 @@
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.5.0",
|
||||
"ioredis": "^5.6.1",
|
||||
"prisma": "^6.5.0"
|
||||
},
|
||||
"scripts": {
|
||||
"db:generate": "prisma generate",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:deploy": "prisma migrate deploy",
|
||||
"build": "tsc --build"
|
||||
"build": "prisma generate && tsc --build",
|
||||
"dev": "tsc --watch --preserveWatchOutput"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.1",
|
||||
"typescript": "^5.8.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "StreamInfo" ADD COLUMN "enableNotifications" BOOLEAN NOT NULL DEFAULT true;
|
||||
@@ -0,0 +1,3 @@
|
||||
UPDATE "User"
|
||||
SET "pfpUrl" = 'https://cachet.dunkirk.sh/users/' || "slack_id" || '/r'
|
||||
WHERE "slack_id" IS NOT NULL AND "slack_id" != '';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user