mirror of
https://github.com/SrIzan10/hctv.git
synced 2026-06-06 00:56:56 +00:00
Compare commits
152 Commits
dependabot
...
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 |
69
.github/workflows/docker.yml
vendored
69
.github/workflows/docker.yml
vendored
@@ -10,6 +10,12 @@ jobs:
|
||||
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,6 +35,22 @@ 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@v6
|
||||
with:
|
||||
@@ -41,41 +63,6 @@ jobs:
|
||||
secrets: |
|
||||
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
|
||||
db:
|
||||
name: Push db to Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- 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-db
|
||||
tags: latest
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./packages/db/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 }}
|
||||
chat:
|
||||
name: Push chat module to Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
@@ -112,14 +99,10 @@ jobs:
|
||||
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
|
||||
deploy:
|
||||
name: Deploy to server
|
||||
name: Deploy to Coolify
|
||||
runs-on: ubuntu-latest
|
||||
needs: [frontend, db, chat]
|
||||
needs: [frontend, chat]
|
||||
steps:
|
||||
- name: Emit a webhook to the server
|
||||
env:
|
||||
AUTH_HEADER: ${{ secrets.WHSERVER_TOKEN }}
|
||||
- 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
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
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -42,4 +42,9 @@ dev/redis
|
||||
|
||||
.turbo
|
||||
packages/db/generated/client
|
||||
*dist
|
||||
*dist
|
||||
|
||||
slack-import-emojis/target
|
||||
**/*/emojis.json
|
||||
|
||||
.idea
|
||||
8
.vscode/mcp.json
vendored
Normal file
8
.vscode/mcp.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"servers": {
|
||||
"Sentry": {
|
||||
"url": "https://mcp.sentry.dev/mcp/sr-izan/hctv",
|
||||
"type": "http"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,7 @@
|
||||
# hackclub.tv
|
||||
|
||||
This is the source code for [hackclub.tv (hctv.srizan.dev)](https://hctv.srizan.dev), a livestreaming website for hackclubbers.
|
||||
This is the source code for [hackclub.tv (hackclub.tv)](https://hackclub.tv), a livestreaming website for hackclubbers.
|
||||
|
||||
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.
|
||||
|
||||
Join [#hctv](https://hackclub.slack.com/archives/C08HGLXGXAB) on the HC Slack for discussion and updates!
|
||||
|
||||
## Features
|
||||
|
||||
- High quality video streaming (low latency coming soon)
|
||||
- Chat with other viewers
|
||||
- Multiaccount support (database schema laid out, UI not implemented)
|
||||
@@ -1,8 +0,0 @@
|
||||
```
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
```
|
||||
open http://localhost:3000
|
||||
```
|
||||
@@ -12,6 +12,7 @@
|
||||
"@hctv/hono-ws": "*",
|
||||
"@hono/node-server": "^1.14.0",
|
||||
"@hono/node-ws": "^1.1.0",
|
||||
"@leeoniya/ufuzzy": "^1.0.18",
|
||||
"@oslojs/encoding": "^1.1.0",
|
||||
"hono": "^4.7.5"
|
||||
},
|
||||
|
||||
@@ -5,9 +5,15 @@ import { readFile } from 'node:fs/promises';
|
||||
import { lucia } from '@hctv/auth';
|
||||
import { getCookie } from 'hono/cookie';
|
||||
import { getPersonalChannel } from './utils/personalChannel.js';
|
||||
import { prisma } from '@hctv/db';
|
||||
import { getRedisConnection, prisma, type 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 });
|
||||
@@ -23,50 +29,99 @@ app.get('/up', async (c) => {
|
||||
app.get(
|
||||
'/ws/:username',
|
||||
upgradeWebSocket((c) => ({
|
||||
// https://hono.dev/helpers/websocket
|
||||
async onOpen(evt, ws) {
|
||||
const token = getCookie(c, 'auth_session');
|
||||
if (!token) {
|
||||
const grant = c.req.query('grant');
|
||||
const authHeader = c.req.header('Authorization');
|
||||
|
||||
if (!token && (!grant || grant === 'null') && !authHeader) {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const { user } = await lucia.validateSession(token);
|
||||
if (!user) {
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const personalChannel = await getPersonalChannel(user.id);
|
||||
if (!personalChannel) {
|
||||
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.user = user;
|
||||
ws.chatUser = chatUser;
|
||||
ws.personalChannel = personalChannel;
|
||||
ws.viewerId = randomString(10);
|
||||
|
||||
if (ws.raw) {
|
||||
ws.raw.targetUsername = username;
|
||||
// @ts-ignore
|
||||
ws.raw.user = user;
|
||||
ws.raw.chatUser = chatUser;
|
||||
ws.raw.personalChannel = personalChannel;
|
||||
}
|
||||
|
||||
await prisma.streamInfo.update({
|
||||
where: {
|
||||
username,
|
||||
},
|
||||
data: {
|
||||
viewers: {
|
||||
increment: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
const channelKey = `chat:history:${username}`;
|
||||
const messages = await redis.zrange(channelKey, 0, MESSAGE_HISTORY_SIZE - 1);
|
||||
|
||||
if (messages.length > 0) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'history',
|
||||
messages: messages.map((msg) => JSON.parse(msg)),
|
||||
}));
|
||||
}
|
||||
},
|
||||
async onClose(evt, ws) {
|
||||
// if prematurely exiting due to authentication issues
|
||||
console.log('client disconnected');
|
||||
if (!ws.targetUsername) return;
|
||||
|
||||
const streamInfo = await prisma.streamInfo.findUnique({
|
||||
where: {
|
||||
username: ws.targetUsername,
|
||||
@@ -78,41 +133,119 @@ app.get(
|
||||
|
||||
if (!streamInfo) return;
|
||||
|
||||
await prisma.streamInfo.update({
|
||||
where: {
|
||||
username: ws.targetUsername,
|
||||
},
|
||||
data: {
|
||||
viewers: streamInfo.viewers === 0 ? { set: 0 } : { decrement: 1 },
|
||||
},
|
||||
});
|
||||
await redis.del(`viewer:${ws.targetUsername}:${ws.viewerId}`);
|
||||
},
|
||||
onMessage(evt, ws) {
|
||||
async onMessage(evt, ws) {
|
||||
const msg = JSON.parse(evt.data.toString());
|
||||
|
||||
if (msg.type === 'ping') {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'pong',
|
||||
})
|
||||
);
|
||||
await redis.setex(`viewer:${ws.targetUsername}:${ws.viewerId}`, 30, '1');
|
||||
ws.send(JSON.stringify({ type: 'pong' }));
|
||||
return;
|
||||
} else if (msg.type === 'message') {
|
||||
}
|
||||
|
||||
if (msg.type === 'message') {
|
||||
if (!ws.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(
|
||||
JSON.stringify({
|
||||
user: {
|
||||
id: ws.user.id,
|
||||
username: ws.personalChannel.name,
|
||||
pfpUrl: ws.user.pfpUrl,
|
||||
},
|
||||
message: msg.message,
|
||||
})
|
||||
);
|
||||
c.send(msgStr);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (msg.type === 'emojiMsg') {
|
||||
const emojis = msg.emojis as string[];
|
||||
const emojiMap: Record<string, string> = {};
|
||||
|
||||
await Promise.all(
|
||||
emojis.map(async (emoji) => {
|
||||
let url = await redis.hget('emojis', emoji);
|
||||
|
||||
if (!url) {
|
||||
url = await redis.hget(`emojis:${emoji}`, 'url');
|
||||
}
|
||||
if (!url) {
|
||||
url = await redis.hget(`emoji:${emoji}`, 'url');
|
||||
}
|
||||
|
||||
emojiMap[emoji] = url ?? '';
|
||||
})
|
||||
);
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'emojiMsgResponse',
|
||||
emojis: emojiMap,
|
||||
})
|
||||
);
|
||||
}
|
||||
if (msg.type === 'emojiSearch') {
|
||||
console.log('emoji search request:', msg);
|
||||
const searchTerm = msg.searchTerm as string;
|
||||
|
||||
const emojis = await redis.hgetall('emojis');
|
||||
const emojiKeys = Object.keys(emojis);
|
||||
const idxs = uf.filter(emojiKeys, searchTerm);
|
||||
console.log(`Emoji search for "${searchTerm}" found ${idxs?.length || 0} results.`);
|
||||
|
||||
if (idxs && idxs.length > 0) {
|
||||
const results: string[] = [];
|
||||
|
||||
if (idxs.length <= 150) {
|
||||
const info = uf.info(idxs, emojiKeys, searchTerm);
|
||||
const order = uf.sort(info, emojiKeys, searchTerm);
|
||||
for (let i = 0; i < order.length && i < 10; i++) {
|
||||
results.push(emojiKeys[idxs[order[i]]]);
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < idxs.length && i < 10; i++) {
|
||||
results.push(emojiKeys[idxs[i]]);
|
||||
}
|
||||
}
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'emojiSearchResponse',
|
||||
results: results,
|
||||
})
|
||||
);
|
||||
console.log(`Sending emoji search results: ${results.join(', ')}`);
|
||||
} else {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'emojiSearchResponse',
|
||||
results: [],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
}))
|
||||
);
|
||||
@@ -127,3 +260,11 @@ const server = serve(
|
||||
}
|
||||
);
|
||||
injectWebSocket(server);
|
||||
|
||||
interface ChatUser {
|
||||
id: string;
|
||||
username: string;
|
||||
pfpUrl: string;
|
||||
displayName?: string;
|
||||
isBot: boolean;
|
||||
}
|
||||
8
apps/chat/src/utils/randomString.ts
Normal file
8
apps/chat/src/utils/randomString.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export function randomString(length: number): string {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
21
apps/docs/.gitignore
vendored
Normal file
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
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
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
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
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
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
BIN
apps/docs/src/assets/houston.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
7
apps/docs/src/content.config.ts
Normal file
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
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
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
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
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
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
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
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
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
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
0
apps/docs/yarn.lock
Normal file
@@ -1,14 +1,19 @@
|
||||
FROM node:lts-alpine AS base
|
||||
FROM node:lts-slim AS base
|
||||
|
||||
FROM base AS builder
|
||||
RUN apk update
|
||||
RUN apk add --no-cache libc6-compat
|
||||
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" }
|
||||
@@ -16,25 +21,57 @@ RUN turbo prune @hctv/web --docker
|
||||
|
||||
# Add lockfile and package.json's of isolated subworkspace
|
||||
FROM base AS installer
|
||||
RUN apk update
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN 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 TURBO_TOKEN=$(cat /run/secrets/TURBO_TOKEN) TURBO_TEAM=$(cat /run/secrets/TURBO_TEAM) yarn turbo run build
|
||||
RUN --mount=type=secret,id=TURBO_TOKEN --mount=type=secret,id=TURBO_TEAM \
|
||||
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 apk add --no-cache ffmpeg
|
||||
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
|
||||
|
||||
# Don't run production as root
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
USER nextjs
|
||||
|
||||
# Automatically leverage output traces to reduce image size
|
||||
@@ -42,5 +79,6 @@ USER nextjs
|
||||
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 node apps/web/server.js
|
||||
CMD ["/usr/local/bin/start.sh"]
|
||||
@@ -1,3 +1,5 @@
|
||||
# quick and dirty ai generated benchmark i created for hls streams
|
||||
|
||||
#!/usr/bin/env python3
|
||||
import asyncio
|
||||
import aiohttp
|
||||
|
||||
Binary file not shown.
@@ -1,12 +1,21 @@
|
||||
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'
|
||||
? 'https://backend.hctv.srizan.dev'
|
||||
? '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: {
|
||||
@@ -17,15 +26,28 @@ const nextConfig = {
|
||||
{
|
||||
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 [
|
||||
{
|
||||
@@ -34,6 +56,40 @@ const nextConfig = {
|
||||
},
|
||||
];
|
||||
},
|
||||
logging: {
|
||||
incomingRequests: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
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,
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hctv/web",
|
||||
"version": "0.1.0",
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -11,53 +11,67 @@
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"ui:add": "shadcn add",
|
||||
"check-types": "tsc --noEmit"
|
||||
"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",
|
||||
"@livekit/components-react": "^2.7.0",
|
||||
"@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.1.1",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.1.3",
|
||||
"@radix-ui/react-tooltip": "^1.1.6",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@scalar/api-reference-react": "^0.7.42",
|
||||
"@sentry/nextjs": "^10",
|
||||
"@slack/web-api": "^7.9.1",
|
||||
"@uidotdev/usehooks": "^2.4.1",
|
||||
"arctic": "^3.1.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",
|
||||
"ioredis": "^5.6.0",
|
||||
"livekit-client": "^2.8.0",
|
||||
"livekit-server-sdk": "^2.9.7",
|
||||
"lucia": "^3.2.2",
|
||||
"lucide-react": "^0.473.0",
|
||||
"media-chrome": "^4.8.0",
|
||||
"next": "^15.2.4",
|
||||
"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",
|
||||
"react-dom": "19",
|
||||
"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",
|
||||
@@ -67,12 +81,12 @@
|
||||
"@types/node": "^20",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/react-dom": "^18",
|
||||
"@types/ws": "^8.18.0",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "15.1.3",
|
||||
"postcss": "^8",
|
||||
"shadcn": "^2.1.8",
|
||||
"shadcn": "^2.7.0",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5"
|
||||
}
|
||||
|
||||
20
apps/web/sentry.edge.config.ts
Normal file
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
18
apps/web/src/app/(other)/chat/[username]/page.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import ChatPanel from "@/components/app/ChatPanel/ChatPanel";
|
||||
import { prisma } from '@hctv/db';
|
||||
|
||||
export default async function Page({ params }: { params: Promise<{ username: string }> }) {
|
||||
const { username } = await params;
|
||||
const streamInfo = await prisma.streamInfo.findUnique({
|
||||
where: { username },
|
||||
include: { channel: true },
|
||||
});
|
||||
if (!streamInfo) {
|
||||
return <div>Stream not found</div>;
|
||||
}
|
||||
return (
|
||||
<div className="bg-transparent h-screen">
|
||||
<ChatPanel isObsPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { validateRequest } from '@/lib/auth/validate';
|
||||
import { prisma } from '@hctv/db';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const shouldGetOwned = searchParams.get('owned') === 'true';
|
||||
const { user } = await validateRequest();
|
||||
/* const db = await prisma.streamInfo.findMany({
|
||||
include: { ownedBy: true },
|
||||
}); */
|
||||
|
||||
if (shouldGetOwned) {
|
||||
if (!user) {
|
||||
return new Response('No user found in cookies', { status: 401 });
|
||||
}
|
||||
|
||||
const db = await prisma.streamInfo.findMany({
|
||||
where: {
|
||||
channel: {
|
||||
ownerId: user.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
return Response.json(db);
|
||||
} else {
|
||||
const db = await prisma.streamInfo.findMany({
|
||||
include: {
|
||||
channel: true,
|
||||
}
|
||||
});
|
||||
return Response.json(db);
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
'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 { redirect, useRouter } from 'next/navigation';
|
||||
|
||||
export default function OnboardingClient() {
|
||||
const { user } = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Card className="mx-auto max-w-sm border-0 shadow-none">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle>Welcome to hackclub.tv!</CardTitle>
|
||||
<CardDescription>
|
||||
To get started, please enter the username of your personal channel.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p>join #hctv! you will get welcomed to the channel after submitting the form!</p>
|
||||
<UniversalForm
|
||||
fields={[
|
||||
{ name: 'userId', label: 'User ID', type: 'hidden', value: user?.id },
|
||||
{ name: 'username', label: 'Username', type: 'text' },
|
||||
]}
|
||||
schemaName="onboard"
|
||||
action={onboard}
|
||||
onActionComplete={() => {
|
||||
window.location.href = '/';
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ export default async function Page({ params }: { params: Promise<{ username: str
|
||||
const { username } = await params;
|
||||
const streamInfo = await prisma.streamInfo.findUnique({
|
||||
where: { username },
|
||||
include: { ownedBy: true },
|
||||
include: { channel: true },
|
||||
});
|
||||
if (!streamInfo) {
|
||||
return <div>Stream not found</div>;
|
||||
6
apps/web/src/app/(ui)/(protected)/api/commit/route.ts
Normal file
6
apps/web/src/app/(ui)/(protected)/api/commit/route.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export function GET() {
|
||||
return Response.json({
|
||||
version: process.env.version,
|
||||
commit: process.env.commit,
|
||||
});
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -1,14 +1,21 @@
|
||||
import fsP from 'fs/promises';
|
||||
import fs from 'fs';
|
||||
import { getRedisConnection } from '@/lib/services/redis';
|
||||
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();
|
||||
if (!getRedisConnection().exists(`sessions:${c.get('auth_session')?.value}`)) {
|
||||
|
||||
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 });
|
||||
}
|
||||
@@ -3,7 +3,12 @@ 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/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}`;
|
||||
}
|
||||
@@ -103,7 +103,7 @@ export async function POST(request: NextRequest) {
|
||||
});
|
||||
|
||||
await queue.add(`newFollow:${username}`, {
|
||||
text: `You started following \`${username}\`!\n_Stream notifications are enabled by default. If you want to disable them, you can do so in \`Profile > Notifications\`._`,
|
||||
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,
|
||||
});
|
||||
}
|
||||
64
apps/web/src/app/(ui)/(protected)/api/stream/info/route.ts
Normal file
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);
|
||||
}
|
||||
@@ -12,8 +12,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ user
|
||||
return new Response("nuh uh", { status: 403 });
|
||||
}
|
||||
|
||||
const basePath = '/dev/shm/hctv-thumb';
|
||||
const filePath = `${basePath}/${username}.webp`;
|
||||
const filePath = `/dev/shm/hctv-thumb/${username}.webp`;
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return new Response("Not Found", { status: 404 });
|
||||
@@ -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,
|
||||
});
|
||||
@@ -4,7 +4,7 @@ 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
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
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>;
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
import { slack, lucia } from '@hctv/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 '@hctv/db';
|
||||
import { getRedisConnection } from '@/lib/services/redis';
|
||||
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, {
|
||||
@@ -19,22 +19,38 @@ export async function GET(request: Request): Promise<Response> {
|
||||
}
|
||||
|
||||
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}`, '');
|
||||
@@ -52,8 +68,9 @@ 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,
|
||||
},
|
||||
});
|
||||
@@ -83,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 '@hctv/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
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>
|
||||
);
|
||||
}
|
||||
@@ -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,7 +42,8 @@ export default async function Home() {
|
||||
<Card className="overflow-hidden hover:shadow-lg transition-shadow">
|
||||
<CardContent className="p-0">
|
||||
<div className="relative">
|
||||
<Image
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={`/api/stream/thumb/${stream.channel.name}`}
|
||||
width={512}
|
||||
height={512}
|
||||
77
apps/web/src/app/(ui)/layout.tsx
Normal file
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>
|
||||
);
|
||||
}
|
||||
23
apps/web/src/app/global-error.tsx
Normal file
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>
|
||||
);
|
||||
}
|
||||
@@ -4,111 +4,125 @@
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 220 23.077% 94.902%;
|
||||
--foreground: 233.793 16.022% 35.49%;
|
||||
/* Light theme - based on your color scheme */
|
||||
|
||||
--muted: 222.857 15.909% 82.745%;
|
||||
--muted-foreground: 233.333 12.796% 41.373%;
|
||||
/* Main background and foreground */
|
||||
--background: 350 59% 98%; /* FDF7F8 - main background */
|
||||
--foreground: 351 34% 30%; /* 5D3A3F - main text */
|
||||
|
||||
--popover: 220 23.077% 94.902%;
|
||||
--popover-foreground: 233.793 16.022% 35.49%;
|
||||
/* Muted elements */
|
||||
--muted: 350 40% 93%; /* F8E8EA - muted background */
|
||||
--muted-foreground: 350 30% 45%; /* Lighter version of main text */
|
||||
|
||||
--card: 220 23.077% 94.902%;
|
||||
--card-foreground: 233.793 16.022% 35.49%;
|
||||
/* 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: 225 13.559% 76.863%;
|
||||
--input: 225 13.559% 76.863%;
|
||||
/* Border and input */
|
||||
--border: 350 30% 85%; /* Derived border color */
|
||||
--input: 350 30% 85%; /* Input background */
|
||||
|
||||
--primary: 219.907 91.489% 53.922%;
|
||||
--primary-foreground: 220 23.077% 94.902%;
|
||||
/* Primary actions */
|
||||
--primary: 350 70% 50%; /* C8394F - primary button */
|
||||
--primary-foreground: 0 0% 100%; /* FFFFFF - text on primary */
|
||||
|
||||
--secondary: 222.857 15.909% 82.745%;
|
||||
--secondary-foreground: 233.793 16.022% 35.49%;
|
||||
/* Secondary elements */
|
||||
--secondary: 350 40% 93%; /* F8E8EA - secondary background */
|
||||
--secondary-foreground: 351 34% 30%; /* 5D3A3F - text on secondary */
|
||||
|
||||
--accent: 222.857 15.909% 82.745%;
|
||||
--accent-foreground: 233.793 16.022% 35.49%;
|
||||
/* Accent elements */
|
||||
--accent: 350 70% 40%; /* A12D3E - accent color */
|
||||
--accent-foreground: 0 0% 100%; /* FFFFFF - text on accent */
|
||||
|
||||
--destructive: 347.077 86.667% 44.118%;
|
||||
--destructive-foreground: 220 21.951% 91.961%;
|
||||
/* Destructive actions */
|
||||
--destructive: 350 70% 55%; /* D63C56 - error/destroy */
|
||||
--destructive-foreground: 0 0% 100%; /* FFFFFF - text on destructive */
|
||||
|
||||
--ring: 233.793 16.022% 35.49%;
|
||||
/* Focus ring */
|
||||
--ring: 350 70% 50%; /* C8394F - focus ring */
|
||||
|
||||
--surface-1: 225 14% 77%;
|
||||
--surface-2: 227 12% 71%;
|
||||
/* Surface colors */
|
||||
--surface-1: 350 40% 93%; /* F8E8EA - surface 1 */
|
||||
--surface-2: 350 35% 88%; /* Derived surface 2 */
|
||||
|
||||
--mantle: 220 22% 92%;
|
||||
/* Mantle */
|
||||
--mantle: 350 59% 98%; /* FDF7F8 - mantle */
|
||||
|
||||
/* Radius */
|
||||
--radius: 0.5rem;
|
||||
|
||||
--sidebar-background: 0 0% 98%;
|
||||
|
||||
--sidebar-foreground: 240 5.3% 26.1%;
|
||||
|
||||
--sidebar-primary: 240 5.9% 10%;
|
||||
|
||||
--sidebar-primary-foreground: 0 0% 98%;
|
||||
|
||||
--sidebar-accent: 240 4.8% 95.9%;
|
||||
|
||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||
|
||||
--sidebar-border: 220 13% 91%;
|
||||
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
/* 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 {
|
||||
--background: 240 21.053% 14.902%;
|
||||
--foreground: 226.154 63.934% 88.039%;
|
||||
/* Dark theme - based on your color scheme */
|
||||
|
||||
--muted: 240 12% 19%;
|
||||
--muted-foreground: 240 12% 69%;
|
||||
/* Main background and foreground */
|
||||
--background: 350 20% 15%; /* 2A1F21 - main background */
|
||||
--foreground: 350 30% 92%; /* F5E6E8 - main text */
|
||||
|
||||
--popover: 240 21.053% 14.902%;
|
||||
--popover-foreground: 226.154 63.934% 88.039%;
|
||||
/* Muted elements */
|
||||
--muted: 350 20% 25%; /* 4A2D31 - muted background */
|
||||
--muted-foreground: 350 30% 75%; /* Lighter version of main text */
|
||||
|
||||
--card: 240 21.053% 14.902%;
|
||||
--card-foreground: 226.154 63.934% 88.039%;
|
||||
/* 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: 234.286 13.208% 31.176%;
|
||||
--input: 234.286 13.208% 31.176%;
|
||||
/* Border and input */
|
||||
--border: 350 20% 35%; /* Derived border color */
|
||||
--input: 350 20% 35%; /* Input background */
|
||||
|
||||
--primary: 267 84% 81%;
|
||||
--primary-foreground: 267 84% 21%;
|
||||
/* Primary actions */
|
||||
--primary: 350 100% 75%; /* FF7A8A - primary button */
|
||||
--primary-foreground: 350 20% 15%; /* 2A1F21 - text on primary */
|
||||
|
||||
--secondary: 236.842 16.239% 22.941%;
|
||||
--secondary-foreground: 226.154 63.934% 88.039%;
|
||||
/* Secondary elements */
|
||||
--secondary: 350 20% 25%; /* 4A2D31 - secondary background */
|
||||
--secondary-foreground: 350 30% 92%; /* F5E6E8 - text on secondary */
|
||||
|
||||
--accent: 236.842 16.239% 22.941%;
|
||||
--accent-foreground: 226.154 63.934% 88.039%;
|
||||
/* Accent elements */
|
||||
--accent: 350 100% 80%; /* FF9AAA - accent color */
|
||||
--accent-foreground: 350 20% 15%; /* 2A1F21 - text on accent */
|
||||
|
||||
--destructive: 343.269 81.25% 74.902%;
|
||||
--destructive-foreground: 240 21.311% 11.961%;
|
||||
/* Destructive actions */
|
||||
--destructive: 350 100% 70%; /* FF6B7D - error/destroy */
|
||||
--destructive-foreground: 350 20% 15%; /* 2A1F21 - text on destructive */
|
||||
|
||||
--ring: 226.154 63.934% 88.039%;
|
||||
/* Focus ring */
|
||||
--ring: 350 100% 75%; /* FF7A8A - focus ring */
|
||||
|
||||
--surface-1: 234 13% 31%;
|
||||
--surface-2: 233 12% 39%;
|
||||
/* Surface colors */
|
||||
--surface-1: 350 20% 25%; /* 4A2D31 - surface 1 */
|
||||
--surface-2: 350 20% 35%; /* Derived surface 2 */
|
||||
|
||||
--mantle: 240 21.311% 11.961%;
|
||||
/* Mantle */
|
||||
--mantle: 350 20% 12%; /* 1F1617 - mantle */
|
||||
|
||||
/* Radius */
|
||||
--radius: 0.5rem;
|
||||
|
||||
--sidebar-background: 240 21.311% 11.961%; /* crust - matches mantle var */
|
||||
|
||||
--sidebar-foreground: 226.154 63.934% 88.039%; /* matches main foreground */
|
||||
|
||||
--sidebar-primary: 217.168 91.87% 75.882%; /* matches primary */
|
||||
|
||||
--sidebar-primary-foreground: 240 21.053% 14.902%; /* matches primary-foreground */
|
||||
|
||||
--sidebar-accent: 236.842 16.239% 22.941%; /* matches accent */
|
||||
|
||||
--sidebar-accent-foreground: 226.154 63.934% 88.039%; /* matches accent-foreground */
|
||||
|
||||
--sidebar-border: 234.286 13.208% 31.176%; /* matches border */
|
||||
|
||||
--sidebar-ring: 217.168 91.87% 75.882%; /* matches primary */
|
||||
/* 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 */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,14 +133,20 @@
|
||||
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);
|
||||
@@ -158,7 +178,7 @@ media-time-range {
|
||||
}
|
||||
|
||||
media-time-display {
|
||||
--media-text-color: #ffffff;
|
||||
--media-text-color: #ffffff;
|
||||
}
|
||||
|
||||
media-controller::part(centered-layer) {
|
||||
|
||||
@@ -1,55 +1,14 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
import { NuqsAdapter } from 'nuqs/adapters/next/app';
|
||||
import './globals.css';
|
||||
import Navbar from '@/components/app/NavBar/NavBar';
|
||||
import { SessionProvider } from '@/lib/providers/SessionProvider';
|
||||
import { validateRequest } from '@/lib/auth/validate';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import { ThemeProvider } from '@/lib/providers/ThemeProvider';
|
||||
import { SidebarProvider } from '@/components/ui/sidebar';
|
||||
import Sidebar from '@/components/app/Sidebar/Sidebar';
|
||||
import { cn } from '@/lib/utils';
|
||||
import EditLivestream from '@/components/app/EditLivestream/EditLivestream';
|
||||
import { StreamInfoProvider } from '@/lib/providers/StreamInfoProvider';
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'hackclub.tv',
|
||||
description: "Hack Club's livestreaming platform",
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const sessionData = await validateRequest();
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={cn('flex flex-col h-screen', inter.className)}>
|
||||
<SessionProvider value={sessionData}>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<SidebarProvider>
|
||||
<StreamInfoProvider>
|
||||
{/* this promise is ugly but i'm lazy to fix the type errors */}
|
||||
<Navbar editLivestream={Promise.resolve(<EditLivestream />)} />
|
||||
<div className="flex flex-1 pt-16">
|
||||
{/* pt-16 for navbar height */}
|
||||
<Sidebar className="pt-16" />
|
||||
<main className="flex-1 overflow-auto">{children}</main>
|
||||
</div>
|
||||
<Toaster />
|
||||
</StreamInfoProvider>
|
||||
</SidebarProvider>
|
||||
</ThemeProvider>
|
||||
</SessionProvider>
|
||||
</body>
|
||||
<NuqsAdapter>
|
||||
<body>
|
||||
<main>{children}</main>
|
||||
</body>
|
||||
</NuqsAdapter>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
46
apps/web/src/components/app/ChannelSelect/ChannelSelect.tsx
Normal file
46
apps/web/src/components/app/ChannelSelect/ChannelSelect.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
'use client'
|
||||
|
||||
import type { Channel } from "@hctv/db";
|
||||
import * as React from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
|
||||
export function ChannelSelect(props: Props) {
|
||||
const { channelList } = props;
|
||||
return (
|
||||
<Select onValueChange={props.onSelect} value={props.value}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Channel" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{channelList.map((channel) => (
|
||||
<SelectItem key={channel.id} value={channel.name}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={channel.pfpUrl} alt={channel.name} />
|
||||
<AvatarFallback>{channel.name[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="font-medium">{channel.name}</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem key="create" value="create" icon={<Plus className="h-4 w-4" />} className='h-11'>
|
||||
Create Channel
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
channelList: Channel[];
|
||||
value?: string;
|
||||
onSelect: (value: string) => void;
|
||||
}
|
||||
@@ -2,22 +2,32 @@
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Send } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { Message } from './message';
|
||||
import { useMap } from '@uidotdev/usehooks';
|
||||
import { EmojiSearch } from './EmojiSearch';
|
||||
import { useQueryState } from 'nuqs';
|
||||
|
||||
export default function ChatPanel() {
|
||||
export default function ChatPanel(props: Props) {
|
||||
const { username } = useParams();
|
||||
const [grant, setGrant] = useQueryState('grant');
|
||||
const [message, setMessage] = useState('');
|
||||
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const socketRef = useRef<WebSocket | null>(null);
|
||||
const emojiMap = useMap() as Map<string, string>;
|
||||
const [emojisToReq, setEmojisToReq] = useState<string[]>([]);
|
||||
const [cursorPosition, setCursorPosition] = useState(0);
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('Initializing WebSocket connection for user:', username);
|
||||
const socket = new WebSocket(
|
||||
`ws${window.location.protocol === 'https:' ? 's' : ''}://${
|
||||
window.location.host
|
||||
}/api/stream/chat/ws/${username}`
|
||||
}/api/stream/chat/ws/${username}?grant=${grant}`
|
||||
);
|
||||
socketRef.current = socket;
|
||||
|
||||
@@ -28,10 +38,30 @@ export default function ChatPanel() {
|
||||
socket.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === 'ping' || data.type === 'pong' || !data.user) return;
|
||||
setChatMessages((prev) => [...prev, data]);
|
||||
console.log('Received websocket message:', data);
|
||||
|
||||
if (data.type === 'history') {
|
||||
const messages = data.messages as ChatMessage[];
|
||||
setChatMessages((prev) => [
|
||||
...prev,
|
||||
...messages,
|
||||
{ message: 'Welcome to the chat!', type: 'systemMsg' },
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === 'message') {
|
||||
console.log('Adding new chat message:', data);
|
||||
setChatMessages((prev) => [...prev, data]);
|
||||
}
|
||||
|
||||
if (!data.type && data.message && data.user) {
|
||||
console.log('Adding legacy chat message format:', data);
|
||||
setChatMessages((prev) => [...prev, { ...data, type: 'message' }]);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Received message confirmation:', event.data);
|
||||
console.error('Error processing message:', e);
|
||||
console.log('Raw message data:', event.data);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -42,7 +72,7 @@ export default function ChatPanel() {
|
||||
return () => {
|
||||
socket.close();
|
||||
};
|
||||
}, []);
|
||||
}, [username]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
@@ -63,7 +93,7 @@ export default function ChatPanel() {
|
||||
const socket = new WebSocket(
|
||||
`ws${window.location.protocol === 'https:' ? 's' : ''}://${
|
||||
window.location.host
|
||||
}/api/stream/chat/ws/${username}`
|
||||
}/api/stream/chat/ws/${username}?grant=${grant}`
|
||||
);
|
||||
socket.onopen = () => {
|
||||
socket.send(JSON.stringify({ type: 'message', message }));
|
||||
@@ -81,54 +111,212 @@ export default function ChatPanel() {
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// emoji message collector
|
||||
useEffect(() => {
|
||||
if (chatMessages.length === 0) return;
|
||||
|
||||
const emojiPattern = /:[\w\-+]+:/g;
|
||||
const newEmojis = chatMessages
|
||||
.filter((msg) => msg.type === 'message')
|
||||
.flatMap((msg) => {
|
||||
if (!msg.message) return [];
|
||||
const message = String(msg.message);
|
||||
const matches = [...message.matchAll(emojiPattern)].map((m) => m[0]);
|
||||
return matches;
|
||||
})
|
||||
.filter((emoji) => {
|
||||
const emojiName = emoji.replaceAll(':', '');
|
||||
return !emojiMap.has(emojiName) && emojiName.length > 0;
|
||||
});
|
||||
|
||||
if (newEmojis.length > 0) {
|
||||
console.log(`Found ${newEmojis.length} new emojis to request: ${newEmojis.join(', ')}`);
|
||||
setEmojisToReq((prev) => [...new Set([...prev, ...newEmojis])]);
|
||||
}
|
||||
}, [chatMessages, emojiMap]);
|
||||
|
||||
// emoji requester
|
||||
useEffect(() => {
|
||||
if (emojisToReq.length === 0) return;
|
||||
|
||||
console.log('Requesting emojis:', emojisToReq);
|
||||
|
||||
// Ensure websocket is connected
|
||||
if (!socketRef.current || socketRef.current.readyState !== WebSocket.OPEN) {
|
||||
const socket = new WebSocket(
|
||||
`ws${window.location.protocol === 'https:' ? 's' : ''}://${
|
||||
window.location.host
|
||||
}/api/stream/chat/ws/${username}?grant=${grant}`
|
||||
);
|
||||
|
||||
socket.onopen = () => {
|
||||
socketRef.current = socket;
|
||||
sendEmojiRequest();
|
||||
};
|
||||
|
||||
return () => {
|
||||
socket.close();
|
||||
};
|
||||
} else {
|
||||
sendEmojiRequest();
|
||||
}
|
||||
|
||||
function sendEmojiRequest() {
|
||||
const handleEmojiResponse = (event: MessageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'emojiMsgResponse') {
|
||||
const emojis = data.emojis as Record<string, string>;
|
||||
|
||||
let validEmojiCount = 0;
|
||||
Object.entries(emojis).forEach(([name, url]) => {
|
||||
if (url) {
|
||||
emojiMap.set(name, url);
|
||||
validEmojiCount++;
|
||||
} else {
|
||||
console.log(`No URL found for emoji: ${name}`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`added ${validEmojiCount} valid emojis to map.`);
|
||||
|
||||
if (validEmojiCount > 0) {
|
||||
const sampleName = Object.entries(emojis).find(([_, url]) => url)?.[0];
|
||||
if (sampleName) {
|
||||
}
|
||||
} else {
|
||||
console.warn('No valid emoji URLs received');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('error processing emoji response:', e);
|
||||
}
|
||||
};
|
||||
|
||||
socketRef.current?.addEventListener('message', handleEmojiResponse);
|
||||
|
||||
const emojiRequest = {
|
||||
type: 'emojiMsg',
|
||||
emojis: emojisToReq.map((e) => e.replaceAll(':', '')),
|
||||
};
|
||||
|
||||
console.log('sending emoji request:', emojiRequest);
|
||||
socketRef.current?.send(JSON.stringify(emojiRequest));
|
||||
|
||||
return () => {
|
||||
socketRef.current?.removeEventListener('message', handleEmojiResponse);
|
||||
setEmojisToReq([]);
|
||||
};
|
||||
}
|
||||
}, [emojisToReq, emojiMap, username]);
|
||||
|
||||
const handleEmojiSelect = (emojiName: string) => {
|
||||
if (!textareaRef.current) return;
|
||||
|
||||
const textarea = textareaRef.current;
|
||||
const beforeCursor = message.substring(0, cursorPosition);
|
||||
const afterCursor = message.substring(cursorPosition);
|
||||
|
||||
const match = beforeCursor.match(/:[\w\-+]*$/);
|
||||
if (!match) return;
|
||||
|
||||
const startPos = beforeCursor.lastIndexOf(match[0]);
|
||||
const newBeforeCursor = beforeCursor.substring(0, startPos);
|
||||
|
||||
const newMessage = `${newBeforeCursor}:${emojiName}: ${afterCursor}`;
|
||||
setMessage(newMessage);
|
||||
|
||||
// 3 for colons and space
|
||||
const newCursorPos = newBeforeCursor.length + emojiName.length + 3;
|
||||
|
||||
setTimeout(() => {
|
||||
textarea.focus();
|
||||
textarea.selectionStart = newCursorPos;
|
||||
textarea.selectionEnd = newCursorPos;
|
||||
setCursorPosition(newCursorPos);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const isEmojiSearchOpen = () => {
|
||||
const beforeCursor = message.substring(0, cursorPosition);
|
||||
const match = beforeCursor.match(/:[\w\-+]*$/);
|
||||
return match !== null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="md:border flex flex-col w-full min-w-[350px] h-full bg-mantle">
|
||||
<div ref={scrollRef} className="flex-1 p-4 overflow-y-auto flex flex-col">
|
||||
<div className={`${props.isObsPanel ? 'w-full text-white' : 'md:border bg-mantle w-[350px] max-w-[350px]'} flex flex-col h-full`}>
|
||||
<div ref={scrollRef} className={`flex-1 p-4 ${props.isObsPanel ? 'scrollbar-hide' : ''} overflow-y-auto flex flex-col`}>
|
||||
<div className="space-y-4 flex-1">
|
||||
{chatMessages.map((msg, i) => (
|
||||
<div key={i} className="flex space-x-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="font-bold shrink-0">{msg.user.username}</div>
|
||||
</div>
|
||||
<div
|
||||
lang="en"
|
||||
className="max-w-[calc(100%-4rem)] break-all whitespace-pre-wrap hyphens-auto"
|
||||
>
|
||||
{msg.message}
|
||||
</div>
|
||||
</div>
|
||||
<Message
|
||||
key={i}
|
||||
user={msg.user}
|
||||
message={msg.message}
|
||||
type={msg.type}
|
||||
emojiMap={emojiMap}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 border-t">
|
||||
<div className="flex space-x-2">
|
||||
<Input
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
sendMessage();
|
||||
}
|
||||
}}
|
||||
placeholder="Type a message"
|
||||
className="flex-1 bg-transparent focus-visible:ring-offset-0"
|
||||
{!props.isObsPanel && (
|
||||
<div className="p-4 border-t relative">
|
||||
<div className="flex space-x-2">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={message}
|
||||
onChange={(e) => {
|
||||
setMessage(e.target.value);
|
||||
setCursorPosition(e.target.selectionStart || 0);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !isEmojiSearchOpen()) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
}}
|
||||
onKeyUp={(e) => {
|
||||
setCursorPosition(e.currentTarget.selectionStart || 0);
|
||||
}}
|
||||
onClick={(e) => {
|
||||
setCursorPosition(e.currentTarget.selectionStart || 0);
|
||||
}}
|
||||
placeholder="Type a message"
|
||||
className="flex-1 bg-transparent focus-visible:ring-offset-0 min-h-[40px] max-h-[120px] resize-none py-2"
|
||||
rows={1}
|
||||
/>
|
||||
<Button size="icon" className="text-black transition-colors" onClick={sendMessage}>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<EmojiSearch
|
||||
message={message}
|
||||
cursorPosition={cursorPosition}
|
||||
onSelect={handleEmojiSelect}
|
||||
socket={socketRef.current}
|
||||
emojiMap={emojiMap}
|
||||
textareaRef={textareaRef}
|
||||
/>
|
||||
<Button size="icon" className="text-black transition-colors" onClick={sendMessage}>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface User {
|
||||
export interface ChatMessage {
|
||||
user?: User;
|
||||
message: string;
|
||||
type: 'message' | 'systemMsg';
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
pfpUrl: string;
|
||||
isBot: boolean;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
user: User;
|
||||
message: string;
|
||||
interface Props {
|
||||
isObsPanel?: boolean;
|
||||
}
|
||||
|
||||
178
apps/web/src/components/app/ChatPanel/EmojiSearch.tsx
Normal file
178
apps/web/src/components/app/ChatPanel/EmojiSearch.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
// source: ai
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
|
||||
interface EmojiSearchProps {
|
||||
message: string;
|
||||
cursorPosition: number;
|
||||
onSelect: (emoji: string) => void;
|
||||
socket: WebSocket | null;
|
||||
emojiMap: Map<string, string>;
|
||||
textareaRef: React.RefObject<HTMLTextAreaElement>;
|
||||
}
|
||||
|
||||
export function EmojiSearch({
|
||||
message,
|
||||
cursorPosition,
|
||||
onSelect,
|
||||
socket,
|
||||
emojiMap,
|
||||
textareaRef,
|
||||
}: EmojiSearchProps) {
|
||||
const [searchTerm, setSearchTerm] = useState<string | null>(null);
|
||||
const [searchResults, setSearchResults] = useState<string[]>([]);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const resultsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const beforeCursor = message.substring(0, cursorPosition);
|
||||
const match = beforeCursor.match(/:[\w\-+]*$/);
|
||||
|
||||
if (match) {
|
||||
const term = match[0].substring(1);
|
||||
setSearchTerm(term);
|
||||
|
||||
if (term.length > 0) {
|
||||
const localResults = Array.from(emojiMap.keys())
|
||||
.filter(name => name.toLowerCase().includes(term.toLowerCase()))
|
||||
.slice(0, 5);
|
||||
|
||||
if (localResults.length > 0) {
|
||||
setSearchResults(localResults);
|
||||
}
|
||||
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({
|
||||
type: 'emojiSearch',
|
||||
searchTerm: term
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
setSearchResults([]);
|
||||
}
|
||||
} else {
|
||||
setSearchTerm(null);
|
||||
setSearchResults([]);
|
||||
}
|
||||
}, [message, cursorPosition, socket, emojiMap]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
const handleEmojiSearchResponse = (event: MessageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'emojiSearchResponse') {
|
||||
const serverResults = data.results || [];
|
||||
|
||||
const localResults = Array.from(emojiMap.keys())
|
||||
.filter(name => searchTerm && name.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
.slice(0, 5);
|
||||
|
||||
const combinedResults = [...serverResults];
|
||||
|
||||
localResults.forEach(name => {
|
||||
if (!combinedResults.includes(name)) {
|
||||
combinedResults.push(name);
|
||||
}
|
||||
});
|
||||
|
||||
setSearchResults(combinedResults.slice(0, 10));
|
||||
setSelectedIndex(0);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('error processing emoji search response:', e);
|
||||
}
|
||||
};
|
||||
|
||||
socket.addEventListener('message', handleEmojiSearchResponse);
|
||||
return () => {
|
||||
socket.removeEventListener('message', handleEmojiSearchResponse);
|
||||
};
|
||||
}, [socket, searchTerm, emojiMap]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!textareaRef.current) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!searchTerm || searchResults.length === 0) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => (prev + 1) % searchResults.length);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => (prev - 1 + searchResults.length) % searchResults.length);
|
||||
break;
|
||||
case 'Enter':
|
||||
if (searchResults[selectedIndex]) {
|
||||
e.preventDefault();
|
||||
onSelect(searchResults[selectedIndex]);
|
||||
}
|
||||
break;
|
||||
case 'Tab':
|
||||
if (searchResults[selectedIndex]) {
|
||||
e.preventDefault();
|
||||
onSelect(searchResults[selectedIndex]);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
setSearchTerm(null);
|
||||
setSearchResults([]);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const textarea = textareaRef.current;
|
||||
textarea.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
textarea.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [searchTerm, searchResults, selectedIndex, onSelect, textareaRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (resultsRef.current) {
|
||||
const selectedElement = resultsRef.current.children[selectedIndex] as HTMLElement;
|
||||
if (selectedElement) {
|
||||
selectedElement.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
|
||||
if (!searchTerm || searchResults.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-16 left-4 bg-mantle border rounded-md shadow-lg max-h-60 overflow-y-auto z-10 min-w-[200px] max-w-[300px]">
|
||||
<div ref={resultsRef} className="py-1">
|
||||
{searchResults.map((emojiName, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
const emojiUrl = emojiMap.get(emojiName);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={emojiName}
|
||||
className={`px-3 py-1.5 flex items-center gap-2 cursor-pointer ${
|
||||
isSelected ? 'bg-primary/10' : 'hover:bg-primary/5'
|
||||
}`}
|
||||
onClick={() => onSelect(emojiName)}
|
||||
>
|
||||
{emojiUrl && (
|
||||
<Image src={emojiUrl} alt={emojiName} width={20} height={20} className="w-5 h-5" />
|
||||
)}
|
||||
<span className="flex-grow text-sm">{emojiName}</span>
|
||||
{isSelected && <Check className="h-4 w-4 text-blue-500" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
apps/web/src/components/app/ChatPanel/message.tsx
Normal file
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>;
|
||||
}
|
||||
@@ -1,17 +1,16 @@
|
||||
import { validateRequest } from '@/lib/auth/validate';
|
||||
import { prisma } from '@hctv/db';
|
||||
import EditLivestreamDialog from './dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default async function EditLivestream() {
|
||||
const { user } = await validateRequest();
|
||||
if (!user?.hasOnboarded) {
|
||||
return null;
|
||||
}
|
||||
const ownedChannels = await prisma.channel.findMany({
|
||||
where: {
|
||||
OR: [{ ownerId: user.id }, { managers: { some: { id: user.id } } }],
|
||||
},
|
||||
});
|
||||
|
||||
return <EditLivestreamDialog ownedChannels={ownedChannels} />;
|
||||
return (
|
||||
<Link href={`/settings/channel?tab=stream`}>
|
||||
<Button variant="outline">Edit Livestream</Button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
'use client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { StreamInfo } from '@hctv/db';
|
||||
import { UniversalForm } from '../UniversalForm/UniversalForm';
|
||||
import { editStreamInfo } from '@/lib/form/actions';
|
||||
import RegenerateKey from '../RegenerateKey/RegenerateKey';
|
||||
import * as React from 'react';
|
||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { Channel } from '@hctv/db';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import useSWR, { Fetcher } from 'swr';
|
||||
import { fetcher } from '@/lib/services/swr';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function EditLivestreamDialog(props: Props) {
|
||||
const [selectedChannel, setSelectedChannel] = React.useState('');
|
||||
const [streamInfo, setStreamInfo] = React.useState<StreamInfo>();
|
||||
|
||||
const { data, error } = useSWR(
|
||||
selectedChannel ? '/api/stream/info?owned=true' : null,
|
||||
fetcher as Fetcher<StreamInfo[]>
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (data && selectedChannel) {
|
||||
const stream = data.find((stream) => stream.username === selectedChannel);
|
||||
setStreamInfo(stream);
|
||||
}
|
||||
}, [data, selectedChannel]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (props.ownedChannels.length > 0 && !selectedChannel) {
|
||||
setSelectedChannel(props.ownedChannels[0].name);
|
||||
}
|
||||
}, [props.ownedChannels, selectedChannel]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
onOpenChange={(op) => {
|
||||
if (op) {
|
||||
setSelectedChannel('');
|
||||
setStreamInfo(undefined);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">Edit Livestream</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit livestream</DialogTitle>
|
||||
<DialogDescription>Regenerate a key or edit your stream metadata</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Link
|
||||
href="https://gist.github.com/SrIzan10/ebd89ced6b21b016d4d389e6711a94e9"
|
||||
target="_blank"
|
||||
>
|
||||
HOW TO STREAM (github gist link)
|
||||
</Link>
|
||||
<ChannelSelect
|
||||
channelList={props.ownedChannels}
|
||||
onSelect={setSelectedChannel}
|
||||
value={selectedChannel}
|
||||
/>
|
||||
{streamInfo && data ? (
|
||||
<Form username={selectedChannel} streamInfo={streamInfo} />
|
||||
) : (
|
||||
<FormSkeleton />
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function Form(props: FormProps) {
|
||||
return (
|
||||
<UniversalForm
|
||||
fields={[
|
||||
{ name: 'username', label: 'Username', value: props.username, type: 'hidden' },
|
||||
{ name: 'title', label: 'Title', type: 'text', value: props.streamInfo?.title },
|
||||
{ name: 'category', label: 'Category', type: 'text', value: props.streamInfo?.category },
|
||||
{ name: 'enableNotifications', label: 'Enable livestream notifications', type: 'hidden', value: props.streamInfo?.enableNotifications },
|
||||
]}
|
||||
schemaName="streamInfoEdit"
|
||||
action={editStreamInfo}
|
||||
submitButtonDivClassname="float-right"
|
||||
submitText="Save"
|
||||
otherSubmitButton={<RegenerateKey channel={props.username} />}
|
||||
key={props.streamInfo?.id}
|
||||
/>
|
||||
)
|
||||
}
|
||||
function FormSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Title field */}
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-12" /> {/* Label */}
|
||||
<Skeleton className="h-10 w-full" /> {/* Input */}
|
||||
</div>
|
||||
|
||||
{/* Category field */}
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-16" /> {/* Label */}
|
||||
<Skeleton className="h-10 w-full" /> {/* Input */}
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="float-right flex gap-2 py-2">
|
||||
<Skeleton className="h-10 w-32" /> {/* RegenerateKey button */}
|
||||
<Skeleton className="h-10 w-24" /> {/* Save button */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChannelSelect(props: SelectProps) {
|
||||
const { channelList } = props;
|
||||
return (
|
||||
<Select onValueChange={props.onSelect} value={props.value}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Channel" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{channelList.map((channel) => (
|
||||
<SelectItem key={channel.id} value={channel.name}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={channel.pfpUrl} alt={channel.name} />
|
||||
<AvatarFallback>{channel.name[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="font-medium">{channel.name}</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
ownedChannels: Channel[];
|
||||
}
|
||||
interface FormProps {
|
||||
username: string;
|
||||
streamInfo: StreamInfo;
|
||||
}
|
||||
interface SelectProps {
|
||||
channelList: Channel[];
|
||||
value?: string;
|
||||
onSelect: (value: string) => void;
|
||||
}
|
||||
@@ -1,80 +1,175 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Play, Users, MessageCircle, Code, Zap, Heart } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function LandingPage() {
|
||||
return (
|
||||
<>
|
||||
<main className="flex-1">
|
||||
<section className="w-full py-12 md:py-24 lg:py-32 xl:py-48">
|
||||
<div className="container px-4 md:px-6">
|
||||
<div className="flex flex-col items-center space-y-4 text-center">
|
||||
<div className="flex items-center rounded-lg px-3 py-1 text-sm bg-mantle text-red-500">
|
||||
THIS IS A PLACEHOLDER WITH NO "HACK CLUB" VIBE, I'M REALLY BAD AT LANDING PAGES SORRY
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl lg:text-6xl/none">
|
||||
This is hackclub.tv
|
||||
</h1>
|
||||
<p className="mx-auto max-w-[700px] text-gray-500 md:text-xl dark:text-gray-400">
|
||||
The streaming service made by and for Hack Clubbers. Share your coding sessions, workshops, and hackathon moments live!
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-x-4">
|
||||
<Link href="/login">
|
||||
<Button>Start Streaming Now!</Button>
|
||||
</Link>
|
||||
</div>
|
||||
{/* Hero Section */}
|
||||
<section className="relative w-full py-20 md:py-32 lg:py-40 xl:py-48 overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/10 via-transparent to-secondary/10"></div>
|
||||
<div className="container px-4 md:px-6 relative">
|
||||
<div className="flex flex-col items-center space-y-8 text-center">
|
||||
<Badge variant="outline" className="px-4 py-2 text-sm font-medium bg-primary/10 text-primary border-primary/20">
|
||||
<Heart className="w-4 h-4 mr-2" />
|
||||
Made with ❤️ by Hack Clubbers
|
||||
</Badge>
|
||||
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl md:text-6xl lg:text-7xl bg-gradient-to-r from-primary via-primary/80 to-primary/60 bg-clip-text text-transparent">
|
||||
hackclub.tv
|
||||
</h1>
|
||||
<p className="mx-auto max-w-[600px] text-lg text-muted-foreground md:text-xl">
|
||||
The streaming platform where Hack Clubbers share their coding journeys, workshops, and hackathon adventures with the world.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row">
|
||||
<Link href="/login" className="space-x-4">
|
||||
<Button size="lg" className="px-8 py-3 text-lg font-semibold">
|
||||
<Play className="w-5 h-5 mr-2" />
|
||||
Start Streaming
|
||||
</Button>
|
||||
<Button variant="outline" size="lg" className="px-8 py-3 text-lg font-semibold">
|
||||
<Users className="w-5 h-5 mr-2" />
|
||||
Watch Streams
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="w-full py-12 md:py-24 lg:py-32 bg-mantle" id="features">
|
||||
<div className="container px-4 md:px-6">
|
||||
<div className="flex flex-col items-center justify-center space-y-4 text-center">
|
||||
<div className="space-y-2">
|
||||
<div className="inline-block rounded-lg bg-gray-100 px-3 py-1 text-sm dark:bg-gray-800">
|
||||
Platform Features
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Section */}
|
||||
<section className="w-full py-16 md:py-24 lg:py-32 bg-muted/30" id="features">
|
||||
<div className="container px-4 md:px-6">
|
||||
<div className="flex flex-col items-center text-center space-y-8 mb-16">
|
||||
<Badge variant="secondary" className="px-4 py-2">
|
||||
Platform Features
|
||||
</Badge>
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl">
|
||||
Built for creators, by creators
|
||||
</h2>
|
||||
<p className="max-w-[700px] text-lg text-muted-foreground">
|
||||
Everything you need to connect, create, and grow within the Hack Club community.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3 max-w-5xl mx-auto">
|
||||
<Card className="border-0 shadow-sm hover:shadow-md transition-shadow">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="w-12 h-12 bg-primary/10 rounded-lg flex items-center justify-center mb-4">
|
||||
<Zap className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold tracking-tighter sm:text-5xl">
|
||||
Built for Hack Clubbers, by Hack Clubbers
|
||||
</h2>
|
||||
<p className="max-w-[900px] text-gray-500 md:text-xl/relaxed lg:text-base/relaxed xl:text-xl/relaxed dark:text-gray-400">
|
||||
Everything you need to have a closer relationship with the Hack Club community!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto max-w-5xl items-center gap-6 py-12 lg:grid-cols-2 lg:gap-12">
|
||||
<div className="flex flex-col justify-center items-center text-center space-y-4">
|
||||
<ul className="grid gap-6">
|
||||
<li>
|
||||
<div className="grid gap-1">
|
||||
<h3 className="text-xl font-bold">Live Streaming</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Stream your coding sessions with low-latency and high quality
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div className="grid gap-1">
|
||||
<h3 className="text-xl font-bold">Chat Integration</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Interact with viewers in real-time through the built-in chat system
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div className="grid gap-1">
|
||||
<h3 className="text-xl font-bold">Community Features</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Follow other streamers and build your network of Hack Club friends!
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle className="text-xl">Low-Latency Streaming</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription className="text-base">
|
||||
Share your coding sessions with ultra-low latency. Your audience stays engaged with real-time interaction.
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 shadow-sm hover:shadow-md transition-shadow">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="w-12 h-12 bg-primary/10 rounded-lg flex items-center justify-center mb-4">
|
||||
<MessageCircle className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">Real-Time Chat</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription className="text-base">
|
||||
Engage with your community through integrated chat. Get instant feedback and build connections.
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 shadow-sm hover:shadow-md transition-shadow">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="w-12 h-12 bg-primary/10 rounded-lg flex items-center justify-center mb-4">
|
||||
<Users className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">Community First</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription className="text-base">
|
||||
Follow your favorite streamers, discover new creators, and be part of the vibrant Hack Club ecosystem.
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 shadow-sm hover:shadow-md transition-shadow">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="w-12 h-12 bg-accent/50 rounded-lg flex items-center justify-center mb-4">
|
||||
<Code className="w-6 h-6 text-accent-foreground" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">Code-Focused</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription className="text-base">
|
||||
Built specifically for developers. Perfect for coding sessions, tutorials, and technical workshops.
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 shadow-sm hover:shadow-md transition-shadow">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="w-12 h-12 bg-secondary/50 rounded-lg flex items-center justify-center mb-4">
|
||||
<Play className="w-6 h-6 text-secondary-foreground" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">Easy to Use</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription className="text-base">
|
||||
Simple, intuitive interface. Start streaming in minutes, not hours. Focus on what you love: coding.
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 shadow-sm hover:shadow-md transition-shadow">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="w-12 h-12 bg-primary/10 rounded-lg flex items-center justify-center mb-4">
|
||||
<Heart className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">Open Source</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription className="text-base">
|
||||
Transparent, community-driven, and built in the open. Contribute, customize, and make it yours.
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="w-full py-16 md:py-24">
|
||||
<div className="container px-4 md:px-6">
|
||||
<div className="flex flex-col items-center text-center space-y-8">
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl">
|
||||
Ready to share your journey?
|
||||
</h2>
|
||||
<p className="max-w-[600px] text-lg text-muted-foreground">
|
||||
Join the community of makers, builders, and dreamers. Start streaming your coding adventures today.
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/login">
|
||||
<Button size="lg" className="px-8 py-3 text-lg font-semibold">
|
||||
<Play className="w-5 h-5 mr-2" />
|
||||
Get Started Now
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import StreamPlayer from '../StreamPlayer/StreamPlayer';
|
||||
import UserInfoCard from '../UserInfoCard/UserInfoCard';
|
||||
import ChatPanel from '../ChatPanel/ChatPanel';
|
||||
import type { StreamInfo, User } from '@hctv/db';
|
||||
import type { StreamInfo, 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 };
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import Link from "next/link";
|
||||
import { links } from "../NavBar/NavBar";
|
||||
|
||||
export default function MobileNavbarLinks() {
|
||||
return (
|
||||
<div className="flex md:hidden">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button>Menu</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56">
|
||||
<DropdownMenuLabel>stack</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
{links.map((link) => (
|
||||
<Link key={link.href} href={link.href}>
|
||||
<DropdownMenuItem>{link.name}</DropdownMenuItem>
|
||||
</Link>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,23 +15,9 @@ import { logout } from '@/lib/auth/actions';
|
||||
import { useSession } from '@/lib/providers/SessionProvider';
|
||||
import Link from 'next/link';
|
||||
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 (
|
||||
@@ -46,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>}
|
||||
@@ -69,6 +51,22 @@ export default function Navbar(props: Props) {
|
||||
<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>
|
||||
@@ -85,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>
|
||||
|
||||
@@ -13,123 +13,153 @@ import {
|
||||
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 { stream, isLoading } = useStreams();
|
||||
const { channels: stream, isLoading } = useAllChannels(5000);
|
||||
const [followedExpanded, setFollowedExpanded] = React.useState(true);
|
||||
const { state } = useSidebar();
|
||||
const isCollapsed = state === 'collapsed';
|
||||
|
||||
if (isLoading) return <SidebarSkeleton />;
|
||||
if (isLoading) return <SidebarSkeleton {...props} />;
|
||||
|
||||
const liveStreamers = stream?.filter((s) => s.isLive) || [];
|
||||
const offlineStreamers = stream?.filter((s) => !s.isLive) || [];
|
||||
|
||||
return (
|
||||
<UISidebar {...props}>
|
||||
<UISidebar collapsible="icon" {...props}>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel asChild>
|
||||
<button
|
||||
onClick={() => setFollowedExpanded(!followedExpanded)}
|
||||
className="w-full flex items-center justify-between"
|
||||
>
|
||||
<span>Live Channels</span>
|
||||
{followedExpanded ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
<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>
|
||||
|
||||
{followedExpanded && (
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{liveStreamers.map((streamer) => (
|
||||
<StreamerItem key={streamer.id} streamer={streamer} />
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
)}
|
||||
<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>
|
||||
|
||||
{offlineStreamers.length > 0 && (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Offline Channels</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{offlineStreamers.map((streamer) => (
|
||||
<StreamerItem key={streamer.id} streamer={streamer} />
|
||||
))}
|
||||
</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 }: { streamer: StreamInfoResponse[0] }) {
|
||||
function StreamerItem({ streamer, isCollapsed }: { streamer: StreamInfoResponse[0], isCollapsed: boolean }) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<SidebarMenuItem key={streamer.id} className={streamer.isLive ? '' : '*:text-muted-foreground'}>
|
||||
<SidebarMenuButton className="flex items-center gap-3 h-full" onClick={() => {
|
||||
router.push(`/${streamer.username}`);
|
||||
}}>
|
||||
<div className="relative">
|
||||
<Avatar className="h-9 w-9">
|
||||
<AvatarImage src={streamer.channel.pfpUrl} alt={streamer.username} />
|
||||
<AvatarFallback>{streamer.username}</AvatarFallback>
|
||||
</Avatar>
|
||||
{streamer.isLive && (
|
||||
<span className="absolute -top-1 -right-1 w-3 h-3 bg-primary rounded-full border-2 border-black" />
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium truncate">{streamer.username}</p>
|
||||
<p className="text-sm truncate">{streamer.category}</p>
|
||||
{streamer.isLive && (
|
||||
<p className="text-sm">
|
||||
{streamer.viewers} viewer{streamer.viewers === 1 ? '' : 's'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarSkeleton({ ...props }: React.ComponentProps<typeof UISidebar>) {
|
||||
const { state } = useSidebar();
|
||||
const isCollapsed = state === 'collapsed';
|
||||
|
||||
return (
|
||||
<UISidebar {...props}>
|
||||
<UISidebar collapsible="icon" {...props}>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel asChild>
|
||||
<button className="w-full flex items-center justify-between">
|
||||
<span>Live Channels</span>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</button>
|
||||
<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} />
|
||||
<StreamerItemSkeleton key={i} isCollapsed={isCollapsed} />
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
<Separator className="group-data-[collapsible=icon]:block hidden" />
|
||||
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Offline Channels</SidebarGroupLabel>
|
||||
<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} />
|
||||
<StreamerItemSkeleton key={i} isCollapsed={isCollapsed} />
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
@@ -139,16 +169,18 @@ function SidebarSkeleton({ ...props }: React.ComponentProps<typeof UISidebar>) {
|
||||
);
|
||||
}
|
||||
|
||||
function StreamerItemSkeleton() {
|
||||
function StreamerItemSkeleton({ isCollapsed }: { isCollapsed: boolean }) {
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton className="flex items-center gap-3 h-full">
|
||||
<div className="relative">
|
||||
<Skeleton className="h-9 w-9 rounded-full" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<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;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useRef, useEffect } from 'react';
|
||||
import {
|
||||
MediaController,
|
||||
MediaLoadingIndicator,
|
||||
@@ -10,35 +11,53 @@ import {
|
||||
MediaSeekForwardButton,
|
||||
MediaMuteButton,
|
||||
MediaVolumeRange,
|
||||
MediaFullscreenButton
|
||||
MediaFullscreenButton,
|
||||
} from 'media-chrome/react';
|
||||
import HlsVideo from 'hls-video-element/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'>
|
||||
<MediaController className="w-full aspect-video">
|
||||
<HlsVideo
|
||||
src={`/api/rtmp/hls/${username}.m3u8`}
|
||||
ref={videoRef}
|
||||
slot="media"
|
||||
crossOrigin="anonymous"
|
||||
autoplay
|
||||
config={{
|
||||
lowLatencyMode: true,
|
||||
liveSyncDurationCount: 2, // Use only 1 segment for sync
|
||||
liveMaxLatencyDurationCount: 3, // Maximum latency allowed
|
||||
liveDurationInfinity: true,
|
||||
enableWorker: true,
|
||||
backBufferLength: 0, // No back buffer
|
||||
startLevel: -1, // Auto level selection
|
||||
maxBufferLength: 4, // Maximum buffer length in seconds
|
||||
maxMaxBufferLength: 6,
|
||||
debug: false,
|
||||
}}
|
||||
/>
|
||||
<MediaLoadingIndicator slot="centered-chrome" noAutohide />
|
||||
<MediaControlBar className='w-full px-2'>
|
||||
<MediaControlBar className="w-full px-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<MediaPlayButton />
|
||||
<MediaMuteButton />
|
||||
|
||||
@@ -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?: 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
108
apps/web/src/components/app/UserCombobox/UserCombobox.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import useSWR from 'swr';
|
||||
import { fetcher } from '@/lib/services/swr';
|
||||
import { Channel, StreamInfo } from '@hctv/db';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
|
||||
export function UserCombobox(props: Props) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [internalValue, setInternalValue] = React.useState('');
|
||||
|
||||
// Use external value if provided, otherwise use internal state
|
||||
const value = props.value ?? internalValue;
|
||||
const setValue = props.onValueChange ?? setInternalValue;
|
||||
const {
|
||||
data: fetchedUsers,
|
||||
error,
|
||||
isLoading,
|
||||
} = useSWR<APIResponse>(
|
||||
props.users ? null : '/api/stream/info?personal=true',
|
||||
fetcher
|
||||
);
|
||||
|
||||
const users = props.users || fetchedUsers;
|
||||
|
||||
if (!props.users && error) return <div>Error loading users</div>;
|
||||
if (!props.users && isLoading) return <div>Loading...</div>;
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen} modal={props.modal}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-[200px] justify-between"
|
||||
>
|
||||
{value
|
||||
? (
|
||||
<div className='flex items-center gap-2'>
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={users?.find((user) => user.username === value)?.channel.pfpUrl} alt={value} />
|
||||
<AvatarFallback>{value[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span>{users?.find((user) => user.username === value)?.username}</span>
|
||||
</div>
|
||||
)
|
||||
: 'Select user...'}
|
||||
<ChevronsUpDown className="opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search user..." className="h-9" />
|
||||
<CommandList>
|
||||
<CommandEmpty>No user found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{users?.filter(user => !props.filter?.some(filterStr => user.userId === filterStr)).map((user) => (
|
||||
<CommandItem
|
||||
key={user.channelId}
|
||||
value={user.username}
|
||||
onSelect={(currentValue) => {
|
||||
setValue(currentValue === value ? '' : currentValue);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={user.channel.pfpUrl} alt={user.username} />
|
||||
<AvatarFallback>{user.username[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
{user.username}
|
||||
<Check
|
||||
className={cn(
|
||||
'ml-auto',
|
||||
value === user.username ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
type APIResponse = (StreamInfo & { channel: Channel })[];
|
||||
type Props = {
|
||||
users?: APIResponse;
|
||||
value?: string;
|
||||
filter?: string[];
|
||||
modal?: boolean;
|
||||
onValueChange?: (value: string) => void;
|
||||
}
|
||||
@@ -1,16 +1,17 @@
|
||||
import { Avatar, AvatarImage } from '@/components/ui/avatar';
|
||||
import type { StreamInfo, User } from '@hctv/db';
|
||||
import type { StreamInfo, Channel } from '@hctv/db';
|
||||
import FollowButton from './follow';
|
||||
import FollowCountText from './followCount';
|
||||
import ViewerCount from './viewerCount';
|
||||
import { Preview } from '@/components/ui/channel-desc-fancy-area/preview';
|
||||
|
||||
export default function UserInfoCard(props: Props) {
|
||||
return (
|
||||
<div className="bg-mantle p-4 border-b">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="bg-mantle p-4 border-b h-48 flex flex-col">
|
||||
<div className="flex items-start justify-between mb-4 flex-shrink-0">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Avatar className="h-16 w-16">
|
||||
<AvatarImage src={props.streamInfo.ownedBy.pfpUrl} alt={props.streamInfo.username} />
|
||||
<AvatarImage src={props.streamInfo.channel.pfpUrl} alt={props.streamInfo.username} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{props.streamInfo.title}</h1>
|
||||
@@ -23,11 +24,13 @@ export default function UserInfoCard(props: Props) {
|
||||
<FollowButton channel={props.streamInfo.username} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="mb-4">markdown description here</p>
|
||||
<div className="max-h-32 overflow-y-auto">
|
||||
<Preview textValue={props.streamInfo.channel.description} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
streamInfo: StreamInfo & { ownedBy: User };
|
||||
streamInfo: StreamInfo & { channel: Channel };
|
||||
}
|
||||
298
apps/web/src/components/examples/ChannelListExample.tsx
Normal file
298
apps/web/src/components/examples/ChannelListExample.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
// Example component demonstrating production-ready useUserList hook
|
||||
// filepath: /home/srizan/Documents/Development/hclive/apps/web/src/components/examples/ChannelListExample.tsx
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import { useUserList, useLiveChannels, channelCacheUtils, type StreamInfoError } from '@/lib/hooks/useUserList'
|
||||
|
||||
// Error display component
|
||||
function ErrorDisplay({ error, onRetry }: { error: StreamInfoError; onRetry: () => void }) {
|
||||
const getErrorMessage = (error: StreamInfoError) => {
|
||||
if (error.status === 401) {
|
||||
return 'Authentication required. Please log in.'
|
||||
}
|
||||
if (error.status === 403) {
|
||||
return 'Access denied. You don\'t have permission to view this content.'
|
||||
}
|
||||
if (error.status && error.status >= 500) {
|
||||
return 'Server error. Please try again later.'
|
||||
}
|
||||
return error.message || 'An unknown error occurred.'
|
||||
}
|
||||
|
||||
const shouldShowRetry = error.status !== 401 && error.status !== 403
|
||||
|
||||
return (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-red-800 font-medium">Error Loading Channels</h3>
|
||||
<p className="text-red-600 text-sm mt-1">{getErrorMessage(error)}</p>
|
||||
{error.status && (
|
||||
<p className="text-red-500 text-xs mt-1">Status: {error.status}</p>
|
||||
)}
|
||||
</div>
|
||||
{shouldShowRetry && (
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="px-3 py-1 text-sm bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Loading skeleton component
|
||||
function ChannelSkeleton() {
|
||||
return (
|
||||
<div className="animate-pulse">
|
||||
<div className="bg-gray-200 h-4 rounded mb-2"></div>
|
||||
<div className="bg-gray-200 h-3 rounded w-3/4 mb-2"></div>
|
||||
<div className="bg-gray-200 h-3 rounded w-1/2"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Individual channel card component
|
||||
function ChannelCard({ channel }: { channel: any }) {
|
||||
return (
|
||||
<div className="border rounded-lg p-4 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="font-medium text-gray-900">{channel.username}</h3>
|
||||
{channel.isLive && (
|
||||
<span className="px-2 py-1 text-xs bg-red-500 text-white rounded-full">
|
||||
LIVE
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm mb-2">{channel.title}</p>
|
||||
<div className="flex items-center justify-between text-xs text-gray-500">
|
||||
<span>{channel.category}</span>
|
||||
{channel.isLive && (
|
||||
<span>{channel.viewers} viewers</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Main channel list component with comprehensive error handling and loading states
|
||||
export function ChannelListExample() {
|
||||
const [filter, setFilter] = useState<'all' | 'live' | 'owned'>('all')
|
||||
|
||||
// Use different hooks based on filter
|
||||
const allChannelsResult = useUserList({
|
||||
refreshInterval: 60000,
|
||||
revalidateOnFocus: true,
|
||||
isPaused: filter !== 'all'
|
||||
})
|
||||
|
||||
const liveChannelsResult = useLiveChannels()
|
||||
const ownedChannelsResult = useUserList({
|
||||
owned: true,
|
||||
isPaused: filter !== 'owned'
|
||||
})
|
||||
|
||||
// Select the appropriate result based on filter
|
||||
const result = filter === 'live'
|
||||
? liveChannelsResult
|
||||
: filter === 'owned'
|
||||
? ownedChannelsResult
|
||||
: allChannelsResult
|
||||
|
||||
const {
|
||||
channels,
|
||||
isLoading,
|
||||
error,
|
||||
isValidating,
|
||||
isBackgroundFetching,
|
||||
totalChannels,
|
||||
liveChannels,
|
||||
lastUpdated,
|
||||
refresh,
|
||||
clearCache
|
||||
} = result
|
||||
|
||||
// Cache management handlers
|
||||
const handleClearCache = useCallback(async () => {
|
||||
try {
|
||||
await clearCache()
|
||||
console.log('Cache cleared successfully')
|
||||
} catch (error) {
|
||||
console.error('Failed to clear cache:', error)
|
||||
}
|
||||
}, [clearCache])
|
||||
|
||||
const handleInvalidateLive = useCallback(async () => {
|
||||
try {
|
||||
await channelCacheUtils.invalidateLive()
|
||||
console.log('Live channels cache invalidated')
|
||||
} catch (error) {
|
||||
console.error('Failed to invalidate live cache:', error)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleWarmUpCache = useCallback(async () => {
|
||||
try {
|
||||
await channelCacheUtils.warmUp()
|
||||
console.log('Cache warmed up successfully')
|
||||
} catch (error) {
|
||||
console.error('Failed to warm up cache:', error)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Render loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">Loading Channels...</h2>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<ChannelSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Render error state
|
||||
if (error) {
|
||||
return <ErrorDisplay error={error} onRetry={refresh} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header with stats and controls */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">
|
||||
Channels
|
||||
{isBackgroundFetching && (
|
||||
<span className="ml-2 text-blue-500 animate-spin">🔄</span>
|
||||
)}
|
||||
</h2>
|
||||
<div className="text-sm text-gray-600 mt-1">
|
||||
{totalChannels} total • {liveChannels} live
|
||||
{lastUpdated && (
|
||||
<span className="ml-2">
|
||||
• Updated {lastUpdated.toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={refresh}
|
||||
disabled={isValidating}
|
||||
className="px-3 py-1 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isValidating ? 'Refreshing...' : 'Refresh'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleClearCache}
|
||||
className="px-3 py-1 text-sm bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
Clear Cache
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter tabs */}
|
||||
<div className="flex gap-2 border-b">
|
||||
{[
|
||||
{ key: 'all', label: 'All Channels' },
|
||||
{ key: 'live', label: 'Live' },
|
||||
{ key: 'owned', label: 'My Channels' }
|
||||
].map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setFilter(key as any)}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
filter === key
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Cache management tools (development only) */}
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div className="bg-gray-50 p-3 rounded-lg">
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-2">
|
||||
Cache Management (Dev Tools)
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleInvalidateLive}
|
||||
className="px-2 py-1 text-xs bg-yellow-500 text-white rounded hover:bg-yellow-600"
|
||||
>
|
||||
Invalidate Live
|
||||
</button>
|
||||
<button
|
||||
onClick={handleWarmUpCache}
|
||||
className="px-2 py-1 text-xs bg-green-500 text-white rounded hover:bg-green-600"
|
||||
>
|
||||
Warm Up Cache
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Channel grid */}
|
||||
{channels.length > 0 ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{channels.map((channel) => (
|
||||
<ChannelCard key={channel.id} channel={channel} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<p>No channels found.</p>
|
||||
{filter !== 'all' && (
|
||||
<button
|
||||
onClick={() => setFilter('all')}
|
||||
className="mt-2 text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
View all channels
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Debug info in development */}
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<details className="bg-gray-50 p-3 rounded-lg">
|
||||
<summary className="text-sm font-medium text-gray-700 cursor-pointer">
|
||||
Debug Information
|
||||
</summary>
|
||||
<pre className="mt-2 text-xs text-gray-600 overflow-auto">
|
||||
{JSON.stringify({
|
||||
filter,
|
||||
totalChannels,
|
||||
liveChannels,
|
||||
isLoading,
|
||||
isValidating,
|
||||
isBackgroundFetching,
|
||||
cacheKey: result.cacheKey,
|
||||
lastUpdated: lastUpdated?.toISOString(),
|
||||
hasError: !!error
|
||||
}, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChannelListExample
|
||||
36
apps/web/src/components/ui/badge.tsx
Normal file
36
apps/web/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -26,7 +26,8 @@ const buttonVariants = cva(
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10"
|
||||
icon: "h-10 w-10",
|
||||
smicon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
source: https://craft.mxkaske.dev/post/fancy-area
|
||||
|
||||
currently only used for the description input.
|
||||
47
apps/web/src/components/ui/channel-desc-fancy-area/data.ts
Normal file
47
apps/web/src/components/ui/channel-desc-fancy-area/data.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
// REMINDER: currently a static file!
|
||||
export const people = [
|
||||
{
|
||||
username: "@shadcn",
|
||||
profileImg:
|
||||
"https://pbs.twimg.com/profile_images/1593304942210478080/TUYae5z7_400x400.jpg",
|
||||
description:
|
||||
"Design · Code · Open Source · I tweet about building products with Next.js",
|
||||
joined: "April 2009",
|
||||
},
|
||||
{
|
||||
username: "@mxkaske",
|
||||
profileImg:
|
||||
"https://pbs.twimg.com/profile_images/1559935773151051778/0O_Bf4LY_400x400.jpg",
|
||||
description: "Developer · Climber · Just call me Max",
|
||||
joined: "January 2017",
|
||||
},
|
||||
{
|
||||
username: "@chronark_",
|
||||
profileImg:
|
||||
"https://pbs.twimg.com/profile_images/1437670380957835264/gu8S0olw_400x400.jpg",
|
||||
description: "Building open source and serverless solutions @upstash",
|
||||
joined: "June 2019",
|
||||
},
|
||||
{
|
||||
username: "@rauchg",
|
||||
profileImg:
|
||||
"https://pbs.twimg.com/profile_images/1576257734810312704/ucxb4lHy_400x400.jpg",
|
||||
description: "@vercel CEO",
|
||||
joined: "June 2008",
|
||||
},
|
||||
{
|
||||
username: "@steventey",
|
||||
profileImg:
|
||||
"https://pbs.twimg.com/profile_images/1506792347840888834/dS-r50Je_400x400.jpg",
|
||||
description:
|
||||
"building the future of the web ▲ @vercel | @dubdotsh | http://oneword.domains",
|
||||
joined: "July 2011",
|
||||
},
|
||||
{
|
||||
username: "@raunofreiberg",
|
||||
profileImg:
|
||||
"https://pbs.twimg.com/profile_images/1525606643794427905/17-x8e9o_400x400.jpg",
|
||||
description: "Devoured by details.",
|
||||
joined: "December 2018",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../tabs";
|
||||
import { Write } from "./write";
|
||||
import { Preview } from "./preview";
|
||||
// TODO: TabsList has an interesting tab focus. Need to investigate on it
|
||||
|
||||
const defaultText = `Build by @mxkaske, _powered by_ @shadcn **ui**.\n\nSupports raw <code>html</code>.`;
|
||||
|
||||
export function FancyArea() {
|
||||
const [textValue, setTextValue] = React.useState(defaultText);
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="write" className="w-full">
|
||||
<TabsList>
|
||||
<TabsTrigger value="write">Write</TabsTrigger>
|
||||
<TabsTrigger value="preview">Preview</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="write">
|
||||
<Write {...{ textValue, setTextValue }} />
|
||||
</TabsContent>
|
||||
<TabsContent value="preview">
|
||||
<Preview {...{ textValue }} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { CalendarDays } from "lucide-react";
|
||||
import React from "react";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import { HoverCardPortal } from "@radix-ui/react-hover-card";
|
||||
import { useAllChannels } from "@/lib/hooks/useUserList";
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
handle: string;
|
||||
}
|
||||
|
||||
export function Mention({ children, handle }: Props) {
|
||||
const { channels, isLoading, error } = useAllChannels();
|
||||
|
||||
if (isLoading) return null;
|
||||
if (error) {
|
||||
console.error("Error fetching channels:", error);
|
||||
return null;
|
||||
}
|
||||
|
||||
const isString = typeof children === "string";
|
||||
// REMINDER: children has other children - return early
|
||||
if (!isString) {
|
||||
return children;
|
||||
}
|
||||
|
||||
// Find channel by name (handle without @)
|
||||
const channel = channels.find((ch) => ch.username.toLowerCase() === handle.toLowerCase());
|
||||
// REMINDER: only allowed users are rendered with HoverCard
|
||||
if (!channel) {
|
||||
return children;
|
||||
}
|
||||
|
||||
const fallback = handle.substring(0, 2).toUpperCase();
|
||||
const url = `https://hackclub.tv/${handle}`;
|
||||
|
||||
return (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<a href={url} target="_blank">
|
||||
{children}
|
||||
</a>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardPortal>
|
||||
<HoverCardContent className="max-w-xs w-auto">
|
||||
<div className="flex justify-between space-x-4">
|
||||
<Avatar>
|
||||
<AvatarImage src={channel.channel.pfpUrl} />
|
||||
<AvatarFallback>{fallback}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-sm font-semibold">{channel.channel.name}</h4>
|
||||
<p className="text-sm">{channel.channel.description}</p>
|
||||
<div className="flex items-center pt-2">
|
||||
<CalendarDays className="mr-2 h-4 w-4 opacity-70" />{" "}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Joined {new Date(channel.channel.createdAt).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCardPortal>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useProcessor } from "./use-processor";
|
||||
|
||||
interface Props {
|
||||
textValue: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Preview({ textValue, className }: Props) {
|
||||
const Component = useProcessor(textValue);
|
||||
return (
|
||||
<div className={cn("w-full overflow-auto prose dark:prose-invert prose-sm px-1 border border-transparent prose-headings:font-cal", className)}>
|
||||
{Component}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { unified } from "unified";
|
||||
import remarkParse from "remark-parse";
|
||||
import remarkRehype from "remark-rehype";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import rehypeReact from "rehype-react";
|
||||
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
|
||||
import { createElement, Fragment, useEffect, useState } from "react";
|
||||
import { Mention } from "./mention";
|
||||
import { jsx, jsxs } from "react/jsx-runtime";
|
||||
|
||||
export function useProcessor(md: string) {
|
||||
const [content, setContent] = useState<React.ReactNode>(null);
|
||||
const mentionRegex = /@(\w+)/g;
|
||||
const text = md.replace(mentionRegex, '<mention handle="$1">@$1</mention>');
|
||||
|
||||
useEffect(() => {
|
||||
unified()
|
||||
.use(remarkParse)
|
||||
.use(remarkRehype, { allowDangerousHtml: true })
|
||||
.use(rehypeRaw)
|
||||
.use(rehypeSanitize, {
|
||||
...defaultSchema,
|
||||
tagNames: [...defaultSchema.tagNames!, "mention"],
|
||||
attributes: {
|
||||
...defaultSchema.attributes,
|
||||
mention: ["handle"],
|
||||
},
|
||||
})
|
||||
// @ts-expect-error because mention is not valid html-tag
|
||||
.use(rehypeReact, {
|
||||
createElement,
|
||||
Fragment,
|
||||
jsx,
|
||||
jsxs,
|
||||
components: {
|
||||
mention: Mention,
|
||||
},
|
||||
})
|
||||
.process(text)
|
||||
.then((file) => {
|
||||
setContent(file.result);
|
||||
});
|
||||
}, [text]);
|
||||
|
||||
return content;
|
||||
}
|
||||
220
apps/web/src/components/ui/channel-desc-fancy-area/utils.ts
Normal file
220
apps/web/src/components/ui/channel-desc-fancy-area/utils.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
// https://github.com/component/textarea-caret-position
|
||||
|
||||
// We'll copy the properties below into the mirror div.
|
||||
// Note that some browsers, such as Firefox, do not concatenate properties
|
||||
// into their shorthand (e.g. padding-top, padding-bottom etc. -> padding),
|
||||
// so we have to list every single property explicitly.
|
||||
const properties = [
|
||||
"direction", // RTL support
|
||||
"boxSizing",
|
||||
"width", // on Chrome and IE, exclude the scrollbar, so the mirror div wraps exactly as the textarea does
|
||||
"height",
|
||||
"overflowX",
|
||||
"overflowY", // copy the scrollbar for IE
|
||||
|
||||
"borderTopWidth",
|
||||
"borderRightWidth",
|
||||
"borderBottomWidth",
|
||||
"borderLeftWidth",
|
||||
"borderStyle",
|
||||
|
||||
"paddingTop",
|
||||
"paddingRight",
|
||||
"paddingBottom",
|
||||
"paddingLeft",
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/CSS/font
|
||||
"fontStyle",
|
||||
"fontVariant",
|
||||
"fontWeight",
|
||||
"fontStretch",
|
||||
"fontSize",
|
||||
"fontSizeAdjust",
|
||||
"lineHeight",
|
||||
"fontFamily",
|
||||
|
||||
"textAlign",
|
||||
"textTransform",
|
||||
"textIndent",
|
||||
"textDecoration", // might not make a difference, but better be safe
|
||||
|
||||
"letterSpacing",
|
||||
"wordSpacing",
|
||||
|
||||
"tabSize",
|
||||
"MozTabSize",
|
||||
] as const;
|
||||
|
||||
const isBrowser = typeof window !== "undefined";
|
||||
// @ts-expect-error
|
||||
const isFirefox = isBrowser && window.mozInnerScreenX != null;
|
||||
|
||||
export function getCaretPosition(element: HTMLTextAreaElement) {
|
||||
return {
|
||||
caretStartIndex: element.selectionStart || 0,
|
||||
caretEndIndex: element.selectionEnd || 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function getCurrentWord(element: HTMLTextAreaElement) {
|
||||
const text = element.value;
|
||||
const { caretStartIndex } = getCaretPosition(element);
|
||||
|
||||
// Find the start position of the word
|
||||
let start = caretStartIndex;
|
||||
while (start > 0 && text[start - 1].match(/\S/)) {
|
||||
start--;
|
||||
}
|
||||
|
||||
// Find the end position of the word
|
||||
let end = caretStartIndex;
|
||||
while (end < text.length && text[end].match(/\S/)) {
|
||||
end++;
|
||||
}
|
||||
|
||||
const w = text.substring(start, end);
|
||||
|
||||
return w;
|
||||
}
|
||||
|
||||
export function replaceWord(element: HTMLTextAreaElement, value: string) {
|
||||
const text = element.value;
|
||||
const caretPos = element.selectionStart;
|
||||
|
||||
// Find the word that needs to be replaced
|
||||
const wordRegex = /[\w@#]+/g;
|
||||
let match;
|
||||
let startIndex;
|
||||
let endIndex;
|
||||
|
||||
while ((match = wordRegex.exec(text)) !== null) {
|
||||
startIndex = match.index;
|
||||
endIndex = startIndex + match[0].length;
|
||||
|
||||
if (caretPos >= startIndex && caretPos <= endIndex) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Replace the word with a new word using document.execCommand
|
||||
if (startIndex !== undefined && endIndex !== undefined) {
|
||||
// Preserve the current selection range
|
||||
const selectionStart = element.selectionStart;
|
||||
const selectionEnd = element.selectionEnd;
|
||||
|
||||
// Modify the selected range to encompass the word to be replaced
|
||||
element.setSelectionRange(startIndex, endIndex);
|
||||
|
||||
// REMINDER: Fastest way to include CMD + Z compatibility
|
||||
// Execute the command to replace the selected text with the new word
|
||||
document.execCommand("insertText", false, value);
|
||||
|
||||
// Restore the original selection range
|
||||
element.setSelectionRange(
|
||||
selectionStart - (endIndex - startIndex) + value.length,
|
||||
selectionEnd - (endIndex - startIndex) + value.length,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function getCaretCoordinates(
|
||||
element: HTMLTextAreaElement,
|
||||
position: number,
|
||||
options?: { debug: boolean },
|
||||
) {
|
||||
if (!isBrowser) {
|
||||
throw new Error(
|
||||
"textarea-caret-position#getCaretCoordinates should only be called in a browser",
|
||||
);
|
||||
}
|
||||
|
||||
var debug = (options && options.debug) || false;
|
||||
if (debug) {
|
||||
var el = document.querySelector(
|
||||
"#input-textarea-caret-position-mirror-div",
|
||||
);
|
||||
if (el) el?.parentNode?.removeChild(el);
|
||||
}
|
||||
|
||||
// The mirror div will replicate the textarea's style
|
||||
var div = document.createElement("div");
|
||||
div.id = "input-textarea-caret-position-mirror-div";
|
||||
document.body.appendChild(div);
|
||||
|
||||
var style = div.style;
|
||||
var computed = window.getComputedStyle(element);
|
||||
var isInput = element.nodeName === "INPUT";
|
||||
|
||||
// Default textarea styles
|
||||
style.whiteSpace = "pre-wrap";
|
||||
if (!isInput) style.wordWrap = "break-word"; // only for textarea-s
|
||||
|
||||
// Position off-screen
|
||||
style.position = "absolute"; // required to return coordinates properly
|
||||
if (!debug) style.visibility = "hidden"; // not 'display: none' because we want rendering
|
||||
|
||||
// Transfer the element's properties to the div
|
||||
properties.forEach(function (prop) {
|
||||
if (isInput && prop === "lineHeight") {
|
||||
// Special case for <input>s because text is rendered centered and line height may be != height
|
||||
if (computed.boxSizing === "border-box") {
|
||||
var height = parseInt(computed.height);
|
||||
var outerHeight =
|
||||
parseInt(computed.paddingTop) +
|
||||
parseInt(computed.paddingBottom) +
|
||||
parseInt(computed.borderTopWidth) +
|
||||
parseInt(computed.borderBottomWidth);
|
||||
var targetHeight = outerHeight + parseInt(computed.lineHeight);
|
||||
if (height > targetHeight) {
|
||||
style.lineHeight = height - outerHeight + "px";
|
||||
} else if (height === targetHeight) {
|
||||
style.lineHeight = computed.lineHeight;
|
||||
} else {
|
||||
style.lineHeight = 0 + "px";
|
||||
}
|
||||
} else {
|
||||
style.lineHeight = computed.height;
|
||||
}
|
||||
} else {
|
||||
// @ts-expect-error
|
||||
style[prop] = computed[prop];
|
||||
}
|
||||
});
|
||||
|
||||
if (isFirefox) {
|
||||
// Firefox lies about the overflow property for textareas: https://bugzilla.mozilla.org/show_bug.cgi?id=984275
|
||||
if (element.scrollHeight > parseInt(computed.height))
|
||||
style.overflowY = "scroll";
|
||||
} else {
|
||||
style.overflow = "hidden"; // for Chrome to not render a scrollbar; IE keeps overflowY = 'scroll'
|
||||
}
|
||||
|
||||
div.textContent = element.value.substring(0, position);
|
||||
// The second special handling for input type="text" vs textarea:
|
||||
// spaces need to be replaced with non-breaking spaces - http://stackoverflow.com/a/13402035/1269037
|
||||
if (isInput) div.textContent = div.textContent.replace(/\s/g, "\u00a0");
|
||||
|
||||
var span = document.createElement("span");
|
||||
// Wrapping must be replicated *exactly*, including when a long word gets
|
||||
// onto the next line, with whitespace at the end of the line before (#7).
|
||||
// The *only* reliable way to do that is to copy the *entire* rest of the
|
||||
// textarea's content into the <span> created at the caret position.
|
||||
// For inputs, just '.' would be enough, but no need to bother.
|
||||
// REMINDER: changed it from "." to empty string ""...
|
||||
span.textContent = element.value.substring(position) || ""; // || because a completely empty faux span doesn't render at all
|
||||
div.appendChild(span);
|
||||
|
||||
var coordinates = {
|
||||
top: span.offsetTop + parseInt(computed["borderTopWidth"]),
|
||||
left: span.offsetLeft + parseInt(computed["borderLeftWidth"]),
|
||||
height: parseInt(computed["lineHeight"]),
|
||||
};
|
||||
|
||||
if (debug) {
|
||||
span.style.backgroundColor = "#aaa";
|
||||
} else {
|
||||
document.body.removeChild(div);
|
||||
}
|
||||
|
||||
return coordinates;
|
||||
}
|
||||
191
apps/web/src/components/ui/channel-desc-fancy-area/write.tsx
Normal file
191
apps/web/src/components/ui/channel-desc-fancy-area/write.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
"use client";
|
||||
|
||||
import React, { useRef, useState, useEffect, useCallback } from "react";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Command,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getCaretCoordinates, getCurrentWord, replaceWord } from "./utils";
|
||||
import { useAllChannels } from "@/lib/hooks/useUserList";
|
||||
|
||||
interface Props {
|
||||
textValue: string;
|
||||
setTextValue: React.Dispatch<React.SetStateAction<string>>;
|
||||
}
|
||||
|
||||
export function Write({ textValue, setTextValue }: Props) {
|
||||
const { channels, isLoading, error } = useAllChannels();
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [commandValue, setCommandValue] = useState("");
|
||||
|
||||
// TODO: check if this is possible?!?
|
||||
// const texarea = textareaRef.current;
|
||||
// const dropdown = dropdownRef.current;
|
||||
|
||||
const handleBlur = useCallback((e: Event) => {
|
||||
const dropdown = dropdownRef.current;
|
||||
if (dropdown) {
|
||||
dropdown.classList.add("hidden");
|
||||
setCommandValue("");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
const textarea = textareaRef.current;
|
||||
const input = inputRef.current;
|
||||
const dropdown = dropdownRef.current;
|
||||
if (textarea && input && dropdown) {
|
||||
const currentWord = getCurrentWord(textarea);
|
||||
const isDropdownHidden = dropdown.classList.contains("hidden");
|
||||
if (currentWord.startsWith("@") && !isDropdownHidden) {
|
||||
// FIXME: handle Escape
|
||||
if (
|
||||
e.key === "ArrowUp" ||
|
||||
e.keyCode === 38 ||
|
||||
e.key === "ArrowDown" ||
|
||||
e.keyCode === 40 ||
|
||||
e.key === "Enter" ||
|
||||
e.keyCode === 13 ||
|
||||
e.key === "Escape" ||
|
||||
e.keyCode === 27
|
||||
) {
|
||||
e.preventDefault();
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onTextValueChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const text = e.target.value;
|
||||
const textarea = textareaRef.current;
|
||||
const dropdown = dropdownRef.current;
|
||||
|
||||
if (textarea && dropdown) {
|
||||
const caret = getCaretCoordinates(textarea, textarea.selectionEnd);
|
||||
const currentWord = getCurrentWord(textarea);
|
||||
setTextValue(text);
|
||||
|
||||
if (currentWord.startsWith("@")) {
|
||||
// Remove the @ symbol for the Command component filtering
|
||||
const searchTerm = currentWord.slice(1);
|
||||
setCommandValue(searchTerm);
|
||||
dropdown.style.left = caret.left + "px";
|
||||
dropdown.style.top = caret.top + caret.height + "px";
|
||||
dropdown.classList.remove("hidden");
|
||||
} else {
|
||||
// REMINDER: apparently, we need it when deleting
|
||||
if (commandValue !== "") {
|
||||
setCommandValue("");
|
||||
dropdown.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[setTextValue, commandValue],
|
||||
);
|
||||
|
||||
const onCommandSelect = useCallback((value: string) => {
|
||||
const textarea = textareaRef.current;
|
||||
const dropdown = dropdownRef.current;
|
||||
if (textarea && dropdown) {
|
||||
// Add the @ symbol back when replacing
|
||||
replaceWord(textarea, `@${value}`);
|
||||
setCommandValue("");
|
||||
dropdown.classList.add("hidden");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleMouseDown = useCallback((e: Event) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleSectionChange = useCallback(
|
||||
(e: Event) => {
|
||||
const textarea = textareaRef.current;
|
||||
const dropdown = dropdownRef.current;
|
||||
if (textarea && dropdown) {
|
||||
const currentWord = getCurrentWord(textarea);
|
||||
if (!currentWord.startsWith("@") && commandValue !== "") {
|
||||
setCommandValue("");
|
||||
dropdown.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
},
|
||||
[commandValue],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const textarea = textareaRef.current;
|
||||
const dropdown = dropdownRef.current;
|
||||
textarea?.addEventListener("keydown", handleKeyDown);
|
||||
textarea?.addEventListener("blur", handleBlur);
|
||||
document?.addEventListener("selectionchange", handleSectionChange);
|
||||
dropdown?.addEventListener("mousedown", handleMouseDown);
|
||||
return () => {
|
||||
textarea?.removeEventListener("keydown", handleKeyDown);
|
||||
textarea?.removeEventListener("blur", handleBlur);
|
||||
document?.removeEventListener("selectionchange", handleSectionChange);
|
||||
dropdown?.removeEventListener("mousedown", handleMouseDown);
|
||||
};
|
||||
}, [handleBlur, handleKeyDown, handleMouseDown, handleSectionChange]);
|
||||
|
||||
if (isLoading) return null;
|
||||
if (error) {
|
||||
console.error("Error fetching channels:", error);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
className="h-auto resize-none"
|
||||
value={textValue}
|
||||
onChange={onTextValueChange}
|
||||
rows={5}
|
||||
/>
|
||||
<p className="prose-none mt-1 text-sm text-muted-foreground">
|
||||
Supports markdown.
|
||||
</p>
|
||||
<Command
|
||||
ref={dropdownRef}
|
||||
className={cn(
|
||||
"absolute hidden h-auto max-h-32 max-w-min overflow-y-scroll border border-popover shadow",
|
||||
)}
|
||||
>
|
||||
<div className="hidden">
|
||||
{/* REMINDER: className="hidden" won't hide the SearchIcon and border */}
|
||||
<CommandInput ref={inputRef} value={commandValue} />
|
||||
</div>
|
||||
<CommandList>
|
||||
<CommandGroup className="max-w-min overflow-auto">
|
||||
{channels.map((c) => {
|
||||
return (
|
||||
<CommandItem
|
||||
key={c.id}
|
||||
value={c.username}
|
||||
onSelect={onCommandSelect}
|
||||
>
|
||||
{c.username}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
apps/web/src/components/ui/hover-card.tsx
Normal file
29
apps/web/src/components/ui/hover-card.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const HoverCard = HoverCardPrimitive.Root
|
||||
|
||||
const HoverCardTrigger = HoverCardPrimitive.Trigger
|
||||
|
||||
const HoverCardContent = React.forwardRef<
|
||||
React.ElementRef<typeof HoverCardPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<HoverCardPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-hover-card-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||
@@ -113,8 +113,10 @@ SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> & {
|
||||
icon?: React.ReactNode
|
||||
}
|
||||
>(({ className, children, icon, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
@@ -124,9 +126,11 @@ const SelectItem = React.forwardRef<
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
{icon ? icon : (
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user