Compare commits

...

77 Commits

Author SHA1 Message Date
0581cc6a61 chore: github copilot code review nitpicks 2025-12-20 02:26:46 +01:00
18025ced9d chore: update next 2025-12-20 01:28:08 +01:00
044221f147 fix: stream keeps playing after leaving page 2025-12-19 22:04:24 +01:00
0cabbd8720 fix: constraint error when deleting channel 2025-12-18 23:37:24 +01:00
5fdb6921d9 fix: generate stream key right on channel creation 2025-12-18 23:33:03 +01:00
312ad480a2 feat: channel deletion 2025-12-18 23:23:59 +01:00
a37554d205 refactor: sidebar 2025-12-18 22:52:00 +01:00
5244275264 feat: make stream key stuff more compact + automatic url generator 2025-12-18 19:26:20 +01:00
5275e8cb2a docs: change url stuff 2025-12-18 19:25:52 +01:00
1ff51fad61 feat: streaminfo and thumbnail wiring 2025-12-17 18:33:21 +01:00
440eb407dd chore: mediamtx types 2025-12-15 22:24:46 +01:00
4ab1756230 feat: new color scheme 2025-12-15 22:09:27 +01:00
f9d11476bf refactor: optimize authentication requests 2025-12-15 21:47:00 +01:00
8e8c58e195 feat: add preliminary hls reading 2025-12-13 22:34:29 +01:00
6fcbeaa2a7 Merge branch 'main' into feat/protocol-migration 2025-12-13 21:57:57 +01:00
caef4e428a chore: update deps AGAIN 2025-12-13 21:56:36 +01:00
cf49fea907 Merge branch 'main' into feat/protocol-migration 2025-12-13 21:51:10 +01:00
7683f765b0 chore: update react dom for the vuln 2025-12-13 21:50:45 +01:00
4d91f15a43 Merge branch 'main' into feat/protocol-migration 2025-12-13 21:49:59 +01:00
3b49f8d25a feat: srt support and auth 2025-12-13 21:19:13 +01:00
c99ace0ef5 chore: patch next again 2025-12-13 20:33:54 +01:00
a834b63ac8 chore: update next to patch react2shell 2025-12-05 12:11:45 +00:00
09871d3fae fix: slight vulnerability 2025-12-03 20:49:33 +01:00
2a0a7abe1a chore: change github gist link to docs 2025-12-03 20:25:33 +01:00
2a15a6367a chore: change navbar login icon 2025-12-03 20:25:21 +01:00
6fad756bd2 chore: prevent people from signing in without slack 2025-12-03 20:24:49 +01:00
0bb44960b4 chore: nuke ome 2025-11-29 00:25:26 +01:00
ac2276b112 Merge branch 'main' into feat/protocol-migration 2025-11-25 17:00:26 +01:00
1adb9be6cc fix: change ident url 2025-11-25 15:48:59 +01:00
a9625f3505 chore: bump emoji converter version 2025-11-24 22:43:39 +01:00
3611e23869 feat: migrate to idv 2025-11-24 22:41:03 +01:00
f8aa1454ff protocol migration thing 2025-11-24 20:41:57 +01:00
6e8539a8d1 fix: headers before url 2025-11-12 16:57:20 +01:00
592524eedb ci: add github workflow to send redeploy webhook 2025-11-12 16:43:28 +01:00
989462e639 fix: thumbnail not showing 2025-11-12 16:43:10 +01:00
a7e9115587 fix: commit hash not showing 2025-11-12 16:43:01 +01:00
934f589b5f fix: prisma and emoji display erroring obs panel 2025-11-12 08:16:00 +01:00
27a6838d9f chore: switch docker image to debian 2025-11-12 08:03:04 +01:00
75085630be fix: that aint workin 2025-11-11 22:03:34 +01:00
642270ee91 fix: implement lazy loading through another way 2025-11-11 21:51:02 +01:00
f543061672 fix: lazy load avatar 2025-11-11 21:43:41 +01:00
3a89f07a6f chore: temp selfhosted migration + flv module refactors 2025-11-10 21:40:56 +01:00
fb40d87736 fix: prisma schema not copied 2025-10-18 22:47:48 +02:00
34d7afd03d ci: remove db image 2025-10-18 22:23:16 +02:00
495027ca7e chore: domain transition 2025-10-03 22:47:12 +02:00
55df22341e feat: #56 (feat/bot-accounts) 2025-09-30 17:47:46 +02:00
17f550ce9d chore: some toast stuff 2025-09-30 17:46:09 +02:00
ebd0a8bffd chore: separator 2025-09-30 17:33:42 +02:00
2298b8bc0c feat: small amount of documentation 2025-09-30 17:31:07 +02:00
7dd9bf765e feat: chats work now! 2025-09-30 17:21:06 +02:00
8b3df28f1e feat: back button 2025-09-30 08:09:56 +02:00
ecca138257 chore: listen to enter key 2025-09-30 08:06:40 +02:00
cb0f75dfb9 feat: bot accounts (without api stuff) 2025-09-30 08:00:19 +02:00
747af0aeb3 fix: not right cwd 2025-09-05 00:53:56 +02:00
64c7a80883 fix: vips stuff again aaa 2025-09-05 00:50:06 +02:00
e8fdfa8f49 chore: add vips-dev to both stages 2025-09-05 00:06:22 +02:00
9e965c779f chore: also install vips inside installer 2025-09-04 23:51:21 +02:00
9ce6770115 fix: libvips not installed in container 2025-09-04 23:42:59 +02:00
6d413bc53e fix: onrequesterror not exposed 2025-09-04 23:34:58 +02:00
93ae6bd73e fix: move sentry server to instrumentation instead 2025-09-04 23:24:06 +02:00
ae99121af6 fix: sentry errors not being verbose enough 2025-09-04 23:20:37 +02:00
331a0185af chore: remove all monorepo readmes 2025-09-04 17:03:16 +02:00
d223902a9f ci: add default flag 2025-09-04 17:01:16 +02:00
18a00bba6e fix: emojis not showing + some optimizations 2025-09-03 23:35:07 +02:00
061d1d3348 chore: up version 2025-09-03 23:20:43 +02:00
95ec96fe72 feat: add default emojis 2025-09-03 23:15:36 +02:00
9eca54cbb5 fix: follow notifications showing untrue information 2025-09-03 22:41:45 +02:00
31fa5f36de feat: add 24/7 toggle (also make label optional) 2025-09-03 22:37:07 +02:00
d327da90ef chore: add 24/7 check to notification stuff 2025-09-03 00:54:40 +02:00
7072b762d8 fix: build issues 2025-09-02 19:58:36 +02:00
82a13007c8 chore: there should be less latency now! 2025-09-02 18:46:23 +02:00
a35fd858dc feat: use catppuccin on docs 2025-09-02 18:20:55 +02:00
4f03f002ab feat: use astro starlight instead of fumadocs 2025-09-02 17:39:58 +02:00
d36e590ab6 chore: static 2025-09-02 00:43:26 +02:00
c77d7a16e6 chore: use static export 2025-09-02 00:40:09 +02:00
d656d0f579 fix: docs builds 2025-09-02 00:31:02 +02:00
2896cae2bb feat: docs (#55) 2025-09-02 00:19:50 +02:00
111 changed files with 7126 additions and 2263 deletions

View File

@@ -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
@@ -41,7 +47,7 @@ jobs:
mkdir -p apps/web/src/lib/instrumentation/
export SLACK_TOKEN=${{ secrets.SLACK_TOKEN }}
./slack-import-emojis-bin
./slack-import-emojis-bin default
cp emojis.json apps/web/
@@ -57,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
@@ -128,21 +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:
# source https://github.com/taciturnaxolotl/cachet/blob/main/.github/workflows/deploy.yaml
- name: Deploy with Docker
uses: appleboy/ssh-action@v1
with:
host: hackclub.app
username: srizan
key: ${{ secrets.SSH_KEY }}
port: 22
script: |
cd ~/compose/hctv
docker compose pull
docker compose up -d --remove-orphans
# for some reason, without the restart, the rtmp container stops working
docker compose restart
- name: Send coolify redeploy webhook
run: |
curl -X POST -H "Authorization: Bearer ${{ secrets.COOLIFY_API_KEY }}" https://coolify.srizan.dev/api/v1/deploy?uuid=${{ secrets.COOLIFY_APP_UUID }}&force=true

4
.gitignore vendored
View File

@@ -45,4 +45,6 @@ packages/db/generated/client
*dist
slack-import-emojis/target
**/*/emojis.json
**/*/emojis.json
.idea

View File

@@ -1,6 +1,6 @@
# 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.

View File

@@ -1,8 +0,0 @@
```
npm install
npm run dev
```
```
open http://localhost:3000
```

View File

@@ -5,7 +5,7 @@ import { readFile } from 'node:fs/promises';
import { lucia } from '@hctv/auth';
import { getCookie } from 'hono/cookie';
import { getPersonalChannel } from './utils/personalChannel.js';
import { getRedisConnection, prisma, type User } 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';
@@ -29,71 +29,92 @@ 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');
const grant = c.req.query('grant');
console.log({
token,
grant,
})
const authHeader = c.req.header('Authorization');
// random checks that actually make sense if you read trust me bro
if (!token && !grant) {
ws.close();
return;
}
if (!token && grant === 'null') {
if (!token && (!grant || grant === 'null') && !authHeader) {
ws.close();
return;
}
let user: User | null = null
let chatUser: ChatUser | null = null;
let personalChannel: any = null;
if (authHeader && authHeader.startsWith('Bearer ')) {
const apiKey = authHeader.substring(7);
const botAccount = await prisma.botApiKey.findUnique({
where: { key: apiKey },
include: { botAccount: true }
});
if (botAccount) {
chatUser = {
id: botAccount.botAccount.id,
username: botAccount.botAccount.slug,
pfpUrl: botAccount.botAccount.pfpUrl,
displayName: botAccount.botAccount.displayName,
isBot: true
};
personalChannel = {
id: botAccount.botAccount.id,
name: botAccount.botAccount.slug
};
}
}
if (!chatUser && token) {
const session = await lucia.validateSession(token);
if (session.user) {
const userChannel = await getPersonalChannel(session.user.id);
if (userChannel) {
chatUser = {
id: session.user.id,
username: userChannel.name,
pfpUrl: session.user.pfpUrl,
isBot: false
};
personalChannel = userChannel;
}
}
}
const dbGrant = await prisma.channel.findFirst({
where: {
obsChatGrantToken: grant,
}
where: { obsChatGrantToken: grant }
});
if (token) {
user = (await lucia.validateSession(token)).user;
const personalChannel = await getPersonalChannel(user!.id);
if (!personalChannel) {
ws.close();
return;
}
ws.personalChannel = personalChannel;
}
if (!user && !dbGrant) {
if (!chatUser && !dbGrant) {
ws.close();
return;
}
const { username } = c.req.param();
if (dbGrant && dbGrant?.name !== username) {
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.personalChannel = ws.personalChannel;
ws.raw.chatUser = chatUser;
ws.raw.personalChannel = personalChannel;
}
const redis = getRedisConnection();
const channelKey = `chat:history:${username}`;
const messages = await redis.zrange(channelKey, 0, MESSAGE_HISTORY_SIZE - 1);
if (messages.length > 0) {
ws.send(
JSON.stringify({
type: 'history',
messages: messages.map((msg) => JSON.parse(msg)),
})
);
ws.send(JSON.stringify({
type: 'history',
messages: messages.map((msg) => JSON.parse(msg)),
}));
}
},
async onClose(evt, ws) {
@@ -116,33 +137,34 @@ app.get(
},
async onMessage(evt, ws) {
const msg = JSON.parse(evt.data.toString());
if (msg.type === 'ping') {
await redis.setex(`viewer:${ws.targetUsername}:${ws.viewerId}`, 30, '1');
ws.send(
JSON.stringify({
type: 'pong',
})
);
ws.send(JSON.stringify({ type: 'pong' }));
return;
}
if (msg.type === 'message') {
if (!ws.personalChannel) return;
if (!ws.chatUser || !ws.personalChannel) return;
const message = (msg.message as string).trim();
const msgObj = {
user: {
id: ws.user.id,
username: ws.personalChannel.name,
pfpUrl: ws.user.pfpUrl,
id: ws.chatUser.id,
username: ws.chatUser.username,
pfpUrl: ws.chatUser.pfpUrl,
displayName: ws.chatUser.displayName,
isBot: ws.chatUser.isBot || false
},
message,
};
// Save to Redis without the type field to maintain compatibility
const redisObj = {
user: msgObj.user,
message: msgObj.message,
type: 'message',
};
const redisStr = JSON.stringify(redisObj);
const msgStr = JSON.stringify(msgObj);
@@ -238,3 +260,11 @@ const server = serve(
}
);
injectWebSocket(server);
interface ChatUser {
id: string;
username: string;
pfpUrl: string;
displayName?: string;
isBot: boolean;
}

37
apps/docs/.gitignore vendored
View File

@@ -1,28 +1,21 @@
# deps
/node_modules
# build output
dist/
# generated types
.astro/
# generated content
.contentlayer
.content-collections
.source
# dependencies
node_modules/
# test & build
/coverage
/.next/
/out/
/build
*.tsbuildinfo
# misc
.DS_Store
*.pem
/.pnp
.pnp.js
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# others
.env*.local
.vercel
next-env.d.ts
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store

4
apps/docs/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,4 @@
{
"recommendations": ["astro-build.astro-vscode"],
"unwantedRecommendations": []
}

11
apps/docs/.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,11 @@
{
"version": "0.2.0",
"configurations": [
{
"command": "./node_modules/.bin/astro dev",
"name": "Development server",
"request": "launch",
"type": "node-terminal"
}
]
}

View File

@@ -1,45 +0,0 @@
# docs
This is a Next.js application generated with
[Create Fumadocs](https://github.com/fuma-nama/fumadocs).
Run development server:
```bash
npm run dev
# or
pnpm dev
# or
yarn dev
```
Open http://localhost:3000 with your browser to see the result.
## Explore
In the project, you can see:
- `lib/source.ts`: Code for content source adapter, [`loader()`](https://fumadocs.dev/docs/headless/source-api) provides the interface to access your content.
- `lib/layout.shared.tsx`: Shared options for layouts, optional but preferred to keep.
| Route | Description |
| ------------------------- | ------------------------------------------------------ |
| `app/(home)` | The route group for your landing page and other pages. |
| `app/docs` | The documentation layout and pages. |
| `app/api/search/route.ts` | The Route Handler for search. |
### Fumadocs MDX
A `source.config.ts` config file has been included, you can customise different options like frontmatter schema.
Read the [Introduction](https://fumadocs.dev/docs/mdx) for further details.
## Learn More
To learn more about Next.js and Fumadocs, take a look at the following
resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js
features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
- [Fumadocs](https://fumadocs.vercel.app) - learn about Fumadocs

View 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" }
}),
]
}),
],
});

View File

@@ -1,10 +0,0 @@
import { createMDX } from 'fumadocs-mdx/next';
const withMDX = createMDX();
/** @type {import('next').NextConfig} */
const config = {
reactStrictMode: true,
};
export default withMDX(config);

View File

@@ -1,31 +1,20 @@
{
"name": "@hctv/docs",
"version": "0.0.0",
"private": true,
"type": "module",
"version": "0.0.1",
"scripts": {
"build": "next build",
"dev": "next dev --turbo -p 3727",
"start": "next start",
"postinstall": "fumadocs-mdx"
"dev": "astro dev",
"start": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
},
"dependencies": {
"fumadocs-core": "15.7.7",
"fumadocs-mdx": "11.8.2",
"fumadocs-ui": "15.7.7",
"@astrojs/starlight": "^0.35.2",
"@catppuccin/starlight": "^1.0.2",
"astro": "^5.6.1",
"astro-mermaid": "^1.0.4",
"mermaid": "^11.10.1",
"next": "15.5.2",
"next-themes": "^0.4.6",
"react": "^19.1.1",
"react-dom": "^19.1.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.12",
"@types/mdx": "^2.0.13",
"@types/node": "24.3.0",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.12",
"typescript": "^5.9.2"
"sharp": "^0.34.2"
}
}

View File

@@ -1,5 +0,0 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
},
};

View 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

View File

@@ -1,25 +0,0 @@
import {
defineConfig,
defineDocs,
frontmatterSchema,
metaSchema,
} from 'fumadocs-mdx/config';
import { remarkMdxMermaid } from 'fumadocs-core/mdx-plugins';
// You can customise Zod schemas for frontmatter and `meta.json` here
// see https://fumadocs.dev/docs/mdx/collections#define-docs
export const docs = defineDocs({
docs: {
schema: frontmatterSchema,
},
meta: {
schema: metaSchema,
},
});
export default defineConfig({
mdxOptions: {
// MDX options
remarkPlugins: [remarkMdxMermaid],
},
});

View File

@@ -1,8 +0,0 @@
export function GET() {
return new Response('Redirecting...', {
status: 302,
headers: {
Location: '/docs',
},
});
}

View File

@@ -1,7 +0,0 @@
import { source } from '@/lib/source';
import { createFromSource } from 'fumadocs-core/search/server';
export const { GET } = createFromSource(source, {
// https://docs.orama.com/docs/orama-js/supported-languages
language: 'english',
});

View File

@@ -1,52 +0,0 @@
import { source } from '@/lib/source';
import {
DocsBody,
DocsDescription,
DocsPage,
DocsTitle,
} from 'fumadocs-ui/page';
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { createRelativeLink } from 'fumadocs-ui/mdx';
import { getMDXComponents } from '@/mdx-components';
export default async function Page(props: PageProps<'/docs/[[...slug]]'>) {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) notFound();
const MDXContent = page.data.body;
return (
<DocsPage toc={page.data.toc} full={page.data.full}>
<DocsTitle>{page.data.title}</DocsTitle>
<DocsDescription>{page.data.description}</DocsDescription>
<DocsBody>
<MDXContent
components={getMDXComponents({
// this allows you to link to other pages with relative file paths
// @ts-ignore
a: createRelativeLink(source, page),
})}
/>
</DocsBody>
</DocsPage>
);
}
export async function generateStaticParams() {
return source.generateParams();
}
export async function generateMetadata(
props: PageProps<'/docs/[[...slug]]'>,
): Promise<Metadata> {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) notFound();
return {
title: page.data.title,
description: page.data.description,
};
}

View File

@@ -1,11 +0,0 @@
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
import { baseOptions } from '@/lib/layout.shared';
import { source } from '@/lib/source';
export default function Layout({ children }: LayoutProps<'/docs'>) {
return (
<DocsLayout tree={source.pageTree} {...baseOptions()}>
{children}
</DocsLayout>
);
}

View File

@@ -1,3 +0,0 @@
@import 'tailwindcss';
@import 'fumadocs-ui/css/neutral.css';
@import 'fumadocs-ui/css/preset.css';

View File

@@ -1,17 +0,0 @@
import '@/app/global.css';
import { RootProvider } from 'fumadocs-ui/provider';
import { Inter } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
});
export default function Layout({ children }: LayoutProps<'/'>) {
return (
<html lang="en" className={inter.className} suppressHydrationWarning>
<body className="flex flex-col min-h-screen">
<RootProvider>{children}</RootProvider>
</body>
</html>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

View File

@@ -1,60 +0,0 @@
'use client';
import { use, useEffect, useId, useState } from 'react';
import { useTheme } from 'next-themes';
export function Mermaid({ chart }: { chart: string }) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return;
return <MermaidContent chart={chart} />;
}
const cache = new Map<string, Promise<unknown>>();
function cachePromise<T>(
key: string,
setPromise: () => Promise<T>,
): Promise<T> {
const cached = cache.get(key);
if (cached) return cached as Promise<T>;
const promise = setPromise();
cache.set(key, promise);
return promise;
}
function MermaidContent({ chart }: { chart: string }) {
const id = useId();
const { resolvedTheme } = useTheme();
const { default: mermaid } = use(
cachePromise('mermaid', () => import('mermaid')),
);
mermaid.initialize({
startOnLoad: false,
securityLevel: 'loose',
fontFamily: 'inherit',
themeCSS: 'margin: 1.5rem auto 0;',
theme: resolvedTheme === 'dark' ? 'dark' : 'default',
});
const { svg, bindFunctions } = use(
cachePromise(`${chart}-${resolvedTheme}`, () => {
return mermaid.render(id, chart.replaceAll('\\n', '\n'));
}),
);
return (
<div
ref={(container) => {
if (container) bindFunctions?.(container);
}}
dangerouslySetInnerHTML={{ __html: svg }}
/>
);
}

View 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() }),
};

View File

@@ -3,12 +3,19 @@ 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://hctv.srizan.dev/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.
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.

View File

@@ -9,7 +9,7 @@ since this is beta software, the API is subject to change. additionally, many en
## Base url
base url for all endpoints is `https://hctv.srizan.dev/api`.
base url for all endpoints is `https://hackclub.tv/api`.
## Authentication

View File

@@ -3,10 +3,6 @@ title: RTMP
description: RTMP related endpoint group
---
## GET `/rtmp/hls/:path`
gets HLS segments, the backbone of hctv livestreaming. **authentication required**.
not really sure why you would need this? but check the browser console when playing a stream for an insight on how it's used.
## POST `/rtmp/streamKey`
regenerates your stream key. **authentication required**.
body parameters (json):

View File

@@ -7,7 +7,7 @@ description: Get started with OBS and streaming on hackclub.tv
- open settings
- open "Stream"
- set service to custom
- set url to `rtmp://hackclub.app:45913/live`
- 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!

View File

@@ -1,30 +0,0 @@
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
/**
* Shared layout configurations
*
* you can customise layouts individually from:
* Home Layout: app/(home)/layout.tsx
* Docs Layout: app/docs/layout.tsx
*/
export function baseOptions(): BaseLayoutProps {
return {
nav: {
title: (
<>
<svg
width="24"
height="24"
xmlns="http://www.w3.org/2000/svg"
aria-label="Logo"
>
<circle cx={12} cy={12} r={12} fill="currentColor" />
</svg>
My App
</>
),
},
// see https://fumadocs.dev/docs/ui/navigation/links
links: [],
};
}

View File

@@ -1,9 +0,0 @@
import { docs } from '@/.source';
import { loader } from 'fumadocs-core/source';
// See https://fumadocs.vercel.app/docs/headless/source-api for more info
export const source = loader({
// it assigns a URL to your pages
baseUrl: '/docs',
source: docs.toFumadocsSource(),
});

View File

@@ -1,12 +0,0 @@
import defaultMdxComponents from 'fumadocs-ui/mdx';
import { Mermaid } from '@/components/mdx/mermaid';
import type { MDXComponents } from 'mdx/types';
// use this function to get MDX components, you will need it for rendering MDX
export function getMDXComponents(components?: MDXComponents): MDXComponents {
return {
...defaultMdxComponents,
Mermaid,
...components,
};
}

View File

@@ -1,45 +1,5 @@
{
"compilerOptions": {
"baseUrl": ".",
"target": "ESNext",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"paths": {
"@/.source": [
"./.source/index.ts"
],
"@/*": [
"./src/*"
]
},
"plugins": [
{
"name": "next"
}
]
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"]
}

0
apps/docs/yarn.lock Normal file
View File

View File

@@ -1,8 +1,10 @@
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 git
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:
@@ -19,14 +21,16 @@ 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 git
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
# Read commit hash and set as build arg
ARG COMMIT_HASH_FILE=/tmp/commit_hash
RUN COMMIT_HASH=$(cat /tmp/commit_hash 2>/dev/null || echo "unknown") && \
echo "COMMIT_HASH=$COMMIT_HASH" > /tmp/build_env
WORKDIR /app
# First install the dependencies (as they change less often)
@@ -35,24 +39,36 @@ RUN yarn install --frozen-lockfile
COPY --from=builder /app/out/full/ .
RUN --mount=type=secret,id=TURBO_TOKEN --mount=type=secret,id=TURBO_TEAM \
. /tmp/build_env && \
export commit=$COMMIT_HASH && \
TURBO_TOKEN=$(cat /run/secrets/TURBO_TOKEN) TURBO_TEAM=$(cat /run/secrets/TURBO_TEAM) yarn turbo run build --env-mode=loose
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/*
# Don't run production as root
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
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
# Get the commit hash from the installer stage and create a startup script
COPY --from=installer /tmp/commit_hash /tmp/commit_hash
RUN COMMIT_VALUE=$(cat /tmp/commit_hash 2>/dev/null || echo "unknown") && \
echo "#!/bin/sh" > /usr/local/bin/start.sh && \
echo "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

View File

@@ -28,7 +28,14 @@ const nextConfig = {
},
{
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,
},
@@ -40,6 +47,7 @@ const nextConfig = {
reactStrictMode: false,
output: 'standalone',
outputFileTracingRoot: path.join(__dirname, '../../'),
serverExternalPackages: ['bullmq'],
async rewrites() {
return [
{
@@ -48,6 +56,9 @@ const nextConfig = {
},
];
},
logging: {
incomingRequests: false,
},
};
export default withSentryConfig(nextConfig, {

View File

@@ -1,6 +1,6 @@
{
"name": "@hctv/web",
"version": "0.3.0",
"version": "0.5.0",
"private": true,
"type": "module",
"scripts": {
@@ -12,7 +12,7 @@
"lint": "next lint",
"ui:add": "shadcn add",
"check-types": "tsc --noEmit",
"openapi": "next-openapi-gen"
"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": "*",
@@ -20,6 +20,8 @@
"@hookform/resolvers": "^3.9.1",
"@lucia-auth/adapter-prisma": "^4.0.1",
"@node-rs/argon2": "^2.0.2",
"@omit/react-confirm-dialog": "^1.2.0",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.5",
@@ -29,7 +31,7 @@
"@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-tabs": "^1.1.12",
"@radix-ui/react-tooltip": "^1.2.7",
@@ -46,25 +48,24 @@
"clsx": "^2.1.0",
"cmdk": "1.0.0",
"hls-video-element": "^1.5.0",
"ioredis": "5.7.0",
"lucia": "^3.2.2",
"lucide-react": "^0.473.0",
"media-chrome": "^4.8.0",
"next": "^15.3.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.2",
"sharp": "^0.34.3",
"sonner": "^1.4.41",
"swr": "^2.3.0",
"tailwind-merge": "^2.2.2",

View File

@@ -16,4 +16,5 @@ Sentry.init({
// 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',
});

View File

@@ -1,18 +0,0 @@
// This file configures the initialization of Sentry on the server.
// The config you add here will be used whenever the server handles a request.
// 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,
});

View File

@@ -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(),
});

View File

@@ -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();
@@ -34,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'

View File

@@ -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}`;
}

View File

@@ -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,
});
}

View File

@@ -51,5 +51,14 @@ export async function GET(request: NextRequest) {
},
});
db.forEach((obj) => {
if (obj.channel.personalFor) {
// @ts-ignore
delete obj.channel.personalFor.email;
}
// @ts-ignore
delete obj.channel.obsChatGrantToken;
});
return Response.json(db);
}

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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>
)
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -19,6 +19,8 @@ import {
Copy,
Check,
Wrench,
Eye,
EyeOff,
} from 'lucide-react';
import { UniversalForm } from '@/components/app/UniversalForm/UniversalForm';
import {
@@ -50,6 +52,7 @@ 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 & {
@@ -61,6 +64,7 @@ interface ChannelSettingsClientProps {
streamKey: StreamKey | null;
followers: (Follow & { user: { id: string; slack_id: string } })[];
followerPersonalChannels: (Channel | null)[];
is247: boolean;
};
isOwner: boolean;
currentUser: User;
@@ -73,9 +77,13 @@ export default function ChannelSettingsClient({
currentUser,
isPersonal,
}: ChannelSettingsClientProps) {
const confirm = useConfirm();
const [streamKey, setStreamKey] = useState(channel.streamKey?.key || '');
const [keyVisible, setKeyVisible] = useState(false);
const [copied, setCopied] = 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);
@@ -97,9 +105,9 @@ export default function ChannelSettingsClient({
const copyStreamKey = async () => {
if (streamKey) {
await navigator.clipboard.writeText(streamKey);
setCopied(true);
setCopied({ ...copied, streamKey: true });
toast.success('Stream key copied to clipboard');
setTimeout(() => setCopied(false), 2000);
setTimeout(() => setCopied({ ...copied, streamKey: false }), 2000);
}
};
@@ -123,6 +131,24 @@ export default function ChannelSettingsClient({
}
};
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">
@@ -149,7 +175,7 @@ export default function ChannelSettingsClient({
value={channel.name}
onSelect={(value) => {
if (value === 'create') {
router.push(`/create`);
router.push(`/settings/channel/create`);
} else {
router.push(`/settings/channel/${value}?tab=${selTab}`);
}
@@ -306,6 +332,27 @@ export default function ChannelSettingsClient({
</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}
@@ -313,36 +360,45 @@ export default function ChannelSettingsClient({
onActionComplete={handleChannelSettingsActionComplete}
/>
{false && isOwner && (
{isOwner && !isPersonal && (
<>
<Separator />
<div className="space-y-4">
<h3 className="text-lg font-semibold text-destructive">Danger Zone</h3>
<Card className="border-destructive">
<CardHeader>
<CardTitle className="text-destructive">Delete Channel</CardTitle>
<CardDescription>
<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.
</CardDescription>
</CardHeader>
<CardContent>
<Button
variant="destructive"
onClick={() => {
if (
confirm(
'Are you sure you want to delete this channel? This action cannot be undone.'
)
) {
deleteChannel(channel.id);
</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 Channel
</Button>
</CardContent>
</Card>
}
}}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</Button>
</div>
</div>
</>
)}
@@ -357,51 +413,74 @@ export default function ChannelSettingsClient({
<CardDescription>Manage your stream key and streaming configuration.</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold mb-2">Stream Key</h3>
<p className="text-sm text-mantle-foreground mb-4">
Use this key to start streaming to your channel. Keep it secure!
</p>
<p className="text-xs text-muted-foreground mb-4">
Need help getting started? Check out our{' '}
<Link
href="https://gist.github.com/SrIzan10/ebd89ced6b21b016d4d389e6711a94e9"
className="text-primary hover:underline"
target="_blank"
rel="noopener noreferrer"
>
streaming guide
</Link>
.
</p>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<input
type={keyVisible ? 'text' : 'password'}
value={streamKey}
readOnly
className="w-full px-3 py-2 border rounded-md bg-mantle font-mono text-sm"
/>
<div>
<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>
<Button variant="outline" size="sm" onClick={() => setKeyVisible(!keyVisible)}>
{keyVisible ? 'Hide' : 'Show'}
</Button>
<Button
variant="outline"
size="sm"
onClick={copyStreamKey}
disabled={!streamKey}
>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
</div>
<Button onClick={regenerateStreamKey} variant="outline">
<Key className="h-4 w-4 mr-2" />
Regenerate Stream Key
</Button>
<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 />
@@ -514,8 +593,13 @@ export default function ChannelSettingsClient({
<Button
variant="outline"
size="sm"
onClick={() => {
if (confirm('Remove this manager?')) {
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);
}
}}
@@ -627,7 +711,7 @@ export default function ChannelSettingsClient({
<div className="relative flex-1">
<input
type={keyVisible ? 'text' : 'password'}
value={`https://hctv.srizan.dev/chat/${channel.name}?grant=${channel.obsChatGrantToken}`}
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"
/>

View File

@@ -13,7 +13,7 @@ export default async function ChannelSettingsPage({
const { user } = await validateRequest();
if (!user) {
redirect('/auth/slack');
redirect('/auth/hackclub');
}
const channel = await prisma.channel.findUnique({

View File

@@ -32,8 +32,7 @@ function CreateChannelPage() {
schemaName="createChannel"
action={createChannel}
onActionComplete={(r) => {
// @ts-expect-error
const channelName = r?.channel;
const channelName = r.channel;
if (channelName) {
router.push(`/${channelName}`);
}

View File

@@ -1,6 +1,6 @@
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 '@hctv/db';
@@ -10,7 +10,7 @@ export async function GET(request: Request): Promise<Response> {
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: `https://cachet.dunkirk.sh/users/${slackUser.sub}/r`,
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;
}

View File

@@ -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,

View File

@@ -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}

View File

@@ -1,5 +1,6 @@
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';
@@ -16,6 +17,7 @@ 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'] });
@@ -30,6 +32,9 @@ export default async function RootLayout({
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)}>
@@ -44,20 +49,26 @@ export default async function RootLayout({
<NextSSRPlugin
routerConfig={extractRouterConfig(ourFileRouter)}
/>
<NuqsAdapter>
<SidebarProvider>
<StreamInfoProvider>
{/* this promise is ugly but i'm lazy to fix the type errors */}
<Navbar editLivestream={Promise.resolve(<EditLivestream />)} />
<div className="flex flex-1 pt-16">
{/* pt-16 for navbar height */}
<Sidebar className="pt-16" />
<main className="flex-1 overflow-auto">{children}</main>
</div>
<Toaster />
</StreamInfoProvider>
</SidebarProvider>
</NuqsAdapter>
<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>

View File

@@ -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,16 +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);
@@ -160,7 +178,7 @@ media-time-range {
}
media-time-display {
--media-text-color: #ffffff;
--media-text-color: #ffffff;
}
media-controller::part(centered-layer) {

View File

@@ -313,6 +313,8 @@ export interface User {
id: string;
username: string;
pfpUrl: string;
isBot: boolean;
displayName?: string;
}
interface Props {

View File

@@ -1,11 +1,8 @@
import { User } from './ChatPanel';
import React from 'react';
import Image from 'next/image';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
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') {
@@ -18,12 +15,18 @@ export function Message({ user, message, type, emojiMap }: MessageProps) {
return (
<div className="flex">
<div
lang="en"
className="max-w-full break-all whitespace-pre-wrap hyphens-auto"
>
<p>
<span className="font-bold mr-2">{user?.username}</span>
<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>
@@ -45,16 +48,26 @@ export function EmojiRenderer({ text, emojiMap }: EmojiRendererProps) {
if (emojiUrl) {
return (
<Tooltip key={index} delayDuration={250}>
<TooltipTrigger>
<span key={index} className="inline-block align-middle" style={{ height: '1.2em' }}>
<Image src={emojiUrl} alt={part} width={20} height={20} className="inline-block" />
</span>
</TooltipTrigger>
<TooltipContent>
{part}
</TooltipContent>
</Tooltip>
<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>
);
}
}

View File

@@ -3,7 +3,7 @@
import StreamPlayer from '../StreamPlayer/StreamPlayer';
import UserInfoCard from '../UserInfoCard/UserInfoCard';
import ChatPanel from '../ChatPanel/ChatPanel';
import type { StreamInfo, User, Channel } from '@hctv/db';
import type { StreamInfo, Channel } from '@hctv/db';
import { useIsMobile } from '@/lib/hooks/useMobile';
export default function LiveStream(props: Props) {

View File

@@ -15,7 +15,7 @@ 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 default function Navbar(props: Props) {
@@ -51,9 +51,22 @@ export default function Navbar(props: Props) {
<Link href={`/settings/follows`}>
<DropdownMenuItem className="cursor-pointer">Follows</DropdownMenuItem>
</Link>
<Link href={`/create`}>
<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>
@@ -84,9 +97,9 @@ export default function Navbar(props: Props) {
</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>

View File

@@ -13,124 +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 { 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>
@@ -140,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>

View File

@@ -1,6 +1,7 @@
'use client';
import { useParams } from 'next/navigation';
import { useRef, useEffect } from 'react';
import {
MediaController,
MediaLoadingIndicator,
@@ -10,47 +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,
liveMaxLatencyDurationCount: 4,
liveDurationInfinity: true,
enableWorker: true,
backBufferLength: 2,
startLevel: 0,
maxBufferLength: 4,
maxMaxBufferLength: 8,
startFragPrefetch: true,
testBandwidth: false,
progressive: true,
maxBufferSize: 30 * 1000 * 1000,
maxBufferHole: 0.3,
highBufferWatchdogPeriod: 1,
nudgeOffset: 0.05,
nudgeMaxRetry: 2,
manifestLoadingTimeOut: 5000,
manifestLoadingMaxRetry: 2,
levelLoadingTimeOut: 5000,
fragLoadingTimeOut: 10000,
debug: process.env.NODE_ENV === 'development',
}}
/>
<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 />

View File

@@ -19,13 +19,18 @@ import React from 'react';
import { toast } from 'sonner';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import { createChannelSchema, onboardSchema, streamInfoEditSchema, updateChannelSettingsSchema } 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: 'createChannel', zod: createChannelSchema },
{ name: 'updateChannelSettings', zod: updateChannelSettingsSchema },
{ name: 'createBot', zod: createBotSchema },
{ name: 'editBot', zod: editBotSchema }
] as const;
export function UniversalForm<T extends z.ZodType>({
@@ -39,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;
@@ -56,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(() => {
@@ -77,10 +84,10 @@ 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.component ? (
field.component({ field: formField, ...field.componentProps })

View File

@@ -5,7 +5,7 @@ import { schemaDb } from './UniversalForm';
export type FormFieldConfig = {
name: string;
label: string;
label?: string;
type?: HTMLInputTypeAttribute;
placeholder?: string;
description?: string;
@@ -14,13 +14,14 @@ export type FormFieldConfig = {
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;

View File

@@ -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: {

View File

@@ -37,7 +37,7 @@ export function Mention({ children, handle }: Props) {
}
const fallback = handle.substring(0, 2).toUpperCase();
const url = `https://hctv.srizan.dev/${handle}`;
const url = `https://hackclub.tv/${handle}`;
return (
<HoverCard>

View File

@@ -23,7 +23,7 @@ const SIDEBAR_COOKIE_NAME = "sidebar:state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_WIDTH_ICON = "4rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContext = {
@@ -512,7 +512,7 @@ const SidebarMenuItem = React.forwardRef<
SidebarMenuItem.displayName = "SidebarMenuItem"
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-12 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {

View File

@@ -14,6 +14,7 @@ Sentry.init({
// 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'
});
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;

View File

@@ -1,7 +1,12 @@
import * as Sentry from "@sentry/nextjs";
export const onRequestError = Sentry.captureRequestError;
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
await (await import('@/lib/instrumentation/streamInfo')).default();
await (await import('@/lib/instrumentation/writeSessions')).default();
await (await import('@/lib/instrumentation/syncStreamKeys')).default();
}
if (process.env.NEXT_RUNTIME === 'nodejs') {
@@ -40,4 +45,21 @@ export async function register() {
await viewerCountSync();
}, 2000);
}
process.env.NODE_ENV === 'production' && 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,
integrations: [
Sentry.extraErrorDataIntegration(),
],
});
}

View File

@@ -1,4 +1,5 @@
import { prisma } from '@hctv/db';
import {Prisma, prisma} from '@hctv/db';
import {validateRequest} from "@/lib/auth/validate";
export async function resolveChannelNameId(channelName: string) {
const channel = await prisma.channel.findUnique({
@@ -28,4 +29,14 @@ export async function resolveUserPersonalChannel(userId: string) {
}
return channel;
}
export async function getBotBySlug(slug: string) {
const bot = await prisma.botAccount.findFirst({
where: {
slug,
},
});
return bot;
}

View File

@@ -0,0 +1,35 @@
import { prisma, getRedisConnection } from '@hctv/db';
export async function generateStreamKey(channelId: string, channelName: string) {
const streamKey = await prisma.streamKey.create({
data: {
key: crypto.randomUUID(),
channelId,
},
});
const redis = getRedisConnection();
await redis.set(`streamKey:${channelName}`, streamKey.key);
return streamKey;
}
export async function regenerateStreamKey(channelId: string, channelName: string) {
const streamKey = await prisma.streamKey.upsert({
create: {
key: crypto.randomUUID(),
channelId,
},
update: {
key: crypto.randomUUID(),
},
where: {
channelId,
},
});
const redis = getRedisConnection();
await redis.set(`streamKey:${channelName}`, streamKey.key);
return streamKey;
}

View File

@@ -4,10 +4,22 @@ import { revalidatePath } from 'next/cache';
import { validateRequest } from '@/lib/auth/validate';
import { prisma } from '@hctv/db';
import zodVerify from '../zodVerify';
import { createChannelSchema, onboardSchema, streamInfoEditSchema, updateChannelSettingsSchema } from './zod';
import {
createBotSchema,
createChannelSchema,
editBotSchema,
onboardSchema,
streamInfoEditSchema,
updateChannelSettingsSchema,
} from './zod';
import { initializeStreamInfo } from '../instrumentation/streamInfo';
import { resolveFollowedChannels, resolveStreamInfo, resolveUserFromPersonalChannelName } from '../auth/resolve';
import {
resolveFollowedChannels,
resolveStreamInfo,
resolveUserFromPersonalChannelName,
} from '../auth/resolve';
import { genIdenticonUpload } from '../utils/genIdenticonUpload';
import { generateStreamKey } from '../db/streamKey';
export async function editStreamInfo(prev: any, formData: FormData) {
const { user } = await validateRequest();
@@ -78,8 +90,8 @@ export async function onboard(prev: any, formData: FormData) {
ownerId: user.id,
personalFor: { connect: { id: user.id } },
pfpUrl: user.pfpUrl,
}
})
},
});
await prisma.user.update({
where: { id: user.id },
data: {
@@ -91,13 +103,15 @@ export async function onboard(prev: any, formData: FormData) {
});
await initializeStreamInfo(createdChannel.id);
await generateStreamKey(createdChannel.id, createdChannel.name);
if (process.env.NODE_ENV === 'production') {
await fetch(process.env.WELCOME_WORKFLOW_URL!, {
method: 'POST',
body: JSON.stringify({
username: zod.data.username,
}),
})
});
}
return { success: true };
@@ -150,11 +164,13 @@ export async function createChannel(prev: any, formData: FormData) {
name: zod.data.name,
ownerId: user.id,
pfpUrl: identicon,
}
},
});
await initializeStreamInfo(createdChannel.id);
await generateStreamKey(createdChannel.id, createdChannel.name);
return { success: true, channel: createdChannel.name };
}
@@ -163,9 +179,10 @@ export async function updateChannelSettings(prev: any, formData: FormData) {
if (!user) {
return { success: false, error: 'Unauthorized' };
}
const zod = await zodVerify(updateChannelSettingsSchema, formData);
const urlRegex = /(?:http[s]?:\/\/.)?(?:www\.)?[-a-zA-Z0-9@%._\+~#=]{2,256}\.[a-z]{2,6}\b(?:[-a-zA-Z0-9@:%_\+.~#?&\/\/=]*)/gm;
const urlRegex =
/(?:http[s]?:\/\/.)?(?:www\.)?[-a-zA-Z0-9@%._\+~#=]{2,256}\.[a-z]{2,6}\b(?:[-a-zA-Z0-9@:%_\+.~#?&\/\/=]*)/gm;
if (!zod.success) {
return zod;
}
@@ -186,7 +203,7 @@ export async function updateChannelSettings(prev: any, formData: FormData) {
}
const isOwner = channel.ownerId === user.id;
const isManager = channel.managers.some(manager => manager.id === user.id);
const isManager = channel.managers.some((manager) => manager.id === user.id);
if (!isOwner && !isManager) {
return { success: false, error: 'Unauthorized' };
@@ -202,6 +219,7 @@ export async function updateChannelSettings(prev: any, formData: FormData) {
data: {
description: zod.data.description || undefined,
pfpUrl: zod.data.pfpUrl,
is247: zod.data.is247,
},
});
@@ -229,7 +247,7 @@ export async function addChannelManager(channelId: string, userChannel: string)
}
if (channel.ownerId === userChannel) {
return { success: false, error: 'Owner can\'t add themselves as managers' };
return { success: false, error: "Owner can't add themselves as managers" };
}
const userDb = await resolveUserFromPersonalChannelName(userChannel);
@@ -311,8 +329,8 @@ export async function toggleGlobalChannelNotifs(channelId: string) {
},
data: {
enableNotifications: !streamInfo.enableNotifications,
}
})
},
});
revalidatePath(`/settings/channel/${channel.name}`);
@@ -320,15 +338,14 @@ export async function toggleGlobalChannelNotifs(channelId: string) {
}
export async function deleteChannel(channelId: string) {
return { success: false, error: 'disabled atm. dm @eth0 if you want to request a deletion.' }
/* const { user } = await validateRequest();
const { user } = await validateRequest();
if (!user) {
return { success: false, error: 'Unauthorized' };
}
const channel = await prisma.channel.findUnique({
where: { id: channelId },
include: {
include: {
owner: true,
personalFor: true,
},
@@ -342,7 +359,6 @@ export async function deleteChannel(channelId: string) {
return { success: false, error: 'Only channel owners can delete channels' };
}
// Prevent deletion of personal channels
if (channel.personalFor) {
return { success: false, error: 'Cannot delete personal channels' };
}
@@ -351,5 +367,77 @@ export async function deleteChannel(channelId: string) {
where: { id: channelId },
});
return { success: true }; */
}
return { success: true };
}
export async function createBot(prev: any, formData: FormData) {
const { user } = await validateRequest();
if (!user) {
return { success: false, error: 'Unauthorized' };
}
const zod = await zodVerify(createBotSchema, formData);
if (!zod.success) {
return zod;
}
const botExists = await prisma.botAccount.findFirst({
where: { slug: zod.data.slug },
});
if (botExists) {
return { success: false, error: 'Bot slug already exists' };
}
const createdBot = await prisma.botAccount.create({
data: {
displayName: zod.data.name,
slug: zod.data.slug,
ownerId: user.id,
description: zod.data.description,
pfpUrl: await genIdenticonUpload(zod.data.slug, 'botpfp'),
},
});
return { success: true, slug: createdBot.slug };
}
export async function editBot(prev: any, formData: FormData) {
const { user } = await validateRequest();
if (!user) {
return { success: false, error: 'Unauthorized' };
}
const zod = await zodVerify(editBotSchema, formData);
if (!zod.success) {
return zod;
}
const bot = await prisma.botAccount.findUnique({
where: { id: zod.data.from },
});
if (!bot) {
return { success: false, error: 'Bot not found' };
}
if (bot.ownerId !== user.id) {
return { success: false, error: 'Unauthorized' };
}
if (bot.slug !== zod.data.slug) {
const botExists = await prisma.botAccount.findFirst({
where: { slug: zod.data.slug },
});
if (botExists) {
return { success: false, error: 'Bot slug already exists' };
}
}
const updatedBot = await prisma.botAccount.update({
where: { id: zod.data.from },
data: {
displayName: zod.data.name,
slug: zod.data.slug,
description: zod.data.description,
},
});
revalidatePath(`/settings/bot/${updatedBot.slug}`);
return { success: true, slug: updatedBot.slug };
}

View File

@@ -1,9 +1,20 @@
import { z } from 'zod';
const disallowedUsernames = [
'admin',
'administrator',
'settings',
'create',
// i hope this doesn't age well tbh
'zrl',
];
const username = z
.string()
.min(1)
.regex(/^[a-z0-9_-]+$/, { message: 'Only characters from a-z, 0-9, underscores and dashes' });
.regex(/^[a-z0-9_-]+$/, { message: 'Only characters from a-z, 0-9, underscores and dashes' })
.refine((val) => !disallowedUsernames.includes(val.toLowerCase()), {
message: 'This username is reserved',
});
export const streamInfoEditSchema = z.object({
username: z.string().min(1),
@@ -24,4 +35,17 @@ export const updateChannelSettingsSchema = z.object({
channelId: z.string().min(1),
pfpUrl: z.string(),
description: z.string().min(1).max(500),
});
is247: z.boolean(),
});
export const createBotSchema = z.object({
name: z.string().min(1, { message: 'Name is required' }),
slug: username.refine((val) => val !== 'settings', { message: 'This slug is reserved' }),
description: z.string().max(300).optional(),
});
export const editBotSchema = createBotSchema.and(
z.object({
from: z.string().min(1),
})
);

View File

@@ -2,6 +2,7 @@ import { prisma } from '@hctv/db';
import { HttpFlv } from '../types/liveBackendJson';
import { getNotificationQueue } from '../workers';
import client from '../services/slackNotifier';
import type { paths } from '../types/mediamtx.d.ts';
export default async function runner() {
// if there are no users it explodes so yeah
@@ -48,28 +49,21 @@ export async function initializeStreamInfo(channelId?: string) {
export async function syncStream() {
try {
const response = await fetch(`${process.env.LIVE_SERVER_URL}/stat`, {
headers: {
Authorization: process.env.STAT_AUTH!,
},
});
const response = await fetch(`${process.env.MEDIAMTX_API}/v3/paths/list?itemsPerPage=1000`);
if (!response.ok) {
console.error(`Failed to fetch stream stats: ${response.status} ${response.statusText}`);
return;
}
const data = await response.json();
const httpFlv = data['http-flv'] as HttpFlv;
type ResponseType = paths['/v3/paths/list']['get']['responses']['200']['content']['application/json'];
const data = await response.json() as ResponseType;
if (!httpFlv?.servers?.[0]?.applications) {
if (!data) {
return;
}
const channelLiveApp = httpFlv.servers[0].applications.find(
(app) => app.name === 'channel-live'
);
const activeStreams = channelLiveApp?.live?.streams || [];
const activeStreams = data.items!;
const currentLiveStreams = await prisma.streamInfo.findMany({
where: { isLive: true },
@@ -78,8 +72,7 @@ export async function syncStream() {
const activeStreamMap = new Map();
for (const stream of activeStreams) {
activeStreamMap.set(stream.name, {
isLive: stream.active,
viewers: stream.clients.filter((c) => !c.publishing).length,
isLive: stream.ready,
});
}
@@ -99,9 +92,10 @@ export async function syncStream() {
}
for (const stream of activeStreams) {
if (stream.active) {
if (stream.ready) {
const existingStream = await prisma.streamInfo.findUnique({
where: { username: stream.name },
include: { channel: true },
});
if (existingStream && !existingStream.isLive) {
@@ -125,15 +119,17 @@ export async function syncStream() {
const queue = getNotificationQueue();
queue.add(`streamStartChannel:${existingStream.username}`, {
text: `${existingStream.username} is now *live*, streaming *${existingStream.title}* (${existingStream.category})!\n<https://hctv.srizan.dev/${existingStream.username}|Go check them out>`,
channel: process.env.NOTIFICATION_CHANNEL_ID!,
unfurl_links: true,
});
if (existingStream.enableNotifications) {
if (!existingStream.channel.is247) {
queue.add(`streamStartChannel:${existingStream.username}`, {
text: `${existingStream.username} is now *live*, streaming *${existingStream.title}* (${existingStream.category})!\n<https://hackclub.tv/${existingStream.username}|Go check them out>`,
channel: process.env.NOTIFICATION_CHANNEL_ID!,
unfurl_links: true,
});
}
if (existingStream.enableNotifications && !existingStream.channel.is247) {
for (const follower of subscribedFollowers) {
queue.add(`streamStartDm:${follower.user.id}`, {
text: `${existingStream.username} is now *live*, streaming *${existingStream.title}* (${existingStream.category})!\n<https://hctv.srizan.dev/${existingStream.username}|Go check them out>\n_Stream notifications are enabled for this user. If you want to disable them, you can do so in \`Profile > Follows\`._`,
text: `${existingStream.username} is now *live*, streaming *${existingStream.title}* (${existingStream.category})!\n<https://hackclub.tv/${existingStream.username}|Go check them out>\n_Stream notifications are enabled for this user. If you want to disable them, you can do so in \`Profile > Follows\`._`,
channel: follower.user.slack_id,
unfurl_links: true,
});

View File

@@ -0,0 +1,31 @@
import { prisma, getRedisConnection } from '@hctv/db';
export default async function syncStreamKeys() {
console.log('Syncing stream keys to Redis...');
try {
const keys = await prisma.streamKey.findMany({
include: {
channel: true,
},
});
if (keys.length === 0) {
console.log('No stream keys found to sync.');
return;
}
const redis = getRedisConnection();
const pipeline = redis.pipeline();
for (const key of keys) {
if (key.channel && key.channel.name) {
pipeline.set(`streamKey:${key.channel.name}`, key.key);
}
}
await pipeline.exec();
console.log(`Synced ${keys.length} stream keys to Redis`);
} catch (error) {
console.error('Failed to sync stream keys to Redis:', error);
}
}

View File

@@ -0,0 +1,21 @@
'use client';
import {
ConfirmDialogProvider as BaseConfirmDialogProvider,
ConfirmOptions,
} from '@omit/react-confirm-dialog';
interface Props {
children: React.ReactNode;
defaultOptions?: ConfirmOptions;
}
export const ConfirmDialogProvider = ({ children, defaultOptions }: Props) => {
return (
<BaseConfirmDialogProvider defaultOptions={defaultOptions}>
{children}
</BaseConfirmDialogProvider>
);
};
export default ConfirmDialogProvider;

3313
apps/web/src/lib/types/mediamtx.d.ts vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,9 @@
import { Worker } from 'bullmq';
import { getRedisConnection } from '@hctv/db';
import { exec } from 'node:child_process';
import { promisify } from 'node:util';
import { existsSync } from 'node:fs';
const pExec = promisify(exec);
import { exec as execCallback } from 'node:child_process';
const pExec = promisify(execCallback);
const globalForWorker = global as unknown as {
thumbnailWorker: Worker | null;
@@ -26,28 +26,24 @@ export async function registerThumbnailWorker(): Promise<void> {
try {
// this is totally unnecessary, but i'll keep it for security purposes.
const name = job.data.name.replace(/[^a-zA-Z0-9]/g, '_');
const m3u8location = `/dev/shm/hls/${name}.m3u8`;
const m3u8location = `${process.env.NEXT_PUBLIC_MEDIAMTX_URL}/${name}/index.m3u8`;
const thumbDir = '/dev/shm/hctv-thumb';
if (!existsSync(m3u8location)) return;
if (!existsSync(thumbDir)) {
await pExec(`mkdir -p ${thumbDir}`);
}
// unnecessary for development, but maybe docker volumes mess with permissions in prod
// also ik it's not the best practice to use 777, but it'll be fiiiiiine
// await pExec('chown -R 777 /dev/shm/hctv-thumb');
exec(
`ffmpeg -i ${m3u8location} -vframes 1 -an -y -f image2 ${thumbDir}/${name}.webp`,
(error) => {
if (error) {
console.error(`Error: ${error.message}`);
return { success: false, error: error.message };
}
}
);
return { success: true };
const header = `-headers "Authorization: Basic ${Buffer.from(`skibiditoilet:${process.env.MEDIAMTX_PUBLISH_KEY}`).toString('base64')}\r\n" `;
try {
await pExec(
`ffmpeg ${header} -i ${m3u8location} -vframes 1 -an -y -f image2 ${thumbDir}/${name}.webp`
);
return { success: true };
} catch (ffmpegError) {
console.error(`FFmpeg error for ${name}:`, ffmpegError);
return { success: false, error: ffmpegError instanceof Error ? ffmpegError.message : String(ffmpegError) };
}
} catch (e) {
console.error('Slack notification failed:', e);
// @ts-ignore e is unknown

View File

@@ -1,5 +1,6 @@
import type { Config } from "tailwindcss"
import { withUt } from "uploadthing/tw";
import { uploadthingPlugin } from 'uploadthing/tw'
import * as tan from 'tailwindcss-animate'
const config = {
darkMode: ["class"],
@@ -102,7 +103,7 @@ const config = {
}
}
},
plugins: [require("tailwindcss-animate")],
plugins: [tan, uploadthingPlugin],
} satisfies Config
export default withUt(config)
export default config

View File

@@ -14,7 +14,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
@@ -32,7 +32,9 @@
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"tailwind.config.mts"
],
"exclude": [
"node_modules"

View File

@@ -15,38 +15,11 @@ services:
- ./redis:/data
ports:
- 6379:6379
nginx-rtmp:
# ports:
# - 1935:1935
# - 8888:8888
network_mode: host
environment:
UID: 1000
GID: 1000
API_AUTH: skibiditoilet
mediamtx:
image: bluenviron/mediamtx:latest
ports:
- 8890:8890/udp
- 8891:8888
- 9997:9997
volumes:
- ./nginx.conf:/etc/nginx/templates/nginx.conf.template
- ./html:/var/www/html
- /dev/shm/hls:/dev/shm/hls
image: srizan10/flv-module
entrypoint:
- /bin/sh
- -c
- |
# Process the template file
mkdir -p /usr/local/nginx/conf
envsubst '$${API_AUTH}' < /etc/nginx/templates/nginx.conf.template > /usr/local/nginx/conf/nginx.conf
echo "Setting UID to $${UID} and GID to $${GID}"
usermod -u $${UID} nginx || echo "failed to change uid"
groupmod -g $${GID} nginx || echo "failed to change gid"
mkdir -p /usr/local/nginx/proxy_temp /usr/local/nginx/client_body_temp
chown -R nginx:nginx /usr/local/nginx
mkdir -p /var/www/html
chown -R nginx:nginx /var/www/html
echo "testing nginx config..."
/usr/local/nginx/sbin/nginx -t
/usr/local/nginx/sbin/nginx -g 'daemon off;'
- ./mediamtx.yml:/mediamtx.yml

13
dev/mediamtx.yml Normal file
View File

@@ -0,0 +1,13 @@
paths:
all:
source: publisher
srt: yes
srtAddress: :8890
hls: yes
authMethod: http
authHTTPAddress: http://192.168.1.47:3000/api/mediamtx/publish
api: yes

View File

@@ -29,7 +29,7 @@ rtmp {
hls_type live;
hls_path /dev/shm/hls;
hls_fragment 2s;
hls_playlist_length 20s;
hls_playlist_length 10s;
hls_cleanup on;
hls_fragment_naming timestamp;
@@ -54,7 +54,7 @@ http {
map $http_authorization $is_authorized {
default 0;
$API_AUTH 1;
${API_AUTH} 1;
}
server {

View File

@@ -1,4 +1,4 @@
FROM alpine:3.19 as builder
FROM alpine:3.19 AS builder
RUN apk add --no-cache \
build-base \
@@ -37,20 +37,8 @@ RUN mkdir -p /etc/nginx/templates
EXPOSE 80 1935 8888
# Create an entrypoint script to handle environment variable substitution
RUN echo '#!/bin/sh \n\
# Replace environment variables in configuration templates \n\
for template in /etc/nginx/templates/*.conf.template; do \n\
if [ -f "$template" ]; then \n\
output_file="/usr/local/nginx/conf/$(basename $template .template)" \n\
echo "Processing template: $template -> $output_file" \n\
envsubst "$(env | awk -F= "{printf \\\"\\\$%s \\\",\\\$1}")" < $template > $output_file \n\
fi \n\
done \n\
\n\
# Start Nginx \n\
exec "$@"' > /docker-entrypoint.sh && \
chmod +x /docker-entrypoint.sh
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["/usr/local/nginx/sbin/nginx", "-g", "daemon off;"]

View File

@@ -0,0 +1,27 @@
#!/bin/sh
set -e
UID=${UID:-1000}
GID=${GID:-1000}
echo "Setting UID to $UID and GID to $GID"
usermod -u $UID nginx 2>/dev/null || echo "Failed to change UID"
groupmod -g $GID nginx 2>/dev/null || echo "Failed to change GID"
mkdir -p /usr/local/nginx/conf
for template in /etc/nginx/templates/*.conf.template; do
if [ -f "$template" ]; then
output_file="/usr/local/nginx/conf/$(basename $template .template)"
echo "Processing template: $template -> $output_file"
envsubst '${API_AUTH}' < $template > $output_file
fi
done
mkdir -p /usr/local/nginx/proxy_temp /usr/local/nginx/client_body_temp
mkdir -p /var/www/html
chown -R nginx:nginx /usr/local/nginx /var/www/html
echo "Testing nginx configuration..."
/usr/local/nginx/sbin/nginx -t
exec "$@"

View File

@@ -20,6 +20,7 @@
"r:rtmp": "docker compose -f dev/docker-compose.yml restart nginx-rtmp -t 0"
},
"devDependencies": {
"prettier": "^3.6.2",
"turbo": "^2.4.4"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"

View File

@@ -1,10 +1,18 @@
import { PrismaAdapter } from '@lucia-auth/adapter-prisma';
import { Lucia } from 'lucia';
import { prisma } from '@hctv/db';
import { Slack } from 'arctic';
import { OAuth2Client } from 'arctic';
const adapter = new PrismaAdapter(prisma.session, prisma.user);
export const slack = new Slack(process.env.SLACK_ID!, process.env.SLACK_SECRET!, process.env.SLACK_REDIRECT_URI!);
export const hackClub = new OAuth2Client(
process.env.HCID_CLIENT!,
process.env.HCID_SECRET!,
process.env.HCID_REDIRECT_URI!
);
export const HCID_AUTH_URL = "https://account.hackclub.com/oauth/authorize";
export const HCID_TOKEN_URL = "https://account.hackclub.com/oauth/token";
export const HCID_USER_INFO_URL = "https://account.hackclub.com/api/v1/me";
export const lucia = new Lucia(adapter, {
sessionCookie: {
@@ -19,6 +27,7 @@ export const lucia = new Lucia(adapter, {
getUserAttributes: (attributes) => {
return {
slack_id: attributes.slack_id,
email: attributes.email,
pfpUrl: attributes.pfpUrl,
hasOnboarded: attributes.hasOnboarded,
personalChannelId: attributes.personalChannelId,
@@ -35,6 +44,7 @@ declare module 'lucia' {
interface DatabaseUserAttributes {
slack_id: string;
email: string | null;
pfpUrl: string;
hasOnboarded: boolean;
personalChannelId: string | null;

View File

@@ -1,44 +0,0 @@
FROM node:lts-alpine AS base
FROM base AS builder
RUN apk update
RUN apk add --no-cache libc6-compat
# Set working directory
WORKDIR /app
RUN yarn global add turbo@^2
COPY . .
# Generate a partial monorepo with a pruned lockfile for the db package
RUN turbo prune @hctv/db --docker
FROM base AS installer
RUN apk update
RUN apk add --no-cache libc6-compat
WORKDIR /app
# First install the dependencies
COPY --from=builder /app/out/json/ .
RUN yarn install --frozen-lockfile
COPY --from=builder /app/out/full/ .
RUN --mount=type=secret,id=TURBO_TOKEN --mount=type=secret,id=TURBO_TEAM TURBO_TOKEN=$(cat /run/secrets/TURBO_TOKEN) TURBO_TEAM=$(cat /run/secrets/TURBO_TEAM) yarn turbo run build --filter=@hctv/db
FROM base AS runner
WORKDIR /app
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 prisma
USER prisma
COPY --from=installer --chown=prisma:nodejs /app/packages ./packages
COPY --from=installer --chown=prisma:nodejs /app/node_modules ./node_modules
COPY --from=installer --chown=prisma:nodejs /app/package.json ./package.json
# Set environment variables for database connection
ENV NODE_ENV=production
# Set the working directory to the db package
WORKDIR /app/packages/db
# Run Prisma migrations as the entrypoint
ENTRYPOINT ["npx", "prisma", "migrate", "deploy"]

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Channel" ADD COLUMN "is247" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,42 @@
-- CreateTable
CREATE TABLE "BotAccounts" (
"id" TEXT NOT NULL,
"displayName" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT NOT NULL,
"pfpUrl" TEXT NOT NULL,
"channelId" TEXT NOT NULL,
CONSTRAINT "BotAccounts_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ApiKey" (
"id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"botAccountId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ApiKey_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "BotAccounts_slug_key" ON "BotAccounts"("slug");
-- CreateIndex
CREATE INDEX "BotAccounts_channelId_idx" ON "BotAccounts"("channelId");
-- CreateIndex
CREATE INDEX "BotAccounts_slug_idx" ON "BotAccounts"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "ApiKey_key_key" ON "ApiKey"("key");
-- CreateIndex
CREATE INDEX "ApiKey_botAccountId_idx" ON "ApiKey"("botAccountId");
-- AddForeignKey
ALTER TABLE "BotAccounts" ADD CONSTRAINT "BotAccounts_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "Channel"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_botAccountId_fkey" FOREIGN KEY ("botAccountId") REFERENCES "BotAccounts"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,22 @@
/*
Warnings:
- You are about to drop the column `channelId` on the `BotAccounts` table. All the data in the column will be lost.
- Added the required column `ownerId` to the `BotAccounts` table without a default value. This is not possible if the table is not empty.
*/
-- DropForeignKey
ALTER TABLE "BotAccounts" DROP CONSTRAINT "BotAccounts_channelId_fkey";
-- DropIndex
DROP INDEX "BotAccounts_channelId_idx";
-- AlterTable
ALTER TABLE "BotAccounts" DROP COLUMN "channelId",
ADD COLUMN "ownerId" TEXT NOT NULL;
-- CreateIndex
CREATE INDEX "BotAccounts_ownerId_idx" ON "BotAccounts"("ownerId");
-- AddForeignKey
ALTER TABLE "BotAccounts" ADD CONSTRAINT "BotAccounts_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,9 @@
/*
Warnings:
- Added the required column `updatedAt` to the `BotAccounts` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "BotAccounts" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL;

View File

@@ -0,0 +1,43 @@
/*
Warnings:
- You are about to drop the `BotAccounts` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "ApiKey" DROP CONSTRAINT "ApiKey_botAccountId_fkey";
-- DropForeignKey
ALTER TABLE "BotAccounts" DROP CONSTRAINT "BotAccounts_ownerId_fkey";
-- DropTable
DROP TABLE "BotAccounts";
-- CreateTable
CREATE TABLE "BotAccount" (
"id" TEXT NOT NULL,
"displayName" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT NOT NULL,
"pfpUrl" TEXT NOT NULL,
"ownerId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BotAccount_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "BotAccount_slug_key" ON "BotAccount"("slug");
-- CreateIndex
CREATE INDEX "BotAccount_ownerId_idx" ON "BotAccount"("ownerId");
-- CreateIndex
CREATE INDEX "BotAccount_slug_idx" ON "BotAccount"("slug");
-- AddForeignKey
ALTER TABLE "BotAccount" ADD CONSTRAINT "BotAccount_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_botAccountId_fkey" FOREIGN KEY ("botAccountId") REFERENCES "BotAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "BotAccount" ALTER COLUMN "description" SET DEFAULT 'A hctv bot account';

Some files were not shown because too many files have changed in this diff Show More