Compare commits
213 Commits
revert-34-
...
feat/proto
| Author | SHA1 | Date | |
|---|---|---|---|
| 0581cc6a61 | |||
| 18025ced9d | |||
| 044221f147 | |||
| 0cabbd8720 | |||
| 5fdb6921d9 | |||
| 312ad480a2 | |||
| a37554d205 | |||
| 5244275264 | |||
| 5275e8cb2a | |||
| 1ff51fad61 | |||
| 440eb407dd | |||
| 4ab1756230 | |||
| f9d11476bf | |||
| 8e8c58e195 | |||
| 6fcbeaa2a7 | |||
| caef4e428a | |||
| cf49fea907 | |||
| 7683f765b0 | |||
| 4d91f15a43 | |||
| 3b49f8d25a | |||
| c99ace0ef5 | |||
| a834b63ac8 | |||
| 09871d3fae | |||
| 2a0a7abe1a | |||
| 2a15a6367a | |||
| 6fad756bd2 | |||
| 0bb44960b4 | |||
| ac2276b112 | |||
| 1adb9be6cc | |||
| a9625f3505 | |||
| 3611e23869 | |||
| f8aa1454ff | |||
| 6e8539a8d1 | |||
| 592524eedb | |||
| 989462e639 | |||
| a7e9115587 | |||
| 934f589b5f | |||
| 27a6838d9f | |||
| 75085630be | |||
| 642270ee91 | |||
| f543061672 | |||
| 3a89f07a6f | |||
| fb40d87736 | |||
| 34d7afd03d | |||
| 495027ca7e | |||
| 55df22341e | |||
| 17f550ce9d | |||
| ebd0a8bffd | |||
| 2298b8bc0c | |||
| 7dd9bf765e | |||
| 8b3df28f1e | |||
| ecca138257 | |||
| cb0f75dfb9 | |||
| 747af0aeb3 | |||
| 64c7a80883 | |||
| e8fdfa8f49 | |||
| 9e965c779f | |||
| 9ce6770115 | |||
| 6d413bc53e | |||
| 93ae6bd73e | |||
| ae99121af6 | |||
| 331a0185af | |||
| d223902a9f | |||
| 18a00bba6e | |||
| 061d1d3348 | |||
| 95ec96fe72 | |||
| 9eca54cbb5 | |||
| 31fa5f36de | |||
| d327da90ef | |||
| 7072b762d8 | |||
| 82a13007c8 | |||
| a35fd858dc | |||
| 4f03f002ab | |||
| d36e590ab6 | |||
| c77d7a16e6 | |||
| d656d0f579 | |||
| 2896cae2bb | |||
| 6710423b92 | |||
| a4f940b990 | |||
| 307b697ac9 | |||
| 49c0da708a | |||
| 4c415dacc4 | |||
| e616ac20d4 | |||
| 387f0943a3 | |||
| a22937793c | |||
| 302e9be737 | |||
| d5d8894f9c | |||
| c10b4b3674 | |||
| c18cdc83b9 | |||
| 6e10da6f98 | |||
| 6a7b449363 | |||
| ab72dacb61 | |||
| 31d27f92ca | |||
| cd685edd78 | |||
| bd7047d19b | |||
| 9a7c72321d | |||
| 8d6e097f94 | |||
| 3fbb2b5d7f | |||
| 834e5087c3 | |||
| 2e2185568d | |||
| 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 | |||
| 71890520cb | |||
| a5768cfe0a | |||
| 17228d20be | |||
| 502e60d85d | |||
| 752dc641b3 | |||
| 1b384de455 | |||
| d39d6f8ed7 | |||
| 18d69a6aab | |||
| 648d3296e2 | |||
| 836b5b6951 | |||
| e83d0cf713 | |||
| 6974a78201 | |||
| 185fc910a0 | |||
| 6406df0501 | |||
| 30d3ad2f2d | |||
| dd16124cb5 | |||
| 6a63fe388e | |||
| 441aa64166 | |||
| 41c50a8ee5 | |||
| ede61678aa | |||
| f2c0abbcdc | |||
| fbdb43e6df | |||
| 6b82975350 | |||
| 72d49df94c | |||
| 9d88f32f6a | |||
| 59b07928c6 | |||
| 9d6bcf25b9 | |||
| 981b0b8b77 | |||
| e57312e48f | |||
| e736375d17 | |||
| fe3a146bf1 | |||
| f216ddc57d | |||
| bd29c4848d | |||
| 089b1fd157 | |||
| 679a741155 | |||
| 021962b78b | |||
|
|
9c2b166b8a | ||
|
|
b935be00f0 | ||
| b7484bbfa1 | |||
| ab6652f2c7 | |||
| 5e1609abc2 | |||
| 9538d23ed1 | |||
| ed81b494f7 | |||
| 7af4137ff9 | |||
| c702db9121 | |||
| 95e821727b | |||
| a9924d19e4 | |||
| acd5f5b5f4 |
@@ -1,35 +1,41 @@
|
||||
# Ignore node_modules and build output
|
||||
node_modules
|
||||
.next
|
||||
out
|
||||
|
||||
# Ignore logs and temporary files
|
||||
*.log
|
||||
*.tmp
|
||||
*.swp
|
||||
|
||||
# Ignore local environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Ignore Docker files
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
|
||||
# Ignore git files
|
||||
# Version control
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Dependencies
|
||||
**/node_modules
|
||||
.pnpm-store
|
||||
|
||||
# Build outputs
|
||||
**/dist
|
||||
**/.next
|
||||
**/build
|
||||
**/out
|
||||
|
||||
# Development files
|
||||
**/.env*
|
||||
!**/.env.example
|
||||
**/.vscode
|
||||
**/.idea
|
||||
**/coverage
|
||||
**/.turbo
|
||||
**/.cache
|
||||
|
||||
# System files
|
||||
.DS_Store
|
||||
**/Thumbs.db
|
||||
|
||||
# Logs
|
||||
**/npm-debug.log*
|
||||
**/yarn-debug.log*
|
||||
**/yarn-error.log*
|
||||
**/pnpm-debug.log*
|
||||
|
||||
# Test files
|
||||
**/__tests__
|
||||
**/*.test.*
|
||||
**/*.spec.*
|
||||
|
||||
# Ignore editor directories and files
|
||||
.vscode
|
||||
.idea
|
||||
*.sublime-project
|
||||
*.sublime-workspace
|
||||
|
||||
# Ignore other unnecessary files
|
||||
README.md
|
||||
packages/db/generated
|
||||
dev/
|
||||
flv-module/
|
||||
84
.github/workflows/docker.yml
vendored
@@ -6,10 +6,16 @@ on:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
push_to_registry:
|
||||
name: Push Docker image to Docker Hub
|
||||
frontend:
|
||||
name: Push frontend to Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Wait
|
||||
uses: NathanFirmo/wait-for-other-action@v1.0.4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
workflow: 'emojis.yml'
|
||||
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
|
||||
@@ -29,24 +35,74 @@ jobs:
|
||||
images: srizan10/hclive
|
||||
tags: latest
|
||||
|
||||
- name: Download latest emoji importer
|
||||
run: |
|
||||
RELEASE_URL=$(curl -s https://api.github.com/repos/srizan10/hctv/releases/latest | \
|
||||
grep "browser_download_url.*slack-import-emojis-linux-x86_64" | \
|
||||
cut -d '"' -f 4)
|
||||
|
||||
curl -L -o slack-import-emojis-bin $RELEASE_URL
|
||||
chmod +x slack-import-emojis-bin
|
||||
|
||||
mkdir -p apps/web/src/lib/instrumentation/
|
||||
|
||||
export SLACK_TOKEN=${{ secrets.SLACK_TOKEN }}
|
||||
./slack-import-emojis-bin default
|
||||
|
||||
cp emojis.json apps/web/
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
file: ./apps/web/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
platforms: linux/amd64
|
||||
build-args: |
|
||||
BUILDKIT_INLINE_CACHE=1
|
||||
secrets: |
|
||||
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
|
||||
chat:
|
||||
name: Push chat module to Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Emit a webhook to the server
|
||||
env:
|
||||
AUTH_HEADER: ${{ secrets.WHSERVER_TOKEN }}
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
|
||||
with:
|
||||
images: srizan10/hclive-chat
|
||||
tags: latest
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./apps/chat/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64
|
||||
secrets: |
|
||||
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
|
||||
deploy:
|
||||
name: Deploy to Coolify
|
||||
runs-on: ubuntu-latest
|
||||
needs: [frontend, chat]
|
||||
steps:
|
||||
- name: Send coolify redeploy webhook
|
||||
run: |
|
||||
curl -X POST \
|
||||
-H "Authorization: $AUTH_HEADER" \
|
||||
https://webhooks.srizan.dev/hooks/hctv
|
||||
curl -X POST -H "Authorization: Bearer ${{ secrets.COOLIFY_API_KEY }}" https://coolify.srizan.dev/api/v1/deploy?uuid=${{ secrets.COOLIFY_APP_UUID }}&force=true
|
||||
64
.github/workflows/emojis.yml
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
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
|
||||
```
|
||||
|
||||
- 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
|
||||
18
.gitignore
vendored
@@ -1,7 +1,7 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
*node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
.yarn/install-state.gz
|
||||
@@ -10,7 +10,7 @@
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
*.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
@@ -27,7 +27,7 @@ yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
.env
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
@@ -37,4 +37,14 @@ yarn-error.log*
|
||||
next-env.d.ts
|
||||
|
||||
certificates
|
||||
dev/psql
|
||||
dev/psql
|
||||
dev/redis
|
||||
|
||||
.turbo
|
||||
packages/db/generated/client
|
||||
*dist
|
||||
|
||||
slack-import-emojis/target
|
||||
**/*/emojis.json
|
||||
|
||||
.idea
|
||||
8
.vscode/mcp.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"servers": {
|
||||
"Sentry": {
|
||||
"url": "https://mcp.sentry.dev/mcp/sr-izan/hctv",
|
||||
"type": "http"
|
||||
}
|
||||
}
|
||||
}
|
||||
45
Dockerfile
@@ -1,45 +0,0 @@
|
||||
# Stage 1: Building the code
|
||||
FROM node:lts-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json yarn.lock ./
|
||||
|
||||
# Install dependencies
|
||||
RUN yarn install --frozen-lockfile
|
||||
|
||||
# Copy app files
|
||||
COPY . .
|
||||
|
||||
# Build app
|
||||
RUN yarn build
|
||||
|
||||
# Stage 2: Production
|
||||
FROM node:lts-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Copy necessary files
|
||||
COPY --from=builder /app/next.config.mjs ./
|
||||
COPY --from=builder /app/package.json ./
|
||||
COPY --from=builder /app/yarn.lock ./
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/.next ./.next
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
|
||||
# Install production dependencies only
|
||||
RUN apk add --no-cache openssl
|
||||
RUN yarn install --frozen-lockfile --production && \
|
||||
yarn cache clean && yarn run prepare
|
||||
|
||||
# Remove unnecessary files
|
||||
RUN rm -rf /app/.git \
|
||||
/app/.next/cache \
|
||||
/app/README.md
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["yarn", "start"]
|
||||
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>.
|
||||
25
README.md
@@ -1,24 +1,7 @@
|
||||
# Sr Izan Stack
|
||||
# hackclub.tv
|
||||
|
||||
Sr Izan Stack is a next.js template which runs on modern technologies, with a focus on developer experience and ease-of-use.
|
||||
This is the source code for [hackclub.tv (hackclub.tv)](https://hackclub.tv), a livestreaming website for hackclubbers.
|
||||
|
||||
## The stack
|
||||
Development has been ongoing for a few months, and the site is now live! There are some half-baked features, but I'm all ears for feedback.
|
||||
|
||||
- Framework: [Next.js](https://nextjs.org/)
|
||||
- Language: [TypeScript](https://www.typescriptlang.org/)
|
||||
- Styling: [Tailwind CSS](https://tailwindcss.com/)
|
||||
- UI Library: [shadcn/ui](https://ui.shadcn.com)
|
||||
- Authentication: [Lucia](https://lucia-auth.com)
|
||||
- Deployment: [Vercel](https://vercel.com)
|
||||
- Database: [Supabase Postgres](https://supabase.com) with [Prisma](https://www.prisma.io/)
|
||||
|
||||
## Why (insert tool here)?
|
||||
|
||||
- **Next.js**: I like the next.js app router because it has a very good developer experience and it's very easy to use.
|
||||
- **TypeScript**: Don't even need to explain why
|
||||
- **Tailwind CSS**: I like the utility-first approach and the speed of development it provides
|
||||
- **shadcn/ui**: Copy-pasting components is so fire (also is Radix UI)
|
||||
- **Lucia**: The DevEX is amazing and it's very easy to use
|
||||
- **Vercel**: Next.js and Vercel are like bread and butter, but it's a bit slow with the free tier.
|
||||
- **MongoDB Atlas**: It has a very generous free tier, and I'm choosing NoSQL because Postgres hates me.
|
||||
- **Prisma**: Even though there are solid competitors like Drizzle, Prisma is easy to use, understand, and fast enough for my use case.
|
||||
Join [#hctv](https://hackclub.slack.com/archives/C08HGLXGXAB) on the HC Slack for discussion and updates!
|
||||
|
||||
28
apps/chat/.gitignore
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
# dev
|
||||
.yarn/
|
||||
!.yarn/releases
|
||||
.vscode/*
|
||||
!.vscode/launch.json
|
||||
!.vscode/*.code-snippets
|
||||
.idea/workspace.xml
|
||||
.idea/usage.statistics.xml
|
||||
.idea/shelf
|
||||
|
||||
# deps
|
||||
node_modules/
|
||||
|
||||
# env
|
||||
.env
|
||||
.env.production
|
||||
|
||||
# logs
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
44
apps/chat/Dockerfile
Normal file
@@ -0,0 +1,44 @@
|
||||
FROM node:lts-alpine AS base
|
||||
|
||||
FROM base AS builder
|
||||
RUN apk update
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
RUN yarn global add turbo@^2
|
||||
COPY . .
|
||||
|
||||
RUN turbo prune @hctv/chat --docker
|
||||
|
||||
FROM base AS installer
|
||||
RUN apk update
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
# First install the dependencies
|
||||
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 --concurrency=1
|
||||
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nodeapp
|
||||
USER nodeapp
|
||||
|
||||
COPY --from=installer --chown=nodeapp:nodejs /app/apps ./apps
|
||||
COPY --from=installer --chown=nodeapp:nodejs /app/packages ./packages
|
||||
COPY --from=installer --chown=nodeapp:nodejs /app/node_modules ./node_modules
|
||||
COPY --from=installer --chown=nodeapp:nodejs /app/package.json ./package.json
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
WORKDIR /app/apps/chat
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
ENTRYPOINT ["node", "dist/index.js"]
|
||||
24
apps/chat/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@hctv/chat",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc --build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hctv/auth": "*",
|
||||
"@hctv/db": "*",
|
||||
"@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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.17",
|
||||
"tsx": "^4.7.1",
|
||||
"typescript": "^5.8.2"
|
||||
}
|
||||
}
|
||||
15
apps/chat/src/3d.txt
Normal file
@@ -0,0 +1,15 @@
|
||||
,---, ___
|
||||
,--.' | ,--.'|_
|
||||
| | : | | :,'
|
||||
: : : : : ' : .---.
|
||||
: | |,--. ,---. .;__,' / /. ./|
|
||||
| : ' | / \| | | .-' . ' |
|
||||
| | /' : / / ':__,'| : /___/ \: |
|
||||
' : | | |. ' / ' : |__. \ ' .
|
||||
| | ' | :' ; :__ | | '.'|\ \ '
|
||||
| : :_:,'' | '.'| ; : ; \ \
|
||||
| | ,' | : : | , / \ \ |
|
||||
`--'' \ \ / ---`-' '---"
|
||||
`----'
|
||||
|
||||
This is hctv's chat backend. There's not much here, so go back to where you came from :)
|
||||
270
apps/chat/src/index.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import { serve } from '@hono/node-server';
|
||||
import { createNodeWebSocket, type ModifiedWebSocket } from '@hctv/hono-ws';
|
||||
import { Hono } from 'hono';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { lucia } from '@hctv/auth';
|
||||
import { getCookie } from 'hono/cookie';
|
||||
import { getPersonalChannel } from './utils/personalChannel.js';
|
||||
import { getRedisConnection, prisma, type BotAccount, type BotApiKey, 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 });
|
||||
|
||||
app.get('/', async (c) => {
|
||||
return c.text(threed);
|
||||
});
|
||||
|
||||
app.get('/up', async (c) => {
|
||||
return c.text('it works');
|
||||
});
|
||||
|
||||
app.get(
|
||||
'/ws/:username',
|
||||
upgradeWebSocket((c) => ({
|
||||
async onOpen(evt, ws) {
|
||||
const token = getCookie(c, 'auth_session');
|
||||
const grant = c.req.query('grant');
|
||||
const authHeader = c.req.header('Authorization');
|
||||
|
||||
if (!token && (!grant || grant === 'null') && !authHeader) {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
let chatUser: ChatUser | null = null;
|
||||
let personalChannel: any = null;
|
||||
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
const apiKey = authHeader.substring(7);
|
||||
const botAccount = await prisma.botApiKey.findUnique({
|
||||
where: { key: apiKey },
|
||||
include: { botAccount: true }
|
||||
});
|
||||
|
||||
if (botAccount) {
|
||||
chatUser = {
|
||||
id: botAccount.botAccount.id,
|
||||
username: botAccount.botAccount.slug,
|
||||
pfpUrl: botAccount.botAccount.pfpUrl,
|
||||
displayName: botAccount.botAccount.displayName,
|
||||
isBot: true
|
||||
};
|
||||
|
||||
personalChannel = {
|
||||
id: botAccount.botAccount.id,
|
||||
name: botAccount.botAccount.slug
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!chatUser && token) {
|
||||
const session = await lucia.validateSession(token);
|
||||
if (session.user) {
|
||||
const userChannel = await getPersonalChannel(session.user.id);
|
||||
if (userChannel) {
|
||||
chatUser = {
|
||||
id: session.user.id,
|
||||
username: userChannel.name,
|
||||
pfpUrl: session.user.pfpUrl,
|
||||
isBot: false
|
||||
};
|
||||
personalChannel = userChannel;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dbGrant = await prisma.channel.findFirst({
|
||||
where: { obsChatGrantToken: grant }
|
||||
});
|
||||
|
||||
if (!chatUser && !dbGrant) {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const { username } = c.req.param();
|
||||
if (dbGrant && dbGrant.name !== username) {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
ws.targetUsername = username;
|
||||
ws.chatUser = chatUser;
|
||||
ws.personalChannel = personalChannel;
|
||||
ws.viewerId = randomString(10);
|
||||
|
||||
if (ws.raw) {
|
||||
ws.raw.targetUsername = username;
|
||||
ws.raw.chatUser = chatUser;
|
||||
ws.raw.personalChannel = personalChannel;
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
select: {
|
||||
viewers: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!streamInfo) return;
|
||||
|
||||
await redis.del(`viewer:${ws.targetUsername}:${ws.viewerId}`);
|
||||
},
|
||||
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;
|
||||
}
|
||||
|
||||
if (msg.type === 'message') {
|
||||
if (!ws.chatUser || !ws.personalChannel) return;
|
||||
|
||||
const message = (msg.message as string).trim();
|
||||
const msgObj = {
|
||||
user: {
|
||||
id: ws.chatUser.id,
|
||||
username: ws.chatUser.username,
|
||||
pfpUrl: ws.chatUser.pfpUrl,
|
||||
displayName: ws.chatUser.displayName,
|
||||
isBot: ws.chatUser.isBot || false
|
||||
},
|
||||
message,
|
||||
};
|
||||
|
||||
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(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: [],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
}))
|
||||
);
|
||||
|
||||
const server = serve(
|
||||
{
|
||||
fetch: app.fetch,
|
||||
port: 8000,
|
||||
},
|
||||
(info) => {
|
||||
console.log(`Server is running on http://localhost:${info.port}`);
|
||||
}
|
||||
);
|
||||
injectWebSocket(server);
|
||||
|
||||
interface ChatUser {
|
||||
id: string;
|
||||
username: string;
|
||||
pfpUrl: string;
|
||||
displayName?: string;
|
||||
isBot: boolean;
|
||||
}
|
||||
17
apps/chat/src/utils/personalChannel.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { prisma } from "@hctv/db";
|
||||
|
||||
export async function getPersonalChannel(id: string) {
|
||||
const db = await prisma.user.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
select: {
|
||||
personalChannel: true,
|
||||
},
|
||||
});
|
||||
if (!db) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return db.personalChannel;
|
||||
}
|
||||
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;
|
||||
}
|
||||
16
apps/chat/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "NodeNext",
|
||||
"strict": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"skipLibCheck": true,
|
||||
"types": [
|
||||
"node"
|
||||
],
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "hono/jsx",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
}
|
||||
}
|
||||
21
apps/docs/.gitignore
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# build output
|
||||
dist/
|
||||
# generated types
|
||||
.astro/
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
.env.production
|
||||
|
||||
# macOS-specific files
|
||||
.DS_Store
|
||||
4
apps/docs/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"recommendations": ["astro-build.astro-vscode"],
|
||||
"unwantedRecommendations": []
|
||||
}
|
||||
11
apps/docs/.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"command": "./node_modules/.bin/astro dev",
|
||||
"name": "Development server",
|
||||
"request": "launch",
|
||||
"type": "node-terminal"
|
||||
}
|
||||
]
|
||||
}
|
||||
25
apps/docs/astro.config.mjs
Normal file
@@ -0,0 +1,25 @@
|
||||
// @ts-check
|
||||
import { defineConfig } from 'astro/config';
|
||||
import starlight from '@astrojs/starlight';
|
||||
import mermaid from 'astro-mermaid';
|
||||
import catppuccin from "@catppuccin/starlight";
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
mermaid({
|
||||
theme: 'base',
|
||||
autoTheme: true
|
||||
}),
|
||||
starlight({
|
||||
title: 'hctv docs',
|
||||
social: [{ icon: 'github', label: 'GitHub', href: 'https://github.com/SrIzan10/hctv' }],
|
||||
plugins: [
|
||||
catppuccin({
|
||||
dark: { flavor: "mocha", accent: "blue" },
|
||||
light: { flavor: "latte", accent: "blue" }
|
||||
}),
|
||||
]
|
||||
}),
|
||||
],
|
||||
});
|
||||
20
apps/docs/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@hctv/docs",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"start": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/starlight": "^0.35.2",
|
||||
"@catppuccin/starlight": "^1.0.2",
|
||||
"astro": "^5.6.1",
|
||||
"astro-mermaid": "^1.0.4",
|
||||
"mermaid": "^11.10.1",
|
||||
"sharp": "^0.34.2"
|
||||
}
|
||||
}
|
||||
1
apps/docs/public/favicon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><path fill-rule="evenodd" d="M81 36 64 0 47 36l-1 2-9-10a6 6 0 0 0-9 9l10 10h-2L0 64l36 17h2L28 91a6 6 0 1 0 9 9l9-10 1 2 17 36 17-36v-2l9 10a6 6 0 1 0 9-9l-9-9 2-1 36-17-36-17-2-1 9-9a6 6 0 1 0-9-9l-9 10v-2Zm-17 2-2 5c-4 8-11 15-19 19l-5 2 5 2c8 4 15 11 19 19l2 5 2-5c4-8 11-15 19-19l5-2-5-2c-8-4-15-11-19-19l-2-5Z" clip-rule="evenodd"/><path d="M118 19a6 6 0 0 0-9-9l-3 3a6 6 0 1 0 9 9l3-3Zm-96 4c-2 2-6 2-9 0l-3-3a6 6 0 1 1 9-9l3 3c3 2 3 6 0 9Zm0 82c-2-2-6-2-9 0l-3 3a6 6 0 1 0 9 9l3-3c3-2 3-6 0-9Zm96 4a6 6 0 0 1-9 9l-3-3a6 6 0 1 1 9-9l3 3Z"/><style>path{fill:#000}@media (prefers-color-scheme:dark){path{fill:#fff}}</style></svg>
|
||||
|
After Width: | Height: | Size: 696 B |
BIN
apps/docs/src/assets/houston.webp
Normal file
|
After Width: | Height: | Size: 96 KiB |
7
apps/docs/src/content.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineCollection } from 'astro:content';
|
||||
import { docsLoader } from '@astrojs/starlight/loaders';
|
||||
import { docsSchema } from '@astrojs/starlight/schema';
|
||||
|
||||
export const collections = {
|
||||
docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }),
|
||||
};
|
||||
162
apps/docs/src/content/docs/api/chat.mdx
Normal file
@@ -0,0 +1,162 @@
|
||||
---
|
||||
title: Chat
|
||||
description: Chat websocket
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
The chat system is powered by a websocket server. Please read the entire page before implementing anything, as there are some important notes.
|
||||
|
||||
## Connection and messages
|
||||
|
||||
The websocket server is located at `wss://hackclub.tv/api/chat/ws/:username`, where `:username` is the channel you want to connect to.
|
||||
|
||||
You'll need to provide authentication, which can be done by providing an `auth_session` cookie, just like the REST API.
|
||||
<Aside type="tip">
|
||||
Bot accounts are now supported. You can choose to connect as a bot by providing a bot account's API key on the Authentication header: `Bearer hctvb_xxxxxxx`
|
||||
It is highly advised to use a bot account for any automated task, and to implement anything pointed out in this page.
|
||||
</Aside>
|
||||
|
||||
Once connected, you must implement a subroutine in your code to send ping messages every 5 seconds. This is because of Cloudflare limitations.
|
||||
|
||||
Messages are sent and received in JSON format. The following message types are supported:
|
||||
- `message`: a chat message.
|
||||
- sent by client:
|
||||
```json
|
||||
{
|
||||
"type": "message",
|
||||
"content": "Hello, world!"
|
||||
}
|
||||
```
|
||||
- received by client:
|
||||
```json
|
||||
{
|
||||
"user": {
|
||||
"id": "user_id",
|
||||
"username": "user_who_sent_message",
|
||||
"avatar": "https://emoji.slack-edge.com/avatar.png"
|
||||
},
|
||||
"message": "Hello, world!",
|
||||
}
|
||||
```
|
||||
- `ping`: a ping message to keep the connection alive.
|
||||
- sent by client:
|
||||
```json
|
||||
{
|
||||
"type": "ping"
|
||||
}
|
||||
```
|
||||
- received by client:
|
||||
```json
|
||||
{
|
||||
"type": "ping"
|
||||
}
|
||||
```
|
||||
- `history`: a message containing the chat history. This is sent upon connection.
|
||||
- received by client:
|
||||
```json
|
||||
{
|
||||
"type": "history",
|
||||
"messages": [
|
||||
{
|
||||
"user": {
|
||||
"id": "user_id",
|
||||
"username": "user_who_sent_message",
|
||||
"avatar": "https://emoji.slack-edge.com/avatar.png"
|
||||
},
|
||||
"message": "Hello, world!",
|
||||
"type": "message",
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
## Emoji handling
|
||||
|
||||
*diagram source: devin deepwiki*
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Emoji Processing Pipeline"
|
||||
CHAT_MSG["Chat Message"]
|
||||
PATTERN_MATCH["Regex :emoji: Pattern"]
|
||||
EMOJI_REQUEST["emojiMsg WebSocket"]
|
||||
REDIS_LOOKUP["Redis HGET emojis"]
|
||||
FUZZY_SEARCH["uFuzzy"]
|
||||
EMOJI_RESPONSE["emojiMsgResponse"]
|
||||
end
|
||||
|
||||
subgraph "Redis Storage"
|
||||
EMOJI_HASH["emojis hash key"]
|
||||
EMOJI_PREFIXED["emoji:{name} url"]
|
||||
EMOJIS_PREFIXED["emojis:{name} url"]
|
||||
end
|
||||
|
||||
CHAT_MSG --> PATTERN_MATCH
|
||||
PATTERN_MATCH --":emojiname:"--> EMOJI_REQUEST
|
||||
EMOJI_REQUEST --> REDIS_LOOKUP
|
||||
|
||||
REDIS_LOOKUP --> EMOJI_HASH
|
||||
REDIS_LOOKUP --> EMOJI_PREFIXED
|
||||
REDIS_LOOKUP --> EMOJIS_PREFIXED
|
||||
|
||||
REDIS_LOOKUP --> EMOJI_RESPONSE
|
||||
|
||||
FUZZY_SEARCH --> EMOJI_HASH
|
||||
FUZZY_SEARCH --"search results"--> EMOJI_RESPONSE
|
||||
```
|
||||
|
||||
When a chat message is sent, the server looks for patterns in the format `:emojiname:` using regex. For each match, it sends a request to the `emojiMsg` WebSocket.
|
||||
The server then checks Redis for the emoji URL and returns it.
|
||||
|
||||
When a user wants to look up an emoji (by typing `:(partial name)`), the server uses uFuzzy to find matching emojis in the Redis `emojis` hash key and returns the results.
|
||||
|
||||
Here's what gets sent on the websocket:
|
||||
- `emojiMsg`: Looks up emojis
|
||||
- sent by client:
|
||||
```json
|
||||
{
|
||||
"type": "emojiMsg",
|
||||
"emojis": ["aga", "yapa", "heavysob", "yay", "yay-bounce"]
|
||||
}
|
||||
```
|
||||
- received by client:
|
||||
```json
|
||||
{
|
||||
"type": "emojiMsgResponse",
|
||||
"emojis": {
|
||||
// rough example of urls
|
||||
"aga": "https://emoji.slack-edge.com/aga.png",
|
||||
"yapa": "https://emoji.slack-edge.com/yapa.png",
|
||||
"heavysob": "https://emoji.slack-edge.com/heavysob.png",
|
||||
"yay": "https://emoji.slack-edge.com/yay.png",
|
||||
"yay-bounce": "https://emoji.slack-edge.com/yay-bounce.png"
|
||||
}
|
||||
}
|
||||
```
|
||||
- `emojiSearch`: Searches for emojis
|
||||
- sent by client:
|
||||
```json
|
||||
{
|
||||
"type": "emojiSearch",
|
||||
"searchTerm": "aga"
|
||||
}
|
||||
```
|
||||
- received by client:
|
||||
```json
|
||||
{
|
||||
"type": "emojiSearchResponse",
|
||||
"results": [
|
||||
// real results btw
|
||||
"aga",
|
||||
"aga-brick-throw",
|
||||
"aga-dance",
|
||||
"aga-transparent",
|
||||
"a-aga",
|
||||
"a-aga-transparent",
|
||||
"agaban",
|
||||
"agaboing",
|
||||
"agabounce",
|
||||
"agabusiness"
|
||||
]
|
||||
}
|
||||
```
|
||||
19
apps/docs/src/content/docs/api/index.mdx
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
title: API Documentation
|
||||
description: Documented API endpoints for hackclub.tv
|
||||
---
|
||||
|
||||
hctv is meant to be one of the most hackable streaming platforms out there. to that end, we have a (currently limited) public API that you can use to interact with the platform.
|
||||
|
||||
since this is beta software, the API is subject to change. additionally, many endpoints are yet not implemented or not fun to implement. please send a message in #hctv on the Hack Club Slack if you have any requests.
|
||||
|
||||
## Base url
|
||||
|
||||
base url for all endpoints is `https://hackclub.tv/api`.
|
||||
|
||||
## Authentication
|
||||
|
||||
most endpoints require authentication. this will be pointed out in the documentation.
|
||||
for now, it is done via a cookie called `auth_session` (as per lucia auth). this will change in the future as bot accounts are planned.
|
||||
|
||||
you'll need your user account to authenticate. as a recommendation, open an incognito window, log in to hctv, and copy the `auth_session` cookie from there.
|
||||
4
apps/docs/src/content/docs/api/meta.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "API",
|
||||
"description": "Documented API endpoints for hackclub.tv"
|
||||
}
|
||||
12
apps/docs/src/content/docs/api/rtmp.mdx
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
title: RTMP
|
||||
description: RTMP related endpoint group
|
||||
---
|
||||
|
||||
## POST `/rtmp/streamKey`
|
||||
regenerates your stream key. **authentication required**.
|
||||
body parameters (json):
|
||||
- `channel`: string - the channel name you want to regenerate the key for. must be one of your channels.
|
||||
|
||||
response (json):
|
||||
- `key`: string - the new stream key
|
||||
47
apps/docs/src/content/docs/api/stream.mdx
Normal file
@@ -0,0 +1,47 @@
|
||||
---
|
||||
title: Stream
|
||||
description: Stream related endpoint group
|
||||
---
|
||||
|
||||
## GET `/stream/follow`
|
||||
checks if **authenticated user** is following a certain channel.
|
||||
query parameters:
|
||||
- `username`: string - the channel name you want to check.
|
||||
|
||||
response (json):
|
||||
- `following`: boolean - whether the authenticated user is following the channel or not.
|
||||
|
||||
## POST `/stream/follow`
|
||||
cycle through follow or unfollow a channel. **authentication required**.
|
||||
body parameters (json):
|
||||
- `channel`: string - the channel name you want to make the action. must be one of your channels.
|
||||
|
||||
response (json):
|
||||
- `success`: boolean - whether the operation was successful or not.
|
||||
|
||||
## GET `/stream/followers/:channel`
|
||||
gets the followcount of a channel.
|
||||
path parameters:
|
||||
- `channel`: string - the channel name you want to get the followcount of.
|
||||
|
||||
response (json):
|
||||
- `count`: integer - the number of followers the channel has.
|
||||
- `success`: boolean - whether the operation was successful or not. (true if 200 status code)
|
||||
|
||||
## GET `/stream/info`
|
||||
get stream info of certain channels by filtering. **authentication on some**.
|
||||
query parameters:
|
||||
- `owned`: boolean (optional) - if true, only returns channels owned by the authenticated user. requires authentication.
|
||||
- `personal`: boolean (optional) - if true, only returns personal channels. requires authentication.
|
||||
- `live`: boolean (optional) - if true, only returns channels that are currently live.
|
||||
|
||||
response (json):
|
||||
- StreamInfo[] (check database schema for all returned data or just try it out!)
|
||||
|
||||
## GET `/stream/thumb/:channel`
|
||||
gets the preview thumbnail of a channel's livestream. **authentication required**
|
||||
|
||||
path parameters:
|
||||
- `channel`: string - the channel name you want to get the thumbnail of.
|
||||
|
||||
response: image (webp)
|
||||
4
apps/docs/src/content/docs/guides/meta.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "Guides",
|
||||
"description": "Useful guides to help you get started with hackclub.tv"
|
||||
}
|
||||
15
apps/docs/src/content/docs/guides/start-stream.mdx
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
title: How to stream
|
||||
description: Get started with OBS and streaming on hackclub.tv
|
||||
---
|
||||
|
||||
- open obs
|
||||
- open settings
|
||||
- open "Stream"
|
||||
- set service to custom
|
||||
- set url to `srt://localhost:8890?streamid=publish:CHANNEL_NAME:thisusernameislongonpurposesoyoudontaccidentallyleakyourstreamkey:STREAM_KEY&pkt_size=1316`
|
||||
- on the website, click "Regenerate key"
|
||||
- paste the key into your obs "Stream Key"
|
||||
- start streaming!
|
||||
|
||||
(screenshots to be added soon)
|
||||
8
apps/docs/src/content/docs/index.mdx
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
title: About hctv docs
|
||||
description: Index documentation page!
|
||||
---
|
||||
|
||||
Welcome to the hackclub.tv docs! Here you'll find tips and tricks to get the most out of hackclub.tv and its features.
|
||||
|
||||
Additionally, some useful guides are available to help you get started with common tasks.
|
||||
5
apps/docs/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["dist"]
|
||||
}
|
||||
0
apps/docs/yarn.lock
Normal file
84
apps/web/Dockerfile
Normal file
@@ -0,0 +1,84 @@
|
||||
FROM node:lts-slim AS base
|
||||
|
||||
FROM base AS builder
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# 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" }
|
||||
RUN turbo prune @hctv/web --docker
|
||||
|
||||
# Add lockfile and package.json's of isolated subworkspace
|
||||
FROM base AS installer
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
libvips-dev \
|
||||
python3 \
|
||||
make \
|
||||
g++ \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# Get the commit hash from the builder stage
|
||||
COPY --from=builder /tmp/commit_hash /tmp/commit_hash
|
||||
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 \
|
||||
COMMIT=$(cat /tmp/commit_hash 2>/dev/null || echo "unknown") && \
|
||||
TURBO_TOKEN=$(cat /run/secrets/TURBO_TOKEN) TURBO_TEAM=$(cat /run/secrets/TURBO_TEAM) \
|
||||
commit=$COMMIT yarn turbo run build --env-mode=loose
|
||||
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ffmpeg \
|
||||
libvips42 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN groupadd --system --gid 1001 nodejs
|
||||
RUN useradd --system --uid 1001 nextjs
|
||||
|
||||
# Copy Prisma files for migrations
|
||||
COPY --from=installer --chown=nextjs:nodejs /app/packages/db/prisma ./packages/db/prisma
|
||||
COPY --from=installer --chown=nextjs:nodejs /app/packages/db/generated ./packages/db/generated
|
||||
COPY --from=installer --chown=nextjs:nodejs /app/packages/db/package.json ./packages/db/package.json
|
||||
COPY --from=installer --chown=nextjs:nodejs /app/node_modules ./node_modules
|
||||
|
||||
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 "set -e" >> /usr/local/bin/start.sh && \
|
||||
echo "echo 'Running database migrations...'" >> /usr/local/bin/start.sh && \
|
||||
echo "npx prisma migrate deploy --schema=/app/packages/db/prisma/schema.prisma" >> /usr/local/bin/start.sh && \
|
||||
echo "cd /app" >> /usr/local/bin/start.sh && \
|
||||
echo "export commit=$COMMIT_VALUE" >> /usr/local/bin/start.sh && \
|
||||
echo "echo 'Starting Next.js application...'" >> /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
|
||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||
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
|
||||
COPY --chown=nextjs:nodejs apps/web/emojis.json .
|
||||
|
||||
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
|
||||
95
apps/web/next.config.mjs
Normal file
@@ -0,0 +1,95 @@
|
||||
import {withSentryConfig} from '@sentry/nextjs';
|
||||
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);
|
||||
|
||||
const LIVE_SERVER_URL =
|
||||
process.env.NODE_ENV === 'production'
|
||||
? 'http://nginx-rtmp:8888'
|
||||
: '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: {
|
||||
remotePatterns: [
|
||||
{
|
||||
hostname: 'picsum.photos',
|
||||
},
|
||||
{
|
||||
hostname: 'secure.gravatar.com',
|
||||
},
|
||||
{
|
||||
hostname: 'emoji.slack-edge.com',
|
||||
},
|
||||
{
|
||||
hostname: 'cdn.jsdelivr.net',
|
||||
pathname: '/npm/emoji-datasource-twitter@15.1.2/img/twitter/64/*',
|
||||
},
|
||||
{
|
||||
hostname: 'eoceqrx2r7.ufs.sh'
|
||||
},
|
||||
],
|
||||
minimumCacheTTL: 120,
|
||||
},
|
||||
env: {
|
||||
LIVE_SERVER_URL,
|
||||
commit,
|
||||
version,
|
||||
},
|
||||
reactStrictMode: false,
|
||||
output: 'standalone',
|
||||
outputFileTracingRoot: path.join(__dirname, '../../'),
|
||||
serverExternalPackages: ['bullmq'],
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/api/stream/chat/:path*',
|
||||
destination: `http://${process.env.NODE_ENV === 'production' ? 'chat' : 'localhost'}:8000/:path*`,
|
||||
},
|
||||
];
|
||||
},
|
||||
logging: {
|
||||
incomingRequests: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default withSentryConfig(nextConfig, {
|
||||
// For all available options, see:
|
||||
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
|
||||
|
||||
org: "sr-izan",
|
||||
|
||||
project: "hctv",
|
||||
|
||||
// Only print logs for uploading source maps in CI
|
||||
silent: !process.env.CI,
|
||||
|
||||
// For all available options, see:
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
|
||||
|
||||
// Upload a larger set of source maps for prettier stack traces (increases build time)
|
||||
widenClientFileUpload: true,
|
||||
|
||||
// Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
|
||||
// This can increase your server load as well as your hosting bill.
|
||||
// Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
|
||||
// side errors will fail.
|
||||
tunnelRoute: "/monitoring",
|
||||
|
||||
// Automatically tree-shake Sentry logger statements to reduce bundle size
|
||||
disableLogger: true,
|
||||
|
||||
// Enables automatic instrumentation of Vercel Cron Monitors. (Does not yet work with App Router route handlers.)
|
||||
// See the following for more information:
|
||||
// https://docs.sentry.io/product/crons/
|
||||
// https://vercel.com/docs/cron-jobs
|
||||
automaticVercelMonitors: true,
|
||||
});
|
||||
93
apps/web/package.json
Normal file
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"name": "@hctv/web",
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dd": "docker compose --file ../../dev/docker-compose.yml up -d",
|
||||
"dev": "next dev --turbo",
|
||||
"donly": "docker compose --file ../../dev/docker-compose.yml up",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"ui:add": "shadcn add",
|
||||
"check-types": "tsc --noEmit",
|
||||
"genMtxTypes": "bunx openapi-typescript https://github.com/bluenviron/mediamtx/raw/refs/tags/v1.15.5/api/openapi.yaml -o ./src/lib/types/mediamtx.d.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hctv/auth": "*",
|
||||
"@hctv/db": "*",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@lucia-auth/adapter-prisma": "^4.0.1",
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@omit/react-confirm-dialog": "^1.2.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@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.2.4",
|
||||
"@radix-ui/react-switch": "^1.1.3",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@scalar/api-reference-react": "^0.7.42",
|
||||
"@sentry/nextjs": "^10",
|
||||
"@slack/web-api": "^7.9.1",
|
||||
"@uidotdev/usehooks": "^2.4.1",
|
||||
"@uploadthing/react": "^7.3.1",
|
||||
"ajv": "^8.17.1",
|
||||
"arctic": "^3.7.0",
|
||||
"bullmq": "^5.45.2",
|
||||
"cheerio": "^1.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.0",
|
||||
"cmdk": "1.0.0",
|
||||
"hls-video-element": "^1.5.0",
|
||||
"lucia": "^3.2.2",
|
||||
"lucide-react": "^0.473.0",
|
||||
"media-chrome": "^4.8.0",
|
||||
"next": "^16.1.0",
|
||||
"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.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"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.3",
|
||||
"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",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"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.7.0",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 629 B After Width: | Height: | Size: 629 B |
20
apps/web/sentry.edge.config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
// This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on).
|
||||
// The config you add here will be used whenever one of the edge features is loaded.
|
||||
// Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally.
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
Sentry.init({
|
||||
dsn: "https://f3c26671c39af48406c6e23702a4f3dd@o4506961023860736.ingest.us.sentry.io/4509895816773632",
|
||||
|
||||
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
|
||||
tracesSampleRate: 1,
|
||||
|
||||
// Enable logs to be sent to Sentry
|
||||
enableLogs: true,
|
||||
|
||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||
debug: false,
|
||||
enabled: process.env.NODE_ENV === 'production',
|
||||
});
|
||||
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,11 +1,11 @@
|
||||
import LiveStream from "@/components/app/Livestream/Livestream";
|
||||
import prisma from "@/lib/db";
|
||||
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: { 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
@@ -0,0 +1,6 @@
|
||||
export function GET() {
|
||||
return Response.json({
|
||||
version: process.env.version,
|
||||
commit: process.env.commit,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { prisma, getRedisConnection } from '@hctv/db';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { lucia } from '@hctv/auth';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const redis = getRedisConnection();
|
||||
const body = await request.json();
|
||||
|
||||
const parsed = schema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return new Response('invalid request', { status: 400 });
|
||||
}
|
||||
const { action, protocol, path, password } = parsed.data;
|
||||
if (action === 'publish' && protocol === 'srt') {
|
||||
const channelKey = await redis.get(`streamKey:${path}`);
|
||||
|
||||
if (channelKey) {
|
||||
if (channelKey !== password) {
|
||||
return new Response('invalid stream key', { status: 403 });
|
||||
}
|
||||
return new Response('youre in yay', { status: 200 });
|
||||
}
|
||||
} else if (action === 'read' && protocol === 'hls') {
|
||||
if (password === process.env.MEDIAMTX_PUBLISH_KEY) {
|
||||
return new Response('authorized', { status: 200 });
|
||||
}
|
||||
const sessionExists = await redis.exists(`sessions:${password}`);
|
||||
if (!sessionExists) {
|
||||
return new Response('unauthorized', { status: 401 });
|
||||
}
|
||||
return new Response('authorized', { status: 200 });
|
||||
}
|
||||
|
||||
return new Response('uhh', { status: 401 });
|
||||
}
|
||||
|
||||
const schema = z.object({
|
||||
user: z.string(),
|
||||
password: z.string(),
|
||||
token: z.string(),
|
||||
ip: z.string(),
|
||||
action: z.enum(['publish', 'read', 'playback', 'api', 'metrics', 'pprof']),
|
||||
path: z.string(),
|
||||
protocol: z.enum(['rtsp', 'rtmp', 'hls', 'webrtc', 'srt']),
|
||||
id: z.string().nullable(),
|
||||
query: z.string(),
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
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 c = await cookies();
|
||||
|
||||
const sessionCookie = c.get('auth_session')?.value;
|
||||
if (!sessionCookie) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
const sessionExists = await getRedisConnection().exists(`sessions:${sessionCookie}`);
|
||||
if (sessionExists === 0) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
if (path.includes('..')) {
|
||||
return new Response("nuh uh", { status: 403 });
|
||||
}
|
||||
|
||||
const basePath = '/dev/shm/hls';
|
||||
const filePath = `${basePath}/${path}`;
|
||||
const exists = fs.existsSync(filePath);
|
||||
|
||||
if (!exists) {
|
||||
return new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const file = await fsP.readFile(filePath);
|
||||
return new Response(file, {
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
import prisma from '@/lib/db';
|
||||
import { prisma } from '@hctv/db';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const formData = await request.formData();
|
||||
const streamKey = formData.get('name')?.toString() || '';
|
||||
const streamKey = formData.get('name');
|
||||
if (typeof streamKey !== 'string') {
|
||||
return new Response('bad request', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const key = await prisma.streamKey.findFirst({
|
||||
where: {
|
||||
@@ -19,12 +24,10 @@ export async function POST(request: NextRequest) {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
const headers = new Headers();
|
||||
headers.append('Location', `rtmp://127.0.0.1/channel-live/${key.channel.name}`);
|
||||
|
||||
return new Response(null, {
|
||||
return new Response('', {
|
||||
status: 302,
|
||||
headers: headers,
|
||||
headers: {
|
||||
'Location': key.channel.name,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { validateRequest } from "@/lib/auth";
|
||||
import prisma from "@/lib/db";
|
||||
import { validateRequest } from '@/lib/auth/validate';
|
||||
import { prisma } from '@hctv/db';
|
||||
import { NextRequest } from "next/server";
|
||||
import { regenerateStreamKey } from '@/lib/db/streamKey';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const { user } = await validateRequest();
|
||||
@@ -11,6 +12,10 @@ export async function POST(request: NextRequest) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
if (!channel || typeof channel !== 'string') {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
const channelInfo = await prisma.channel.findUnique({
|
||||
where: { name: channel },
|
||||
include: {
|
||||
@@ -30,20 +35,9 @@ export async function POST(request: NextRequest) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const dbUpdate = await prisma.streamKey.upsert({
|
||||
create: {
|
||||
key: crypto.randomUUID(),
|
||||
channelId: channelInfo.id
|
||||
},
|
||||
update: {
|
||||
key: crypto.randomUUID()
|
||||
},
|
||||
where: {
|
||||
channelId: channelInfo.id
|
||||
}
|
||||
})
|
||||
const streamKey = await regenerateStreamKey(channelInfo.id, channel);
|
||||
|
||||
return new Response(JSON.stringify({ key: dbUpdate.key }), {
|
||||
return new Response(JSON.stringify({ key: streamKey.key }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
@@ -0,0 +1,124 @@
|
||||
import { validateRequest } from "@/lib/auth/validate";
|
||||
import { prisma } from "@hctv/db";
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
type Params = Promise<{ slug: string }>;
|
||||
|
||||
export async function POST(request: NextRequest, segmentData: { params: Params }) {
|
||||
const { slug } = await segmentData.params;
|
||||
const { user } = await validateRequest();
|
||||
if (!user) {
|
||||
return new Response(JSON.stringify({ success: false, error: 'Unauthorized' }), { status: 401 });
|
||||
}
|
||||
|
||||
const bodySchema = z.object({
|
||||
action: z.enum(['revoke', 'regenerate', 'create']),
|
||||
name: z.string().min(3, 'Name must be at least 3 characters long').max(50, 'Name must be at most 50 characters long'),
|
||||
});
|
||||
const body = await request.json();
|
||||
const parsedBody = bodySchema.safeParse(body);
|
||||
if (!parsedBody.success) {
|
||||
return new Response(JSON.stringify({ success: false, error: parsedBody.error.errors.map(e => e.message).join(', ') }), { status: 400 });
|
||||
}
|
||||
|
||||
const { action, name } = parsedBody.data;
|
||||
|
||||
if (action === 'create') {
|
||||
const exists = await prisma.botApiKey.findFirst({
|
||||
where: {
|
||||
name,
|
||||
botAccount: {
|
||||
ownerId: user.id,
|
||||
slug,
|
||||
}
|
||||
}
|
||||
});
|
||||
if (exists) {
|
||||
return new Response(JSON.stringify({ success: false, error: 'API Key with this name already exists' }), { status: 400 });
|
||||
}
|
||||
const newKey = await prisma.botApiKey.create({
|
||||
data: {
|
||||
name,
|
||||
botAccount: {
|
||||
connect: {
|
||||
ownerId: user.id,
|
||||
slug,
|
||||
}
|
||||
},
|
||||
key: generateApiKey(),
|
||||
}
|
||||
});
|
||||
return new Response(JSON.stringify({ success: true, apiKey: newKey.key, id: newKey.id }));
|
||||
}
|
||||
if (action === 'regenerate') {
|
||||
const existingKey = await prisma.botApiKey.findFirst({
|
||||
where: {
|
||||
name,
|
||||
botAccount: {
|
||||
ownerId: user.id,
|
||||
slug,
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!existingKey) {
|
||||
return new Response(JSON.stringify({ success: false, error: 'API Key not found' }), { status: 404 });
|
||||
}
|
||||
const newKey = generateApiKey();
|
||||
await prisma.botApiKey.update({
|
||||
where: { id: existingKey.id },
|
||||
data: { key: newKey },
|
||||
});
|
||||
return new Response(JSON.stringify({ success: true, apiKey: newKey, id: existingKey.id }));
|
||||
}
|
||||
if (action === 'revoke') {
|
||||
const existingKey = await prisma.botApiKey.findFirst({
|
||||
where: {
|
||||
name,
|
||||
botAccount: {
|
||||
ownerId: user.id,
|
||||
slug,
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!existingKey) {
|
||||
return new Response(JSON.stringify({ success: false, error: 'API Key not found' }), { status: 404 });
|
||||
}
|
||||
await prisma.botApiKey.delete({
|
||||
where: { id: existingKey.id },
|
||||
});
|
||||
return new Response(JSON.stringify({ success: true }));
|
||||
}
|
||||
return new Response(JSON.stringify({ success: false, error: 'Invalid action' }), { status: 400 });
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest, segmentData: { params: Params }) {
|
||||
const { slug } = await segmentData.params;
|
||||
const { user } = await validateRequest();
|
||||
if (!user) {
|
||||
return new Response(JSON.stringify({ success: false, error: 'Unauthorized' }), { status: 401 });
|
||||
}
|
||||
|
||||
const apiKeys = await prisma.botApiKey.findMany({
|
||||
where: {
|
||||
botAccount: {
|
||||
ownerId: user.id,
|
||||
slug,
|
||||
}
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
}
|
||||
});
|
||||
return new Response(JSON.stringify({ success: true, apiKeys }));
|
||||
}
|
||||
|
||||
function generateApiKey() {
|
||||
const uuid = crypto.randomUUID().replace(/-/g, '');
|
||||
return `hctvb_${uuid}`;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { validateRequest } from '@/lib/auth';
|
||||
import prisma from '@/lib/db';
|
||||
import { validateRequest } from '@/lib/auth/validate';
|
||||
import { getNotificationQueue } from '@/lib/workers';
|
||||
import { prisma } from '@hctv/db';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
@@ -12,6 +13,17 @@ export async function GET(request: NextRequest) {
|
||||
if (!username) {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
const channelOwner = await prisma.channel.findFirst({
|
||||
where: {
|
||||
name: username,
|
||||
}
|
||||
})
|
||||
if (!channelOwner) {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
if (channelOwner.ownerId === user.id) {
|
||||
return new Response('you are of course not able to follow yourself', { status: 418 });
|
||||
}
|
||||
|
||||
const isFollowing =
|
||||
(await prisma.follow.count({
|
||||
@@ -30,6 +42,7 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const { user } = await validateRequest();
|
||||
const queue = getNotificationQueue();
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
const username = searchParams.get('username');
|
||||
if (!user) {
|
||||
@@ -38,6 +51,17 @@ export async function POST(request: NextRequest) {
|
||||
if (!username) {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
const channelOwner = await prisma.channel.findFirst({
|
||||
where: {
|
||||
name: username,
|
||||
}
|
||||
})
|
||||
if (!channelOwner) {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
if (channelOwner.ownerId === user.id) {
|
||||
return new Response('you are of course not able to follow yourself', { status: 418 });
|
||||
}
|
||||
|
||||
const isFollowing =
|
||||
(await prisma.follow.count({
|
||||
@@ -77,6 +101,11 @@ export async function POST(request: NextRequest) {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await queue.add(`newFollow:${username}`, {
|
||||
text: `You started following \`${username}\`!\n_Stream notifications are disabled by default. If you want to enable them, you can do so in \`Profile > Notifications\`._`,
|
||||
channel: user.slack_id,
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ following: !isFollowing }), { status: 200 });
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import db from '@/lib/db';
|
||||
import { prisma } from '@hctv/db';
|
||||
import { resolveChannelNameId } from '@/lib/db/resolve';
|
||||
|
||||
export async function GET(
|
||||
@@ -15,7 +15,7 @@ export async function GET(
|
||||
|
||||
const channelId = await resolveChannelNameId(channel);
|
||||
|
||||
const count = await db.follow.count({
|
||||
const count = await prisma.follow.count({
|
||||
where: {
|
||||
channelId,
|
||||
},
|
||||
64
apps/web/src/app/(ui)/(protected)/api/stream/info/route.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
// 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,
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
db.forEach((obj) => {
|
||||
if (obj.channel.personalFor) {
|
||||
// @ts-ignore
|
||||
delete obj.channel.personalFor.email;
|
||||
}
|
||||
// @ts-ignore
|
||||
delete obj.channel.obsChatGrantToken;
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
@@ -1,10 +1,10 @@
|
||||
import { validateRequest } from '@/lib/auth';
|
||||
import { validateRequest } from '@/lib/auth/validate';
|
||||
import { redirect, RedirectType } from 'next/navigation';
|
||||
|
||||
export default async function Layout({ children }: { children: React.ReactNode }) {
|
||||
const { user } = await validateRequest();
|
||||
if (!user) {
|
||||
return redirect('/auth/slack');
|
||||
return redirect('/auth/hackclub');
|
||||
}
|
||||
if (!user.hasOnboarded) {
|
||||
return redirect(`/onboarding`, RedirectType.push);
|
||||
@@ -0,0 +1,221 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { fetcher } from '@/lib/services/swr';
|
||||
import { useConfirm } from '@omit/react-confirm-dialog';
|
||||
import { Plus, RefreshCcw, Trash } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import useSWR from 'swr';
|
||||
import useSWRMutation from 'swr/mutation';
|
||||
|
||||
export function ApiKeys({ slug }: { slug: string }) {
|
||||
const confirm = useConfirm();
|
||||
const [newApiKeyName, setNewApiKeyName] = useState('');
|
||||
const { data, error, isLoading, mutate } = useSWR<GetResponse>(
|
||||
`/api/settings/bot/${slug}/apiKey`,
|
||||
fetcher
|
||||
);
|
||||
const { trigger } = useSWRMutation(`/api/settings/bot/${slug}/apiKey`, createApiKey);
|
||||
|
||||
const apiKeyCreate = () => {
|
||||
if (newApiKeyName.trim().length < 3) {
|
||||
toast.error('API Key name must be at least 3 characters long');
|
||||
return;
|
||||
}
|
||||
if (newApiKeyName.trim().length > 50) {
|
||||
toast.error('API Key name must be at most 50 characters long');
|
||||
return;
|
||||
}
|
||||
trigger({ action: 'create', name: newApiKeyName }).then(
|
||||
async (res: PostResponse) => {
|
||||
if (res.success) {
|
||||
setNewApiKeyName('');
|
||||
await navigator.clipboard
|
||||
.writeText(res.apiKey || '')
|
||||
.then(() => toast.success('API key copied to clipboard'))
|
||||
.catch(() => {
|
||||
alert('Failed to copy API key to clipboard, here it is: ' + res.apiKey);
|
||||
});
|
||||
await mutate();
|
||||
} else {
|
||||
toast.error(res.error || 'Error creating API key');
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>API Keys</CardTitle>
|
||||
<CardDescription>Manage your API keys</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading && <ApiKeysSkeleton />}
|
||||
{error && <p>Error loading API keys</p>}
|
||||
{data && !data.success && <p>Error: Could not fetch API keys</p>}
|
||||
{data && (
|
||||
<div className="flex">
|
||||
<Input
|
||||
placeholder="New API Key Name"
|
||||
className="flex-1 mr-2"
|
||||
value={newApiKeyName}
|
||||
onChange={(e) => setNewApiKeyName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
apiKeyCreate();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={apiKeyCreate}
|
||||
>
|
||||
<Plus />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{data && data.success && data.apiKeys.length === 0 && <p>No API keys found</p>}
|
||||
{data && data.success && data.apiKeys.length > 0 && (
|
||||
<ul className="space-y-2 pt-4">
|
||||
{data.apiKeys.map((key) => (
|
||||
<li
|
||||
key={key.id}
|
||||
className="flex items-center justify-between p-3 bg-mantle/50 rounded-md"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<strong className="text-sm font-medium">{key.name}</strong>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
const confirmation = await confirm({
|
||||
title: 'Regenerate API Key',
|
||||
description:
|
||||
'Are you sure you want to regenerate this API key? The old key will stop working.',
|
||||
confirmText: 'Regenerate',
|
||||
cancelText: 'Cancel',
|
||||
});
|
||||
if (!confirmation) return;
|
||||
|
||||
trigger({ action: 'regenerate', name: key.name }).then(
|
||||
async (res: PostResponse) => {
|
||||
if (res.success) {
|
||||
await navigator.clipboard
|
||||
.writeText(res.apiKey || '')
|
||||
.then(() => {
|
||||
toast.success('API key copied to clipboard');
|
||||
})
|
||||
.catch(() => {
|
||||
alert(
|
||||
'Failed to copy API key to clipboard, here it is: ' + res.apiKey
|
||||
);
|
||||
});
|
||||
mutate();
|
||||
} else {
|
||||
toast.error(res.error || 'Error regenerating API key');
|
||||
}
|
||||
}
|
||||
);
|
||||
}}
|
||||
>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
const confirmation = await confirm({
|
||||
title: 'Revoke API Key',
|
||||
description:
|
||||
'Are you sure you want to revoke this API key? This action cannot be undone.',
|
||||
confirmText: 'Revoke',
|
||||
cancelText: 'Cancel',
|
||||
});
|
||||
if (!confirmation) return;
|
||||
trigger({ action: 'revoke', name: key.name }).then(
|
||||
async (res: PostResponse) => {
|
||||
if (res.success) {
|
||||
await mutate();
|
||||
} else {
|
||||
toast.error(res.error || 'Error revoking API key');
|
||||
}
|
||||
}
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ApiKeysSkeleton() {
|
||||
return (
|
||||
<>
|
||||
<div className='flex'>
|
||||
<Skeleton className='h-10 flex-1 mr-2' />
|
||||
<Skeleton className='h-10 w-10' />
|
||||
</div>
|
||||
<div className='space-y-2 pt-4'>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className='flex items-center justify-between p-3 bg-mantle/50 rounded-md'>
|
||||
<div className='flex-1'>
|
||||
<Skeleton className='h-4 w-1/2' />
|
||||
</div>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<Skeleton className='h-8 w-8' />
|
||||
<Skeleton className='h-8 w-8' />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
async function createApiKey(url: string, { arg }: { arg: PostRequest }) {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(arg),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
interface GetResponse {
|
||||
success: boolean;
|
||||
apiKeys: Array<{ id: string; name: string; createdAt: string }>;
|
||||
}
|
||||
|
||||
interface PostRequest {
|
||||
action: 'revoke' | 'regenerate' | 'create';
|
||||
name: string;
|
||||
}
|
||||
interface PostResponse {
|
||||
success: boolean;
|
||||
apiKey?: string;
|
||||
id?: string;
|
||||
error?: string;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
'use client';
|
||||
import { UniversalForm } from '@/components/app/UniversalForm/UniversalForm';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { editBot } from '@/lib/form/actions';
|
||||
import { BotAccount } from '@hctv/db';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export function GeneralSettings(props: BotAccount) {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>General Settings</CardTitle>
|
||||
<CardDescription>Edit your bot settings!</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<UniversalForm
|
||||
fields={[
|
||||
{
|
||||
name: 'from',
|
||||
type: 'hidden',
|
||||
value: props.id,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
label: 'Bot Name',
|
||||
placeholder: 'Enter bot name',
|
||||
required: true,
|
||||
value: props.displayName,
|
||||
},
|
||||
{
|
||||
name: 'slug',
|
||||
type: 'text',
|
||||
label: 'Bot Slug',
|
||||
placeholder: 'Enter bot slug',
|
||||
required: true,
|
||||
value: props.slug
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'textarea',
|
||||
label: 'Description',
|
||||
placeholder: 'Enter bot description',
|
||||
value: props.description,
|
||||
textArea: true,
|
||||
},
|
||||
]}
|
||||
schemaName={'editBot'}
|
||||
action={editBot}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { getBotBySlug } from '@/lib/db/resolve';
|
||||
import { validateRequest } from '@/lib/auth/validate';
|
||||
import { redirect } from 'next/navigation';
|
||||
import Image from 'next/image';
|
||||
import { GeneralSettings } from '@/app/(ui)/(protected)/settings/bot/[slug]/gensettings';
|
||||
import { ApiKeys } from '@/app/(ui)/(protected)/settings/bot/[slug]/apikeys';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { user } = await validateRequest();
|
||||
const { slug } = await params;
|
||||
const bot = await getBotBySlug(slug);
|
||||
|
||||
if (!bot || bot.ownerId !== user?.id) {
|
||||
redirect('/settings/bot');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'container mx-auto py-6 space-y-6'}>
|
||||
<Link href={'/settings/bot'} className="text-sm text-muted-foreground hover:underline flex items-center gap-2">
|
||||
<ArrowLeft className='size-4' /> Back to Bot Accounts
|
||||
</Link>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className={'flex items-center space-x-4'}>
|
||||
<Image
|
||||
src={bot.pfpUrl}
|
||||
alt={'Bot Avatar'}
|
||||
width={48}
|
||||
height={48}
|
||||
className="rounded-full"
|
||||
/>
|
||||
<div className={'flex flex-col'}>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{bot.displayName}</h1>
|
||||
<p className="text-muted-foreground">Manage your bot account settings</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full gap-4 flex-col md:flex-row *:w-1/2">
|
||||
<GeneralSettings {...bot} />
|
||||
<ApiKeys slug={slug} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
'use client';
|
||||
|
||||
import { UniversalForm } from '@/components/app/UniversalForm/UniversalForm';
|
||||
import { createBot } from '@/lib/form/actions';
|
||||
import { Bot } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function Page() {
|
||||
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">
|
||||
<Bot className="h-8 w-8 text-primary-foreground" />
|
||||
</div>
|
||||
<h1 className="mb-2 text-3xl font-bold text-foreground">Create Bot Account</h1>
|
||||
<p className="text-muted-foreground max-w-xl">
|
||||
Create an automated bot account to provide custom functionality for your community.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-md bg-card rounded-xl p-8 border border-border">
|
||||
<UniversalForm
|
||||
fields={[
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
label: 'Bot Name',
|
||||
placeholder: 'Enter bot name',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'slug',
|
||||
type: 'text',
|
||||
label: 'Bot Slug',
|
||||
placeholder: 'Enter bot slug',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'textarea',
|
||||
label: 'Description',
|
||||
placeholder: 'Enter bot description',
|
||||
},
|
||||
]}
|
||||
schemaName={'createBot'}
|
||||
action={createBot}
|
||||
onActionComplete={(res) => {
|
||||
router.push(`/settings/bot/${res.slug}`);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
<p className="mt-6 text-sm text-muted-foreground text-center max-w-md">
|
||||
Your bot will be created with chat permissions. You can configure advanced settings and
|
||||
permissions after creation.
|
||||
</p>
|
||||
*/}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
apps/web/src/app/(ui)/(protected)/settings/bot/page.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { validateRequest } from '@/lib/auth/validate';
|
||||
import { prisma } from '@hctv/db';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import Link from 'next/link';
|
||||
import { Plus, Bot, Calendar, Hash } from 'lucide-react';
|
||||
import { redirect } from 'next/navigation';
|
||||
import Image from 'next/image';
|
||||
|
||||
export default async function Page() {
|
||||
const { user } = await validateRequest();
|
||||
if (!user) {
|
||||
redirect('/');
|
||||
}
|
||||
|
||||
const bots = await prisma.user.findFirst({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
botAccounts: true,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Bot Accounts</h1>
|
||||
<p className="text-muted-foreground">Manage your automated bot accounts</p>
|
||||
</div>
|
||||
<Link href="/settings/bot/create">
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Bot
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{bots?.botAccounts.length ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{bots.botAccounts.map((bot) => (
|
||||
<Link href={`/settings/bot/${bot.slug}`} key={bot.id}>
|
||||
<Card key={bot.id} className="hover:shadow-md transition-shadow">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Image src={bot.pfpUrl} alt={'Bot Avatar'} width={32} height={32} className="rounded-full" />
|
||||
<CardTitle className="text-lg">{bot.displayName}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
|
||||
<Hash className="h-4 w-4" />
|
||||
<span>{bot.slug}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>{new Date(bot.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card className="text-center py-8">
|
||||
<CardContent className="space-y-4">
|
||||
<Bot className="mx-auto h-12 w-12 text-muted-foreground" />
|
||||
<div>
|
||||
<CardTitle>No bot accounts yet</CardTitle>
|
||||
<CardDescription className="mt-2">
|
||||
Get started by creating your first bot account
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Link href="/settings/bot/create">
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Your First Bot
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,784 @@
|
||||
'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,
|
||||
Eye,
|
||||
EyeOff,
|
||||
} 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';
|
||||
import { useConfirm } from '@omit/react-confirm-dialog';
|
||||
|
||||
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)[];
|
||||
is247: boolean;
|
||||
};
|
||||
isOwner: boolean;
|
||||
currentUser: User;
|
||||
isPersonal: boolean;
|
||||
}
|
||||
|
||||
export default function ChannelSettingsClient({
|
||||
channel,
|
||||
isOwner,
|
||||
currentUser,
|
||||
isPersonal,
|
||||
}: ChannelSettingsClientProps) {
|
||||
const confirm = useConfirm();
|
||||
const [streamKey, setStreamKey] = useState(channel.streamKey?.key || '');
|
||||
const [keyVisible, setKeyVisible] = useState(false);
|
||||
const [copied, setCopied] = useState({
|
||||
streamKey: false,
|
||||
streamUrl: 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({ ...copied, streamKey: true });
|
||||
toast.success('Stream key copied to clipboard');
|
||||
setTimeout(() => setCopied({ ...copied, streamKey: 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');
|
||||
}
|
||||
};
|
||||
|
||||
const generateStreamUrl = () => {
|
||||
if (!streamKey) {
|
||||
toast.error('Stream key not available');
|
||||
return '';
|
||||
}
|
||||
return `srt://${process.env.NEXT_PUBLIC_MEDIAMTX_INGEST_ROUTE}?streamid=publish:${channel.name}:thisusernameislongonpurposesoyoudontaccidentallyleakyourstreamkey:${streamKey}&pkt_size=1316`;
|
||||
};
|
||||
|
||||
const copyStreamUrl = async () => {
|
||||
const url = generateStreamUrl();
|
||||
if (url) {
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopied({ ...copied, streamUrl: true });
|
||||
toast.success('Stream URL copied to clipboard');
|
||||
setTimeout(() => setCopied({ ...copied, streamUrl: false }), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
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(`/settings/channel/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>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'is247',
|
||||
value: channel.is247,
|
||||
component: ({ field }) => (
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<div>
|
||||
<label className="text-sm font-medium">24/7 Channel</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Mark this channel as always live. It will disable notifications on #hctv-streams.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={(checked) => {
|
||||
field.onChange(checked);
|
||||
}}
|
||||
/>
|
||||
<input type="hidden" {...field} value={field.value ? 'true' : 'false'} />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
]}
|
||||
schemaName="updateChannelSettings"
|
||||
action={updateChannelSettings}
|
||||
submitText="Save Changes"
|
||||
onActionComplete={handleChannelSettingsActionComplete}
|
||||
/>
|
||||
|
||||
{isOwner && !isPersonal && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-destructive">Danger Zone</h3>
|
||||
<div className="flex items-center justify-between p-4 border border-destructive/20 rounded-lg bg-destructive/5">
|
||||
<div>
|
||||
<p className="font-medium text-destructive">Delete Channel</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Permanently delete this channel. This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
if (
|
||||
await confirm({
|
||||
title: 'Delete Channel',
|
||||
description:
|
||||
'Are you sure you want to delete this channel? This action cannot be undone.',
|
||||
confirmText: 'Delete',
|
||||
cancelText: 'Cancel',
|
||||
})
|
||||
) {
|
||||
const result = await deleteChannel(channel.id);
|
||||
if (result.success) {
|
||||
toast.success('Channel deleted successfully');
|
||||
router.push('/settings/channel');
|
||||
} else {
|
||||
toast.error(result.error || 'Failed to delete channel');
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</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>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Stream Key</label>
|
||||
<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 pr-10 border rounded-md bg-mantle font-mono text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setKeyVisible(!keyVisible)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{keyVisible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<Button onClick={regenerateStreamKey} variant="outline" size="smicon">
|
||||
<Key className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="smicon"
|
||||
onClick={copyStreamKey}
|
||||
disabled={!streamKey}
|
||||
>
|
||||
{copied.streamKey ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Stream URL (for OBS)</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={generateStreamUrl()}
|
||||
readOnly
|
||||
className="w-full px-3 py-2 border rounded-md bg-mantle font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="smicon"
|
||||
onClick={copyStreamUrl}
|
||||
disabled={!streamKey}
|
||||
>
|
||||
{copied.streamUrl ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Need help getting started? Check out our{' '}
|
||||
<Link
|
||||
href="https://docs.hackclub.tv/guides/start-stream/"
|
||||
className="text-primary hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
streaming guide
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</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={async () => {
|
||||
if (await confirm({
|
||||
title: 'Remove Manager',
|
||||
description: `Are you sure you want to remove ${personalChannel?.name} as a manager? They will no longer be able to stream or moderate this channel.`,
|
||||
confirmText: 'Remove',
|
||||
cancelText: 'Cancel',
|
||||
})) {
|
||||
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://hackclub.tv/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/hackclub');
|
||||
}
|
||||
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
'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) => {
|
||||
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;
|
||||
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>;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { notifyStreamToggle } from '@/lib/form/actions';
|
||||
|
||||
export default function NotifyToggle(props: Props) {
|
||||
const [toggled, setToggled] = useState(props.toggled);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleToggle = async () => {
|
||||
setIsLoading(true);
|
||||
notifyStreamToggle(props.channel).then((res) => {
|
||||
if (res.success) {
|
||||
setToggled(res.toggle!);
|
||||
}
|
||||
});
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
return <Switch checked={toggled} onCheckedChange={handleToggle} disabled={isLoading} />;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
channel: string;
|
||||
toggled: boolean;
|
||||
}
|
||||
72
apps/web/src/app/(ui)/(protected)/settings/follows/page.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { validateRequest } from '@/lib/auth/validate';
|
||||
import { prisma } from '@hctv/db';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import Link from 'next/link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import NotifyToggle from './notifyToggle';
|
||||
|
||||
export default async function Page() {
|
||||
const { user } = await validateRequest();
|
||||
const following = await prisma.follow.findMany({
|
||||
where: {
|
||||
userId: user!.id,
|
||||
},
|
||||
include: {
|
||||
channel: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!following.length) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full">
|
||||
<h1 className="text-2xl font-bold">No channels followed</h1>
|
||||
<p className="text-muted-foreground">Go follow some first?</p>
|
||||
<Link href={'/'}>
|
||||
<Button>Back home</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container py-10">
|
||||
<h1 className="text-2xl font-bold mb-6">Followed Channels</h1>
|
||||
<Table className="max-w-2xl mx-auto outline-surface bg-mantle rounded-md overflow-hidden">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Channel</TableHead>
|
||||
<TableHead className="w-[100px] text-center">Notifications</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{following.map((channel) => (
|
||||
<TableRow key={channel.id}>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-9 w-9">
|
||||
<AvatarImage src={channel.channel.pfpUrl} alt={channel.channel.name} />
|
||||
<AvatarFallback>{channel.channel.name.charAt(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<Link href={`/${channel.channel.name}`} className="hover:underline">
|
||||
{channel.channel.name}
|
||||
</Link>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<NotifyToggle channel={channel.channel.name} toggled={channel.notifyStream} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +1,59 @@
|
||||
import { slack, lucia } from '@/lib/auth';
|
||||
import { hackClub, lucia, HCID_TOKEN_URL, HCID_USER_INFO_URL } from '@hctv/auth';
|
||||
import { cookies as nextCookies } from 'next/headers';
|
||||
import { decodeIdToken, OAuth2RequestError } from 'arctic';
|
||||
import { OAuth2RequestError } from 'arctic';
|
||||
import { generateIdFromEntropySize } from 'lucia';
|
||||
import prisma from '@/lib/db';
|
||||
import { prisma } from '@hctv/db';
|
||||
import { getRedisConnection } from '@hctv/db';
|
||||
|
||||
export async function GET(request: Request): Promise<Response> {
|
||||
const cookies = await nextCookies();
|
||||
const url = new URL(request.url);
|
||||
const code = url.searchParams.get("code");
|
||||
const state = url.searchParams.get("state");
|
||||
const storedState = cookies.get("slack_oauth_state")?.value ?? null;
|
||||
const storedState = cookies.get("hackclub_oauth_state")?.value ?? null;
|
||||
if (!code || !state || !storedState || state !== storedState) {
|
||||
console.log('invalid state stuff');
|
||||
return new Response(null, {
|
||||
status: 400
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const tokens = await slack.validateAuthorizationCode(code);
|
||||
const accessToken = tokens.accessToken()
|
||||
const slackUserResponse = await fetch('https://slack.com/api/openid.connect.userInfo', {
|
||||
const tokens = await hackClub.validateAuthorizationCode(HCID_TOKEN_URL, code, null);
|
||||
const accessToken = tokens.accessToken();
|
||||
const userResponse = await fetch(HCID_USER_INFO_URL, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
const slackUser: SlackUserInfo = await slackUserResponse.json();
|
||||
const userResult: HackClubUserResponse = await userResponse.json();
|
||||
const identity = userResult.identity;
|
||||
|
||||
const slackId = identity.slack_id;
|
||||
if (!slackId) {
|
||||
return new Response("Please make sure to have a Slack account before continuing.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
slack_id: slackUser.sub,
|
||||
slack_id: slackId,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
// Update email if it's missing or changed
|
||||
if (existingUser.email !== identity.primary_email) {
|
||||
await prisma.user.update({
|
||||
where: { id: existingUser.id },
|
||||
data: { email: identity.primary_email },
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -49,14 +68,16 @@ export async function GET(request: Request): Promise<Response> {
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: userId,
|
||||
slack_id: slackUser.sub,
|
||||
pfpUrl: slackUser.picture,
|
||||
slack_id: slackId,
|
||||
email: identity.primary_email,
|
||||
pfpUrl: identity.slack_id ? `https://cachet.dunkirk.sh/users/${identity.slack_id}/r` : 'https://github.com/hackclub.png',
|
||||
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,
|
||||
@@ -79,40 +100,15 @@ export async function GET(request: Request): Promise<Response> {
|
||||
}
|
||||
}
|
||||
|
||||
interface SlackUserInfo {
|
||||
// OpenID Connect standard fields
|
||||
ok: boolean;
|
||||
sub: string;
|
||||
email: string;
|
||||
email_verified: boolean;
|
||||
date_email_verified: number;
|
||||
name: string;
|
||||
picture: string;
|
||||
given_name: string;
|
||||
family_name: string;
|
||||
locale: string;
|
||||
|
||||
// Slack-specific fields
|
||||
['https://slack.com/user_id']: string;
|
||||
['https://slack.com/team_id']: string;
|
||||
['https://slack.com/team_name']: string;
|
||||
['https://slack.com/team_domain']: string;
|
||||
|
||||
// User image URLs
|
||||
['https://slack.com/user_image_24']: string;
|
||||
['https://slack.com/user_image_32']: string;
|
||||
['https://slack.com/user_image_48']: string;
|
||||
['https://slack.com/user_image_72']: string;
|
||||
['https://slack.com/user_image_192']: string;
|
||||
['https://slack.com/user_image_512']: string;
|
||||
|
||||
// Team image URLs
|
||||
['https://slack.com/team_image_34']?: string;
|
||||
['https://slack.com/team_image_44']?: string;
|
||||
['https://slack.com/team_image_68']?: string;
|
||||
['https://slack.com/team_image_88']?: string;
|
||||
['https://slack.com/team_image_102']?: string;
|
||||
['https://slack.com/team_image_132']?: string;
|
||||
['https://slack.com/team_image_230']?: string;
|
||||
['https://slack.com/team_image_default']?: boolean;
|
||||
interface HackClubIdentity {
|
||||
id: string;
|
||||
slack_id?: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
primary_email: string;
|
||||
}
|
||||
|
||||
interface HackClubUserResponse {
|
||||
identity: HackClubIdentity;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { generateState } from "arctic";
|
||||
import { slack } from "@/lib/auth";
|
||||
import { hackClub, HCID_AUTH_URL } from '@hctv/auth';
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
export async function GET(): Promise<Response> {
|
||||
const state = generateState();
|
||||
const url = slack.createAuthorizationURL(state, ['openid', 'profile']);
|
||||
const url = hackClub.createAuthorizationURL(HCID_AUTH_URL, state, ['slack_id', 'verification_status', 'email']);
|
||||
|
||||
(await cookies()).set("slack_oauth_state", state, {
|
||||
(await cookies()).set("hackclub_oauth_state", state, {
|
||||
path: "/",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
httpOnly: true,
|
||||
130
apps/web/src/app/(ui)/(public)/onboarding/page.client.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
'use client';
|
||||
|
||||
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 { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { User, Tv, Heart, MessageSquare } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
|
||||
export default function OnboardingClient() {
|
||||
const { user } = useSession();
|
||||
|
||||
return (
|
||||
<div className="min-h-[93vh] flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-2xl space-y-8">
|
||||
{/* welcome header */}
|
||||
<div className="text-center space-y-2">
|
||||
<div className="flex justify-center">
|
||||
<div className="relative">
|
||||
<Avatar className="h-20 w-20 border-4 border-primary/20">
|
||||
<AvatarImage src={user?.pfpUrl} alt={`@${user?.id}`} />
|
||||
<AvatarFallback className="text-2xl font-bold">
|
||||
{user?.id?.charAt(0)?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
Welcome to hackclub.tv!
|
||||
</h1>
|
||||
<p className="text-lg text-muted-foreground flex gap-2 justify-center">
|
||||
Let's get you set up <Image src="https://emoji.slack-edge.com/T0266FRGM/blahaj-heart/db9adf8229e9a4fb.png" alt=":blahaj-heart:" width={24} height={24} />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* explanation */}
|
||||
<Card className="border-2 border-primary/10 bg-primary/5">
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="flex items-center gap-2 text-xl">
|
||||
<User className="h-5 w-5 text-primary" />
|
||||
Why do you need a personal channel?
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 w-8 h-8 bg-primary/10 rounded-lg flex items-center justify-center">
|
||||
<Tv className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm">Stream content</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Share your coding sessions and projects!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 w-8 h-8 bg-primary/10 rounded-lg flex items-center justify-center">
|
||||
<MessageSquare className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm">Chat with others</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Connect with other Hack Clubbers and grow your audience
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 w-8 h-8 bg-primary/10 rounded-lg flex items-center justify-center">
|
||||
<Heart className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm">Follow hackclubbers</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Stay updated with your favorite creators and streams
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 p-4 bg-secondary/50 rounded-lg border border-muted">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<strong>Your personal channel</strong> is your home base on hctv.
|
||||
It's where your profile, streams, and content will live. You can always create
|
||||
additional channels later for different types of content!
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* form */}
|
||||
<Card className="shadow-lg">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-xl">Choose Your Channel Username</CardTitle>
|
||||
<CardDescription>
|
||||
This will be your unique identifier on hctv. Choose something memorable!
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<UniversalForm
|
||||
fields={[
|
||||
{ name: 'userId', label: 'User ID', type: 'hidden', value: user?.id },
|
||||
{
|
||||
name: 'username',
|
||||
label: 'Channel Username',
|
||||
type: 'text',
|
||||
placeholder: 'e.g., yourname or yourname-codes'
|
||||
},
|
||||
]}
|
||||
schemaName="onboard"
|
||||
action={onboard}
|
||||
onActionComplete={() => {
|
||||
window.location.href = '/';
|
||||
}}
|
||||
/>
|
||||
<div className="mt-4 p-3 bg-muted/30 rounded-md">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<strong>Username rules:</strong> Only lowercase letters (a-z), numbers (0-9),
|
||||
underscores (_), and dashes (-) are allowed. Must be unique across the platform.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { validateRequest } from "@/lib/auth";
|
||||
import { validateRequest } from '@/lib/auth/validate';
|
||||
import { redirect } from "next/navigation";
|
||||
import OnboardingClient from "./page.client";
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import LandingPage from '@/components/app/LandingPage/LandingPage';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import ConfusedDino from '@/components/ui/confuseddino';
|
||||
import { validateRequest } from '@/lib/auth';
|
||||
import prisma from '@/lib/db';
|
||||
import { validateRequest } from '@/lib/auth/validate';
|
||||
import { prisma } from '@hctv/db';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@radix-ui/react-avatar';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
@@ -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>
|
||||
@@ -42,8 +42,9 @@ export default async function Home() {
|
||||
<Card className="overflow-hidden hover:shadow-lg transition-shadow">
|
||||
<CardContent className="p-0">
|
||||
<div className="relative">
|
||||
<Image
|
||||
src={stream.channel.pfpUrl || '/placeholder.svg'}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={`/api/stream/thumb/${stream.channel.name}`}
|
||||
width={512}
|
||||
height={512}
|
||||
alt={stream.title}
|
||||
77
apps/web/src/app/(ui)/layout.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
import { cookies } from 'next/headers';
|
||||
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';
|
||||
import ConfirmDialogProvider from '@/lib/providers/ConfirmProvider';
|
||||
|
||||
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();
|
||||
const cookieStore = await cookies();
|
||||
const defaultOpen = cookieStore.get('sidebar:state')?.value === 'true';
|
||||
|
||||
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)}
|
||||
/>
|
||||
<ConfirmDialogProvider defaultOptions={{
|
||||
cancelButton: {
|
||||
variant: 'outline',
|
||||
},
|
||||
}}>
|
||||
<NuqsAdapter>
|
||||
<SidebarProvider defaultOpen={defaultOpen}>
|
||||
<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>
|
||||
</ConfirmDialogProvider>
|
||||
</ThemeProvider>
|
||||
</SessionProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
23
apps/web/src/app/global-error.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import NextError from "next/error";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function GlobalError({ error }: { error: Error & { digest?: string } }) {
|
||||
useEffect(() => {
|
||||
Sentry.captureException(error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<html>
|
||||
<body>
|
||||
{/* `NextError` is the default Next.js error page component. Its type
|
||||
definition requires a `statusCode` prop. However, since the App Router
|
||||
does not expose status codes for errors, we simply pass 0 to render a
|
||||
generic error message. */}
|
||||
<NextError statusCode={0} />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
206
apps/web/src/app/globals.css
Normal file
@@ -0,0 +1,206 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* Light theme - based on your color scheme */
|
||||
|
||||
/* Main background and foreground */
|
||||
--background: 350 59% 98%; /* FDF7F8 - main background */
|
||||
--foreground: 351 34% 30%; /* 5D3A3F - main text */
|
||||
|
||||
/* Muted elements */
|
||||
--muted: 350 40% 93%; /* F8E8EA - muted background */
|
||||
--muted-foreground: 350 30% 45%; /* Lighter version of main text */
|
||||
|
||||
/* Popover and card */
|
||||
--popover: 0 0% 100%; /* FFFFFF - popover background */
|
||||
--popover-foreground: 351 34% 30%; /* 5D3A3F - popover text */
|
||||
--card: 0 0% 100%; /* FFFFFF - card background */
|
||||
--card-foreground: 351 34% 30%; /* 5D3A3F - card text */
|
||||
|
||||
/* Border and input */
|
||||
--border: 350 30% 85%; /* Derived border color */
|
||||
--input: 350 30% 85%; /* Input background */
|
||||
|
||||
/* Primary actions */
|
||||
--primary: 350 70% 50%; /* C8394F - primary button */
|
||||
--primary-foreground: 0 0% 100%; /* FFFFFF - text on primary */
|
||||
|
||||
/* Secondary elements */
|
||||
--secondary: 350 40% 93%; /* F8E8EA - secondary background */
|
||||
--secondary-foreground: 351 34% 30%; /* 5D3A3F - text on secondary */
|
||||
|
||||
/* Accent elements */
|
||||
--accent: 350 70% 40%; /* A12D3E - accent color */
|
||||
--accent-foreground: 0 0% 100%; /* FFFFFF - text on accent */
|
||||
|
||||
/* Destructive actions */
|
||||
--destructive: 350 70% 55%; /* D63C56 - error/destroy */
|
||||
--destructive-foreground: 0 0% 100%; /* FFFFFF - text on destructive */
|
||||
|
||||
/* Focus ring */
|
||||
--ring: 350 70% 50%; /* C8394F - focus ring */
|
||||
|
||||
/* Surface colors */
|
||||
--surface-1: 350 40% 93%; /* F8E8EA - surface 1 */
|
||||
--surface-2: 350 35% 88%; /* Derived surface 2 */
|
||||
|
||||
/* Mantle */
|
||||
--mantle: 350 59% 98%; /* FDF7F8 - mantle */
|
||||
|
||||
/* Radius */
|
||||
--radius: 0.5rem;
|
||||
|
||||
/* Sidebar specific */
|
||||
--sidebar-background: 350 59% 98%; /* FDF7F8 - sidebar bg */
|
||||
--sidebar-foreground: 351 34% 30%; /* 5D3A3F - sidebar text */
|
||||
--sidebar-primary: 350 70% 50%; /* C8394F - sidebar primary */
|
||||
--sidebar-primary-foreground: 0 0% 100%; /* FFFFFF - text on sidebar primary */
|
||||
--sidebar-accent: 350 40% 93%; /* F8E8EA - sidebar accent */
|
||||
--sidebar-accent-foreground: 351 34% 30%; /* 5D3A3F - text on sidebar accent */
|
||||
--sidebar-border: 350 30% 85%; /* Derived border */
|
||||
--sidebar-ring: 350 70% 50%; /* C8394F - sidebar focus ring */
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* Dark theme - based on your color scheme */
|
||||
|
||||
/* Main background and foreground */
|
||||
--background: 350 20% 15%; /* 2A1F21 - main background */
|
||||
--foreground: 350 30% 92%; /* F5E6E8 - main text */
|
||||
|
||||
/* Muted elements */
|
||||
--muted: 350 20% 25%; /* 4A2D31 - muted background */
|
||||
--muted-foreground: 350 30% 75%; /* Lighter version of main text */
|
||||
|
||||
/* Popover and card */
|
||||
--popover: 350 20% 15%; /* 2A1F21 - popover background */
|
||||
--popover-foreground: 350 30% 92%; /* F5E6E8 - popover text */
|
||||
--card: 350 20% 15%; /* 2A1F21 - card background */
|
||||
--card-foreground: 350 30% 92%; /* F5E6E8 - card text */
|
||||
|
||||
/* Border and input */
|
||||
--border: 350 20% 35%; /* Derived border color */
|
||||
--input: 350 20% 35%; /* Input background */
|
||||
|
||||
/* Primary actions */
|
||||
--primary: 350 100% 75%; /* FF7A8A - primary button */
|
||||
--primary-foreground: 350 20% 15%; /* 2A1F21 - text on primary */
|
||||
|
||||
/* Secondary elements */
|
||||
--secondary: 350 20% 25%; /* 4A2D31 - secondary background */
|
||||
--secondary-foreground: 350 30% 92%; /* F5E6E8 - text on secondary */
|
||||
|
||||
/* Accent elements */
|
||||
--accent: 350 100% 80%; /* FF9AAA - accent color */
|
||||
--accent-foreground: 350 20% 15%; /* 2A1F21 - text on accent */
|
||||
|
||||
/* Destructive actions */
|
||||
--destructive: 350 100% 70%; /* FF6B7D - error/destroy */
|
||||
--destructive-foreground: 350 20% 15%; /* 2A1F21 - text on destructive */
|
||||
|
||||
/* Focus ring */
|
||||
--ring: 350 100% 75%; /* FF7A8A - focus ring */
|
||||
|
||||
/* Surface colors */
|
||||
--surface-1: 350 20% 25%; /* 4A2D31 - surface 1 */
|
||||
--surface-2: 350 20% 35%; /* Derived surface 2 */
|
||||
|
||||
/* Mantle */
|
||||
--mantle: 350 20% 12%; /* 1F1617 - mantle */
|
||||
|
||||
/* Radius */
|
||||
--radius: 0.5rem;
|
||||
|
||||
/* Sidebar specific */
|
||||
--sidebar-background: 350 20% 12%; /* 1F1617 - sidebar bg */
|
||||
--sidebar-foreground: 350 30% 92%; /* F5E6E8 - sidebar text */
|
||||
--sidebar-primary: 350 100% 75%; /* FF7A8A - sidebar primary */
|
||||
--sidebar-primary-foreground: 350 20% 15%; /* 2A1F21 - text on sidebar primary */
|
||||
--sidebar-accent: 350 20% 25%; /* 4A2D31 - sidebar accent */
|
||||
--sidebar-accent-foreground: 350 30% 92%; /* F5E6E8 - text on sidebar accent */
|
||||
--sidebar-border: 350 20% 35%; /* Derived border */
|
||||
--sidebar-ring: 350 100% 75%; /* FF7A8A - sidebar focus ring */
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
h2 {
|
||||
@apply scroll-m-20 pb-2 text-3xl font-semibold tracking-tight first:mt-0;
|
||||
}
|
||||
|
||||
/* Media controller styles remain unchanged */
|
||||
media-controller {
|
||||
--media-primary-color: #ffffff;
|
||||
--media-secondary-color: hsla(var(--background), 0.85);
|
||||
--media-control-hover-background: hsla(var(--accent), 0.85);
|
||||
--media-control-background: hsla(var(--secondary), 0.85);
|
||||
--media-loading-icon-color: #ffffff;
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
border: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
media-control-bar {
|
||||
background-color: hsla(var(--background), 0.8);
|
||||
backdrop-filter: blur(8px);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
media-time-range {
|
||||
--media-range-track-height: 6px;
|
||||
--media-range-thumb-height: 14px;
|
||||
--media-range-thumb-width: 14px;
|
||||
--media-range-thumb-border-radius: 50%;
|
||||
--media-range-bar-color: #ffffff;
|
||||
--media-range-thumb-background: #ffffff;
|
||||
--media-preview-background: hsla(var(--card), 0.9);
|
||||
--media-preview-border-radius: var(--radius);
|
||||
}
|
||||
|
||||
media-time-display {
|
||||
--media-text-color: #ffffff;
|
||||
}
|
||||
|
||||
media-controller::part(centered-layer) {
|
||||
background-color: hsla(var(--background), 0.2);
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
media-controller:not([mediapaused])[userinactive]::part(centered-layer) {
|
||||
opacity: 0;
|
||||
transition: opacity 1s ease;
|
||||
}
|
||||
|
||||
media-loading-indicator {
|
||||
--media-loading-icon-width: 48px;
|
||||
--media-loading-icon-height: 48px;
|
||||
--media-loading-icon-color: #ffffff;
|
||||
}
|
||||
|
||||
media-play-button:hover,
|
||||
media-mute-button:hover,
|
||||
media-fullscreen-button:hover,
|
||||
media-seek-backward-button:hover,
|
||||
media-seek-forward-button:hover {
|
||||
--media-control-hover-background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
14
apps/web/src/app/layout.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { NuqsAdapter } from 'nuqs/adapters/next/app';
|
||||
import './globals.css';
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<NuqsAdapter>
|
||||
<body>
|
||||
<main>{children}</main>
|
||||
</body>
|
||||
</NuqsAdapter>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
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;
|
||||
}
|
||||
322
apps/web/src/components/app/ChatPanel/ChatPanel.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Send } from 'lucide-react';
|
||||
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(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}?grant=${grant}`
|
||||
);
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.onopen = () => {
|
||||
console.log('WebSocket connected');
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.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.error('Error processing message:', e);
|
||||
console.log('Raw message data:', event.data);
|
||||
}
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
console.log('WebSocket closed');
|
||||
};
|
||||
|
||||
return () => {
|
||||
socket.close();
|
||||
};
|
||||
}, [username]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
if (chatMessages.length > 100) {
|
||||
setChatMessages((prev) => prev.slice(chatMessages.length - 100));
|
||||
}
|
||||
}, [chatMessages]);
|
||||
|
||||
const sendMessage = () => {
|
||||
if (!message.trim()) return;
|
||||
|
||||
if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) {
|
||||
socketRef.current.send(JSON.stringify({ type: 'message', message }));
|
||||
setMessage('');
|
||||
} else {
|
||||
const socket = new WebSocket(
|
||||
`ws${window.location.protocol === 'https:' ? 's' : ''}://${
|
||||
window.location.host
|
||||
}/api/stream/chat/ws/${username}?grant=${grant}`
|
||||
);
|
||||
socket.onopen = () => {
|
||||
socket.send(JSON.stringify({ type: 'message', message }));
|
||||
setMessage('');
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) {
|
||||
socketRef.current.send(JSON.stringify({ type: 'ping' }));
|
||||
}
|
||||
}, 5000);
|
||||
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={`${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) => (
|
||||
<Message
|
||||
key={i}
|
||||
user={msg.user}
|
||||
message={msg.message}
|
||||
type={msg.type}
|
||||
emojiMap={emojiMap}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{!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}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
user?: User;
|
||||
message: string;
|
||||
type: 'message' | 'systemMsg';
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
pfpUrl: string;
|
||||
isBot: boolean;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
isObsPanel?: boolean;
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
91
apps/web/src/components/app/ChatPanel/message.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { User } from './ChatPanel';
|
||||
import React from 'react';
|
||||
import Image from 'next/image';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Bot } from 'lucide-react';
|
||||
|
||||
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 className="flex flex-wrap items-center">
|
||||
<span className="font-bold mr-2 flex items-center">
|
||||
{user?.isBot && (
|
||||
<span className="text-xs text-muted-foreground flex mr-1">
|
||||
{' '}
|
||||
<Bot className="size-5" />
|
||||
</span>
|
||||
)}
|
||||
{user?.displayName || 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 (
|
||||
<TooltipProvider key={index}>
|
||||
<Tooltip 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>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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>;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { validateRequest } from '@/lib/auth/validate';
|
||||
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;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={`/settings/channel?tab=stream`}>
|
||||
<Button variant="outline">Edit Livestream</Button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
175
apps/web/src/components/app/LandingPage/LandingPage.tsx
Normal file
@@ -0,0 +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">
|
||||
{/* 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>
|
||||
</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>
|
||||
<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>
|
||||
</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 '@prisma/client';
|
||||
import type { StreamInfo, 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 };
|
||||
}
|
||||
@@ -14,25 +14,10 @@ import {
|
||||
import { logout } from '@/lib/auth/actions';
|
||||
import { useSession } from '@/lib/providers/SessionProvider';
|
||||
import Link from 'next/link';
|
||||
import MobileNavbarLinks from '../MobileNavbarLinks/MobileNavbarLinks';
|
||||
import { ThemeSwitcher } from '../ThemeSwitcher/ThemeSwitcher';
|
||||
import { Slack } from 'lucide-react';
|
||||
import { IdCard, Slack } from 'lucide-react';
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||
|
||||
export const links = [{ href: '/', name: 'home (placeholder link)' }];
|
||||
|
||||
function NavbarLinks() {
|
||||
return (
|
||||
<>
|
||||
{links.map((link) => (
|
||||
<Link key={link.href} href={link.href}>
|
||||
<Button variant={'link'}>{link.name}</Button>
|
||||
</Link>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Navbar(props: Props) {
|
||||
const { user } = useSession();
|
||||
return (
|
||||
@@ -47,10 +32,6 @@ export default function Navbar(props: Props) {
|
||||
<SidebarTrigger />
|
||||
</div>
|
||||
|
||||
<div className="hidden md:flex">
|
||||
<NavbarLinks />
|
||||
</div>
|
||||
|
||||
{/* Right Side Items */}
|
||||
<div className="flex items-center gap-1 md:gap-3 shrink-0">
|
||||
{props.editLivestream && <div className="hidden sm:block">{props.editLivestream}</div>}
|
||||
@@ -66,6 +47,28 @@ export default function Navbar(props: Props) {
|
||||
<DropdownMenuContent className="w-56">
|
||||
<DropdownMenuLabel>My Account</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<Link href={`/settings/follows`}>
|
||||
<DropdownMenuItem className="cursor-pointer">Follows</DropdownMenuItem>
|
||||
</Link>
|
||||
<Link href={`/settings/channel/create`}>
|
||||
<DropdownMenuItem className="cursor-pointer">Create channel</DropdownMenuItem>
|
||||
</Link>
|
||||
<Link href={`/settings/bot`}>
|
||||
<DropdownMenuItem className="cursor-pointer">Bot accounts</DropdownMenuItem>
|
||||
</Link>
|
||||
<DropdownMenuSeparator />
|
||||
<Link href={'https://docs.hackclub.tv'} target="_blank" rel="noreferrer">
|
||||
<DropdownMenuItem className="cursor-pointer">API Docs</DropdownMenuItem>
|
||||
</Link>
|
||||
<Link href={'https://github.com/SrIzan10/hctv'} target="_blank" rel="noreferrer">
|
||||
<DropdownMenuItem className="cursor-pointer">Github</DropdownMenuItem>
|
||||
</Link>
|
||||
<Link href={'https://github.com/sponsors/SrIzan10'} target="_blank" rel="noreferrer">
|
||||
<DropdownMenuItem className="cursor-pointer">Sponsor</DropdownMenuItem>
|
||||
</Link>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
@@ -80,12 +83,23 @@ 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>
|
||||
) : (
|
||||
<Link href="/auth/slack">
|
||||
<Link href="/auth/hackclub">
|
||||
<Button variant="outline" size="sm" className="gap-1 md:gap-2 text-xs md:text-sm">
|
||||
<Slack className="w-3 h-3 md:w-4 md:h-4" />
|
||||
<IdCard className="w-3 h-3 md:w-4 md:h-4" />
|
||||
<span className="hidden sm:inline">Sign in</span>
|
||||
<span className="sm:hidden">Login</span>
|
||||
</Button>
|
||||
188
apps/web/src/components/app/Sidebar/Sidebar.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import {
|
||||
Sidebar as UISidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { StreamInfoResponse, useStreams } from '@/lib/providers/StreamInfoProvider';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useAllChannels } from '@/lib/hooks/useUserList';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
|
||||
export default function Sidebar({ ...props }: React.ComponentProps<typeof UISidebar>) {
|
||||
const { channels: stream, isLoading } = useAllChannels(5000);
|
||||
const [followedExpanded, setFollowedExpanded] = React.useState(true);
|
||||
const { state } = useSidebar();
|
||||
const isCollapsed = state === 'collapsed';
|
||||
|
||||
if (isLoading) return <SidebarSkeleton {...props} />;
|
||||
|
||||
const liveStreamers = stream?.filter((s) => s.isLive) || [];
|
||||
const offlineStreamers = stream?.filter((s) => !s.isLive) || [];
|
||||
|
||||
return (
|
||||
<UISidebar collapsible="icon" {...props}>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel className="flex items-center justify-between px-2 py-1.5">
|
||||
<span className="text-xs font-semibold uppercase text-muted-foreground group-data-[collapsible=icon]:opacity-0 transition-opacity duration-200">
|
||||
Live Channels
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground group-data-[collapsible=icon]:opacity-0 transition-opacity duration-200">
|
||||
{liveStreamers.length}
|
||||
</span>
|
||||
</SidebarGroupLabel>
|
||||
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{liveStreamers.length === 0 && !isCollapsed && (
|
||||
<div className="px-4 py-2 text-sm text-muted-foreground">
|
||||
No channels live
|
||||
</div>
|
||||
)}
|
||||
{liveStreamers.map((streamer) => (
|
||||
<StreamerItem key={streamer.id} streamer={streamer} isCollapsed={isCollapsed} />
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
<Separator className="group-data-[collapsible=icon]:block hidden" />
|
||||
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel className="flex items-center justify-between px-2 py-1.5">
|
||||
<span className="text-xs font-semibold uppercase text-muted-foreground group-data-[collapsible=icon]:opacity-0 transition-opacity duration-200">
|
||||
Offline Channels
|
||||
</span>
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{offlineStreamers.map((streamer) => (
|
||||
<StreamerItem key={streamer.id} streamer={streamer} isCollapsed={isCollapsed} />
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
</UISidebar>
|
||||
);
|
||||
}
|
||||
|
||||
function StreamerItem({ streamer, isCollapsed }: { streamer: StreamInfoResponse[0], isCollapsed: boolean }) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
tooltip={streamer.username}
|
||||
className="h-12"
|
||||
onClick={() => router.push(`/${streamer.username}`)}
|
||||
>
|
||||
<button className="flex w-full items-center gap-3">
|
||||
<div className="relative flex-shrink-0">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={streamer.channel.pfpUrl} alt={streamer.username} className="object-cover" />
|
||||
<AvatarFallback>{streamer.username[0]?.toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
{streamer.isLive && (
|
||||
<span className="absolute -bottom-0.5 -right-0.5 flex h-3 w-3 items-center justify-center rounded-full bg-background ring-2 ring-background">
|
||||
<span className="h-2 w-2 rounded-full bg-red-500 animate-pulse" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isCollapsed && (
|
||||
<div className="flex flex-1 flex-col items-start overflow-hidden">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span className="truncate font-medium text-sm leading-none">
|
||||
{streamer.username}
|
||||
</span>
|
||||
{streamer.isLive && (
|
||||
<div className="flex items-center gap-1 text-xs text-red-500">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-red-500" />
|
||||
<span>{streamer.viewers}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="truncate text-xs text-muted-foreground w-full text-left">
|
||||
{streamer.isLive ? streamer.title || streamer.category || 'Live' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarSkeleton({ ...props }: React.ComponentProps<typeof UISidebar>) {
|
||||
const { state } = useSidebar();
|
||||
const isCollapsed = state === 'collapsed';
|
||||
|
||||
return (
|
||||
<UISidebar collapsible="icon" {...props}>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel className="px-2 py-1.5">
|
||||
<Skeleton className="h-4 w-24 group-data-[collapsible=icon]:opacity-0 transition-opacity duration-200" />
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{Array(3).fill(0).map((_, i) => (
|
||||
<StreamerItemSkeleton key={i} isCollapsed={isCollapsed} />
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
<Separator className="group-data-[collapsible=icon]:block hidden" />
|
||||
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel className="px-2 py-1.5">
|
||||
<Skeleton className="h-4 w-24 group-data-[collapsible=icon]:opacity-0 transition-opacity duration-200" />
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{Array(5).fill(0).map((_, i) => (
|
||||
<StreamerItemSkeleton key={i} isCollapsed={isCollapsed} />
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
</UISidebar>
|
||||
);
|
||||
}
|
||||
|
||||
function StreamerItemSkeleton({ isCollapsed }: { isCollapsed: boolean }) {
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton className="h-12">
|
||||
<div className="flex w-full items-center gap-3">
|
||||
<Skeleton className="h-8 w-8 rounded-full flex-shrink-0" />
|
||||
{!isCollapsed && (
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Skeleton className="h-3.5 w-24" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
72
apps/web/src/components/app/StreamPlayer/StreamPlayer.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
'use client';
|
||||
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useRef, useEffect } from 'react';
|
||||
import {
|
||||
MediaController,
|
||||
MediaLoadingIndicator,
|
||||
MediaControlBar,
|
||||
MediaPlayButton,
|
||||
MediaSeekBackwardButton,
|
||||
MediaSeekForwardButton,
|
||||
MediaMuteButton,
|
||||
MediaVolumeRange,
|
||||
MediaFullscreenButton,
|
||||
} from 'media-chrome/react';
|
||||
import HlsVideo from 'hls-video-element/react';
|
||||
import { useSession } from '@/lib/providers/SessionProvider';
|
||||
|
||||
export default function StreamPlayer() {
|
||||
const { username } = useParams();
|
||||
const { session } = useSession();
|
||||
const videoRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (video && username && session) {
|
||||
const user = 'skibiditoilet';
|
||||
const credentials = btoa(`${user}:${session.id}`);
|
||||
|
||||
// @ts-ignore
|
||||
video.config = {
|
||||
xhrSetup: (xhr: XMLHttpRequest) => {
|
||||
xhr.setRequestHeader('Authorization', `Basic ${credentials}`);
|
||||
},
|
||||
lowLatencyMode: true,
|
||||
debug: process.env.NODE_ENV === 'development',
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
video.src = `${process.env.NEXT_PUBLIC_MEDIAMTX_URL}/${username}/index.m3u8`;
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (video) {
|
||||
// @ts-ignore
|
||||
video.src = '';
|
||||
}
|
||||
};
|
||||
}, [username, session]);
|
||||
|
||||
return (
|
||||
<MediaController className="w-full aspect-video">
|
||||
<HlsVideo
|
||||
ref={videoRef}
|
||||
slot="media"
|
||||
crossOrigin="anonymous"
|
||||
autoplay
|
||||
/>
|
||||
<MediaLoadingIndicator slot="centered-chrome" noAutohide />
|
||||
<MediaControlBar className="w-full px-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<MediaPlayButton />
|
||||
<MediaMuteButton />
|
||||
<MediaVolumeRange />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<MediaFullscreenButton />
|
||||
</div>
|
||||
</MediaControlBar>
|
||||
</MediaController>
|
||||
);
|
||||
}
|
||||
@@ -19,11 +19,18 @@ 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 {
|
||||
createBotSchema,
|
||||
createChannelSchema, editBotSchema, 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 },
|
||||
{ name: 'createBot', zod: createBotSchema },
|
||||
{ name: 'editBot', zod: editBotSchema }
|
||||
] as const;
|
||||
|
||||
export function UniversalForm<T extends z.ZodType>({
|
||||
@@ -37,7 +44,7 @@ export function UniversalForm<T extends z.ZodType>({
|
||||
otherSubmitButton,
|
||||
submitButtonDivClassname,
|
||||
}: UniversalFormProps<T>) {
|
||||
// @ts-ignore idk why this error is happening, first apprearing on the react 19 update.
|
||||
// @ts-expect-error - idk
|
||||
const [state, formAction] = useActionState<{ success: boolean; error?: string }>(action, null);
|
||||
const schema = schemaDb.find((s) => s.name === schemaName)?.zod;
|
||||
|
||||
@@ -54,9 +61,11 @@ export function UniversalForm<T extends z.ZodType>({
|
||||
return { ...values, ...defaultValues };
|
||||
}, [fields, defaultValues]);
|
||||
|
||||
const form = useForm<z.infer<T>>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: initialValues as z.infer<T>,
|
||||
type FormData = z.infer<T>;
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(schema as any),
|
||||
defaultValues: initialValues as FormData,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -75,12 +84,14 @@ export function UniversalForm<T extends z.ZodType>({
|
||||
<FormField
|
||||
key={field.name}
|
||||
control={form.control}
|
||||
name={field.name as Path<z.infer<T>>}
|
||||
name={field.name as Path<FormData>}
|
||||
render={({ field: formField }) => (
|
||||
<FormItem>
|
||||
{field.type !== 'hidden' && <FormLabel>{field.label}</FormLabel>}
|
||||
{(field.type !== 'hidden' || field.label) && <FormLabel>{field.label}</FormLabel>}
|
||||
<FormControl>
|
||||
{field.textArea ? (
|
||||
{field.component ? (
|
||||
field.component({ field: formField, ...field.componentProps })
|
||||
) : field.textArea ? (
|
||||
<Textarea
|
||||
placeholder={field.placeholder}
|
||||
{...formField}
|
||||
@@ -1,23 +1,27 @@
|
||||
import { z } from 'zod';
|
||||
import { HTMLInputTypeAttribute } from 'react';
|
||||
import { ControllerRenderProps } from 'react-hook-form';
|
||||
import { schemaDb } from './UniversalForm';
|
||||
|
||||
export type FormFieldConfig = {
|
||||
name: string;
|
||||
label: string;
|
||||
label?: string;
|
||||
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>;
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
export type UniversalFormProps<T extends z.ZodType> = {
|
||||
fields: FormFieldConfig[];
|
||||
schemaName: (typeof schemaDb)[number]['name'];
|
||||
action: (prev: any, formData: FormData) => void;
|
||||
onActionComplete?: (result: unknown) => void;
|
||||
onActionComplete?: (result: any) => void;
|
||||
defaultValues?: Partial<z.infer<T>>;
|
||||
submitText?: string;
|
||||
submitClassname?: string;
|
||||
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;
|
||||
}
|
||||
36
apps/web/src/components/app/UserInfoCard/UserInfoCard.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Avatar, AvatarImage } from '@/components/ui/avatar';
|
||||
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 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.channel.pfpUrl} alt={props.streamInfo.username} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{props.streamInfo.title}</h1>
|
||||
<p>{props.streamInfo.username}</p>
|
||||
<FollowCountText channel={props.streamInfo.username} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<ViewerCount />
|
||||
<FollowButton channel={props.streamInfo.username} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-32 overflow-y-auto">
|
||||
<Preview textValue={props.streamInfo.channel.description} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
streamInfo: StreamInfo & { channel: Channel };
|
||||
}
|
||||
@@ -10,25 +10,26 @@ import React from 'react';
|
||||
|
||||
export default function FollowButton(props: Props) {
|
||||
const [ref, isHovering] = useHover();
|
||||
// const [following, setFollowing] = React.useState(props.isFollowing);
|
||||
// make a get request to check if the user is following the channel and set it as the initial state. use swr to make the request
|
||||
const [bye, setBye] = React.useState(false);
|
||||
const { data: followingData, isLoading: isLoadingFollowing } = useSWR(
|
||||
`/api/stream/follow?username=${props.channel}`,
|
||||
async (url) => fetcher(url)
|
||||
);
|
||||
const [following, setFollowing] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (followingData) {
|
||||
setFollowing(followingData.following);
|
||||
}
|
||||
}, [followingData]);
|
||||
|
||||
const { trigger, data, isMutating } = mutatedUseSWR(
|
||||
`/api/stream/follow?username=${props.channel}`,
|
||||
async (url) => fetcher(url, { method: 'POST' })
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isLoadingFollowing && followingData) {
|
||||
setFollowing(followingData.following);
|
||||
}
|
||||
if (!isLoadingFollowing && followingData === undefined) {
|
||||
setBye(true);
|
||||
}
|
||||
}, [followingData, isLoadingFollowing]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (data) {
|
||||
setFollowing(data.following);
|
||||
@@ -44,6 +45,7 @@ export default function FollowButton(props: Props) {
|
||||
disabled={isMutating || isLoadingFollowing}
|
||||
ref={ref}
|
||||
variant='outlineMantle'
|
||||
className={bye ? 'hidden' : ''}
|
||||
>
|
||||
{isHovering && following ? <HeartCrack className={followingCn} /> : <Heart className={following ? followingCn : notFollowingCn} />}
|
||||
</Button>
|
||||
19
apps/web/src/components/app/UserInfoCard/viewerCount.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useStreams } from "@/lib/providers/StreamInfoProvider";
|
||||
import { User } from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
|
||||
export default function ViewerCount() {
|
||||
const streamInfo = useStreams();
|
||||
const { username } = useParams();
|
||||
|
||||
if (streamInfo.isLoading) return null;
|
||||
|
||||
const viewerCount = streamInfo.stream!.find(s => s.username === username)?.viewers;
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2 *:text-destructive">
|
||||
<span className="text-sm font-semibold"><User /></span>
|
||||
<span className="text-sm">{viewerCount}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||