mirror of
https://github.com/SrIzan10/hctv.git
synced 2026-06-06 00:56:56 +00:00
Compare commits
215 Commits
dependabot
...
feat/js-sd
| Author | SHA1 | Date | |
|---|---|---|---|
| 7456e80473 | |||
| a0cabbfa63 | |||
| 2f8ac7d343 | |||
| 0157eff9f3 | |||
| ebcb062b6a | |||
| fdc8e0f33c | |||
| eeb44dfae7 | |||
|
|
0e9f0a54dd | ||
|
|
5d81d32276 | ||
|
|
fe21d19250 | ||
| eac736b9fb | |||
| 381f4fc523 | |||
| 7d350cfc04 | |||
| 2dfbab5d0e | |||
| 4eef997d63 | |||
| 7574b94933 | |||
| 6c26ca9d2f | |||
| a1727b9a3d | |||
| f486c3b28e | |||
| 8e86be97d1 | |||
| 099b321b79 | |||
| 6fdadbec28 | |||
| 92cde437af | |||
| 28cbe4e8ed | |||
| 09d099d0ee | |||
| 5c99fee95d | |||
| df845b5601 | |||
| d4a6516157 | |||
| 17bbba7df3 | |||
|
|
1e27c7e77a | ||
|
|
80595d6299 | ||
|
|
aa9d0c1ca5 | ||
|
|
45894fc900 | ||
| ddbdf3caf9 | |||
| 80a8e670e1 | |||
| 3e5824093e | |||
| 75d6e648f9 | |||
|
|
1fadaa3600 | ||
|
|
7262b0e5c2 | ||
|
|
70832c7de8 | ||
|
|
61972da255 | ||
| 221aff0050 | |||
| 5b6addac9a | |||
| 5add3b0e5d | |||
| b623de5bdd | |||
| cc15a06ffb | |||
| c0f3e9d52e | |||
| a22dcf0746 | |||
|
|
b4d3cd5bb8 | ||
|
|
d5c02889de | ||
| c0657cc1ce | |||
| d97add9659 | |||
| 8f07dbadf3 | |||
| 21ab8a5e4f | |||
| 689c410828 | |||
| 593baa6505 | |||
| 786a2afb6c | |||
| 75f25eb8fe | |||
| 0e500037c4 | |||
| b49318f9e6 | |||
| 927d7d1bda | |||
| d1f5cc7a6d | |||
| 0afc54f0bf | |||
| 0581cc6a61 | |||
| 18025ced9d | |||
| 044221f147 | |||
| 0cabbd8720 | |||
| 5fdb6921d9 | |||
| 312ad480a2 | |||
| a37554d205 | |||
| 5244275264 | |||
| 5275e8cb2a | |||
| 1ff51fad61 | |||
| 440eb407dd | |||
| 4ab1756230 | |||
| f9d11476bf | |||
| 8e8c58e195 | |||
| 6fcbeaa2a7 | |||
| caef4e428a | |||
| cf49fea907 | |||
| 7683f765b0 | |||
| 4d91f15a43 | |||
| 3b49f8d25a | |||
| c99ace0ef5 | |||
| a834b63ac8 | |||
| 09871d3fae | |||
| 2a0a7abe1a | |||
| 2a15a6367a | |||
| 6fad756bd2 | |||
| 0bb44960b4 | |||
| ac2276b112 | |||
| 1adb9be6cc | |||
| a9625f3505 | |||
| 3611e23869 | |||
| f8aa1454ff | |||
| 6e8539a8d1 | |||
| 592524eedb | |||
| 989462e639 | |||
| a7e9115587 | |||
| 934f589b5f | |||
| 27a6838d9f | |||
| 75085630be | |||
| 642270ee91 | |||
| f543061672 | |||
| 3a89f07a6f | |||
| fb40d87736 | |||
| 34d7afd03d | |||
| 495027ca7e | |||
| 55df22341e | |||
| 17f550ce9d | |||
| ebd0a8bffd | |||
| 2298b8bc0c | |||
| 7dd9bf765e | |||
| 8b3df28f1e | |||
| ecca138257 | |||
| cb0f75dfb9 | |||
| 747af0aeb3 | |||
| 64c7a80883 | |||
| e8fdfa8f49 | |||
| 9e965c779f | |||
| 9ce6770115 | |||
| 6d413bc53e | |||
| 93ae6bd73e | |||
| ae99121af6 | |||
| 331a0185af | |||
| d223902a9f | |||
| 18a00bba6e | |||
| 061d1d3348 | |||
| 95ec96fe72 | |||
| 9eca54cbb5 | |||
| 31fa5f36de | |||
| d327da90ef | |||
| 7072b762d8 | |||
| 82a13007c8 | |||
| a35fd858dc | |||
| 4f03f002ab | |||
| d36e590ab6 | |||
| c77d7a16e6 | |||
| d656d0f579 | |||
| 2896cae2bb | |||
| 6710423b92 | |||
| a4f940b990 | |||
| 307b697ac9 | |||
| 49c0da708a | |||
| 4c415dacc4 | |||
| e616ac20d4 | |||
| 387f0943a3 | |||
| a22937793c | |||
| 302e9be737 | |||
| d5d8894f9c | |||
| c10b4b3674 | |||
| c18cdc83b9 | |||
| 6e10da6f98 | |||
| 6a7b449363 | |||
| ab72dacb61 | |||
| 31d27f92ca | |||
| cd685edd78 | |||
| bd7047d19b | |||
| 9a7c72321d | |||
| 8d6e097f94 | |||
| 3fbb2b5d7f | |||
| 834e5087c3 | |||
| 2e2185568d | |||
| 451952e3f8 | |||
| f236086dba | |||
| d4e09389ad | |||
| 2b078c9c1e | |||
| 74461398c5 | |||
| 41ad53b531 | |||
| 2dc28ae8d9 | |||
| 1fa8c59c33 | |||
| 4237b1b41b | |||
| 54e3d120f6 | |||
| ed5f29824c | |||
| f4e809ae83 | |||
| cb7f6b0676 | |||
| f46fd35980 | |||
| 9b8e83d2e2 | |||
| c89a3bed87 | |||
| ed1fd6a18e | |||
| 79fe992f6c | |||
| 3b684c2cfd | |||
| a0ef59e577 | |||
| ff91e4b8d6 | |||
| 0ef38f0270 | |||
| a1d849092b | |||
| 87961cd18f | |||
| c0ff5912e4 | |||
| 21f7c0fb82 | |||
| a7a046abd8 | |||
| be5576c2c6 | |||
| 78fe930771 | |||
| 28061a83b0 | |||
| 877fd490bc | |||
| 9305b62e2a | |||
| c7d3a4bf1f | |||
| 92429c1390 | |||
| f7ae4cecf4 | |||
| a3d6d587e5 | |||
| cd90281cf9 | |||
| 5366116e26 | |||
| 12daf61c5b | |||
| db01e3bb59 | |||
| 3aa7cd0c87 | |||
| 818566b1c5 | |||
| 338254889a | |||
| 3029de4fae | |||
| 87ba1c0f3a | |||
| d2e2d0ce62 | |||
| 47fdbf66f9 | |||
| 7e26ffb2d1 | |||
| f45d604468 | |||
| 67989a694c | |||
| 10c10b6d2a | |||
| 6218d328cd |
69
.github/workflows/docker.yml
vendored
69
.github/workflows/docker.yml
vendored
@@ -10,6 +10,12 @@ jobs:
|
||||
name: Push frontend to Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Wait
|
||||
uses: NathanFirmo/wait-for-other-action@v1.0.4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
workflow: 'emojis.yml'
|
||||
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
|
||||
@@ -29,6 +35,21 @@ jobs:
|
||||
images: srizan10/hclive
|
||||
tags: latest
|
||||
|
||||
- name: Download latest emoji importer
|
||||
run: |
|
||||
RELEASE_URL=$(curl -s https://api.github.com/repos/srizan10/hctv/releases/latest | \
|
||||
grep "browser_download_url.*slack-import-emojis-linux-x86_64" | \
|
||||
cut -d '"' -f 4)
|
||||
|
||||
curl -L -o slack-import-emojis-bin $RELEASE_URL
|
||||
chmod +x slack-import-emojis-bin
|
||||
|
||||
mkdir -p apps/web/src/lib/instrumentation/
|
||||
|
||||
./slack-import-emojis-bin default
|
||||
|
||||
cp emojis.json apps/web/
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
@@ -41,41 +62,7 @@ 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 }}
|
||||
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
chat:
|
||||
name: Push chat module to Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
@@ -112,14 +99,10 @@ jobs:
|
||||
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
|
||||
deploy:
|
||||
name: Deploy to server
|
||||
name: Deploy to Coolify
|
||||
runs-on: ubuntu-latest
|
||||
needs: [frontend, db, chat]
|
||||
needs: [frontend, chat]
|
||||
steps:
|
||||
- name: Emit a webhook to the server
|
||||
env:
|
||||
AUTH_HEADER: ${{ secrets.WHSERVER_TOKEN }}
|
||||
- name: Send coolify redeploy webhook
|
||||
run: |
|
||||
curl -X POST \
|
||||
-H "Authorization: $AUTH_HEADER" \
|
||||
https://webhooks.srizan.dev/hooks/hctv
|
||||
curl -X POST -H "Authorization: Bearer ${{ secrets.COOLIFY_API_KEY }}" https://coolify.srizan.dev/api/v1/deploy?uuid=${{ secrets.COOLIFY_APP_UUID }}&force=true
|
||||
64
.github/workflows/emojis.yml
vendored
Normal file
64
.github/workflows/emojis.yml
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
name: Slack Emoji Importer Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'slack-import-emojis/**'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-release:
|
||||
name: Build and create release
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./slack-import-emojis
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: "./slack-import-emojis -> target"
|
||||
|
||||
- name: Build release binary
|
||||
run: cargo build --release
|
||||
|
||||
- name: Extract version from Cargo.toml
|
||||
id: get_version
|
||||
run: |
|
||||
VERSION=$(grep '^version =' Cargo.toml | head -n 1 | cut -d '"' -f 2)
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
id: create_release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: emoji-importer-v${{ steps.get_version.outputs.version }}
|
||||
name: Slack Emoji Importer v${{ steps.get_version.outputs.version }}
|
||||
body: |
|
||||
Slack Emoji Importer v${{ steps.get_version.outputs.version }}
|
||||
|
||||
Fetches emojis from the Slack workspace and exports them to JSON.
|
||||
|
||||
## Usage
|
||||
```
|
||||
export SLACK_TOKEN=xoxb-your-token
|
||||
./slack-import-emojis
|
||||
```
|
||||
|
||||
- name: Upload Linux binary
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: ./slack-import-emojis/target/release/slack-import-emojis
|
||||
asset_name: slack-import-emojis-linux-x86_64
|
||||
asset_content_type: application/octet-stream
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -28,6 +28,7 @@ yarn-error.log*
|
||||
# local env files
|
||||
.env*.local
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
@@ -42,4 +43,11 @@ dev/redis
|
||||
|
||||
.turbo
|
||||
packages/db/generated/client
|
||||
*dist
|
||||
*dist
|
||||
|
||||
slack-import-emojis/target
|
||||
**/*/emojis.json
|
||||
|
||||
.idea
|
||||
|
||||
/apps/docs/src/content/docs/typedoc-sdk
|
||||
8
.vscode/mcp.json
vendored
Normal file
8
.vscode/mcp.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"servers": {
|
||||
"Sentry": {
|
||||
"url": "https://mcp.sentry.dev/mcp/sr-izan/hctv",
|
||||
"type": "http"
|
||||
}
|
||||
}
|
||||
}
|
||||
343
AGENTS.md
Normal file
343
AGENTS.md
Normal file
@@ -0,0 +1,343 @@
|
||||
# Agent Guidelines for HackClub.tv
|
||||
|
||||
This document provides essential information for AI coding agents working on the HackClub.tv codebase.
|
||||
|
||||
## Project Overview
|
||||
|
||||
HackClub.tv is a live streaming platform built with Next.js 16, Prisma, and Turbo monorepo architecture.
|
||||
|
||||
- **Monorepo**: Turborepo with pnpm workspaces
|
||||
- **Apps**: web (Next.js), chat (Hono), docs
|
||||
- **Packages**: db (Prisma), auth (Lucia), hono-ws, sdk
|
||||
- **Package Manager**: pnpm 10.6.5
|
||||
|
||||
## Build, Lint, and Test Commands
|
||||
|
||||
### Root Level Commands
|
||||
|
||||
```bash
|
||||
pnpm install # Install all dependencies
|
||||
pnpm build # Build all apps and packages (uses Turbo)
|
||||
pnpm dev # Start all apps in dev mode (uses Turbo)
|
||||
pnpm lint # Lint all apps (uses Turbo)
|
||||
```
|
||||
|
||||
### Database Commands
|
||||
|
||||
```bash
|
||||
pnpm db:migrate # Run Prisma migrations in dev
|
||||
pnpm prisma # Run any Prisma command in db package
|
||||
```
|
||||
|
||||
### App-Specific Commands
|
||||
|
||||
```bash
|
||||
# Web app (Next.js)
|
||||
pnpm --filter=@hctv/web dev # Start Next.js dev server
|
||||
pnpm --filter=@hctv/web build # Build Next.js app
|
||||
pnpm --filter=@hctv/web lint # Lint web app
|
||||
pnpm --filter=@hctv/web check-types # Type check (tsc --noEmit)
|
||||
pnpm --filter=@hctv/web ui:add # Add shadcn components
|
||||
|
||||
# Chat app (Hono)
|
||||
pnpm --filter=@hctv/chat dev # Start chat server with watch
|
||||
pnpm --filter=@hctv/chat build # Build chat server
|
||||
|
||||
# DB package (Prisma)
|
||||
pnpm --filter=@hctv/db db:generate # Generate Prisma client
|
||||
pnpm --filter=@hctv/db db:migrate # Run migrations
|
||||
pnpm --filter=@hctv/db build # Generate client and build
|
||||
```
|
||||
|
||||
### Running Single Tests
|
||||
|
||||
This project does not currently have a test suite configured. When adding tests:
|
||||
|
||||
- Use Vitest or Jest for unit tests
|
||||
- Use Playwright for E2E tests (recommended for Next.js)
|
||||
- Follow the pattern: `pnpm test -- <test-file-path>`
|
||||
|
||||
### Docker Commands
|
||||
|
||||
```bash
|
||||
pnpm docker:web # Build web app Docker image
|
||||
pnpm docker:chat # Build chat app Docker image
|
||||
pnpm r:rtmp # Restart RTMP server
|
||||
```
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
### Formatting
|
||||
|
||||
- **Formatter**: Prettier (`.prettierrc.json`)
|
||||
- **Indentation**: 2 spaces (no tabs)
|
||||
- **Line Width**: 100 characters
|
||||
- **Quotes**: Single quotes
|
||||
- **Semicolons**: Required
|
||||
- **Trailing Commas**: ES5 style
|
||||
- **Linter**: ESLint with Next.js rules (`next/core-web-vitals`)
|
||||
|
||||
### Import Organization
|
||||
|
||||
Imports should be ordered as follows (no blank lines between groups):
|
||||
|
||||
1. React/Next.js core imports
|
||||
2. Third-party libraries
|
||||
3. Internal components (`@/components`)
|
||||
4. Internal utilities/libs (`@/lib`)
|
||||
5. Package imports (`@hctv/*`)
|
||||
6. Type imports (use `import type` keyword)
|
||||
7. Relative imports
|
||||
|
||||
```typescript
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Send } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { validateRequest } from '@/lib/auth/validate';
|
||||
import { prisma } from '@hctv/db';
|
||||
import type { User, Channel } from '@hctv/db';
|
||||
import { helper } from './utils';
|
||||
```
|
||||
|
||||
### TypeScript Usage
|
||||
|
||||
- **Strict mode**: Enabled
|
||||
- **Type over interface**: Use `type` for unions/intersections, `interface` for object shapes
|
||||
- **No implicit any**: Always type your variables
|
||||
- **Type imports**: Use `import type` for type-only imports
|
||||
- **Prisma types**: Import from `@hctv/db` and use type composition
|
||||
|
||||
```typescript
|
||||
// Interfaces for props and object shapes
|
||||
interface ChatMessage {
|
||||
user?: User;
|
||||
message: string;
|
||||
type: 'message' | 'systemMsg';
|
||||
}
|
||||
|
||||
// Type aliases for complex types
|
||||
type FormFieldConfig = {
|
||||
name: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
// Prisma type composition
|
||||
type StreamWithChannel = StreamInfo & { channel: Channel };
|
||||
```
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
**Files:**
|
||||
|
||||
- React components: `PascalCase.tsx` (e.g., `ChatPanel.tsx`)
|
||||
- Utilities/helpers: `camelCase.ts` (e.g., `validate.ts`)
|
||||
- Next.js pages: `page.tsx`, `route.ts`, `layout.tsx`
|
||||
- Client components: `page.client.tsx`
|
||||
|
||||
**Variables & Functions:**
|
||||
|
||||
- Components: `PascalCase`
|
||||
- Functions: `camelCase`
|
||||
- Constants: `SCREAMING_SNAKE_CASE`
|
||||
- Booleans: Use `is`, `has`, `should` prefixes
|
||||
- Refs: `Ref` suffix (e.g., `socketRef`)
|
||||
|
||||
```typescript
|
||||
const MESSAGE_HISTORY_SIZE = 15;
|
||||
const isFollowing = await checkFollowing();
|
||||
const socketRef = useRef<WebSocket | null>(null);
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
**API Routes:**
|
||||
|
||||
- Return `Response` objects with appropriate status codes
|
||||
- Use descriptive error messages
|
||||
- Status codes: 400 (bad request), 401 (unauthorized), 403 (forbidden), 404 (not found)
|
||||
|
||||
```typescript
|
||||
if (!user) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
```
|
||||
|
||||
**Server Actions:**
|
||||
|
||||
- Return objects with `success` boolean and `error` or `data` fields
|
||||
- Use `zodVerify` helper for validation
|
||||
|
||||
```typescript
|
||||
const zod = await zodVerify(schema, formData);
|
||||
if (!zod.success) {
|
||||
return { success: false, error: zod.error };
|
||||
}
|
||||
return { success: true, data: result };
|
||||
```
|
||||
|
||||
**Client-side:**
|
||||
|
||||
- Use try-catch for async operations
|
||||
- Use toast notifications (sonner) for user feedback
|
||||
- Log errors with `console.error`
|
||||
|
||||
### Async Patterns
|
||||
|
||||
- **Prefer**: async/await over Promise chains
|
||||
- **Parallel operations**: Use `Promise.all()`
|
||||
- **No .then() chaining**: Except in utility functions like fetchers
|
||||
|
||||
```typescript
|
||||
// Standard async/await
|
||||
const data = await prisma.model.findMany();
|
||||
|
||||
// Parallel operations
|
||||
const [channelA, channelB] = await Promise.all([
|
||||
prisma.channel.findUnique({ where: { id: 'a' } }),
|
||||
prisma.channel.findUnique({ where: { id: 'b' } }),
|
||||
]);
|
||||
```
|
||||
|
||||
## React Component Patterns
|
||||
|
||||
### Component Structure
|
||||
|
||||
1. Directive (`'use client'` or `'use server'`)
|
||||
2. Imports
|
||||
3. Component function
|
||||
4. Helper functions (if needed)
|
||||
5. Type/interface definitions (at bottom)
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export default function ChatPanel(props: Props) {
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
return <div>{/* JSX */}</div>;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
username: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Server vs Client Components
|
||||
|
||||
- **Server components**: Default (no directive), use for data fetching
|
||||
- **Client components**: Add `'use client'`, use for interactivity
|
||||
- Fetch data in server components, pass to client components as props
|
||||
|
||||
## Database Patterns (Prisma)
|
||||
|
||||
### Imports
|
||||
|
||||
```typescript
|
||||
import { prisma } from '@hctv/db';
|
||||
import type { User, Channel, StreamInfo } from '@hctv/db';
|
||||
```
|
||||
|
||||
### Common Queries
|
||||
|
||||
```typescript
|
||||
// FindUnique with relations
|
||||
const channel = await prisma.channel.findUnique({
|
||||
where: { name: channelName },
|
||||
include: { owner: true, streamInfo: true },
|
||||
});
|
||||
|
||||
// FindMany with dynamic filters
|
||||
const where: Prisma.StreamInfoWhereInput = {};
|
||||
if (isLive) where.isLive = true;
|
||||
const streams = await prisma.streamInfo.findMany({ where });
|
||||
|
||||
// Create with relations
|
||||
await prisma.channel.create({
|
||||
data: {
|
||||
name: channelName,
|
||||
ownerId: user.id,
|
||||
personalFor: { connect: { id: user.id } },
|
||||
},
|
||||
});
|
||||
|
||||
// Update
|
||||
await prisma.streamInfo.update({
|
||||
where: { username },
|
||||
data: { title: newTitle },
|
||||
});
|
||||
|
||||
// Upsert
|
||||
await prisma.streamKey.upsert({
|
||||
create: { key: newKey, channelId },
|
||||
update: { key: newKey },
|
||||
where: { channelId },
|
||||
});
|
||||
```
|
||||
|
||||
### Redis Usage
|
||||
|
||||
```typescript
|
||||
import { getRedisConnection } from '@hctv/db';
|
||||
|
||||
const redis = getRedisConnection();
|
||||
await redis.set('key', 'value');
|
||||
await redis.get('key');
|
||||
await redis.setex('key', 30, 'value'); // with expiry
|
||||
```
|
||||
|
||||
## API Route Patterns
|
||||
|
||||
### Next.js App Router
|
||||
|
||||
```typescript
|
||||
import { validateRequest } from '@/lib/auth/validate';
|
||||
import { prisma } from '@hctv/db';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { user } = await validateRequest();
|
||||
if (!user) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const param = searchParams.get('param');
|
||||
|
||||
const data = await prisma.model.findMany();
|
||||
return Response.json(data);
|
||||
}
|
||||
```
|
||||
|
||||
### Server Actions
|
||||
|
||||
```typescript
|
||||
'use server';
|
||||
|
||||
export async function createChannel(prev: any, formData: FormData) {
|
||||
const { user } = await validateRequest();
|
||||
if (!user) {
|
||||
return { success: false, error: 'Unauthorized' };
|
||||
}
|
||||
|
||||
const zod = await zodVerify(createChannelSchema, formData);
|
||||
if (!zod.success) {
|
||||
return zod;
|
||||
}
|
||||
|
||||
// ... processing
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
```
|
||||
|
||||
## Important Notes
|
||||
|
||||
- **Turbo caching**: Build outputs are cached. Use `--force` to bypass cache
|
||||
- **Environment variables**: Use `NEXT_PUBLIC_` prefix for client-side vars
|
||||
- **Styling**: Tailwind CSS with shadcn/ui components, use `cn()` for conditional classes
|
||||
- **Data fetching**: SWR for client-side, direct Prisma for server components
|
||||
- **Validation**: Zod schemas for form and API validation
|
||||
- **Cache invalidation**: Use `revalidatePath()` after mutations
|
||||
12
README.md
12
README.md
@@ -1,13 +1,7 @@
|
||||
# hackclub.tv
|
||||
|
||||
This is the source code for [hackclub.tv (hctv.srizan.dev)](https://hctv.srizan.dev), a livestreaming website for hackclubbers.
|
||||
This is the source code for [hackclub.tv](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.
|
||||
The development setup guide can be read at <https://docs.hackclub.tv/guides/dev/>
|
||||
|
||||
Join [#hctv](https://hackclub.slack.com/archives/C08HGLXGXAB) on the HC Slack for discussion and updates!
|
||||
|
||||
## Features
|
||||
|
||||
- High quality video streaming (low latency coming soon)
|
||||
- Chat with other viewers
|
||||
- Multiaccount support (database schema laid out, UI not implemented)
|
||||
Join [#hctv](https://hackclub.slack.com/archives/C08HGLXGXAB) on the Hack Club Slack for discussion and updates!
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
FROM node:lts-alpine AS base
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
|
||||
FROM base AS builder
|
||||
RUN apk update
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
RUN yarn global add turbo@^2
|
||||
RUN pnpm add -g turbo@^2
|
||||
COPY . .
|
||||
|
||||
RUN turbo prune @hctv/chat --docker
|
||||
@@ -16,10 +19,10 @@ WORKDIR /app
|
||||
|
||||
# First install the dependencies
|
||||
COPY --from=builder /app/out/json/ .
|
||||
RUN yarn install --frozen-lockfile
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY --from=builder /app/out/full/ .
|
||||
RUN --mount=type=secret,id=TURBO_TOKEN --mount=type=secret,id=TURBO_TEAM TURBO_TOKEN=$(cat /run/secrets/TURBO_TOKEN) TURBO_TEAM=$(cat /run/secrets/TURBO_TEAM) yarn turbo run build --concurrency=1
|
||||
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) pnpm turbo run build --concurrency=1
|
||||
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
```
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
```
|
||||
open http://localhost:3000
|
||||
```
|
||||
@@ -7,11 +7,12 @@
|
||||
"build": "tsc --build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hctv/auth": "*",
|
||||
"@hctv/db": "*",
|
||||
"@hctv/hono-ws": "*",
|
||||
"@hctv/auth": "workspace:*",
|
||||
"@hctv/db": "workspace:*",
|
||||
"@hctv/hono-ws": "workspace:*",
|
||||
"@hono/node-server": "^1.14.0",
|
||||
"@hono/node-ws": "^1.1.0",
|
||||
"@leeoniya/ufuzzy": "^1.0.18",
|
||||
"@oslojs/encoding": "^1.1.0",
|
||||
"hono": "^4.7.5"
|
||||
},
|
||||
|
||||
@@ -5,9 +5,15 @@ import { readFile } from 'node:fs/promises';
|
||||
import { lucia } from '@hctv/auth';
|
||||
import { getCookie } from 'hono/cookie';
|
||||
import { getPersonalChannel } from './utils/personalChannel.js';
|
||||
import { prisma } from '@hctv/db';
|
||||
import { getRedisConnection, prisma, type BotAccount, type BotApiKey, type User } from '@hctv/db';
|
||||
import uFuzzy from '@leeoniya/ufuzzy';
|
||||
import { randomString } from './utils/randomString.js';
|
||||
|
||||
const redis = getRedisConnection();
|
||||
const MESSAGE_HISTORY_SIZE = 15;
|
||||
const MESSAGE_TTL = 60 * 60 * 24;
|
||||
const threed = await readFile('./src/3d.txt', 'utf-8');
|
||||
const uf = new uFuzzy();
|
||||
|
||||
const app = new Hono();
|
||||
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app });
|
||||
@@ -23,50 +29,122 @@ app.get('/up', async (c) => {
|
||||
app.get(
|
||||
'/ws/:username',
|
||||
upgradeWebSocket((c) => ({
|
||||
// https://hono.dev/helpers/websocket
|
||||
async onOpen(evt, ws) {
|
||||
const token = getCookie(c, 'auth_session');
|
||||
if (!token) {
|
||||
const grant = c.req.query('grant');
|
||||
const authHeader = c.req.header('Authorization');
|
||||
const botAuth = c.req.query('botAuth');
|
||||
|
||||
if (!token && (!grant || grant === 'null') && !authHeader && !botAuth) {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const { user } = await lucia.validateSession(token);
|
||||
if (!user) {
|
||||
ws.close();
|
||||
return;
|
||||
let chatUser: ChatUser | null = null;
|
||||
let personalChannel: any = null;
|
||||
|
||||
let apiKey: string | null = null;
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
const extractedKey = authHeader.substring(7);
|
||||
if (extractedKey.startsWith('hctvb_')) {
|
||||
apiKey = extractedKey;
|
||||
}
|
||||
} else if (botAuth && typeof botAuth === 'string' && botAuth.trim().length > 0) {
|
||||
if (botAuth.startsWith('hctvb_')) {
|
||||
apiKey = botAuth;
|
||||
}
|
||||
}
|
||||
|
||||
const personalChannel = await getPersonalChannel(user.id);
|
||||
if (!personalChannel) {
|
||||
if (apiKey) {
|
||||
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 =
|
||||
grant && grant !== 'null'
|
||||
? await prisma.channel.findFirst({
|
||||
where: { obsChatGrantToken: grant },
|
||||
})
|
||||
: null;
|
||||
|
||||
if (!chatUser && !dbGrant) {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const { username } = c.req.param();
|
||||
if (dbGrant && dbGrant.name !== username) {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (await prisma.channel.count({ where: { name: username } }) === 0) {
|
||||
// channel doesn't exist
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
ws.targetUsername = username;
|
||||
ws.user = user;
|
||||
ws.chatUser = chatUser;
|
||||
ws.personalChannel = personalChannel;
|
||||
ws.viewerId = randomString(10);
|
||||
|
||||
if (ws.raw) {
|
||||
ws.raw.targetUsername = username;
|
||||
// @ts-ignore
|
||||
ws.raw.user = user;
|
||||
ws.raw.chatUser = chatUser;
|
||||
ws.raw.personalChannel = personalChannel;
|
||||
}
|
||||
|
||||
await prisma.streamInfo.update({
|
||||
where: {
|
||||
username,
|
||||
},
|
||||
data: {
|
||||
viewers: {
|
||||
increment: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
const channelKey = `chat:history:${username}`;
|
||||
const messages = await redis.zrange(channelKey, 0, MESSAGE_HISTORY_SIZE - 1);
|
||||
|
||||
if (messages.length > 0) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'history',
|
||||
messages: messages.map((msg) => JSON.parse(msg)),
|
||||
})
|
||||
);
|
||||
}
|
||||
},
|
||||
async onClose(evt, ws) {
|
||||
// if prematurely exiting due to authentication issues
|
||||
console.log('client disconnected');
|
||||
if (!ws.targetUsername) return;
|
||||
|
||||
const streamInfo = await prisma.streamInfo.findUnique({
|
||||
where: {
|
||||
username: ws.targetUsername,
|
||||
@@ -78,40 +156,124 @@ app.get(
|
||||
|
||||
if (!streamInfo) return;
|
||||
|
||||
await prisma.streamInfo.update({
|
||||
where: {
|
||||
username: ws.targetUsername,
|
||||
},
|
||||
data: {
|
||||
viewers: streamInfo.viewers === 0 ? { set: 0 } : { decrement: 1 },
|
||||
},
|
||||
});
|
||||
await redis.del(`viewer:${ws.targetUsername}:${ws.viewerId}`);
|
||||
},
|
||||
onMessage(evt, ws) {
|
||||
const msg = JSON.parse(evt.data.toString());
|
||||
if (msg.type === 'ping') {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'pong',
|
||||
})
|
||||
);
|
||||
return;
|
||||
} else if (msg.type === 'message') {
|
||||
ws.wss.clients.forEach((c) => {
|
||||
const client = c as ModifiedWebSocket;
|
||||
if (client.readyState === client.OPEN && client.targetUsername === ws.targetUsername) {
|
||||
c.send(
|
||||
async onMessage(evt, ws) {
|
||||
try {
|
||||
const msg = JSON.parse(evt.data.toString());
|
||||
|
||||
if (msg.type === 'ping') {
|
||||
await redis.setex(`viewer:${ws.targetUsername}:${ws.viewerId}`, 30, '1');
|
||||
ws.send(JSON.stringify({ type: 'pong' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'message') {
|
||||
if (!ws.chatUser || !ws.personalChannel) return;
|
||||
|
||||
const message = (msg.message as string).trim();
|
||||
const msgObj = {
|
||||
user: {
|
||||
id: ws.chatUser.id,
|
||||
username: ws.chatUser.username,
|
||||
pfpUrl: ws.chatUser.pfpUrl,
|
||||
displayName: ws.chatUser.displayName,
|
||||
isBot: ws.chatUser.isBot || false,
|
||||
},
|
||||
message,
|
||||
msgId: `${crypto.randomUUID()}`
|
||||
};
|
||||
|
||||
const redisObj = {
|
||||
user: msgObj.user,
|
||||
message: msgObj.message,
|
||||
type: 'message',
|
||||
msgId: `${crypto.randomUUID()}`,
|
||||
};
|
||||
|
||||
const redisStr = JSON.stringify(redisObj);
|
||||
const msgStr = JSON.stringify(msgObj);
|
||||
|
||||
const channelKey = `chat:history:${ws.targetUsername}`;
|
||||
await redis.zadd(channelKey, Date.now(), redisStr);
|
||||
await redis.zremrangebyrank(channelKey, 0, -MESSAGE_HISTORY_SIZE - 1);
|
||||
await redis.expire(channelKey, MESSAGE_TTL);
|
||||
|
||||
ws.wss.clients.forEach((c) => {
|
||||
const client = c as ModifiedWebSocket;
|
||||
if (client.readyState === client.OPEN && client.targetUsername === ws.targetUsername) {
|
||||
c.send(msgStr);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (msg.type === 'emojiMsg') {
|
||||
const emojis = msg.emojis as string[];
|
||||
const emojiMap: Record<string, string> = {};
|
||||
|
||||
await Promise.all(
|
||||
emojis.map(async (emoji) => {
|
||||
let url = await redis.hget('emojis', emoji);
|
||||
|
||||
if (!url) {
|
||||
url = await redis.hget(`emojis:${emoji}`, 'url');
|
||||
}
|
||||
if (!url) {
|
||||
url = await redis.hget(`emoji:${emoji}`, 'url');
|
||||
}
|
||||
|
||||
emojiMap[emoji] = url ?? '';
|
||||
})
|
||||
);
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'emojiMsgResponse',
|
||||
emojis: emojiMap,
|
||||
})
|
||||
);
|
||||
}
|
||||
if (msg.type === 'emojiSearch') {
|
||||
console.log('emoji search request:', msg);
|
||||
const searchTerm = msg.searchTerm as string;
|
||||
|
||||
const emojis = await redis.hgetall('emojis');
|
||||
const emojiKeys = Object.keys(emojis);
|
||||
const idxs = uf.filter(emojiKeys, searchTerm);
|
||||
console.log(`Emoji search for "${searchTerm}" found ${idxs?.length || 0} results.`);
|
||||
|
||||
if (idxs && idxs.length > 0) {
|
||||
const results: string[] = [];
|
||||
|
||||
if (idxs.length <= 150) {
|
||||
const info = uf.info(idxs, emojiKeys, searchTerm);
|
||||
const order = uf.sort(info, emojiKeys, searchTerm);
|
||||
for (let i = 0; i < order.length && i < 10; i++) {
|
||||
results.push(emojiKeys[idxs[order[i]]]);
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < idxs.length && i < 10; i++) {
|
||||
results.push(emojiKeys[idxs[i]]);
|
||||
}
|
||||
}
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
user: {
|
||||
id: ws.user.id,
|
||||
username: ws.personalChannel.name,
|
||||
pfpUrl: ws.user.pfpUrl,
|
||||
},
|
||||
message: msg.message,
|
||||
type: 'emojiSearchResponse',
|
||||
results: results,
|
||||
})
|
||||
);
|
||||
console.log(`Sending emoji search results: ${results.join(', ')}`);
|
||||
} else {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'emojiSearchResponse',
|
||||
results: [],
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error processing message:', e);
|
||||
}
|
||||
},
|
||||
}))
|
||||
@@ -127,3 +289,11 @@ const server = serve(
|
||||
}
|
||||
);
|
||||
injectWebSocket(server);
|
||||
|
||||
interface ChatUser {
|
||||
id: string;
|
||||
username: string;
|
||||
pfpUrl: string;
|
||||
displayName?: string;
|
||||
isBot: boolean;
|
||||
}
|
||||
|
||||
8
apps/chat/src/utils/randomString.ts
Normal file
8
apps/chat/src/utils/randomString.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export function randomString(length: number): string {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
21
apps/docs/.gitignore
vendored
Normal file
21
apps/docs/.gitignore
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# build output
|
||||
dist/
|
||||
# generated types
|
||||
.astro/
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
.env.production
|
||||
|
||||
# macOS-specific files
|
||||
.DS_Store
|
||||
4
apps/docs/.vscode/extensions.json
vendored
Normal file
4
apps/docs/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"recommendations": ["astro-build.astro-vscode"],
|
||||
"unwantedRecommendations": []
|
||||
}
|
||||
11
apps/docs/.vscode/launch.json
vendored
Normal file
11
apps/docs/.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"command": "./node_modules/.bin/astro dev",
|
||||
"name": "Development server",
|
||||
"request": "launch",
|
||||
"type": "node-terminal"
|
||||
}
|
||||
]
|
||||
}
|
||||
45
apps/docs/astro.config.mjs
Normal file
45
apps/docs/astro.config.mjs
Normal file
@@ -0,0 +1,45 @@
|
||||
// @ts-check
|
||||
import { defineConfig } from 'astro/config';
|
||||
import starlight from '@astrojs/starlight';
|
||||
import mermaid from 'astro-mermaid';
|
||||
import catppuccin from '@catppuccin/starlight';
|
||||
import starlightTypeDoc, { typeDocSidebarGroup } from 'starlight-typedoc';
|
||||
|
||||
// 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' },
|
||||
}),
|
||||
starlightTypeDoc({
|
||||
entryPoints: ['../../packages/sdk/src/index.ts'],
|
||||
tsconfig: '../../packages/sdk/tsconfig.json',
|
||||
output: 'typedoc-sdk',
|
||||
sidebar: {
|
||||
label: 'SDK Reference',
|
||||
},
|
||||
}),
|
||||
],
|
||||
sidebar: [
|
||||
{
|
||||
label: 'API',
|
||||
autogenerate: { directory: 'api' },
|
||||
},
|
||||
{
|
||||
label: 'Guides',
|
||||
autogenerate: { directory: 'guides' },
|
||||
},
|
||||
typeDocSidebarGroup,
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
23
apps/docs/package.json
Normal file
23
apps/docs/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@hctv/docs",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"start": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/starlight": "^0.35.2",
|
||||
"@catppuccin/starlight": "^1.0.2",
|
||||
"astro": "^5.6.1",
|
||||
"astro-mermaid": "^1.0.4",
|
||||
"mermaid": "^11.10.1",
|
||||
"sharp": "^0.34.2",
|
||||
"starlight-typedoc": "^0.21.5",
|
||||
"typedoc": "^0.28.16",
|
||||
"typedoc-plugin-markdown": "^4.9.0"
|
||||
}
|
||||
}
|
||||
1
apps/docs/public/favicon.svg
Normal file
1
apps/docs/public/favicon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><path fill-rule="evenodd" d="M81 36 64 0 47 36l-1 2-9-10a6 6 0 0 0-9 9l10 10h-2L0 64l36 17h2L28 91a6 6 0 1 0 9 9l9-10 1 2 17 36 17-36v-2l9 10a6 6 0 1 0 9-9l-9-9 2-1 36-17-36-17-2-1 9-9a6 6 0 1 0-9-9l-9 10v-2Zm-17 2-2 5c-4 8-11 15-19 19l-5 2 5 2c8 4 15 11 19 19l2 5 2-5c4-8 11-15 19-19l5-2-5-2c-8-4-15-11-19-19l-2-5Z" clip-rule="evenodd"/><path d="M118 19a6 6 0 0 0-9-9l-3 3a6 6 0 1 0 9 9l3-3Zm-96 4c-2 2-6 2-9 0l-3-3a6 6 0 1 1 9-9l3 3c3 2 3 6 0 9Zm0 82c-2-2-6-2-9 0l-3 3a6 6 0 1 0 9 9l3-3c3-2 3-6 0-9Zm96 4a6 6 0 0 1-9 9l-3-3a6 6 0 1 1 9-9l3 3Z"/><style>path{fill:#000}@media (prefers-color-scheme:dark){path{fill:#fff}}</style></svg>
|
||||
|
After Width: | Height: | Size: 696 B |
BIN
apps/docs/src/assets/houston.webp
Normal file
BIN
apps/docs/src/assets/houston.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
7
apps/docs/src/content.config.ts
Normal file
7
apps/docs/src/content.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineCollection } from 'astro:content';
|
||||
import { docsLoader } from '@astrojs/starlight/loaders';
|
||||
import { docsSchema } from '@astrojs/starlight/schema';
|
||||
|
||||
export const collections = {
|
||||
docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }),
|
||||
};
|
||||
172
apps/docs/src/content/docs/api/chat.mdx
Normal file
172
apps/docs/src/content/docs/api/chat.mdx
Normal file
@@ -0,0 +1,172 @@
|
||||
---
|
||||
title: Chat
|
||||
description: Chat websocket
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
The chat system is powered by a websocket server. Please read the entire page before implementing anything, as there are some important notes.
|
||||
|
||||
## Connection and messages
|
||||
|
||||
The websocket server is located at `wss://hackclub.tv/api/chat/ws/:username`, where `:username` is the channel you want to connect to.
|
||||
|
||||
You'll need to provide authentication, which can be done by providing an `auth_session` cookie, just like the REST API.
|
||||
|
||||
<Aside type="tip">
|
||||
Bot accounts are now supported. You can choose to connect as a bot by providing a bot account's API key in one of two ways:
|
||||
- Using the `Authorization` header: `Bearer hctvb_xxxxxxx`
|
||||
- Using the `?botAuth=hctvb_xxxxxxx` query parameter
|
||||
|
||||
**Security Note:** When using the `?botAuth=` query parameter, be aware that query parameters may be logged in server logs, and/or proxy logs. Use the `Authorization` header method whenever possible. The query parameter method should only be used when connecting from an environment where headers cannot be set.
|
||||
|
||||
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 about 5 seconds. This is because of Cloudflare limitations.
|
||||
|
||||
Messages are sent and received in JSON format. The following message types are supported:
|
||||
|
||||
- `message`: a chat message.
|
||||
- sent by client:
|
||||
```json
|
||||
{
|
||||
"type": "message",
|
||||
"message": "Hello, world!"
|
||||
}
|
||||
```
|
||||
- received by client:
|
||||
```json
|
||||
{
|
||||
"user": {
|
||||
"id": "user_id",
|
||||
"username": "user_who_sent_message",
|
||||
"avatar": "https://emoji.slack-edge.com/avatar.png"
|
||||
},
|
||||
"message": "Hello, world!"
|
||||
}
|
||||
```
|
||||
- `ping`: a ping message to keep the connection alive.
|
||||
- sent by client:
|
||||
```json
|
||||
{
|
||||
"type": "ping"
|
||||
}
|
||||
```
|
||||
- received by client:
|
||||
```json
|
||||
{
|
||||
"type": "pong"
|
||||
}
|
||||
```
|
||||
- `history`: a message containing the chat history. This is sent upon connection.
|
||||
- received by client:
|
||||
```json
|
||||
{
|
||||
"type": "history",
|
||||
"messages": [
|
||||
{
|
||||
"user": {
|
||||
"id": "user_id",
|
||||
"username": "user_who_sent_message",
|
||||
"avatar": "https://emoji.slack-edge.com/avatar.png"
|
||||
},
|
||||
"message": "Hello, world!",
|
||||
"type": "message",
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Emoji handling
|
||||
|
||||
_diagram source: devin deepwiki_
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Emoji Processing Pipeline"
|
||||
CHAT_MSG["Chat Message"]
|
||||
PATTERN_MATCH["Regex :emoji: Pattern"]
|
||||
EMOJI_REQUEST["emojiMsg WebSocket"]
|
||||
REDIS_LOOKUP["Redis HGET emojis"]
|
||||
FUZZY_SEARCH["uFuzzy"]
|
||||
EMOJI_RESPONSE["emojiMsgResponse"]
|
||||
end
|
||||
|
||||
subgraph "Redis Storage"
|
||||
EMOJI_HASH["emojis hash key"]
|
||||
EMOJI_PREFIXED["emoji:{name} url"]
|
||||
EMOJIS_PREFIXED["emojis:{name} url"]
|
||||
end
|
||||
|
||||
CHAT_MSG --> PATTERN_MATCH
|
||||
PATTERN_MATCH --":emojiname:"--> EMOJI_REQUEST
|
||||
EMOJI_REQUEST --> REDIS_LOOKUP
|
||||
|
||||
REDIS_LOOKUP --> EMOJI_HASH
|
||||
REDIS_LOOKUP --> EMOJI_PREFIXED
|
||||
REDIS_LOOKUP --> EMOJIS_PREFIXED
|
||||
|
||||
REDIS_LOOKUP --> EMOJI_RESPONSE
|
||||
|
||||
FUZZY_SEARCH --> EMOJI_HASH
|
||||
FUZZY_SEARCH --"search results"--> EMOJI_RESPONSE
|
||||
```
|
||||
|
||||
When a chat message is sent, the server looks for patterns in the format `:emojiname:` using regex. For each match, it sends a request to the `emojiMsg` WebSocket.
|
||||
The server then checks Redis for the emoji URL and returns it.
|
||||
|
||||
When a user wants to look up an emoji (by typing `:(partial name)`), the server uses uFuzzy to find matching emojis in the Redis `emojis` hash key and returns the results.
|
||||
|
||||
Here's what gets sent on the websocket:
|
||||
|
||||
- `emojiMsg`: Looks up emojis
|
||||
- sent by client:
|
||||
```json
|
||||
{
|
||||
"type": "emojiMsg",
|
||||
"emojis": ["aga", "yapa", "heavysob", "yay", "yay-bounce"]
|
||||
}
|
||||
```
|
||||
- received by client:
|
||||
```json
|
||||
{
|
||||
"type": "emojiMsgResponse",
|
||||
"emojis": {
|
||||
// rough example of urls
|
||||
"aga": "https://emoji.slack-edge.com/aga.png",
|
||||
"yapa": "https://emoji.slack-edge.com/yapa.png",
|
||||
"heavysob": "https://emoji.slack-edge.com/heavysob.png",
|
||||
"yay": "https://emoji.slack-edge.com/yay.png",
|
||||
"yay-bounce": "https://emoji.slack-edge.com/yay-bounce.png"
|
||||
}
|
||||
}
|
||||
```
|
||||
- `emojiSearch`: Searches for emojis
|
||||
- sent by client:
|
||||
```json
|
||||
{
|
||||
"type": "emojiSearch",
|
||||
"searchTerm": "aga"
|
||||
}
|
||||
```
|
||||
- received by client:
|
||||
```json
|
||||
{
|
||||
"type": "emojiSearchResponse",
|
||||
"results": [
|
||||
// real results btw
|
||||
"aga",
|
||||
"aga-brick-throw",
|
||||
"aga-dance",
|
||||
"aga-transparent",
|
||||
"a-aga",
|
||||
"a-aga-transparent",
|
||||
"agaban",
|
||||
"agaboing",
|
||||
"agabounce",
|
||||
"agabusiness"
|
||||
]
|
||||
}
|
||||
```
|
||||
19
apps/docs/src/content/docs/api/index.mdx
Normal file
19
apps/docs/src/content/docs/api/index.mdx
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
title: API Documentation
|
||||
description: Documented API endpoints for hackclub.tv
|
||||
---
|
||||
|
||||
hctv is meant to be one of the most hackable streaming platforms out there. to that end, we have a (currently limited) public API that you can use to interact with the platform.
|
||||
|
||||
since this is beta software, the API is subject to change. additionally, many endpoints are yet not implemented or not fun to implement. please send a message in #hctv on the Hack Club Slack if you have any requests.
|
||||
|
||||
## Base url
|
||||
|
||||
base url for all endpoints is `https://hackclub.tv/api`.
|
||||
|
||||
## Authentication
|
||||
|
||||
most endpoints require authentication. this will be pointed out in the documentation.
|
||||
for now, it is done via a cookie called `auth_session` (as per lucia auth). this will change in the future as bot accounts are planned.
|
||||
|
||||
you'll need your user account to authenticate. as a recommendation, open an incognito window, log in to hctv, and copy the `auth_session` cookie from there.
|
||||
4
apps/docs/src/content/docs/api/meta.json
Normal file
4
apps/docs/src/content/docs/api/meta.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "API",
|
||||
"description": "Documented API endpoints for hackclub.tv"
|
||||
}
|
||||
12
apps/docs/src/content/docs/api/rtmp.mdx
Normal file
12
apps/docs/src/content/docs/api/rtmp.mdx
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
title: RTMP
|
||||
description: RTMP related endpoint group
|
||||
---
|
||||
|
||||
## POST `/rtmp/streamKey`
|
||||
regenerates your stream key. **authentication required**.
|
||||
body parameters (json):
|
||||
- `channel`: string - the channel name you want to regenerate the key for. must be one of your channels.
|
||||
|
||||
response (json):
|
||||
- `key`: string - the new stream key
|
||||
47
apps/docs/src/content/docs/api/stream.mdx
Normal file
47
apps/docs/src/content/docs/api/stream.mdx
Normal file
@@ -0,0 +1,47 @@
|
||||
---
|
||||
title: Stream
|
||||
description: Stream related endpoint group
|
||||
---
|
||||
|
||||
## GET `/stream/follow`
|
||||
checks if **authenticated user** is following a certain channel.
|
||||
query parameters:
|
||||
- `username`: string - the channel name you want to check.
|
||||
|
||||
response (json):
|
||||
- `following`: boolean - whether the authenticated user is following the channel or not.
|
||||
|
||||
## POST `/stream/follow`
|
||||
cycle through follow or unfollow a channel. **authentication required**.
|
||||
body parameters (json):
|
||||
- `channel`: string - the channel name you want to make the action. must be one of your channels.
|
||||
|
||||
response (json):
|
||||
- `success`: boolean - whether the operation was successful or not.
|
||||
|
||||
## GET `/stream/followers/:channel`
|
||||
gets the followcount of a channel.
|
||||
path parameters:
|
||||
- `channel`: string - the channel name you want to get the followcount of.
|
||||
|
||||
response (json):
|
||||
- `count`: integer - the number of followers the channel has.
|
||||
- `success`: boolean - whether the operation was successful or not. (true if 200 status code)
|
||||
|
||||
## GET `/stream/info`
|
||||
get stream info of certain channels by filtering. **authentication on some**.
|
||||
query parameters:
|
||||
- `owned`: boolean (optional) - if true, only returns channels owned by the authenticated user. requires authentication.
|
||||
- `personal`: boolean (optional) - if true, only returns personal channels. requires authentication.
|
||||
- `live`: boolean (optional) - if true, only returns channels that are currently live.
|
||||
|
||||
response (json):
|
||||
- StreamInfo[] (check database schema for all returned data or just try it out!)
|
||||
|
||||
## GET `/stream/thumb/:channel`
|
||||
gets the preview thumbnail of a channel's livestream. **authentication required**
|
||||
|
||||
path parameters:
|
||||
- `channel`: string - the channel name you want to get the thumbnail of.
|
||||
|
||||
response: image (webp)
|
||||
32
apps/docs/src/content/docs/guides/dev.mdx
Normal file
32
apps/docs/src/content/docs/guides/dev.mdx
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
title: Development Setup
|
||||
description: Instructions to set up a local development environment for hackclub.tv
|
||||
---
|
||||
|
||||
1. clone repo
|
||||
2. `pnpm install`
|
||||
3. `cp apps/web/.env.example apps/web/.env && cp packages/db/.env.example packages/db/.env`
|
||||
4. `pnpm dev`
|
||||
5. `pnpm db:migrate` (RUN THIS AFTER POPULATING ENV)
|
||||
|
||||
- slack notifier app manifest is as follows:
|
||||
```
|
||||
display_information:
|
||||
# please change the name to something that can be linked to you if possible
|
||||
name: hctv notifier dev
|
||||
features:
|
||||
bot_user:
|
||||
# same with this :pray:
|
||||
display_name: hctv notifier dev
|
||||
always_online: false
|
||||
oauth_config:
|
||||
scopes:
|
||||
bot:
|
||||
- chat:write
|
||||
- users:read
|
||||
- channels:join
|
||||
settings:
|
||||
org_deploy_enabled: false
|
||||
socket_mode_enabled: false
|
||||
token_rotation_enabled: false
|
||||
```
|
||||
4
apps/docs/src/content/docs/guides/meta.json
Normal file
4
apps/docs/src/content/docs/guides/meta.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "Guides",
|
||||
"description": "Useful guides to help you get started with hackclub.tv"
|
||||
}
|
||||
15
apps/docs/src/content/docs/guides/start-stream.mdx
Normal file
15
apps/docs/src/content/docs/guides/start-stream.mdx
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
title: How to stream
|
||||
description: Get started with OBS and streaming on hackclub.tv
|
||||
---
|
||||
|
||||
- open obs
|
||||
- open settings
|
||||
- open "Stream"
|
||||
- set service to custom
|
||||
- set url to `srt://localhost:8890?streamid=publish:CHANNEL_NAME:thisusernameislongonpurposesoyoudontaccidentallyleakyourstreamkey:STREAM_KEY&pkt_size=1316`
|
||||
- on the website, click "Regenerate key"
|
||||
- paste the key into your obs "Stream Key"
|
||||
- start streaming!
|
||||
|
||||
(screenshots to be added soon)
|
||||
8
apps/docs/src/content/docs/index.mdx
Normal file
8
apps/docs/src/content/docs/index.mdx
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
title: About hctv docs
|
||||
description: Index documentation page!
|
||||
---
|
||||
|
||||
Welcome to the hackclub.tv docs! Here you'll find tips and tricks to get the most out of hackclub.tv and its features.
|
||||
|
||||
Additionally, some useful guides are available to help you get started with common tasks.
|
||||
5
apps/docs/tsconfig.json
Normal file
5
apps/docs/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["dist"]
|
||||
}
|
||||
0
apps/docs/yarn.lock
Normal file
0
apps/docs/yarn.lock
Normal file
31
apps/web/.env.example
Normal file
31
apps/web/.env.example
Normal file
@@ -0,0 +1,31 @@
|
||||
DATABASE_URL=postgresql://postgres:skbiditoilet@localhost:5555/postgres
|
||||
|
||||
# make a slack app here: https://api.slack.com/apps
|
||||
SLACK_NOTIFIER_TOKEN=<make a bot for this, check app manifest below>
|
||||
|
||||
# invite your bot to the channel you created. below is #hctv-dev, so use that if you want!
|
||||
NOTIFICATION_CHANNEL_ID=C08M3MGE6PJ
|
||||
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
||||
# get from https://uploadthing.com/
|
||||
UPLOADTHING_TOKEN=<get from uploadthing>
|
||||
|
||||
# enable oauth mode on your hca account and make an app: https://auth.hackclub.com/identity/edit
|
||||
HCID_CLIENT=<auth.hackclub.com client>
|
||||
HCID_SECRET=<auth.hackclub.com secret>
|
||||
# make sure to put this as one of the redirect uri
|
||||
HCID_REDIRECT_URI=http://localhost:3000/auth/hackclub/callback
|
||||
|
||||
# mediamtx settings
|
||||
NEXT_PUBLIC_MEDIAMTX_URL_HQ=http://localhost:8891
|
||||
MEDIAMTX_API_HQ=http://localhost:9997
|
||||
NEXT_PUBLIC_MEDIAMTX_INGEST_ROUTE_HQ=localhost:8890
|
||||
|
||||
# commented because we don't have another ingest server as of right now
|
||||
# NEXT_PUBLIC_MEDIAMTX_URL_ASIA=http://localhost:8991
|
||||
# MEDIAMTX_API_ASIA=http://localhost:9999
|
||||
# NEXT_PUBLIC_MEDIAMTX_INGEST_ROUTE_ASIA=localhost:8990
|
||||
|
||||
# idt you should change this
|
||||
MEDIAMTX_PUBLISH_KEY=rjq1xdpCPA4qyt3jge
|
||||
@@ -1,14 +1,20 @@
|
||||
FROM node:lts-alpine AS base
|
||||
FROM node:lts-slim AS base
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
|
||||
FROM base AS builder
|
||||
RUN apk update
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
# Replace <your-major-version> with the major version installed in your repository. For example:
|
||||
# RUN yarn global add turbo@^2
|
||||
RUN yarn global add turbo@^2
|
||||
RUN pnpm add -g turbo@^2
|
||||
COPY . .
|
||||
|
||||
# Get the git commit hash before pruning (since .git might be removed)
|
||||
RUN git rev-parse --short HEAD > /tmp/commit_hash || echo "unknown" > /tmp/commit_hash
|
||||
|
||||
# Generate a partial monorepo with a pruned lockfile for a target workspace.
|
||||
# Assuming "web" is the name entered in the project's package.json: { name: "web" }
|
||||
@@ -16,25 +22,58 @@ RUN turbo prune @hctv/web --docker
|
||||
|
||||
# Add lockfile and package.json's of isolated subworkspace
|
||||
FROM base AS installer
|
||||
RUN apk update
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
libvips-dev \
|
||||
python3 \
|
||||
make \
|
||||
g++ \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# Get the commit hash from the builder stage
|
||||
COPY --from=builder /tmp/commit_hash /tmp/commit_hash
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
# First install the dependencies (as they change less often)
|
||||
COPY --from=builder /app/out/json/ .
|
||||
RUN yarn install --frozen-lockfile
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY --from=builder /app/out/full/ .
|
||||
RUN --mount=type=secret,id=TURBO_TOKEN --mount=type=secret,id=TURBO_TEAM TURBO_TOKEN=$(cat /run/secrets/TURBO_TOKEN) TURBO_TEAM=$(cat /run/secrets/TURBO_TEAM) yarn turbo run build
|
||||
RUN --mount=type=secret,id=TURBO_TOKEN --mount=type=secret,id=TURBO_TEAM --mount=type=secret,id=SENTRY_AUTH_TOKEN \
|
||||
COMMIT=$(cat /tmp/commit_hash 2>/dev/null || echo "unknown") && \
|
||||
TURBO_TOKEN=$(cat /run/secrets/TURBO_TOKEN) TURBO_TEAM=$(cat /run/secrets/TURBO_TEAM) \
|
||||
SENTRY_AUTH_TOKEN=$(cat /run/secrets/SENTRY_AUTH_TOKEN) \
|
||||
commit=$COMMIT yarn turbo run build --env-mode=loose
|
||||
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache ffmpeg
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ffmpeg \
|
||||
libvips42 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN groupadd --system --gid 1001 nodejs
|
||||
RUN useradd --system --uid 1001 nextjs
|
||||
|
||||
# Copy Prisma files for migrations
|
||||
COPY --from=installer --chown=nextjs:nodejs /app/packages/db/prisma ./packages/db/prisma
|
||||
COPY --from=installer --chown=nextjs:nodejs /app/packages/db/generated ./packages/db/generated
|
||||
COPY --from=installer --chown=nextjs:nodejs /app/packages/db/package.json ./packages/db/package.json
|
||||
COPY --from=installer --chown=nextjs:nodejs /app/node_modules ./node_modules
|
||||
|
||||
COPY --from=installer /tmp/commit_hash /tmp/commit_hash
|
||||
RUN COMMIT_VALUE=$(cat /tmp/commit_hash 2>/dev/null || echo "unknown") && \
|
||||
echo "#!/bin/sh" > /usr/local/bin/start.sh && \
|
||||
echo "set -e" >> /usr/local/bin/start.sh && \
|
||||
echo "echo 'Running database migrations...'" >> /usr/local/bin/start.sh && \
|
||||
echo "pnpm prisma migrate deploy --schema=/app/packages/db/prisma/schema.prisma" >> /usr/local/bin/start.sh && \
|
||||
echo "cd /app" >> /usr/local/bin/start.sh && \
|
||||
echo "export commit=$COMMIT_VALUE" >> /usr/local/bin/start.sh && \
|
||||
echo "echo 'Starting Next.js application...'" >> /usr/local/bin/start.sh && \
|
||||
echo "exec node apps/web/server.js" >> /usr/local/bin/start.sh && \
|
||||
chmod +x /usr/local/bin/start.sh
|
||||
|
||||
# Don't run production as root
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
USER nextjs
|
||||
|
||||
# Automatically leverage output traces to reduce image size
|
||||
@@ -42,5 +81,6 @@ USER nextjs
|
||||
COPY --from=installer --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
|
||||
COPY --from=installer --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
|
||||
COPY --from=installer --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
|
||||
COPY --chown=nextjs:nodejs apps/web/emojis.json .
|
||||
|
||||
CMD node apps/web/server.js
|
||||
CMD ["/usr/local/bin/start.sh"]
|
||||
@@ -1,3 +1,5 @@
|
||||
# quick and dirty ai generated benchmark i created for hls streams
|
||||
|
||||
#!/usr/bin/env python3
|
||||
import asyncio
|
||||
import aiohttp
|
||||
|
||||
Binary file not shown.
@@ -1,12 +1,21 @@
|
||||
import {withSentryConfig} from '@sentry/nextjs';
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const LIVE_SERVER_URL =
|
||||
process.env.NODE_ENV === 'production'
|
||||
? 'https://backend.hctv.srizan.dev'
|
||||
? 'http://nginx-rtmp:8888'
|
||||
: 'http://localhost:8888';
|
||||
|
||||
const packageJson = JSON.parse(readFileSync('./package.json', 'utf8'));
|
||||
const { version } = packageJson;
|
||||
const commit = process.env.commit || execSync('git rev-parse --short HEAD')
|
||||
.toString().trim();
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
images: {
|
||||
@@ -17,15 +26,28 @@ const nextConfig = {
|
||||
{
|
||||
hostname: 'secure.gravatar.com',
|
||||
},
|
||||
{
|
||||
hostname: 'emoji.slack-edge.com',
|
||||
},
|
||||
{
|
||||
hostname: 'cdn.jsdelivr.net',
|
||||
pathname: '/npm/emoji-datasource-twitter@15.1.2/img/twitter/64/*',
|
||||
},
|
||||
{
|
||||
hostname: 'eoceqrx2r7.ufs.sh'
|
||||
},
|
||||
],
|
||||
minimumCacheTTL: 120,
|
||||
},
|
||||
env: {
|
||||
LIVE_SERVER_URL,
|
||||
commit,
|
||||
version,
|
||||
},
|
||||
reactStrictMode: false,
|
||||
output: 'standalone',
|
||||
outputFileTracingRoot: path.join(__dirname, '../../'),
|
||||
serverExternalPackages: ['bullmq'],
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
@@ -34,6 +56,46 @@ const nextConfig = {
|
||||
},
|
||||
];
|
||||
},
|
||||
logging: {
|
||||
incomingRequests: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
export default withSentryConfig(nextConfig, {
|
||||
// For all available options, see:
|
||||
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
|
||||
|
||||
org: "sr-izan",
|
||||
|
||||
project: "hctv",
|
||||
|
||||
// Auth token for uploading source maps
|
||||
authToken: process.env.SENTRY_AUTH_TOKEN,
|
||||
|
||||
// Only print logs for uploading source maps in CI
|
||||
silent: !process.env.CI,
|
||||
|
||||
// For all available options, see:
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
|
||||
|
||||
// Upload a larger set of source maps for prettier stack traces (increases build time)
|
||||
widenClientFileUpload: true,
|
||||
|
||||
// Hides source maps from generated client bundles
|
||||
hideSourceMaps: true,
|
||||
|
||||
// Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
|
||||
// This can increase your server load as well as your hosting bill.
|
||||
// Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
|
||||
// side errors will fail.
|
||||
tunnelRoute: "/monitoring",
|
||||
|
||||
// Automatically tree-shake Sentry logger statements to reduce bundle size
|
||||
disableLogger: true,
|
||||
|
||||
// Enables automatic instrumentation of Vercel Cron Monitors. (Does not yet work with App Router route handlers.)
|
||||
// See the following for more information:
|
||||
// https://docs.sentry.io/product/crons/
|
||||
// https://vercel.com/docs/cron-jobs
|
||||
automaticVercelMonitors: true,
|
||||
});
|
||||
@@ -1,63 +1,80 @@
|
||||
{
|
||||
"name": "@hctv/web",
|
||||
"version": "0.1.0",
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dd": "docker compose --file ../../dev/docker-compose.yml up -d",
|
||||
"dev": "next dev --turbo",
|
||||
"dev": "next dev --turbo -H 0.0.0.0",
|
||||
"donly": "docker compose --file ../../dev/docker-compose.yml up",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"ui:add": "shadcn add",
|
||||
"check-types": "tsc --noEmit"
|
||||
"check-types": "tsc --noEmit",
|
||||
"genMtxTypes": "bunx openapi-typescript https://github.com/bluenviron/mediamtx/raw/refs/tags/v1.15.5/api/openapi.yaml -o ./src/lib/types/mediamtx.d.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hctv/auth": "*",
|
||||
"@hctv/db": "*",
|
||||
"@hctv/auth": "workspace:*",
|
||||
"@hctv/db": "workspace:*",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@livekit/components-react": "^2.7.0",
|
||||
"@lucia-auth/adapter-prisma": "^4.0.1",
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@omit/react-confirm-dialog": "^1.2.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-avatar": "^1.0.4",
|
||||
"@radix-ui/react-checkbox": "^1.1.4",
|
||||
"@radix-ui/react-dialog": "^1.1.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||
"@radix-ui/react-hover-card": "^1.1.14",
|
||||
"@radix-ui/react-label": "^2.1.1",
|
||||
"@radix-ui/react-popover": "^1.1.5",
|
||||
"@radix-ui/react-select": "^2.1.5",
|
||||
"@radix-ui/react-separator": "^1.1.1",
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.1.3",
|
||||
"@radix-ui/react-tooltip": "^1.1.6",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@scalar/api-reference-react": "^0.7.42",
|
||||
"@sentry/nextjs": "^10",
|
||||
"@slack/web-api": "^7.9.1",
|
||||
"@uidotdev/usehooks": "^2.4.1",
|
||||
"arctic": "^3.1.1",
|
||||
"@uploadthing/react": "^7.3.1",
|
||||
"ajv": "^8.17.1",
|
||||
"arctic": "^3.7.0",
|
||||
"bullmq": "^5.45.2",
|
||||
"cheerio": "^1.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.0",
|
||||
"cmdk": "1.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"hls-video-element": "^1.5.0",
|
||||
"ioredis": "^5.6.0",
|
||||
"livekit-client": "^2.8.0",
|
||||
"livekit-server-sdk": "^2.9.7",
|
||||
"hls.js": "^1.6.15",
|
||||
"lucia": "^3.2.2",
|
||||
"lucide-react": "^0.473.0",
|
||||
"media-chrome": "^4.8.0",
|
||||
"next": "^15.2.4",
|
||||
"next": "^16.1.0",
|
||||
"next-themes": "^0.4.4",
|
||||
"node-cron": "^3.0.3",
|
||||
"nuqs": "^2.4.3",
|
||||
"pg": "^8.14.1",
|
||||
"pg-boss": "^10.1.6",
|
||||
"react": "19",
|
||||
"react-dom": "19",
|
||||
"react": "^19.2.3",
|
||||
"react-day-picker": "^9.13.0",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-react": "^8.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-rehype": "^11.1.2",
|
||||
"sharp": "^0.34.3",
|
||||
"sonner": "^1.4.41",
|
||||
"swr": "^2.3.0",
|
||||
"tailwind-merge": "^2.2.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"unified": "^11.0.5",
|
||||
"uploadthing": "^7.7.2",
|
||||
"util-utils": "^1.0.3",
|
||||
"valtio": "^2.1.2",
|
||||
"ws": "^8.18.1",
|
||||
@@ -72,7 +89,7 @@
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "15.1.3",
|
||||
"postcss": "^8",
|
||||
"shadcn": "^2.1.8",
|
||||
"shadcn": "^2.7.0",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5"
|
||||
}
|
||||
|
||||
20
apps/web/sentry.edge.config.ts
Normal file
20
apps/web/sentry.edge.config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
// This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on).
|
||||
// The config you add here will be used whenever one of the edge features is loaded.
|
||||
// Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally.
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
Sentry.init({
|
||||
dsn: "https://f3c26671c39af48406c6e23702a4f3dd@o4506961023860736.ingest.us.sentry.io/4509895816773632",
|
||||
|
||||
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
|
||||
tracesSampleRate: 1,
|
||||
|
||||
// Enable logs to be sent to Sentry
|
||||
enableLogs: true,
|
||||
|
||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||
debug: false,
|
||||
enabled: process.env.NODE_ENV === 'production',
|
||||
});
|
||||
@@ -1,16 +1,18 @@
|
||||
import LiveStream from "@/components/app/Livestream/Livestream";
|
||||
import ChatPanel from "@/components/app/ChatPanel/ChatPanel";
|
||||
import { prisma } from '@hctv/db';
|
||||
|
||||
export default async function Page({ params }: { params: Promise<{ username: string }> }) {
|
||||
const { username } = await params;
|
||||
const streamInfo = await prisma.streamInfo.findUnique({
|
||||
where: { username },
|
||||
include: { ownedBy: true },
|
||||
include: { channel: true },
|
||||
});
|
||||
if (!streamInfo) {
|
||||
return <div>Stream not found</div>;
|
||||
}
|
||||
return (
|
||||
<LiveStream username={username} streamInfo={streamInfo} />
|
||||
<div className="bg-transparent h-screen">
|
||||
<ChatPanel isObsPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import fsP from 'fs/promises';
|
||||
import fs from 'fs';
|
||||
import { getRedisConnection } from '@/lib/services/redis';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ path: string }> }) {
|
||||
const { path } = await params;
|
||||
const c = await cookies();
|
||||
if (!getRedisConnection().exists(`sessions:${c.get('auth_session')?.value}`)) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
if (path.includes('..')) {
|
||||
return new Response("nuh uh", { status: 403 });
|
||||
}
|
||||
|
||||
const basePath = '/dev/shm/hls';
|
||||
const filePath = `${basePath}/${path}`;
|
||||
const exists = fs.existsSync(filePath);
|
||||
|
||||
if (!exists) {
|
||||
return new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const file = await fsP.readFile(filePath);
|
||||
return new Response(file, {
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { prisma } from '@hctv/db';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const formData = await request.formData();
|
||||
const streamKey = formData.get('name')?.toString() || '';
|
||||
|
||||
const key = await prisma.streamKey.findFirst({
|
||||
where: {
|
||||
key: streamKey,
|
||||
},
|
||||
include: {
|
||||
channel: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!key) {
|
||||
return new Response('nay', {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
const headers = new Headers();
|
||||
headers.append('Location', `rtmp://127.0.0.1/channel-live/${key.channel.name}`);
|
||||
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: headers,
|
||||
});
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { validateRequest } from '@/lib/auth/validate';
|
||||
import { prisma } from '@hctv/db';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const shouldGetOwned = searchParams.get('owned') === 'true';
|
||||
const { user } = await validateRequest();
|
||||
/* const db = await prisma.streamInfo.findMany({
|
||||
include: { ownedBy: true },
|
||||
}); */
|
||||
|
||||
if (shouldGetOwned) {
|
||||
if (!user) {
|
||||
return new Response('No user found in cookies', { status: 401 });
|
||||
}
|
||||
|
||||
const db = await prisma.streamInfo.findMany({
|
||||
where: {
|
||||
channel: {
|
||||
ownerId: user.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
return Response.json(db);
|
||||
} else {
|
||||
const db = await prisma.streamInfo.findMany({
|
||||
include: {
|
||||
channel: true,
|
||||
}
|
||||
});
|
||||
return Response.json(db);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { validateRequest } from '@/lib/auth/validate';
|
||||
import { redirect, RedirectType } from 'next/navigation';
|
||||
|
||||
export default async function Layout({ children }: { children: React.ReactNode }) {
|
||||
const { user } = await validateRequest();
|
||||
if (!user) {
|
||||
return redirect('/auth/slack');
|
||||
}
|
||||
if (!user.hasOnboarded) {
|
||||
return redirect(`/onboarding`, RedirectType.push);
|
||||
}
|
||||
return children;
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { UniversalForm } from '@/components/app/UniversalForm/UniversalForm';
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
|
||||
import { onboard } from '@/lib/form/actions';
|
||||
import { useSession } from '@/lib/providers/SessionProvider';
|
||||
import { redirect, useRouter } from 'next/navigation';
|
||||
|
||||
export default function OnboardingClient() {
|
||||
const { user } = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Card className="mx-auto max-w-sm border-0 shadow-none">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle>Welcome to hackclub.tv!</CardTitle>
|
||||
<CardDescription>
|
||||
To get started, please enter the username of your personal channel.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p>join #hctv! you will get welcomed to the channel after submitting the form!</p>
|
||||
<UniversalForm
|
||||
fields={[
|
||||
{ name: 'userId', label: 'User ID', type: 'hidden', value: user?.id },
|
||||
{ name: 'username', label: 'Username', type: 'text' },
|
||||
]}
|
||||
schemaName="onboard"
|
||||
action={onboard}
|
||||
onActionComplete={() => {
|
||||
window.location.href = '/';
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
39
apps/web/src/app/(ui)/(protected)/[username]/page.tsx
Normal file
39
apps/web/src/app/(ui)/(protected)/[username]/page.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import LiveStream from "@/components/app/Livestream/Livestream";
|
||||
import { prisma } from '@hctv/db';
|
||||
|
||||
export default async function Page({ params }: { params: Promise<{ username: string }> }) {
|
||||
const { username } = await params;
|
||||
const streamInfo = await prisma.streamInfo.findUnique({
|
||||
where: { username },
|
||||
include: {
|
||||
channel: {
|
||||
include: {
|
||||
restriction: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!streamInfo) {
|
||||
return <div>Stream not found</div>;
|
||||
}
|
||||
|
||||
if (streamInfo.channel.restriction) {
|
||||
const isExpired = streamInfo.channel.restriction.expiresAt &&
|
||||
new Date(streamInfo.channel.restriction.expiresAt) < new Date();
|
||||
|
||||
if (!isExpired) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-[calc(100vh-64px)] p-4">
|
||||
<h1 className="text-2xl font-bold text-destructive mb-2">Channel Restricted</h1>
|
||||
<p className="text-muted-foreground text-center max-w-md">
|
||||
This channel has been restricted by a moderator and is not currently available for viewing.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<LiveStream username={username} streamInfo={streamInfo} />
|
||||
);
|
||||
}
|
||||
792
apps/web/src/app/(ui)/(protected)/admin/page.client.tsx
Normal file
792
apps/web/src/app/(ui)/(protected)/admin/page.client.tsx
Normal file
@@ -0,0 +1,792 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import {
|
||||
Users,
|
||||
Tv,
|
||||
Ban,
|
||||
ShieldOff,
|
||||
Search,
|
||||
CalendarIcon,
|
||||
ShieldCheck,
|
||||
ShieldMinus,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { toast } from 'sonner';
|
||||
import type { User } from '@hctv/db';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export default function AdminPanelClient({ currentUser }: AdminPanelClientProps) {
|
||||
const [userSearch, setUserSearch] = useState('');
|
||||
const [channelSearch, setChannelSearch] = useState('');
|
||||
const [users, setUsers] = useState<UserWithBan[]>([]);
|
||||
const [channels, setChannels] = useState<ChannelWithRestriction[]>([]);
|
||||
const [usersLoading, setUsersLoading] = useState(false);
|
||||
const [channelsLoading, setChannelsLoading] = useState(false);
|
||||
|
||||
const [banDialogOpen, setBanDialogOpen] = useState(false);
|
||||
const [restrictDialogOpen, setRestrictDialogOpen] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState<UserWithBan | null>(null);
|
||||
const [selectedChannel, setSelectedChannel] = useState<ChannelWithRestriction | null>(null);
|
||||
const [reason, setReason] = useState('');
|
||||
const [expiresAt, setExpiresAt] = useState<Date | undefined>(undefined);
|
||||
|
||||
const fetchUsers = useCallback(async (search: string) => {
|
||||
setUsersLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/users?search=${encodeURIComponent(search)}`);
|
||||
if (res.ok) {
|
||||
setUsers(await res.json());
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('Failed to fetch users');
|
||||
} finally {
|
||||
setUsersLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchChannels = useCallback(async (search: string) => {
|
||||
setChannelsLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/channels?search=${encodeURIComponent(search)}`);
|
||||
if (res.ok) {
|
||||
setChannels(await res.json());
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('Failed to fetch channels');
|
||||
} finally {
|
||||
setChannelsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers('');
|
||||
fetchChannels('');
|
||||
}, [fetchUsers, fetchChannels]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
fetchUsers(userSearch);
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [userSearch, fetchUsers]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
fetchChannels(channelSearch);
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [channelSearch, fetchChannels]);
|
||||
|
||||
const resetDialogState = () => {
|
||||
setReason('');
|
||||
setExpiresAt(undefined);
|
||||
setSelectedUser(null);
|
||||
setSelectedChannel(null);
|
||||
};
|
||||
|
||||
const handleBanUser = async () => {
|
||||
if (!selectedUser || !reason.trim()) {
|
||||
toast.error('Please provide a reason');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
userId: selectedUser.id,
|
||||
action: 'ban',
|
||||
reason,
|
||||
expiresAt: expiresAt?.toISOString(),
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
toast.success('User banned successfully');
|
||||
fetchUsers(userSearch);
|
||||
setBanDialogOpen(false);
|
||||
resetDialogState();
|
||||
} else {
|
||||
const err = await res.text();
|
||||
toast.error(err || 'Failed to ban user');
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('Failed to ban user');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnbanUser = async (userId: string) => {
|
||||
try {
|
||||
const res = await fetch('/api/admin/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
userId,
|
||||
action: 'unban',
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
toast.success('User unbanned successfully');
|
||||
fetchUsers(userSearch);
|
||||
} else {
|
||||
toast.error('Failed to unban user');
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('Failed to unban user');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestrictChannel = async () => {
|
||||
if (!selectedChannel || !reason.trim()) {
|
||||
toast.error('Please provide a reason');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/channels', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
channelId: selectedChannel.id,
|
||||
action: 'restrict',
|
||||
reason,
|
||||
expiresAt: expiresAt?.toISOString(),
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
toast.success('Channel restricted successfully');
|
||||
fetchChannels(channelSearch);
|
||||
setRestrictDialogOpen(false);
|
||||
resetDialogState();
|
||||
} else {
|
||||
const err = await res.text();
|
||||
toast.error(err || 'Failed to restrict channel');
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('Failed to restrict channel');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnrestrictChannel = async (channelId: string) => {
|
||||
try {
|
||||
const res = await fetch('/api/admin/channels', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
channelId,
|
||||
action: 'unrestrict',
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
toast.success('Channel unrestricted successfully');
|
||||
fetchChannels(channelSearch);
|
||||
} else {
|
||||
toast.error('Failed to unrestrict channel');
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('Failed to unrestrict channel');
|
||||
}
|
||||
};
|
||||
|
||||
const handlePromoteUser = async (userId: string) => {
|
||||
try {
|
||||
const res = await fetch('/api/admin/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
userId,
|
||||
action: 'promote',
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
toast.success('User promoted to admin');
|
||||
fetchUsers(userSearch);
|
||||
} else {
|
||||
const err = await res.text();
|
||||
toast.error(err || 'Failed to promote user');
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('Failed to promote user');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDemoteUser = async (userId: string) => {
|
||||
try {
|
||||
const res = await fetch('/api/admin/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
userId,
|
||||
action: 'demote',
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
toast.success('User demoted from admin');
|
||||
fetchUsers(userSearch);
|
||||
} else {
|
||||
const err = await res.text();
|
||||
toast.error(err || 'Failed to demote user');
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('Failed to demote user');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container max-w-6xl mx-auto py-6 px-4">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold flex items-center gap-2">
|
||||
Admin Panel
|
||||
</h1>
|
||||
<p className="text-muted-foreground">Manage users and channels on the platform</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="users" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="users" className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4" />
|
||||
Users
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="channels" className="flex items-center gap-2">
|
||||
<Tv className="h-4 w-4" />
|
||||
Channels
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="users">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>User Management</CardTitle>
|
||||
<CardDescription>
|
||||
Search and manage user accounts. Ban users to prevent them from using the platform.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="mb-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by username or email..."
|
||||
value={userSearch}
|
||||
onChange={(e) => setUserSearch(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{usersLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="text-center py-8">
|
||||
Loading...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : users.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="text-center py-8">
|
||||
No users found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarImage src={user.pfpUrl} />
|
||||
<AvatarFallback>
|
||||
{user.personalChannel?.name?.[0]?.toUpperCase() || 'U'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{user.personalChannel?.name}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{user.isAdmin && (
|
||||
<Badge variant="default">Admin</Badge>
|
||||
)}
|
||||
{user.ban ? (
|
||||
<Badge variant="destructive" className="flex items-center gap-1">
|
||||
<Ban className="h-3 w-3" />
|
||||
Banned
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Active</Badge>
|
||||
)}
|
||||
</div>
|
||||
{user.ban && (
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
<p>Reason: {user.ban.reason}</p>
|
||||
{user.ban.expiresAt && (
|
||||
<p>Expires: {format(new Date(user.ban.expiresAt), 'PPP')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{user.isAdmin ? (
|
||||
user.id !== currentUser.id && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDemoteUser(user.id)}
|
||||
>
|
||||
<ShieldMinus className="h-4 w-4 mr-1" />
|
||||
Demote
|
||||
</Button>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{user.ban ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleUnbanUser(user.id)}
|
||||
>
|
||||
<ShieldOff className="h-4 w-4 mr-1" />
|
||||
Unban
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSelectedUser(user);
|
||||
setBanDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Ban className="h-4 w-4 mr-1" />
|
||||
Ban
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePromoteUser(user.id)}
|
||||
>
|
||||
<ShieldCheck className="h-4 w-4 mr-1" />
|
||||
Promote
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="channels">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Channel Management</CardTitle>
|
||||
<CardDescription>
|
||||
Search and manage channels. Restrict channels to prevent streams from being viewed.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="mb-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by channel name..."
|
||||
value={channelSearch}
|
||||
onChange={(e) => setChannelSearch(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Channel</TableHead>
|
||||
<TableHead>Owner</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{channelsLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center py-8">
|
||||
Loading...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : channels.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center py-8">
|
||||
No channels found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
channels.map((channel) => (
|
||||
<TableRow key={channel.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarImage src={channel.pfpUrl} />
|
||||
<AvatarFallback>
|
||||
{channel.name[0]?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="font-medium">{channel.name}</p>
|
||||
{channel.personalFor && (
|
||||
<Badge variant="outline" className="text-xs">Personal</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarImage src={channel.owner.pfpUrl} />
|
||||
<AvatarFallback>O</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-sm">{channel.owner.personalChannel.name}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{channel.restriction ? (
|
||||
<Badge variant="destructive" className="flex items-center gap-1 w-fit">
|
||||
<Ban className="h-3 w-3" />
|
||||
Restricted
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Active</Badge>
|
||||
)}
|
||||
{channel.restriction && (
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
<p>Reason: {channel.restriction.reason}</p>
|
||||
{channel.restriction.expiresAt && (
|
||||
<p>Expires: {format(new Date(channel.restriction.expiresAt), 'PPP')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{channel.restriction ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleUnrestrictChannel(channel.id)}
|
||||
>
|
||||
<ShieldOff className="h-4 w-4 mr-1" />
|
||||
Unrestrict
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSelectedChannel(channel);
|
||||
setRestrictDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Ban className="h-4 w-4 mr-1" />
|
||||
Restrict
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<Dialog open={banDialogOpen} onOpenChange={(open) => {
|
||||
setBanDialogOpen(open);
|
||||
if (!open) resetDialogState();
|
||||
}}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Ban User</DialogTitle>
|
||||
<DialogDescription>
|
||||
Ban {selectedUser?.personalChannel?.name || selectedUser?.slack_id} from the platform.
|
||||
They will not be able to stream or use the platform while banned.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Reason</label>
|
||||
<Textarea
|
||||
placeholder="Enter the reason for banning this user..."
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Expires (optional)</label>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
Leave empty for a permanent ban
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'flex-1 justify-start text-left font-normal',
|
||||
!expiresAt && 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{expiresAt ? format(expiresAt, 'PPP p') : 'Pick a date & time'}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={expiresAt}
|
||||
onSelect={(date) => {
|
||||
if (date) {
|
||||
const newDate = expiresAt ? new Date(expiresAt) : new Date();
|
||||
date.setHours(newDate.getHours(), newDate.getMinutes());
|
||||
setExpiresAt(date);
|
||||
} else {
|
||||
setExpiresAt(undefined);
|
||||
}
|
||||
}}
|
||||
disabled={(date) => date < new Date(new Date().setHours(0, 0, 0, 0))}
|
||||
/>
|
||||
<div className="border-t p-3">
|
||||
<label className="text-sm font-medium">Time</label>
|
||||
<Input
|
||||
type="time"
|
||||
className="mt-1"
|
||||
value={expiresAt ? format(expiresAt, 'HH:mm') : ''}
|
||||
onChange={(e) => {
|
||||
if (e.target.value) {
|
||||
const [hours, minutes] = e.target.value.split(':').map(Number);
|
||||
const newDate = expiresAt ? new Date(expiresAt) : new Date();
|
||||
newDate.setHours(hours, minutes);
|
||||
setExpiresAt(newDate);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{expiresAt && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setExpiresAt(undefined)}
|
||||
>
|
||||
<X className='w-4 h-4' />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => {
|
||||
setBanDialogOpen(false);
|
||||
resetDialogState();
|
||||
}}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleBanUser}>
|
||||
Ban User
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={restrictDialogOpen} onOpenChange={(open) => {
|
||||
setRestrictDialogOpen(open);
|
||||
if (!open) resetDialogState();
|
||||
}}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Restrict Channel</DialogTitle>
|
||||
<DialogDescription>
|
||||
Restrict {selectedChannel?.name} from streaming. Viewers will not be able to watch
|
||||
streams from this channel.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Reason</label>
|
||||
<Textarea
|
||||
placeholder="Enter the reason for restricting this channel..."
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Expires (optional)</label>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
Leave empty for a permanent restriction
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'flex-1 justify-start text-left font-normal',
|
||||
!expiresAt && 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{expiresAt ? format(expiresAt, 'PPP p') : 'Pick a date & time'}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={expiresAt}
|
||||
onSelect={(date) => {
|
||||
if (date) {
|
||||
const newDate = expiresAt ? new Date(expiresAt) : new Date();
|
||||
date.setHours(newDate.getHours(), newDate.getMinutes());
|
||||
setExpiresAt(date);
|
||||
} else {
|
||||
setExpiresAt(undefined);
|
||||
}
|
||||
}}
|
||||
disabled={(date) => date < new Date(new Date().setHours(0, 0, 0, 0))}
|
||||
/>
|
||||
<div className="border-t p-3">
|
||||
<label className="text-sm font-medium">Time</label>
|
||||
<Input
|
||||
type="time"
|
||||
className="mt-1"
|
||||
value={expiresAt ? format(expiresAt, 'HH:mm') : ''}
|
||||
onChange={(e) => {
|
||||
if (e.target.value) {
|
||||
const [hours, minutes] = e.target.value.split(':').map(Number);
|
||||
const newDate = expiresAt ? new Date(expiresAt) : new Date();
|
||||
newDate.setHours(hours, minutes);
|
||||
setExpiresAt(newDate);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{expiresAt && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setExpiresAt(undefined)}
|
||||
>
|
||||
<X className='w-4 h-4' />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => {
|
||||
setRestrictDialogOpen(false);
|
||||
resetDialogState();
|
||||
}}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleRestrictChannel}>
|
||||
Restrict Channel
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface UserWithBan {
|
||||
id: string;
|
||||
slack_id: string;
|
||||
email: string | null;
|
||||
pfpUrl: string;
|
||||
isAdmin: boolean;
|
||||
ban: {
|
||||
id: string;
|
||||
reason: string;
|
||||
bannedBy: string;
|
||||
createdAt: string;
|
||||
expiresAt: string | null;
|
||||
} | null;
|
||||
personalChannel: { name: string } | null;
|
||||
}
|
||||
|
||||
interface ChannelWithRestriction {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
pfpUrl: string;
|
||||
owner: { id: string; slack_id: string; pfpUrl: string; personalChannel: { name: string } };
|
||||
personalFor: { id: string } | null;
|
||||
restriction: {
|
||||
id: string;
|
||||
reason: string;
|
||||
restrictedBy: string;
|
||||
createdAt: string;
|
||||
expiresAt: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface AdminPanelClientProps {
|
||||
currentUser: User;
|
||||
}
|
||||
|
||||
17
apps/web/src/app/(ui)/(protected)/admin/page.tsx
Normal file
17
apps/web/src/app/(ui)/(protected)/admin/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { validateRequest } from '@/lib/auth/validate';
|
||||
import { redirect } from 'next/navigation';
|
||||
import AdminPanelClient from './page.client';
|
||||
|
||||
export default async function AdminPage() {
|
||||
const { user } = await validateRequest();
|
||||
|
||||
if (!user) {
|
||||
redirect('/auth/hackclub');
|
||||
}
|
||||
|
||||
if (!user.isAdmin) {
|
||||
redirect('/');
|
||||
}
|
||||
|
||||
return <AdminPanelClient currentUser={user} />;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { validateRequest } from '@/lib/auth/validate';
|
||||
import { prisma } from '@hctv/db';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { user } = await validateRequest();
|
||||
if (!user?.isAdmin) {
|
||||
return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const search = searchParams.get('search') || '';
|
||||
|
||||
const channels = await prisma.channel.findMany({
|
||||
where: search
|
||||
? {
|
||||
OR: [
|
||||
{ name: { contains: search, mode: 'insensitive' } },
|
||||
{ description: { contains: search, mode: 'insensitive' } },
|
||||
],
|
||||
}
|
||||
: undefined,
|
||||
include: {
|
||||
restriction: true,
|
||||
owner: {
|
||||
select: { id: true, slack_id: true, pfpUrl: true, personalChannel: { select: { name: true } } },
|
||||
},
|
||||
personalFor: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
take: 50,
|
||||
});
|
||||
return Response.json(channels);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const { user } = await validateRequest();
|
||||
if (!user?.isAdmin) {
|
||||
return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { channelId, action, reason, expiresAt } = body as {
|
||||
channelId: string;
|
||||
action: 'restrict' | 'unrestrict';
|
||||
reason?: string;
|
||||
expiresAt?: string;
|
||||
};
|
||||
|
||||
if (!channelId || !action) {
|
||||
return new Response('Missing required fields', { status: 400 });
|
||||
}
|
||||
|
||||
const channel = await prisma.channel.findUnique({ where: { id: channelId } });
|
||||
if (!channel) {
|
||||
return new Response('Channel not found', { status: 404 });
|
||||
}
|
||||
|
||||
if (action === 'restrict') {
|
||||
if (!reason) {
|
||||
return new Response('Reason is required for restricting', { status: 400 });
|
||||
}
|
||||
|
||||
await prisma.channelRestriction.upsert({
|
||||
where: { channelId },
|
||||
update: {
|
||||
reason,
|
||||
restrictedBy: user.id,
|
||||
expiresAt: expiresAt ? new Date(expiresAt) : null,
|
||||
},
|
||||
create: {
|
||||
channelId,
|
||||
reason,
|
||||
restrictedBy: user.id,
|
||||
expiresAt: expiresAt ? new Date(expiresAt) : null,
|
||||
},
|
||||
});
|
||||
|
||||
return Response.json({ success: true, message: 'Channel restricted' });
|
||||
}
|
||||
|
||||
if (action === 'unrestrict') {
|
||||
await prisma.channelRestriction.delete({ where: { channelId } }).catch(() => { });
|
||||
return Response.json({ success: true, message: 'Channel unrestricted' });
|
||||
}
|
||||
|
||||
return new Response('Invalid action', { status: 400 });
|
||||
}
|
||||
119
apps/web/src/app/(ui)/(protected)/api/admin/users/route.ts
Normal file
119
apps/web/src/app/(ui)/(protected)/api/admin/users/route.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { validateRequest } from '@/lib/auth/validate';
|
||||
import { prisma } from '@hctv/db';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { user } = await validateRequest();
|
||||
if (!user?.isAdmin) {
|
||||
return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const search = searchParams.get('search') || '';
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where: search
|
||||
? {
|
||||
OR: [
|
||||
{ slack_id: { contains: search, mode: 'insensitive' } },
|
||||
{ email: { contains: search, mode: 'insensitive' } },
|
||||
{ personalChannel: { name: { contains: search, mode: 'insensitive' } } },
|
||||
],
|
||||
}
|
||||
: undefined,
|
||||
include: {
|
||||
ban: true,
|
||||
personalChannel: { select: { name: true } },
|
||||
},
|
||||
take: 50,
|
||||
});
|
||||
return Response.json(users);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const { user } = await validateRequest();
|
||||
if (!user?.isAdmin) {
|
||||
return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { userId, action, reason, expiresAt } = body as {
|
||||
userId: string;
|
||||
action: 'ban' | 'unban' | 'promote' | 'demote';
|
||||
reason?: string;
|
||||
expiresAt?: string;
|
||||
};
|
||||
|
||||
if (!userId || !action) {
|
||||
return new Response('Missing required fields', { status: 400 });
|
||||
}
|
||||
|
||||
const targetUser = await prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!targetUser) {
|
||||
return new Response('User not found', { status: 404 });
|
||||
}
|
||||
|
||||
if (action === 'ban') {
|
||||
if (targetUser.isAdmin) {
|
||||
return new Response('Cannot ban an admin', { status: 400 });
|
||||
}
|
||||
|
||||
if (!reason) {
|
||||
return new Response('Reason is required for banning', { status: 400 });
|
||||
}
|
||||
|
||||
await prisma.userBan.upsert({
|
||||
where: { userId },
|
||||
update: {
|
||||
reason,
|
||||
bannedBy: user.id,
|
||||
expiresAt: expiresAt ? new Date(expiresAt) : null,
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
reason,
|
||||
bannedBy: user.id,
|
||||
expiresAt: expiresAt ? new Date(expiresAt) : null,
|
||||
},
|
||||
});
|
||||
|
||||
return Response.json({ success: true, message: 'User banned' });
|
||||
}
|
||||
|
||||
if (action === 'unban') {
|
||||
await prisma.userBan.delete({ where: { userId } }).catch(() => { });
|
||||
return Response.json({ success: true, message: 'User unbanned' });
|
||||
}
|
||||
|
||||
if (action === 'promote') {
|
||||
if (targetUser.isAdmin) {
|
||||
return new Response('User is already an admin', { status: 400 });
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { isAdmin: true },
|
||||
});
|
||||
|
||||
return Response.json({ success: true, message: 'User promoted to admin' });
|
||||
}
|
||||
|
||||
if (action === 'demote') {
|
||||
if (!targetUser.isAdmin) {
|
||||
return new Response('User is not an admin', { status: 400 });
|
||||
}
|
||||
|
||||
if (targetUser.id === user.id) {
|
||||
return new Response('Cannot demote yourself', { status: 400 });
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { isAdmin: false },
|
||||
});
|
||||
|
||||
return Response.json({ success: true, message: 'User demoted from admin' });
|
||||
}
|
||||
|
||||
return new Response('Invalid action', { status: 400 });
|
||||
}
|
||||
6
apps/web/src/app/(ui)/(protected)/api/commit/route.ts
Normal file
6
apps/web/src/app/(ui)/(protected)/api/commit/route.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export function GET() {
|
||||
return Response.json({
|
||||
version: process.env.version,
|
||||
commit: process.env.commit,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { prisma, getRedisConnection } from '@hctv/db';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const redis = getRedisConnection();
|
||||
const body = await request.json();
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.log(
|
||||
'Mediamtx publish auth request:',
|
||||
JSON.stringify(body, null, 2)
|
||||
);
|
||||
};
|
||||
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 });
|
||||
}
|
||||
|
||||
const channel = await prisma.channel.findUnique({
|
||||
where: { name: path },
|
||||
include: {
|
||||
restriction: true,
|
||||
owner: {
|
||||
include: { ban: true },
|
||||
},
|
||||
streamInfo: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (channel?.restriction) {
|
||||
const isExpired = channel.restriction.expiresAt &&
|
||||
new Date(channel.restriction.expiresAt) < new Date();
|
||||
if (!isExpired) {
|
||||
return new Response('channel restricted', { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
if (channel?.owner?.ban) {
|
||||
const isExpired = channel.owner.ban.expiresAt &&
|
||||
new Date(channel.owner.ban.expiresAt) < new Date();
|
||||
if (!isExpired) {
|
||||
return new Response('user banned', { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
if (channel?.streamInfo[0].isLive) {
|
||||
return new Response('stream already live', { 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 (hls read key for thumbs)', { 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(),
|
||||
});
|
||||
57
apps/web/src/app/(ui)/(protected)/api/rtmp/publish/route.ts
Normal file
57
apps/web/src/app/(ui)/(protected)/api/rtmp/publish/route.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { prisma } from '@hctv/db';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const formData = await request.formData();
|
||||
const streamKey = formData.get('name');
|
||||
if (typeof streamKey !== 'string') {
|
||||
return new Response('bad request', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const key = await prisma.streamKey.findFirst({
|
||||
where: {
|
||||
key: streamKey,
|
||||
},
|
||||
include: {
|
||||
channel: {
|
||||
include: {
|
||||
restriction: true,
|
||||
owner: {
|
||||
include: { ban: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!key) {
|
||||
return new Response('nay', {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
if (key.channel.restriction) {
|
||||
const isExpired = key.channel.restriction.expiresAt &&
|
||||
new Date(key.channel.restriction.expiresAt) < new Date();
|
||||
if (!isExpired) {
|
||||
return new Response('channel restricted', { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
if (key.channel.owner?.ban) {
|
||||
const isExpired = key.channel.owner.ban.expiresAt &&
|
||||
new Date(key.channel.owner.ban.expiresAt) < new Date();
|
||||
if (!isExpired) {
|
||||
return new Response('user banned', { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
return new Response('', {
|
||||
status: 302,
|
||||
headers: {
|
||||
'Location': key.channel.name,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { validateRequest } from '@/lib/auth/validate';
|
||||
import { prisma } from '@hctv/db';
|
||||
import { NextRequest } from "next/server";
|
||||
import { regenerateStreamKey } from '@/lib/db/streamKey';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const { user } = await validateRequest();
|
||||
@@ -11,6 +12,10 @@ export async function POST(request: NextRequest) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
if (!channel || typeof channel !== 'string') {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
const channelInfo = await prisma.channel.findUnique({
|
||||
where: { name: channel },
|
||||
include: {
|
||||
@@ -30,20 +35,9 @@ export async function POST(request: NextRequest) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const dbUpdate = await prisma.streamKey.upsert({
|
||||
create: {
|
||||
key: crypto.randomUUID(),
|
||||
channelId: channelInfo.id
|
||||
},
|
||||
update: {
|
||||
key: crypto.randomUUID()
|
||||
},
|
||||
where: {
|
||||
channelId: channelInfo.id
|
||||
}
|
||||
})
|
||||
const streamKey = await regenerateStreamKey(channelInfo.id, channel);
|
||||
|
||||
return new Response(JSON.stringify({ key: dbUpdate.key }), {
|
||||
return new Response(JSON.stringify({ key: streamKey.key }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
@@ -0,0 +1,124 @@
|
||||
import { validateRequest } from "@/lib/auth/validate";
|
||||
import { prisma } from "@hctv/db";
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
type Params = Promise<{ slug: string }>;
|
||||
|
||||
export async function POST(request: NextRequest, segmentData: { params: Params }) {
|
||||
const { slug } = await segmentData.params;
|
||||
const { user } = await validateRequest();
|
||||
if (!user) {
|
||||
return new Response(JSON.stringify({ success: false, error: 'Unauthorized' }), { status: 401 });
|
||||
}
|
||||
|
||||
const bodySchema = z.object({
|
||||
action: z.enum(['revoke', 'regenerate', 'create']),
|
||||
name: z.string().min(3, 'Name must be at least 3 characters long').max(50, 'Name must be at most 50 characters long'),
|
||||
});
|
||||
const body = await request.json();
|
||||
const parsedBody = bodySchema.safeParse(body);
|
||||
if (!parsedBody.success) {
|
||||
return new Response(JSON.stringify({ success: false, error: parsedBody.error.errors.map(e => e.message).join(', ') }), { status: 400 });
|
||||
}
|
||||
|
||||
const { action, name } = parsedBody.data;
|
||||
|
||||
if (action === 'create') {
|
||||
const exists = await prisma.botApiKey.findFirst({
|
||||
where: {
|
||||
name,
|
||||
botAccount: {
|
||||
ownerId: user.id,
|
||||
slug,
|
||||
}
|
||||
}
|
||||
});
|
||||
if (exists) {
|
||||
return new Response(JSON.stringify({ success: false, error: 'API Key with this name already exists' }), { status: 400 });
|
||||
}
|
||||
const newKey = await prisma.botApiKey.create({
|
||||
data: {
|
||||
name,
|
||||
botAccount: {
|
||||
connect: {
|
||||
ownerId: user.id,
|
||||
slug,
|
||||
}
|
||||
},
|
||||
key: generateApiKey(),
|
||||
}
|
||||
});
|
||||
return new Response(JSON.stringify({ success: true, apiKey: newKey.key, id: newKey.id }));
|
||||
}
|
||||
if (action === 'regenerate') {
|
||||
const existingKey = await prisma.botApiKey.findFirst({
|
||||
where: {
|
||||
name,
|
||||
botAccount: {
|
||||
ownerId: user.id,
|
||||
slug,
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!existingKey) {
|
||||
return new Response(JSON.stringify({ success: false, error: 'API Key not found' }), { status: 404 });
|
||||
}
|
||||
const newKey = generateApiKey();
|
||||
await prisma.botApiKey.update({
|
||||
where: { id: existingKey.id },
|
||||
data: { key: newKey },
|
||||
});
|
||||
return new Response(JSON.stringify({ success: true, apiKey: newKey, id: existingKey.id }));
|
||||
}
|
||||
if (action === 'revoke') {
|
||||
const existingKey = await prisma.botApiKey.findFirst({
|
||||
where: {
|
||||
name,
|
||||
botAccount: {
|
||||
ownerId: user.id,
|
||||
slug,
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!existingKey) {
|
||||
return new Response(JSON.stringify({ success: false, error: 'API Key not found' }), { status: 404 });
|
||||
}
|
||||
await prisma.botApiKey.delete({
|
||||
where: { id: existingKey.id },
|
||||
});
|
||||
return new Response(JSON.stringify({ success: true }));
|
||||
}
|
||||
return new Response(JSON.stringify({ success: false, error: 'Invalid action' }), { status: 400 });
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest, segmentData: { params: Params }) {
|
||||
const { slug } = await segmentData.params;
|
||||
const { user } = await validateRequest();
|
||||
if (!user) {
|
||||
return new Response(JSON.stringify({ success: false, error: 'Unauthorized' }), { status: 401 });
|
||||
}
|
||||
|
||||
const apiKeys = await prisma.botApiKey.findMany({
|
||||
where: {
|
||||
botAccount: {
|
||||
ownerId: user.id,
|
||||
slug,
|
||||
}
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
}
|
||||
});
|
||||
return new Response(JSON.stringify({ success: true, apiKeys }));
|
||||
}
|
||||
|
||||
function generateApiKey() {
|
||||
const uuid = crypto.randomUUID().replace(/-/g, '');
|
||||
return `hctvb_${uuid}`;
|
||||
}
|
||||
@@ -103,7 +103,7 @@ export async function POST(request: NextRequest) {
|
||||
});
|
||||
|
||||
await queue.add(`newFollow:${username}`, {
|
||||
text: `You started following \`${username}\`!\n_Stream notifications are enabled by default. If you want to disable them, you can do so in \`Profile > Notifications\`._`,
|
||||
text: `You started following \`${username}\`!\n_Stream notifications are disabled by default. If you want to enable them, you can do so in \`Profile > Notifications\`._`,
|
||||
channel: user.slack_id,
|
||||
});
|
||||
}
|
||||
91
apps/web/src/app/(ui)/(protected)/api/stream/info/route.ts
Normal file
91
apps/web/src/app/(ui)/(protected)/api/stream/info/route.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
// FIXME: THIS EFFING SUCKS OH MY GOD
|
||||
|
||||
import { validateRequest } from '@/lib/auth/validate';
|
||||
import { Prisma, prisma } from '@hctv/db';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const shouldGetOwned = searchParams.get('owned') === 'true';
|
||||
const allPersonalChannels = searchParams.get('personal') === 'true';
|
||||
const isLive = searchParams.get('live') === 'true';
|
||||
const username = searchParams.get('username');
|
||||
const { user } = await validateRequest();
|
||||
|
||||
if ((shouldGetOwned || allPersonalChannels) && !user) {
|
||||
return new Response('No user found in cookies', { status: 401 });
|
||||
}
|
||||
|
||||
const where: Prisma.StreamInfoWhereInput = {};
|
||||
const channelConditions: Prisma.ChannelWhereInput[] = [];
|
||||
|
||||
if (username) {
|
||||
where.username = username;
|
||||
}
|
||||
|
||||
if (shouldGetOwned && user) {
|
||||
channelConditions.push({ ownerId: user.id });
|
||||
}
|
||||
|
||||
if (allPersonalChannels) {
|
||||
channelConditions.push({
|
||||
personalFor: {
|
||||
isNot: null
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (isLive) {
|
||||
where.isLive = true;
|
||||
}
|
||||
|
||||
if (channelConditions.length > 0) {
|
||||
where.channel = channelConditions.length === 1
|
||||
? channelConditions[0]
|
||||
: { OR: channelConditions };
|
||||
}
|
||||
|
||||
const db = await prisma.streamInfo.findMany({
|
||||
where,
|
||||
include: {
|
||||
channel: {
|
||||
include: {
|
||||
personalFor: true,
|
||||
restriction: {
|
||||
select: {
|
||||
id: true,
|
||||
expiresAt: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
db.forEach((obj) => {
|
||||
if (obj.channel.personalFor) {
|
||||
// @ts-ignore
|
||||
delete obj.channel.personalFor.email;
|
||||
}
|
||||
// @ts-ignore
|
||||
delete obj.channel.obsChatGrantToken;
|
||||
|
||||
if (obj.channel.restriction) {
|
||||
const isExpired = obj.channel.restriction.expiresAt &&
|
||||
new Date(obj.channel.restriction.expiresAt) < new Date();
|
||||
if (isExpired) {
|
||||
// @ts-ignore
|
||||
obj.channel.restriction = null;
|
||||
} else {
|
||||
// @ts-ignore
|
||||
obj.channel.isRestricted = true;
|
||||
// @ts-ignore
|
||||
obj.channel.restrictionExpiresAt = obj.channel.restriction.expiresAt;
|
||||
// @ts-ignore
|
||||
delete obj.channel.restriction;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return Response.json(db);
|
||||
}
|
||||
@@ -12,8 +12,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ user
|
||||
return new Response("nuh uh", { status: 403 });
|
||||
}
|
||||
|
||||
const basePath = '/dev/shm/hctv-thumb';
|
||||
const filePath = `${basePath}/${username}.webp`;
|
||||
const filePath = `/dev/shm/hctv-thumb/${username}.webp`;
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return new Response("Not Found", { status: 404 });
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createRouteHandler } from "uploadthing/next";
|
||||
|
||||
import { ourFileRouter } from "@/lib/services/uploadthing/fileRouter";
|
||||
|
||||
// Export routes for Next App Router
|
||||
export const { GET, POST } = createRouteHandler({
|
||||
router: ourFileRouter,
|
||||
});
|
||||
42
apps/web/src/app/(ui)/(protected)/layout.tsx
Normal file
42
apps/web/src/app/(ui)/(protected)/layout.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { validateRequest } from '@/lib/auth/validate';
|
||||
import { redirect, RedirectType } from 'next/navigation';
|
||||
import { prisma } from '@hctv/db';
|
||||
|
||||
export default async function Layout({ children }: { children: React.ReactNode }) {
|
||||
const { user } = await validateRequest();
|
||||
if (!user) {
|
||||
return redirect('/auth/hackclub');
|
||||
}
|
||||
if (!user.hasOnboarded) {
|
||||
return redirect(`/onboarding`, RedirectType.push);
|
||||
}
|
||||
|
||||
const ban = await prisma.userBan.findUnique({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
|
||||
if (ban) {
|
||||
const isExpired = ban.expiresAt && new Date(ban.expiresAt) < new Date();
|
||||
if (!isExpired) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||
<h1 className="text-3xl font-bold text-destructive mb-4">Account Suspended</h1>
|
||||
<p className="text-muted-foreground text-center max-w-md mb-4">
|
||||
Your account has been suspended from hackclub.tv.
|
||||
</p>
|
||||
<div className="bg-muted p-4 rounded-lg max-w-md">
|
||||
<p className="text-sm font-medium">Reason:</p>
|
||||
<p className="text-sm text-muted-foreground">{ban.reason}</p>
|
||||
</div>
|
||||
{ban.expiresAt && (
|
||||
<p className="text-sm text-muted-foreground mt-4">
|
||||
Expires: {new Date(ban.expiresAt).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { fetcher } from '@/lib/services/swr';
|
||||
import { useConfirm } from '@omit/react-confirm-dialog';
|
||||
import { Plus, RefreshCcw, Trash } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import useSWR from 'swr';
|
||||
import useSWRMutation from 'swr/mutation';
|
||||
|
||||
export function ApiKeys({ slug }: { slug: string }) {
|
||||
const confirm = useConfirm();
|
||||
const [newApiKeyName, setNewApiKeyName] = useState('');
|
||||
const { data, error, isLoading, mutate } = useSWR<GetResponse>(
|
||||
`/api/settings/bot/${slug}/apiKey`,
|
||||
fetcher
|
||||
);
|
||||
const { trigger } = useSWRMutation(`/api/settings/bot/${slug}/apiKey`, createApiKey);
|
||||
|
||||
const apiKeyCreate = () => {
|
||||
if (newApiKeyName.trim().length < 3) {
|
||||
toast.error('API Key name must be at least 3 characters long');
|
||||
return;
|
||||
}
|
||||
if (newApiKeyName.trim().length > 50) {
|
||||
toast.error('API Key name must be at most 50 characters long');
|
||||
return;
|
||||
}
|
||||
trigger({ action: 'create', name: newApiKeyName }).then(
|
||||
async (res: PostResponse) => {
|
||||
if (res.success) {
|
||||
setNewApiKeyName('');
|
||||
await navigator.clipboard
|
||||
.writeText(res.apiKey || '')
|
||||
.then(() => toast.success('API key copied to clipboard'))
|
||||
.catch(() => {
|
||||
alert('Failed to copy API key to clipboard, here it is: ' + res.apiKey);
|
||||
});
|
||||
await mutate();
|
||||
} else {
|
||||
toast.error(res.error || 'Error creating API key');
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>API Keys</CardTitle>
|
||||
<CardDescription>Manage your API keys</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading && <ApiKeysSkeleton />}
|
||||
{error && <p>Error loading API keys</p>}
|
||||
{data && !data.success && <p>Error: Could not fetch API keys</p>}
|
||||
{data && (
|
||||
<div className="flex">
|
||||
<Input
|
||||
placeholder="New API Key Name"
|
||||
className="flex-1 mr-2"
|
||||
value={newApiKeyName}
|
||||
onChange={(e) => setNewApiKeyName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
apiKeyCreate();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={apiKeyCreate}
|
||||
>
|
||||
<Plus />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{data && data.success && data.apiKeys.length === 0 && <p>No API keys found</p>}
|
||||
{data && data.success && data.apiKeys.length > 0 && (
|
||||
<ul className="space-y-2 pt-4">
|
||||
{data.apiKeys.map((key) => (
|
||||
<li
|
||||
key={key.id}
|
||||
className="flex items-center justify-between p-3 bg-mantle/50 rounded-md"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<strong className="text-sm font-medium">{key.name}</strong>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
const confirmation = await confirm({
|
||||
title: 'Regenerate API Key',
|
||||
description:
|
||||
'Are you sure you want to regenerate this API key? The old key will stop working.',
|
||||
confirmText: 'Regenerate',
|
||||
cancelText: 'Cancel',
|
||||
});
|
||||
if (!confirmation) return;
|
||||
|
||||
trigger({ action: 'regenerate', name: key.name }).then(
|
||||
async (res: PostResponse) => {
|
||||
if (res.success) {
|
||||
await navigator.clipboard
|
||||
.writeText(res.apiKey || '')
|
||||
.then(() => {
|
||||
toast.success('API key copied to clipboard');
|
||||
})
|
||||
.catch(() => {
|
||||
alert(
|
||||
'Failed to copy API key to clipboard, here it is: ' + res.apiKey
|
||||
);
|
||||
});
|
||||
mutate();
|
||||
} else {
|
||||
toast.error(res.error || 'Error regenerating API key');
|
||||
}
|
||||
}
|
||||
);
|
||||
}}
|
||||
>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
const confirmation = await confirm({
|
||||
title: 'Revoke API Key',
|
||||
description:
|
||||
'Are you sure you want to revoke this API key? This action cannot be undone.',
|
||||
confirmText: 'Revoke',
|
||||
cancelText: 'Cancel',
|
||||
});
|
||||
if (!confirmation) return;
|
||||
trigger({ action: 'revoke', name: key.name }).then(
|
||||
async (res: PostResponse) => {
|
||||
if (res.success) {
|
||||
await mutate();
|
||||
} else {
|
||||
toast.error(res.error || 'Error revoking API key');
|
||||
}
|
||||
}
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ApiKeysSkeleton() {
|
||||
return (
|
||||
<>
|
||||
<div className='flex'>
|
||||
<Skeleton className='h-10 flex-1 mr-2' />
|
||||
<Skeleton className='h-10 w-10' />
|
||||
</div>
|
||||
<div className='space-y-2 pt-4'>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className='flex items-center justify-between p-3 bg-mantle/50 rounded-md'>
|
||||
<div className='flex-1'>
|
||||
<Skeleton className='h-4 w-1/2' />
|
||||
</div>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<Skeleton className='h-8 w-8' />
|
||||
<Skeleton className='h-8 w-8' />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
async function createApiKey(url: string, { arg }: { arg: PostRequest }) {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(arg),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
interface GetResponse {
|
||||
success: boolean;
|
||||
apiKeys: Array<{ id: string; name: string; createdAt: string }>;
|
||||
}
|
||||
|
||||
interface PostRequest {
|
||||
action: 'revoke' | 'regenerate' | 'create';
|
||||
name: string;
|
||||
}
|
||||
interface PostResponse {
|
||||
success: boolean;
|
||||
apiKey?: string;
|
||||
id?: string;
|
||||
error?: string;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
'use client';
|
||||
import { UniversalForm } from '@/components/app/UniversalForm/UniversalForm';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { editBot } from '@/lib/form/actions';
|
||||
import { BotAccount } from '@hctv/db';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export function GeneralSettings(props: BotAccount) {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>General Settings</CardTitle>
|
||||
<CardDescription>Edit your bot settings!</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<UniversalForm
|
||||
fields={[
|
||||
{
|
||||
name: 'from',
|
||||
type: 'hidden',
|
||||
value: props.id,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
label: 'Bot Name',
|
||||
placeholder: 'Enter bot name',
|
||||
required: true,
|
||||
value: props.displayName,
|
||||
},
|
||||
{
|
||||
name: 'slug',
|
||||
type: 'text',
|
||||
label: 'Bot Slug',
|
||||
placeholder: 'Enter bot slug',
|
||||
required: true,
|
||||
value: props.slug
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'textarea',
|
||||
label: 'Description',
|
||||
placeholder: 'Enter bot description',
|
||||
value: props.description,
|
||||
textArea: true,
|
||||
},
|
||||
]}
|
||||
schemaName={'editBot'}
|
||||
action={editBot}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { getBotBySlug } from '@/lib/db/resolve';
|
||||
import { validateRequest } from '@/lib/auth/validate';
|
||||
import { can } from '@/lib/auth/abac';
|
||||
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 (!user || !bot || !can(user, 'update', 'bot', { bot })) {
|
||||
redirect('/settings/bot');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'container mx-auto py-6 space-y-6'}>
|
||||
<Link href={'/settings/bot'} className="text-sm text-muted-foreground hover:underline flex items-center gap-2">
|
||||
<ArrowLeft className='size-4' /> Back to Bot Accounts
|
||||
</Link>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className={'flex items-center space-x-4'}>
|
||||
<Image
|
||||
src={bot.pfpUrl}
|
||||
alt={'Bot Avatar'}
|
||||
width={48}
|
||||
height={48}
|
||||
className="rounded-full"
|
||||
/>
|
||||
<div className={'flex flex-col'}>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{bot.displayName}</h1>
|
||||
<p className="text-muted-foreground">Manage your bot account settings</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full gap-4 flex-col md:flex-row *:w-1/2">
|
||||
<GeneralSettings {...bot} />
|
||||
<ApiKeys slug={slug} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
'use client';
|
||||
|
||||
import { UniversalForm } from '@/components/app/UniversalForm/UniversalForm';
|
||||
import { createBot } from '@/lib/form/actions';
|
||||
import { Bot } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function Page() {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="flex h-full w-full flex-col items-center justify-center px-4 py-12">
|
||||
<div className="mb-8 text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary shadow-lg">
|
||||
<Bot className="h-8 w-8 text-primary-foreground" />
|
||||
</div>
|
||||
<h1 className="mb-2 text-3xl font-bold text-foreground">Create Bot Account</h1>
|
||||
<p className="text-muted-foreground max-w-xl">
|
||||
Create an automated bot account to provide custom functionality for your community.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-md bg-card rounded-xl p-8 border border-border">
|
||||
<UniversalForm
|
||||
fields={[
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
label: 'Bot Name',
|
||||
placeholder: 'Enter bot name',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'slug',
|
||||
type: 'text',
|
||||
label: 'Bot Slug',
|
||||
placeholder: 'Enter bot slug',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'textarea',
|
||||
label: 'Description',
|
||||
placeholder: 'Enter bot description',
|
||||
},
|
||||
]}
|
||||
schemaName={'createBot'}
|
||||
action={createBot}
|
||||
onActionComplete={(res) => {
|
||||
router.push(`/settings/bot/${res.slug}`);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
<p className="mt-6 text-sm text-muted-foreground text-center max-w-md">
|
||||
Your bot will be created with chat permissions. You can configure advanced settings and
|
||||
permissions after creation.
|
||||
</p>
|
||||
*/}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
apps/web/src/app/(ui)/(protected)/settings/bot/page.tsx
Normal file
88
apps/web/src/app/(ui)/(protected)/settings/bot/page.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { validateRequest } from '@/lib/auth/validate';
|
||||
import { prisma } from '@hctv/db';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import Link from 'next/link';
|
||||
import { Plus, Bot, Calendar, Hash } from 'lucide-react';
|
||||
import { redirect } from 'next/navigation';
|
||||
import Image from 'next/image';
|
||||
|
||||
export default async function Page() {
|
||||
const { user } = await validateRequest();
|
||||
if (!user) {
|
||||
redirect('/');
|
||||
}
|
||||
|
||||
const bots = await prisma.user.findFirst({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
botAccounts: true,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Bot Accounts</h1>
|
||||
<p className="text-muted-foreground">Manage your automated bot accounts</p>
|
||||
</div>
|
||||
<Link href="/settings/bot/create">
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Bot
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{bots?.botAccounts.length ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{bots.botAccounts.map((bot) => (
|
||||
<Link href={`/settings/bot/${bot.slug}`} key={bot.id}>
|
||||
<Card key={bot.id} className="hover:shadow-md transition-shadow">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Image src={bot.pfpUrl} alt={'Bot Avatar'} width={32} height={32} className="rounded-full" />
|
||||
<CardTitle className="text-lg">{bot.displayName}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
|
||||
<Hash className="h-4 w-4" />
|
||||
<span>{bot.slug}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>{new Date(bot.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card className="text-center py-8">
|
||||
<CardContent className="space-y-4">
|
||||
<Bot className="mx-auto h-12 w-12 text-muted-foreground" />
|
||||
<div>
|
||||
<CardTitle>No bot accounts yet</CardTitle>
|
||||
<CardDescription className="mt-2">
|
||||
Get started by creating your first bot account
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Link href="/settings/bot/create">
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Your First Bot
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,888 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
Settings,
|
||||
Users,
|
||||
Key,
|
||||
Bell,
|
||||
Trash2,
|
||||
Shield,
|
||||
UserPlus,
|
||||
UserMinus,
|
||||
Copy,
|
||||
Check,
|
||||
Wrench,
|
||||
Eye,
|
||||
EyeOff,
|
||||
} from 'lucide-react';
|
||||
import { UniversalForm } from '@/components/app/UniversalForm/UniversalForm';
|
||||
import {
|
||||
updateChannelSettings,
|
||||
addChannelManager,
|
||||
removeChannelManager,
|
||||
deleteChannel,
|
||||
toggleGlobalChannelNotifs,
|
||||
editStreamInfo,
|
||||
changeUsername,
|
||||
} from '@/lib/form/actions';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { toast } from 'sonner';
|
||||
import type { Channel, User, StreamInfo, StreamKey, Follow } from '@hctv/db';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { UserCombobox } from '@/components/app/UserCombobox/UserCombobox';
|
||||
import { parseAsString, useQueryState } from 'nuqs';
|
||||
import { Write } from '@/components/ui/channel-desc-fancy-area/write';
|
||||
import { Preview } from '@/components/ui/channel-desc-fancy-area/preview';
|
||||
import { UploadButton } from '@/lib/uploadthing';
|
||||
import { useOwnedChannels } from '@/lib/hooks/useUserList';
|
||||
import { ChannelSelect } from '@/components/app/ChannelSelect/ChannelSelect';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useConfirm } from '@omit/react-confirm-dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { getMediamtxClientEnvs } from '@/lib/utils/mediamtx/client';
|
||||
import type { MediaMTXRegion } from '@/lib/utils/mediamtx/regions';
|
||||
|
||||
interface ChannelSettingsClientProps {
|
||||
channel: Channel & {
|
||||
owner: User;
|
||||
ownerPersonalChannel: Channel | null;
|
||||
managers: User[];
|
||||
managerPersonalChannels: (Channel | null)[];
|
||||
streamInfo: StreamInfo[];
|
||||
streamKey: StreamKey | null;
|
||||
followers: (Follow & { user: { id: string; slack_id: string } })[];
|
||||
followerPersonalChannels: (Channel | null)[];
|
||||
is247: boolean;
|
||||
nameLastChanged: Date | null;
|
||||
};
|
||||
isOwner: boolean;
|
||||
currentUser: User;
|
||||
isPersonal: boolean;
|
||||
}
|
||||
|
||||
export default function ChannelSettingsClient({
|
||||
channel,
|
||||
isOwner,
|
||||
currentUser,
|
||||
isPersonal,
|
||||
}: ChannelSettingsClientProps) {
|
||||
const confirm = useConfirm();
|
||||
const [streamKey, setStreamKey] = useState(channel.streamKey?.key || '');
|
||||
const [keyVisible, setKeyVisible] = useState(false);
|
||||
const [copied, setCopied] = useState({
|
||||
streamKey: false,
|
||||
streamUrl: false,
|
||||
});
|
||||
const [selTab, setSelTab] = useQueryState('tab', parseAsString.withDefault('general'));
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const [region, setRegion] = useState<MediaMTXRegion>('hq');
|
||||
const channelList = useOwnedChannels();
|
||||
const router = useRouter();
|
||||
|
||||
const handleStreamInfoActionComplete = useCallback((result: any) => {
|
||||
if (result?.success) {
|
||||
toast.success('Stream information updated');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleChannelSettingsActionComplete = useCallback((result: any) => {
|
||||
if (result?.success) {
|
||||
toast.success('Channel settings updated successfully');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleUsernameChangeComplete = useCallback(
|
||||
(result: any) => {
|
||||
if (result?.success && result?.newUsername) {
|
||||
toast.success('Username changed successfully! Redirecting...');
|
||||
router.push(`/settings/channel/${result.newUsername}?tab=${selTab}`);
|
||||
}
|
||||
},
|
||||
[router, selTab]
|
||||
);
|
||||
|
||||
const getUsernameChangeCooldownInfo = () => {
|
||||
if (!channel.nameLastChanged) {
|
||||
return { canChange: true, daysRemaining: 0 };
|
||||
}
|
||||
const daysSinceLastChange = Math.floor(
|
||||
(Date.now() - new Date(channel.nameLastChanged).getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
const cooldownDays = 30;
|
||||
if (daysSinceLastChange >= cooldownDays) {
|
||||
return { canChange: true, daysRemaining: 0 };
|
||||
}
|
||||
return { canChange: false, daysRemaining: cooldownDays - daysSinceLastChange };
|
||||
};
|
||||
|
||||
const cooldownInfo = getUsernameChangeCooldownInfo();
|
||||
|
||||
const copyStreamKey = async () => {
|
||||
if (streamKey) {
|
||||
await navigator.clipboard.writeText(streamKey);
|
||||
setCopied({ ...copied, streamKey: true });
|
||||
toast.success('Stream key copied to clipboard');
|
||||
setTimeout(() => setCopied({ ...copied, streamKey: false }), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const regenerateStreamKey = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/rtmp/streamKey', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ channel: channel.name }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setStreamKey(data.key);
|
||||
toast.success('Stream key regenerated successfully');
|
||||
} else {
|
||||
toast.error('Failed to regenerate stream key');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to regenerate stream key');
|
||||
}
|
||||
};
|
||||
|
||||
const generateStreamUrl = () => {
|
||||
if (!streamKey) {
|
||||
toast.error('Stream key not available');
|
||||
return '';
|
||||
}
|
||||
const { ingestRoute } = getMediamtxClientEnvs(region);
|
||||
return `srt://${ingestRoute}?streamid=publish:${channel.name}:thisusernameislongonpurposesoyoudontaccidentallyleakyourstreamkey:${streamKey}&pkt_size=1316`;
|
||||
};
|
||||
|
||||
const copyStreamUrl = async () => {
|
||||
const url = generateStreamUrl();
|
||||
if (url) {
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopied({ ...copied, streamUrl: true });
|
||||
toast.success('Stream URL copied to clipboard');
|
||||
setTimeout(() => setCopied({ ...copied, streamUrl: false }), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container max-w-4xl mx-auto py-6 px-4">
|
||||
<div className="mb-6 flex">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<Avatar className="h-16 w-16">
|
||||
<AvatarImage src={channel.pfpUrl} alt={channel.name} />
|
||||
<AvatarFallback>{channel.name[0]?.toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">{channel.name}</h1>
|
||||
<p className="text-mantle-foreground">Channel Settings</p>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Badge variant="secondary">
|
||||
{channel.followers.length} follower{channel.followers.length !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
{isOwner && <Badge variant="outline">Owner</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<div>
|
||||
<ChannelSelect
|
||||
channelList={channelList.channels.map((c) => c.channel)}
|
||||
value={channel.name}
|
||||
onSelect={(value) => {
|
||||
if (value === 'create') {
|
||||
router.push(`/settings/channel/create`);
|
||||
} else {
|
||||
router.push(`/settings/channel/${value}?tab=${selTab}`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs className="w-full" value={selTab} onValueChange={setSelTab}>
|
||||
<TabsList className={`grid w-full ${isPersonal ? 'grid-cols-4' : 'grid-cols-5'}`}>
|
||||
<TabsTrigger value="general" className="flex items-center gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
General
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="stream" className="flex items-center gap-2">
|
||||
<Key className="h-4 w-4" />
|
||||
Streaming
|
||||
</TabsTrigger>
|
||||
{!isPersonal && (
|
||||
<TabsTrigger value="managers" className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4" />
|
||||
Managers
|
||||
</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger value="notifications" className="flex items-center gap-2">
|
||||
<Bell className="h-4 w-4" />
|
||||
Notifications
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="utilities" className="flex items-center gap-2">
|
||||
<Wrench className="size-4" />
|
||||
Utilities
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="general">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>General Settings</CardTitle>
|
||||
<CardDescription>
|
||||
Manage your channel's basic information and settings.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<UniversalForm
|
||||
fields={[
|
||||
{ name: 'channelId', type: 'hidden', value: channel.id, label: 'Channel ID' },
|
||||
{
|
||||
name: 'pfpUrl',
|
||||
label: 'Profile Picture',
|
||||
type: 'url',
|
||||
value: channel.pfpUrl,
|
||||
component: ({ field }) => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<input type="hidden" {...field} />
|
||||
|
||||
{field.value && (
|
||||
<div className="flex items-center space-x-4">
|
||||
<Avatar className="h-16 w-16">
|
||||
<AvatarImage src={field.value} alt="Current profile picture" />
|
||||
<AvatarFallback>{channel.name[0]?.toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">Current profile picture</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Click "Upload new image" to replace
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
field.onChange('');
|
||||
setUploadError(null);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<UploadButton
|
||||
endpoint="pfpUpload"
|
||||
className="mt-2 ut-button:bg-mantle ut-button:text-mantle-foreground ut-allowed-content:text-muted-foreground/70"
|
||||
content={{
|
||||
button: field.value ? 'Upload new image' : 'Upload profile picture',
|
||||
allowedContent: 'Image (1MB max)',
|
||||
}}
|
||||
onUploadBegin={() => {
|
||||
setIsUploading(true);
|
||||
setUploadError(null);
|
||||
}}
|
||||
onClientUploadComplete={(res) => {
|
||||
setIsUploading(false);
|
||||
if (res && res[0]) {
|
||||
field.onChange(res[0].ufsUrl);
|
||||
toast.success('Profile picture uploaded successfully!');
|
||||
}
|
||||
}}
|
||||
onUploadError={(error) => {
|
||||
setIsUploading(false);
|
||||
setUploadError(error.message);
|
||||
toast.error(`Upload failed: ${error.message}`);
|
||||
}}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
|
||||
{isUploading && (
|
||||
<p className="mt-2 text-sm text-primary">Uploading...</p>
|
||||
)}
|
||||
|
||||
{uploadError && (
|
||||
<p className="mt-2 text-sm text-red-600">{uploadError}</p>
|
||||
)}
|
||||
|
||||
{!field.value && !isUploading && !uploadError && (
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Upload a profile picture for your channel.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
label: 'Channel Description',
|
||||
value: channel.description,
|
||||
component: ({ field }) => (
|
||||
<div>
|
||||
<input type="hidden" {...field} />
|
||||
<Tabs defaultValue="write" className="w-full">
|
||||
<TabsList>
|
||||
<TabsTrigger value="write">Write</TabsTrigger>
|
||||
<TabsTrigger value="preview">Preview</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="write">
|
||||
<Write
|
||||
textValue={field.value || ''}
|
||||
setTextValue={(value) => {
|
||||
field.onChange(value);
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="preview">
|
||||
<Preview textValue={field.value || ''} className="h-[159.5px]" />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'is247',
|
||||
value: channel.is247,
|
||||
component: ({ field }) => (
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<div>
|
||||
<label className="text-sm font-medium">24/7 Channel</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Mark this channel as always live. It will disable notifications on
|
||||
#hctv-streams.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={(checked) => {
|
||||
field.onChange(checked);
|
||||
}}
|
||||
/>
|
||||
<input type="hidden" {...field} value={field.value ? 'true' : 'false'} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
schemaName="updateChannelSettings"
|
||||
action={updateChannelSettings}
|
||||
submitText="Save Changes"
|
||||
onActionComplete={handleChannelSettingsActionComplete}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
{isPersonal && isOwner && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-semibold">Username</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your username is how others find and mention you on hctv. You can change it once
|
||||
every 30 days.
|
||||
</p>
|
||||
{!cooldownInfo.canChange && (
|
||||
<div className="p-3 border border-accent/20 rounded-lg bg-accent/5">
|
||||
<p className="text-sm text-accent">
|
||||
You can change your username again in {cooldownInfo.daysRemaining} day
|
||||
{cooldownInfo.daysRemaining === 1 ? '' : 's'}.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<UniversalForm
|
||||
fields={[
|
||||
{ name: 'channelId', type: 'hidden', value: channel.id, label: 'Channel ID' },
|
||||
{
|
||||
name: 'newUsername',
|
||||
label: 'New Username',
|
||||
type: 'text',
|
||||
value: '',
|
||||
placeholder: channel.name,
|
||||
description:
|
||||
'Only lowercase letters, numbers, underscores, and dashes. Max 20 characters.',
|
||||
inputFilter: /[^a-z0-9_-]/g,
|
||||
maxChars: 20,
|
||||
},
|
||||
]}
|
||||
schemaName="changeUsername"
|
||||
action={changeUsername}
|
||||
submitText="Change Username"
|
||||
onActionComplete={handleUsernameChangeComplete}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isOwner && !isPersonal && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-destructive">Danger Zone</h3>
|
||||
<div className="flex items-center justify-between p-4 border border-destructive/20 rounded-lg bg-destructive/5">
|
||||
<div>
|
||||
<p className="font-medium text-destructive">Delete Channel</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Permanently delete this channel. This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
if (
|
||||
await confirm({
|
||||
title: 'Delete Channel',
|
||||
description:
|
||||
'Are you sure you want to delete this channel? This action cannot be undone.',
|
||||
confirmText: 'Delete',
|
||||
cancelText: 'Cancel',
|
||||
})
|
||||
) {
|
||||
const result = await deleteChannel(channel.id);
|
||||
if (result.success) {
|
||||
toast.success('Channel deleted successfully');
|
||||
router.push('/settings/channel');
|
||||
} else {
|
||||
toast.error(result.error || 'Failed to delete channel');
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="stream">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Streaming Settings</CardTitle>
|
||||
<CardDescription>Manage your stream key and streaming configuration.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Stream Key</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type={keyVisible ? 'text' : 'password'}
|
||||
value={streamKey}
|
||||
readOnly
|
||||
className="w-full px-3 py-2 pr-10 border rounded-md bg-mantle font-mono text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setKeyVisible(!keyVisible)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{keyVisible ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<Button onClick={regenerateStreamKey} variant="outline" size="smicon">
|
||||
<Key className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="smicon"
|
||||
onClick={copyStreamKey}
|
||||
disabled={!streamKey}
|
||||
>
|
||||
{copied.streamKey ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">Stream URL (for OBS)</label>
|
||||
<Select value={region} onValueChange={(v) => setRegion(v as MediaMTXRegion)}>
|
||||
<SelectTrigger className="w-[180px] h-8">
|
||||
<SelectValue placeholder="Select region" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="hq">HQ Server A 🇺🇸</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={generateStreamUrl()}
|
||||
readOnly
|
||||
className="w-full px-3 py-2 border rounded-md bg-mantle font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="smicon"
|
||||
onClick={copyStreamUrl}
|
||||
disabled={!streamKey}
|
||||
>
|
||||
{copied.streamUrl ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Need help getting started? Check out our{' '}
|
||||
<Link
|
||||
href="https://docs.hackclub.tv/guides/start-stream/"
|
||||
className="text-primary hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
streaming guide
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Stream Information</h3>
|
||||
{channel.streamInfo.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{channel.streamInfo.map((stream, index) => (
|
||||
<UniversalForm
|
||||
key={stream.id}
|
||||
fields={[
|
||||
{
|
||||
name: 'username',
|
||||
type: 'hidden',
|
||||
value: stream.username,
|
||||
label: 'Username',
|
||||
},
|
||||
{
|
||||
name: 'title',
|
||||
label: 'Stream Title',
|
||||
type: 'text',
|
||||
value: stream.title,
|
||||
},
|
||||
{
|
||||
name: 'category',
|
||||
label: 'Category',
|
||||
type: 'text',
|
||||
value: stream.category,
|
||||
},
|
||||
]}
|
||||
schemaName="streamInfoEdit"
|
||||
action={editStreamInfo}
|
||||
submitText="Update Stream Info"
|
||||
onActionComplete={handleStreamInfoActionComplete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-mantle-foreground">No stream information available.</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{!isPersonal && (
|
||||
<TabsContent value="managers">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Channel Managers</CardTitle>
|
||||
<CardDescription>
|
||||
Manage who can help moderate and stream to this channel.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Current Managers</h3>
|
||||
{isOwner && (
|
||||
<AddManagerDialog
|
||||
channelId={channel.id}
|
||||
existingManagers={[...channel.managers.map((m) => m.id), channel.owner.id]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Owner */}
|
||||
<div className="flex items-center justify-between p-3 border rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarImage src={channel.owner.pfpUrl} />
|
||||
<AvatarFallback>
|
||||
{channel.owner.slack_id[0]?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="font-medium">{channel.ownerPersonalChannel?.name}</p>
|
||||
<p className="text-sm text-mantle-foreground">Channel Owner</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="default">
|
||||
<Shield className="h-3 w-3 mr-1" />
|
||||
Owner
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Managers */}
|
||||
{channel.managers.map((manager) => {
|
||||
const personalChannel = channel.managerPersonalChannels.find(
|
||||
(c) => c?.ownerId === manager.id
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={manager.id}
|
||||
className="flex items-center justify-between p-3 border rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarImage src={manager.pfpUrl} />
|
||||
<AvatarFallback>{personalChannel?.name}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="font-medium">{personalChannel?.name}</p>
|
||||
<p className="text-sm text-mantle-foreground">Manager</p>
|
||||
</div>
|
||||
</div>
|
||||
{isOwner && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
if (
|
||||
await confirm({
|
||||
title: 'Remove Manager',
|
||||
description: `Are you sure you want to remove ${personalChannel?.name} as a manager? They will no longer be able to stream or moderate this channel.`,
|
||||
confirmText: 'Remove',
|
||||
cancelText: 'Cancel',
|
||||
})
|
||||
) {
|
||||
removeChannelManager(channel.id, manager.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<UserMinus className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{channel.managers.length === 0 && (
|
||||
<p className="text-mantle-foreground text-center py-8">
|
||||
No managers added yet.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
<TabsContent value="notifications">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Notification Settings</CardTitle>
|
||||
<CardDescription>
|
||||
Configure when and how followers are notified about your streams.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium">Stream Notifications</h3>
|
||||
<p className="text-sm text-mantle-foreground">
|
||||
Send notifications to followers when you go live
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={channel.streamInfo[0]?.enableNotifications ?? true}
|
||||
onCheckedChange={(checked) => {
|
||||
toast.promise(toggleGlobalChannelNotifs(channel.id), {
|
||||
loading: 'Updating notifications...',
|
||||
success(data) {
|
||||
return `${
|
||||
data.toggle ? 'Enabled' : 'Disabled'
|
||||
} global notifications for this channel.`;
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h3 className="font-medium mb-3">Followers ({channel.followers.length})</h3>
|
||||
<div className="space-y-2 max-h-60 overflow-y-auto">
|
||||
{channel.followers.map((follower) => {
|
||||
const personalChannel = channel.followerPersonalChannels.find(
|
||||
(c) => c?.ownerId === follower.user.id
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={follower.id}
|
||||
className="flex items-center justify-between p-2 border rounded"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarFallback>{personalChannel?.name}</AvatarFallback>
|
||||
<AvatarImage
|
||||
src={personalChannel?.pfpUrl}
|
||||
alt={personalChannel?.name}
|
||||
/>
|
||||
</Avatar>
|
||||
<span className="text-sm">{personalChannel?.name}</span>
|
||||
</div>
|
||||
<Badge variant={follower.notifyStream ? 'default' : 'secondary'}>
|
||||
{follower.notifyStream ? 'Notifications On' : 'Notifications Off'}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{channel.followers.length === 0 && (
|
||||
<p className="text-mantle-foreground text-center py-4">No followers yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="utilities">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Utilities</CardTitle>
|
||||
<CardDescription>OBS overlays, APIs... everything in one neat place!</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">Chat overlay</h3>
|
||||
<p className="text-sm text-mantle-foreground mb-4">
|
||||
Add a 300x600 browser source with this and enjoy!
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type={keyVisible ? 'text' : 'password'}
|
||||
value={`https://hackclub.tv/chat/${channel.name}?grant=${channel.obsChatGrantToken}`}
|
||||
readOnly
|
||||
className="w-full px-3 py-2 border rounded-md bg-mantle font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setKeyVisible(!keyVisible)}>
|
||||
{keyVisible ? 'Hide' : 'Show'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddManagerDialog({
|
||||
channelId,
|
||||
existingManagers,
|
||||
}: {
|
||||
channelId: string;
|
||||
existingManagers: string[];
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [channel, setChannel] = useState('');
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">
|
||||
<UserPlus className="h-4 w-4 mr-2" />
|
||||
Add Manager
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add channel manager</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a channel manager to help manage your channel during big events or projects.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<UserCombobox
|
||||
onValueChange={(value) => {
|
||||
setChannel(value);
|
||||
}}
|
||||
filter={existingManagers}
|
||||
value={channel}
|
||||
modal
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
toast.promise(addChannelManager(channelId, channel), {
|
||||
loading: 'Adding manager...',
|
||||
success: 'Manager added successfully',
|
||||
error: 'Failed to add manager',
|
||||
});
|
||||
setOpen(false);
|
||||
setChannel('');
|
||||
}}
|
||||
>
|
||||
Add Manager
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { validateRequest } from '@/lib/auth/validate';
|
||||
import { prisma } from '@hctv/db';
|
||||
import { redirect } from 'next/navigation';
|
||||
import ChannelSettingsClient from './page.client';
|
||||
import { resolvePersonalChannel } from '@/lib/auth/resolve';
|
||||
import { can } from '@/lib/auth/abac';
|
||||
|
||||
export default async function ChannelSettingsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ channelName: string }>;
|
||||
}) {
|
||||
const { channelName } = await params;
|
||||
const { user } = await validateRequest();
|
||||
|
||||
if (!user) {
|
||||
redirect('/auth/hackclub');
|
||||
}
|
||||
|
||||
const channel = await prisma.channel.findUnique({
|
||||
where: { name: channelName },
|
||||
include: {
|
||||
owner: true,
|
||||
managers: true,
|
||||
streamInfo: true,
|
||||
streamKey: true,
|
||||
followers: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
slack_id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
personalFor: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!channel) {
|
||||
redirect('/');
|
||||
}
|
||||
|
||||
const isOwner = channel.ownerId === user.id;
|
||||
|
||||
if (!can(user, 'update', 'channel', { channel })) {
|
||||
redirect('/');
|
||||
}
|
||||
|
||||
const ownerPersonalChannel = await resolvePersonalChannel(channel.ownerId);
|
||||
const managerPersonalChannels = await Promise.all(
|
||||
channel.managers.map((manager) => resolvePersonalChannel(manager.id))
|
||||
);
|
||||
const followerPersonalChannels = await Promise.all(
|
||||
channel.followers.map((follower) => resolvePersonalChannel(follower.user.id))
|
||||
);
|
||||
|
||||
return (
|
||||
<ChannelSettingsClient
|
||||
channel={{
|
||||
...channel,
|
||||
ownerPersonalChannel,
|
||||
managerPersonalChannels,
|
||||
followerPersonalChannels,
|
||||
}}
|
||||
isOwner={isOwner}
|
||||
currentUser={user}
|
||||
isPersonal={channel.personalFor !== null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
'use client'
|
||||
|
||||
import { UniversalForm } from "@/components/app/UniversalForm/UniversalForm";
|
||||
import { createChannel } from "@/lib/form/actions";
|
||||
import { Hash } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
function CreateChannelPage() {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="flex h-full w-full flex-col items-center justify-center px-4 py-12">
|
||||
<div className="mb-8 text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary shadow-lg">
|
||||
<Hash className="h-8 w-8 text-primary-foreground" />
|
||||
</div>
|
||||
<h1 className="mb-2 text-3xl font-bold text-foreground">
|
||||
Create New Channel
|
||||
</h1>
|
||||
<p className="text-muted-foreground max-w-xl">
|
||||
IRL hackathons, game streams, and more are allowed! Create another channel apart from your personal one to diversify your content.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="w-full max-w-md bg-card rounded-xl shadow-xl p-8 border border-border">
|
||||
<UniversalForm
|
||||
fields={[
|
||||
{ label: "Channel Name", name: "name", type: "text", placeholder: "Enter channel name" },
|
||||
]}
|
||||
schemaName="createChannel"
|
||||
action={createChannel}
|
||||
onActionComplete={(r) => {
|
||||
const channelName = r.channel;
|
||||
if (channelName) {
|
||||
router.push(`/${channelName}`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-sm text-muted-foreground text-center max-w-md">
|
||||
Your channel will be ready to go live immediately after creation. You can always customize settings later.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreateChannelPage;
|
||||
18
apps/web/src/app/(ui)/(protected)/settings/channel/page.tsx
Normal file
18
apps/web/src/app/(ui)/(protected)/settings/channel/page.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { resolvePersonalChannel } from '@/lib/auth/resolve';
|
||||
import { loadSearchParams } from '@/lib/nuqs/channelSettings';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default async function ChannelSettingsRedirector({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
}) {
|
||||
const { tab } = await loadSearchParams(searchParams);
|
||||
const personalChannel = await resolvePersonalChannel();
|
||||
if (personalChannel) {
|
||||
return redirect(`/settings/channel/${personalChannel.name}?tab=${tab}`);
|
||||
}
|
||||
|
||||
// lil easter egg i doubt anyone will see
|
||||
return <p>erm</p>;
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
import { slack, lucia } from '@hctv/auth';
|
||||
import { hackClub, lucia, HCID_TOKEN_URL, HCID_USER_INFO_URL } from '@hctv/auth';
|
||||
import { cookies as nextCookies } from 'next/headers';
|
||||
import { decodeIdToken, OAuth2RequestError } from 'arctic';
|
||||
import { OAuth2RequestError } from 'arctic';
|
||||
import { generateIdFromEntropySize } from 'lucia';
|
||||
import { prisma } from '@hctv/db';
|
||||
import { getRedisConnection } from '@/lib/services/redis';
|
||||
import { getRedisConnection } from '@hctv/db';
|
||||
|
||||
export async function GET(request: Request): Promise<Response> {
|
||||
const cookies = await nextCookies();
|
||||
const url = new URL(request.url);
|
||||
const code = url.searchParams.get("code");
|
||||
const state = url.searchParams.get("state");
|
||||
const storedState = cookies.get("slack_oauth_state")?.value ?? null;
|
||||
const storedState = cookies.get("hackclub_oauth_state")?.value ?? null;
|
||||
if (!code || !state || !storedState || state !== storedState) {
|
||||
console.log('invalid state stuff');
|
||||
return new Response(null, {
|
||||
@@ -19,22 +19,38 @@ export async function GET(request: Request): Promise<Response> {
|
||||
}
|
||||
|
||||
try {
|
||||
const tokens = await slack.validateAuthorizationCode(code);
|
||||
const accessToken = tokens.accessToken()
|
||||
const slackUserResponse = await fetch('https://slack.com/api/openid.connect.userInfo', {
|
||||
const tokens = await hackClub.validateAuthorizationCode(HCID_TOKEN_URL, code, null);
|
||||
const accessToken = tokens.accessToken();
|
||||
const userResponse = await fetch(HCID_USER_INFO_URL, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
const slackUser: SlackUserInfo = await slackUserResponse.json();
|
||||
const userResult: HackClubUserResponse = await userResponse.json();
|
||||
const identity = userResult.identity;
|
||||
|
||||
const slackId = identity.slack_id;
|
||||
if (!slackId) {
|
||||
return new Response("Please make sure to have a Slack account before continuing.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
slack_id: slackUser.sub,
|
||||
slack_id: slackId,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
// Update email if it's missing or changed
|
||||
if (existingUser.email !== identity.primary_email) {
|
||||
await prisma.user.update({
|
||||
where: { id: existingUser.id },
|
||||
data: { email: identity.primary_email },
|
||||
});
|
||||
}
|
||||
|
||||
const session = await lucia.createSession(existingUser.id, {});
|
||||
const sessionCookie = lucia.createSessionCookie(session.id);
|
||||
await getRedisConnection().set(`sessions:${session.id}`, '');
|
||||
@@ -52,8 +68,9 @@ export async function GET(request: Request): Promise<Response> {
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: userId,
|
||||
slack_id: slackUser.sub,
|
||||
pfpUrl: slackUser.picture,
|
||||
slack_id: slackId,
|
||||
email: identity.primary_email,
|
||||
pfpUrl: identity.slack_id ? `https://cachet.dunkirk.sh/users/${identity.slack_id}/r` : 'https://github.com/hackclub.png',
|
||||
hasOnboarded: false,
|
||||
},
|
||||
});
|
||||
@@ -83,40 +100,15 @@ export async function GET(request: Request): Promise<Response> {
|
||||
}
|
||||
}
|
||||
|
||||
interface SlackUserInfo {
|
||||
// OpenID Connect standard fields
|
||||
ok: boolean;
|
||||
sub: string;
|
||||
email: string;
|
||||
email_verified: boolean;
|
||||
date_email_verified: number;
|
||||
name: string;
|
||||
picture: string;
|
||||
given_name: string;
|
||||
family_name: string;
|
||||
locale: string;
|
||||
|
||||
// Slack-specific fields
|
||||
['https://slack.com/user_id']: string;
|
||||
['https://slack.com/team_id']: string;
|
||||
['https://slack.com/team_name']: string;
|
||||
['https://slack.com/team_domain']: string;
|
||||
|
||||
// User image URLs
|
||||
['https://slack.com/user_image_24']: string;
|
||||
['https://slack.com/user_image_32']: string;
|
||||
['https://slack.com/user_image_48']: string;
|
||||
['https://slack.com/user_image_72']: string;
|
||||
['https://slack.com/user_image_192']: string;
|
||||
['https://slack.com/user_image_512']: string;
|
||||
|
||||
// Team image URLs
|
||||
['https://slack.com/team_image_34']?: string;
|
||||
['https://slack.com/team_image_44']?: string;
|
||||
['https://slack.com/team_image_68']?: string;
|
||||
['https://slack.com/team_image_88']?: string;
|
||||
['https://slack.com/team_image_102']?: string;
|
||||
['https://slack.com/team_image_132']?: string;
|
||||
['https://slack.com/team_image_230']?: string;
|
||||
['https://slack.com/team_image_default']?: boolean;
|
||||
interface HackClubIdentity {
|
||||
id: string;
|
||||
slack_id?: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
primary_email: string;
|
||||
}
|
||||
|
||||
interface HackClubUserResponse {
|
||||
identity: HackClubIdentity;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { generateState } from "arctic";
|
||||
import { slack } from '@hctv/auth';
|
||||
import { hackClub, HCID_AUTH_URL } from '@hctv/auth';
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
export async function GET(): Promise<Response> {
|
||||
const state = generateState();
|
||||
const url = slack.createAuthorizationURL(state, ['openid', 'profile']);
|
||||
const url = hackClub.createAuthorizationURL(HCID_AUTH_URL, state, ['slack_id', 'verification_status', 'email']);
|
||||
|
||||
(await cookies()).set("slack_oauth_state", state, {
|
||||
(await cookies()).set("hackclub_oauth_state", state, {
|
||||
path: "/",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
httpOnly: true,
|
||||
132
apps/web/src/app/(ui)/(public)/onboarding/page.client.tsx
Normal file
132
apps/web/src/app/(ui)/(public)/onboarding/page.client.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
'use client';
|
||||
|
||||
import { UniversalForm } from '@/components/app/UniversalForm/UniversalForm';
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
|
||||
import { onboard } from '@/lib/form/actions';
|
||||
import { useSession } from '@/lib/providers/SessionProvider';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { User, Tv, Heart, MessageSquare } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
|
||||
export default function OnboardingClient() {
|
||||
const { user } = useSession();
|
||||
|
||||
return (
|
||||
<div className="min-h-[93vh] flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-2xl space-y-8">
|
||||
{/* welcome header */}
|
||||
<div className="text-center space-y-2">
|
||||
<div className="flex justify-center">
|
||||
<div className="relative">
|
||||
<Avatar className="h-20 w-20 border-4 border-primary/20">
|
||||
<AvatarImage src={user?.pfpUrl} alt={`@${user?.id}`} />
|
||||
<AvatarFallback className="text-2xl font-bold">
|
||||
{user?.id?.charAt(0)?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
Welcome to hackclub.tv!
|
||||
</h1>
|
||||
<p className="text-lg text-muted-foreground flex gap-2 justify-center">
|
||||
Let's get you set up <Image src="https://emoji.slack-edge.com/T0266FRGM/blahaj-heart/db9adf8229e9a4fb.png" alt=":blahaj-heart:" width={24} height={24} />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* explanation */}
|
||||
<Card className="border-2 border-primary/10 bg-primary/5">
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="flex items-center gap-2 text-xl">
|
||||
<User className="h-5 w-5 text-primary" />
|
||||
Why do you need a personal channel?
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 w-8 h-8 bg-primary/10 rounded-lg flex items-center justify-center">
|
||||
<Tv className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm">Stream content</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Share your coding sessions and projects!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 w-8 h-8 bg-primary/10 rounded-lg flex items-center justify-center">
|
||||
<MessageSquare className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm">Chat with others</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Connect with other Hack Clubbers and grow your audience
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 w-8 h-8 bg-primary/10 rounded-lg flex items-center justify-center">
|
||||
<Heart className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm">Follow hackclubbers</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Stay updated with your favorite creators and streams
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 p-4 bg-secondary/50 rounded-lg border border-muted">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<strong>Your personal channel</strong> is your home base on hctv.
|
||||
It's where your profile, streams, and content will live. You can always create
|
||||
additional channels later for different types of content!
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* form */}
|
||||
<Card className="shadow-lg">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-xl">Choose Your Channel Username</CardTitle>
|
||||
<CardDescription>
|
||||
This will be your unique identifier on hctv. Choose something memorable!
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<UniversalForm
|
||||
fields={[
|
||||
{ name: 'userId', label: 'User ID', type: 'hidden', value: user?.id },
|
||||
{
|
||||
name: 'username',
|
||||
label: 'Channel Username',
|
||||
type: 'text',
|
||||
placeholder: 'e.g., yourname or yourname-codes',
|
||||
maxChars: 20,
|
||||
inputFilter: /[^a-z0-9_-]/g,
|
||||
},
|
||||
]}
|
||||
schemaName="onboard"
|
||||
action={onboard}
|
||||
onActionComplete={() => {
|
||||
window.location.href = '/';
|
||||
}}
|
||||
/>
|
||||
<div className="mt-4 p-3 bg-muted/30 rounded-md">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<strong>Username rules:</strong> Only lowercase letters (a-z), numbers (0-9),
|
||||
underscores (_), and dashes (-) are allowed. Up to 20 characters. Must be unique across the platform.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,10 @@ import OnboardingClient from "./page.client";
|
||||
|
||||
export default async function Page() {
|
||||
const { user } = await validateRequest();
|
||||
if (user!.hasOnboarded) {
|
||||
if (!user) {
|
||||
return redirect('/');
|
||||
}
|
||||
if (user.hasOnboarded) {
|
||||
return redirect('/');
|
||||
}
|
||||
return <OnboardingClient />;
|
||||
@@ -27,7 +27,7 @@ export default async function Home() {
|
||||
if (!streams.length) {
|
||||
return (
|
||||
<div className="flex justify-center items-center text-center flex-col pt-4 gap-2">
|
||||
<h2>No streams found!!</h2>
|
||||
<h2>No streams found!</h2>
|
||||
<p>...maybe start one?</p>
|
||||
<ConfusedDino className='w-40 h-40' />
|
||||
</div>
|
||||
@@ -42,7 +42,8 @@ export default async function Home() {
|
||||
<Card className="overflow-hidden hover:shadow-lg transition-shadow">
|
||||
<CardContent className="p-0">
|
||||
<div className="relative">
|
||||
<Image
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={`/api/stream/thumb/${stream.channel.name}`}
|
||||
width={512}
|
||||
height={512}
|
||||
77
apps/web/src/app/(ui)/layout.tsx
Normal file
77
apps/web/src/app/(ui)/layout.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
import { cookies } from 'next/headers';
|
||||
import '../globals.css';
|
||||
import Navbar from '@/components/app/NavBar/NavBar';
|
||||
import { SessionProvider } from '@/lib/providers/SessionProvider';
|
||||
import { validateRequest } from '@/lib/auth/validate';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import { ThemeProvider } from '@/lib/providers/ThemeProvider';
|
||||
import { SidebarProvider } from '@/components/ui/sidebar';
|
||||
import Sidebar from '@/components/app/Sidebar/Sidebar';
|
||||
import { cn } from '@/lib/utils';
|
||||
import EditLivestream from '@/components/app/EditLivestream/EditLivestream';
|
||||
import { StreamInfoProvider } from '@/lib/providers/StreamInfoProvider';
|
||||
import { NextSSRPlugin } from "@uploadthing/react/next-ssr-plugin";
|
||||
import { extractRouterConfig } from 'uploadthing/server';
|
||||
import { ourFileRouter } from '@/lib/services/uploadthing/fileRouter';
|
||||
import { NuqsAdapter } from 'nuqs/adapters/next/app'
|
||||
import SonnerNewVersion from '@/components/app/SonnerNewVersion/SonnerNewVersion';
|
||||
import ConfirmDialogProvider from '@/lib/providers/ConfirmProvider';
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'hackclub.tv',
|
||||
description: "Hack Club's livestreaming platform",
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const sessionData = await validateRequest();
|
||||
const cookieStore = await cookies();
|
||||
const defaultOpen = cookieStore.get('sidebar:state')?.value === 'true';
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={cn('flex flex-col h-screen', inter.className)}>
|
||||
<SessionProvider value={sessionData}>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<SonnerNewVersion />
|
||||
<NextSSRPlugin
|
||||
routerConfig={extractRouterConfig(ourFileRouter)}
|
||||
/>
|
||||
<ConfirmDialogProvider defaultOptions={{
|
||||
cancelButton: {
|
||||
variant: 'outline',
|
||||
},
|
||||
}}>
|
||||
<NuqsAdapter>
|
||||
<SidebarProvider defaultOpen={defaultOpen}>
|
||||
<StreamInfoProvider>
|
||||
{/* this promise is ugly but i'm lazy to fix the type errors */}
|
||||
<Navbar editLivestream={Promise.resolve(<EditLivestream />)} />
|
||||
<div className="flex flex-1 pt-16">
|
||||
{/* pt-16 for navbar height */}
|
||||
<Sidebar className="pt-16" />
|
||||
<main className="flex-1 overflow-auto">{children}</main>
|
||||
</div>
|
||||
<Toaster />
|
||||
</StreamInfoProvider>
|
||||
</SidebarProvider>
|
||||
</NuqsAdapter>
|
||||
</ConfirmDialogProvider>
|
||||
</ThemeProvider>
|
||||
</SessionProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
23
apps/web/src/app/global-error.tsx
Normal file
23
apps/web/src/app/global-error.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import NextError from "next/error";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function GlobalError({ error }: { error: Error & { digest?: string } }) {
|
||||
useEffect(() => {
|
||||
Sentry.captureException(error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<html>
|
||||
<body>
|
||||
{/* `NextError` is the default Next.js error page component. Its type
|
||||
definition requires a `statusCode` prop. However, since the App Router
|
||||
does not expose status codes for errors, we simply pass 0 to render a
|
||||
generic error message. */}
|
||||
<NextError statusCode={0} />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -4,111 +4,125 @@
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 220 23.077% 94.902%;
|
||||
--foreground: 233.793 16.022% 35.49%;
|
||||
/* Light theme - based on your color scheme */
|
||||
|
||||
--muted: 222.857 15.909% 82.745%;
|
||||
--muted-foreground: 233.333 12.796% 41.373%;
|
||||
/* Main background and foreground */
|
||||
--background: 350 59% 98%; /* FDF7F8 - main background */
|
||||
--foreground: 351 34% 30%; /* 5D3A3F - main text */
|
||||
|
||||
--popover: 220 23.077% 94.902%;
|
||||
--popover-foreground: 233.793 16.022% 35.49%;
|
||||
/* Muted elements */
|
||||
--muted: 350 40% 93%; /* F8E8EA - muted background */
|
||||
--muted-foreground: 350 30% 45%; /* Lighter version of main text */
|
||||
|
||||
--card: 220 23.077% 94.902%;
|
||||
--card-foreground: 233.793 16.022% 35.49%;
|
||||
/* Popover and card */
|
||||
--popover: 0 0% 100%; /* FFFFFF - popover background */
|
||||
--popover-foreground: 351 34% 30%; /* 5D3A3F - popover text */
|
||||
--card: 0 0% 100%; /* FFFFFF - card background */
|
||||
--card-foreground: 351 34% 30%; /* 5D3A3F - card text */
|
||||
|
||||
--border: 225 13.559% 76.863%;
|
||||
--input: 225 13.559% 76.863%;
|
||||
/* Border and input */
|
||||
--border: 350 30% 85%; /* Derived border color */
|
||||
--input: 350 30% 85%; /* Input background */
|
||||
|
||||
--primary: 219.907 91.489% 53.922%;
|
||||
--primary-foreground: 220 23.077% 94.902%;
|
||||
/* Primary actions */
|
||||
--primary: 350 70% 50%; /* C8394F - primary button */
|
||||
--primary-foreground: 0 0% 100%; /* FFFFFF - text on primary */
|
||||
|
||||
--secondary: 222.857 15.909% 82.745%;
|
||||
--secondary-foreground: 233.793 16.022% 35.49%;
|
||||
/* Secondary elements */
|
||||
--secondary: 350 40% 93%; /* F8E8EA - secondary background */
|
||||
--secondary-foreground: 351 34% 30%; /* 5D3A3F - text on secondary */
|
||||
|
||||
--accent: 222.857 15.909% 82.745%;
|
||||
--accent-foreground: 233.793 16.022% 35.49%;
|
||||
/* Accent elements */
|
||||
--accent: 350 70% 40%; /* A12D3E - accent color */
|
||||
--accent-foreground: 0 0% 100%; /* FFFFFF - text on accent */
|
||||
|
||||
--destructive: 347.077 86.667% 44.118%;
|
||||
--destructive-foreground: 220 21.951% 91.961%;
|
||||
/* Destructive actions */
|
||||
--destructive: 350 70% 55%; /* D63C56 - error/destroy */
|
||||
--destructive-foreground: 0 0% 100%; /* FFFFFF - text on destructive */
|
||||
|
||||
--ring: 233.793 16.022% 35.49%;
|
||||
/* Focus ring */
|
||||
--ring: 350 70% 50%; /* C8394F - focus ring */
|
||||
|
||||
--surface-1: 225 14% 77%;
|
||||
--surface-2: 227 12% 71%;
|
||||
/* Surface colors */
|
||||
--surface-1: 350 40% 93%; /* F8E8EA - surface 1 */
|
||||
--surface-2: 350 35% 88%; /* Derived surface 2 */
|
||||
|
||||
--mantle: 220 22% 92%;
|
||||
/* Mantle */
|
||||
--mantle: 350 59% 98%; /* FDF7F8 - mantle */
|
||||
|
||||
/* Radius */
|
||||
--radius: 0.5rem;
|
||||
|
||||
--sidebar-background: 0 0% 98%;
|
||||
|
||||
--sidebar-foreground: 240 5.3% 26.1%;
|
||||
|
||||
--sidebar-primary: 240 5.9% 10%;
|
||||
|
||||
--sidebar-primary-foreground: 0 0% 98%;
|
||||
|
||||
--sidebar-accent: 240 4.8% 95.9%;
|
||||
|
||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||
|
||||
--sidebar-border: 220 13% 91%;
|
||||
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
/* Sidebar specific */
|
||||
--sidebar-background: 350 59% 98%; /* FDF7F8 - sidebar bg */
|
||||
--sidebar-foreground: 351 34% 30%; /* 5D3A3F - sidebar text */
|
||||
--sidebar-primary: 350 70% 50%; /* C8394F - sidebar primary */
|
||||
--sidebar-primary-foreground: 0 0% 100%; /* FFFFFF - text on sidebar primary */
|
||||
--sidebar-accent: 350 40% 93%; /* F8E8EA - sidebar accent */
|
||||
--sidebar-accent-foreground: 351 34% 30%; /* 5D3A3F - text on sidebar accent */
|
||||
--sidebar-border: 350 30% 85%; /* Derived border */
|
||||
--sidebar-ring: 350 70% 50%; /* C8394F - sidebar focus ring */
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 240 21.053% 14.902%;
|
||||
--foreground: 226.154 63.934% 88.039%;
|
||||
/* Dark theme - based on your color scheme */
|
||||
|
||||
--muted: 240 12% 19%;
|
||||
--muted-foreground: 240 12% 69%;
|
||||
/* Main background and foreground */
|
||||
--background: 350 20% 15%; /* 2A1F21 - main background */
|
||||
--foreground: 350 30% 92%; /* F5E6E8 - main text */
|
||||
|
||||
--popover: 240 21.053% 14.902%;
|
||||
--popover-foreground: 226.154 63.934% 88.039%;
|
||||
/* Muted elements */
|
||||
--muted: 350 20% 25%; /* 4A2D31 - muted background */
|
||||
--muted-foreground: 350 30% 75%; /* Lighter version of main text */
|
||||
|
||||
--card: 240 21.053% 14.902%;
|
||||
--card-foreground: 226.154 63.934% 88.039%;
|
||||
/* Popover and card */
|
||||
--popover: 350 20% 15%; /* 2A1F21 - popover background */
|
||||
--popover-foreground: 350 30% 92%; /* F5E6E8 - popover text */
|
||||
--card: 350 20% 15%; /* 2A1F21 - card background */
|
||||
--card-foreground: 350 30% 92%; /* F5E6E8 - card text */
|
||||
|
||||
--border: 234.286 13.208% 31.176%;
|
||||
--input: 234.286 13.208% 31.176%;
|
||||
/* Border and input */
|
||||
--border: 350 20% 35%; /* Derived border color */
|
||||
--input: 350 20% 35%; /* Input background */
|
||||
|
||||
--primary: 267 84% 81%;
|
||||
--primary-foreground: 267 84% 21%;
|
||||
/* Primary actions */
|
||||
--primary: 350 100% 75%; /* FF7A8A - primary button */
|
||||
--primary-foreground: 350 20% 15%; /* 2A1F21 - text on primary */
|
||||
|
||||
--secondary: 236.842 16.239% 22.941%;
|
||||
--secondary-foreground: 226.154 63.934% 88.039%;
|
||||
/* Secondary elements */
|
||||
--secondary: 350 20% 25%; /* 4A2D31 - secondary background */
|
||||
--secondary-foreground: 350 30% 92%; /* F5E6E8 - text on secondary */
|
||||
|
||||
--accent: 236.842 16.239% 22.941%;
|
||||
--accent-foreground: 226.154 63.934% 88.039%;
|
||||
/* Accent elements */
|
||||
--accent: 350 100% 80%; /* FF9AAA - accent color */
|
||||
--accent-foreground: 350 20% 15%; /* 2A1F21 - text on accent */
|
||||
|
||||
--destructive: 343.269 81.25% 74.902%;
|
||||
--destructive-foreground: 240 21.311% 11.961%;
|
||||
/* Destructive actions */
|
||||
--destructive: 350 100% 70%; /* FF6B7D - error/destroy */
|
||||
--destructive-foreground: 350 20% 15%; /* 2A1F21 - text on destructive */
|
||||
|
||||
--ring: 226.154 63.934% 88.039%;
|
||||
/* Focus ring */
|
||||
--ring: 350 100% 75%; /* FF7A8A - focus ring */
|
||||
|
||||
--surface-1: 234 13% 31%;
|
||||
--surface-2: 233 12% 39%;
|
||||
/* Surface colors */
|
||||
--surface-1: 350 20% 25%; /* 4A2D31 - surface 1 */
|
||||
--surface-2: 350 20% 35%; /* Derived surface 2 */
|
||||
|
||||
--mantle: 240 21.311% 11.961%;
|
||||
/* Mantle */
|
||||
--mantle: 350 20% 12%; /* 1F1617 - mantle */
|
||||
|
||||
/* Radius */
|
||||
--radius: 0.5rem;
|
||||
|
||||
--sidebar-background: 240 21.311% 11.961%; /* crust - matches mantle var */
|
||||
|
||||
--sidebar-foreground: 226.154 63.934% 88.039%; /* matches main foreground */
|
||||
|
||||
--sidebar-primary: 217.168 91.87% 75.882%; /* matches primary */
|
||||
|
||||
--sidebar-primary-foreground: 240 21.053% 14.902%; /* matches primary-foreground */
|
||||
|
||||
--sidebar-accent: 236.842 16.239% 22.941%; /* matches accent */
|
||||
|
||||
--sidebar-accent-foreground: 226.154 63.934% 88.039%; /* matches accent-foreground */
|
||||
|
||||
--sidebar-border: 234.286 13.208% 31.176%; /* matches border */
|
||||
|
||||
--sidebar-ring: 217.168 91.87% 75.882%; /* matches primary */
|
||||
/* Sidebar specific */
|
||||
--sidebar-background: 350 20% 12%; /* 1F1617 - sidebar bg */
|
||||
--sidebar-foreground: 350 30% 92%; /* F5E6E8 - sidebar text */
|
||||
--sidebar-primary: 350 100% 75%; /* FF7A8A - sidebar primary */
|
||||
--sidebar-primary-foreground: 350 20% 15%; /* 2A1F21 - text on sidebar primary */
|
||||
--sidebar-accent: 350 20% 25%; /* 4A2D31 - sidebar accent */
|
||||
--sidebar-accent-foreground: 350 30% 92%; /* F5E6E8 - text on sidebar accent */
|
||||
--sidebar-border: 350 20% 35%; /* Derived border */
|
||||
--sidebar-ring: 350 100% 75%; /* FF7A8A - sidebar focus ring */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,14 +133,20 @@
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar { display: none; }
|
||||
.scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }
|
||||
}
|
||||
|
||||
h1 {
|
||||
@apply scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl;
|
||||
}
|
||||
|
||||
h2 {
|
||||
@apply scroll-m-20 pb-2 text-3xl font-semibold tracking-tight first:mt-0;
|
||||
}
|
||||
|
||||
/* Media controller styles remain unchanged */
|
||||
media-controller {
|
||||
--media-primary-color: #ffffff;
|
||||
--media-secondary-color: hsla(var(--background), 0.85);
|
||||
@@ -158,7 +178,7 @@ media-time-range {
|
||||
}
|
||||
|
||||
media-time-display {
|
||||
--media-text-color: #ffffff;
|
||||
--media-text-color: #ffffff;
|
||||
}
|
||||
|
||||
media-controller::part(centered-layer) {
|
||||
|
||||
@@ -1,55 +1,28 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
import { NuqsAdapter } from 'nuqs/adapters/next/app';
|
||||
import './globals.css';
|
||||
import Navbar from '@/components/app/NavBar/NavBar';
|
||||
import { SessionProvider } from '@/lib/providers/SessionProvider';
|
||||
import { validateRequest } from '@/lib/auth/validate';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import { ThemeProvider } from '@/lib/providers/ThemeProvider';
|
||||
import { SidebarProvider } from '@/components/ui/sidebar';
|
||||
import Sidebar from '@/components/app/Sidebar/Sidebar';
|
||||
import { cn } from '@/lib/utils';
|
||||
import EditLivestream from '@/components/app/EditLivestream/EditLivestream';
|
||||
import { StreamInfoProvider } from '@/lib/providers/StreamInfoProvider';
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
const publicEnv = Object.keys(process.env).reduce((acc, key) => {
|
||||
if (key.startsWith('NEXT_PUBLIC_')) {
|
||||
acc[key] = process.env[key];
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, string | undefined>);
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'hackclub.tv',
|
||||
description: "Hack Club's livestreaming platform",
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const sessionData = await validateRequest();
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={cn('flex flex-col h-screen', inter.className)}>
|
||||
<SessionProvider value={sessionData}>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<SidebarProvider>
|
||||
<StreamInfoProvider>
|
||||
{/* this promise is ugly but i'm lazy to fix the type errors */}
|
||||
<Navbar editLivestream={Promise.resolve(<EditLivestream />)} />
|
||||
<div className="flex flex-1 pt-16">
|
||||
{/* pt-16 for navbar height */}
|
||||
<Sidebar className="pt-16" />
|
||||
<main className="flex-1 overflow-auto">{children}</main>
|
||||
</div>
|
||||
<Toaster />
|
||||
</StreamInfoProvider>
|
||||
</SidebarProvider>
|
||||
</ThemeProvider>
|
||||
</SessionProvider>
|
||||
</body>
|
||||
<head>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `window.__ENV = ${JSON.stringify(publicEnv)}`,
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<NuqsAdapter>
|
||||
<body>
|
||||
<main>{children}</main>
|
||||
</body>
|
||||
</NuqsAdapter>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
46
apps/web/src/components/app/ChannelSelect/ChannelSelect.tsx
Normal file
46
apps/web/src/components/app/ChannelSelect/ChannelSelect.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
'use client'
|
||||
|
||||
import type { Channel } from "@hctv/db";
|
||||
import * as React from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
|
||||
export function ChannelSelect(props: Props) {
|
||||
const { channelList } = props;
|
||||
return (
|
||||
<Select onValueChange={props.onSelect} value={props.value}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Channel" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{channelList.map((channel) => (
|
||||
<SelectItem key={channel.id} value={channel.name}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={channel.pfpUrl} alt={channel.name} />
|
||||
<AvatarFallback>{channel.name[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="font-medium">{channel.name}</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem key="create" value="create" icon={<Plus className="h-4 w-4" />} className='h-11'>
|
||||
Create Channel
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
channelList: Channel[];
|
||||
value?: string;
|
||||
onSelect: (value: string) => void;
|
||||
}
|
||||
@@ -2,22 +2,32 @@
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Send } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { Message } from './message';
|
||||
import { useMap } from '@uidotdev/usehooks';
|
||||
import { EmojiSearch } from './EmojiSearch';
|
||||
import { useQueryState } from 'nuqs';
|
||||
|
||||
export default function ChatPanel() {
|
||||
export default function ChatPanel(props: Props) {
|
||||
const { username } = useParams();
|
||||
const [grant, setGrant] = useQueryState('grant');
|
||||
const [message, setMessage] = useState('');
|
||||
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const socketRef = useRef<WebSocket | null>(null);
|
||||
const emojiMap = useMap() as Map<string, string>;
|
||||
const [emojisToReq, setEmojisToReq] = useState<string[]>([]);
|
||||
const [cursorPosition, setCursorPosition] = useState(0);
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('Initializing WebSocket connection for user:', username);
|
||||
const socket = new WebSocket(
|
||||
`ws${window.location.protocol === 'https:' ? 's' : ''}://${
|
||||
window.location.host
|
||||
}/api/stream/chat/ws/${username}`
|
||||
}/api/stream/chat/ws/${username}?grant=${grant}`
|
||||
);
|
||||
socketRef.current = socket;
|
||||
|
||||
@@ -28,10 +38,30 @@ export default function ChatPanel() {
|
||||
socket.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === 'ping' || data.type === 'pong' || !data.user) return;
|
||||
setChatMessages((prev) => [...prev, data]);
|
||||
console.log('Received websocket message:', data);
|
||||
|
||||
if (data.type === 'history') {
|
||||
const messages = data.messages as ChatMessage[];
|
||||
setChatMessages((prev) => [
|
||||
...prev,
|
||||
...messages,
|
||||
{ message: 'Welcome to the chat!', type: 'systemMsg' },
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === 'message') {
|
||||
console.log('Adding new chat message:', data);
|
||||
setChatMessages((prev) => [...prev, data]);
|
||||
}
|
||||
|
||||
if (!data.type && data.message && data.user) {
|
||||
console.log('Adding legacy chat message format:', data);
|
||||
setChatMessages((prev) => [...prev, { ...data, type: 'message' }]);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Received message confirmation:', event.data);
|
||||
console.error('Error processing message:', e);
|
||||
console.log('Raw message data:', event.data);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -42,7 +72,7 @@ export default function ChatPanel() {
|
||||
return () => {
|
||||
socket.close();
|
||||
};
|
||||
}, []);
|
||||
}, [username]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
@@ -63,7 +93,7 @@ export default function ChatPanel() {
|
||||
const socket = new WebSocket(
|
||||
`ws${window.location.protocol === 'https:' ? 's' : ''}://${
|
||||
window.location.host
|
||||
}/api/stream/chat/ws/${username}`
|
||||
}/api/stream/chat/ws/${username}?grant=${grant}`
|
||||
);
|
||||
socket.onopen = () => {
|
||||
socket.send(JSON.stringify({ type: 'message', message }));
|
||||
@@ -81,54 +111,222 @@ export default function ChatPanel() {
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// emoji message collector
|
||||
useEffect(() => {
|
||||
if (chatMessages.length === 0) return;
|
||||
|
||||
const emojiPattern = /:[\w\-+]+:/g;
|
||||
const newEmojis = chatMessages
|
||||
.filter((msg) => msg.type === 'message')
|
||||
.flatMap((msg) => {
|
||||
if (!msg.message) return [];
|
||||
const message = String(msg.message);
|
||||
const matches = [...message.matchAll(emojiPattern)].map((m) => m[0]);
|
||||
return matches;
|
||||
})
|
||||
.filter((emoji) => {
|
||||
const emojiName = emoji.replaceAll(':', '');
|
||||
return !emojiMap.has(emojiName) && emojiName.length > 0;
|
||||
});
|
||||
|
||||
if (newEmojis.length > 0) {
|
||||
console.log(`Found ${newEmojis.length} new emojis to request: ${newEmojis.join(', ')}`);
|
||||
setEmojisToReq((prev) => [...new Set([...prev, ...newEmojis])]);
|
||||
}
|
||||
}, [chatMessages, emojiMap]);
|
||||
|
||||
// emoji requester
|
||||
useEffect(() => {
|
||||
if (emojisToReq.length === 0) return;
|
||||
|
||||
console.log('Requesting emojis:', emojisToReq);
|
||||
|
||||
// Ensure websocket is connected
|
||||
if (!socketRef.current || socketRef.current.readyState !== WebSocket.OPEN) {
|
||||
const socket = new WebSocket(
|
||||
`ws${window.location.protocol === 'https:' ? 's' : ''}://${
|
||||
window.location.host
|
||||
}/api/stream/chat/ws/${username}?grant=${grant}`
|
||||
);
|
||||
|
||||
socket.onopen = () => {
|
||||
socketRef.current = socket;
|
||||
sendEmojiRequest();
|
||||
};
|
||||
|
||||
return () => {
|
||||
socket.close();
|
||||
};
|
||||
} else {
|
||||
sendEmojiRequest();
|
||||
}
|
||||
|
||||
function sendEmojiRequest() {
|
||||
const handleEmojiResponse = (event: MessageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'emojiMsgResponse') {
|
||||
const emojis = data.emojis as Record<string, string>;
|
||||
|
||||
let validEmojiCount = 0;
|
||||
Object.entries(emojis).forEach(([name, url]) => {
|
||||
if (url) {
|
||||
emojiMap.set(name, url);
|
||||
validEmojiCount++;
|
||||
} else {
|
||||
console.log(`No URL found for emoji: ${name}`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`added ${validEmojiCount} valid emojis to map.`);
|
||||
|
||||
if (validEmojiCount > 0) {
|
||||
const sampleName = Object.entries(emojis).find(([_, url]) => url)?.[0];
|
||||
if (sampleName) {
|
||||
}
|
||||
} else {
|
||||
console.warn('No valid emoji URLs received');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('error processing emoji response:', e);
|
||||
}
|
||||
};
|
||||
|
||||
socketRef.current?.addEventListener('message', handleEmojiResponse);
|
||||
|
||||
const emojiRequest = {
|
||||
type: 'emojiMsg',
|
||||
emojis: emojisToReq.map((e) => e.replaceAll(':', '')),
|
||||
};
|
||||
|
||||
console.log('sending emoji request:', emojiRequest);
|
||||
socketRef.current?.send(JSON.stringify(emojiRequest));
|
||||
|
||||
return () => {
|
||||
socketRef.current?.removeEventListener('message', handleEmojiResponse);
|
||||
setEmojisToReq([]);
|
||||
};
|
||||
}
|
||||
}, [emojisToReq, emojiMap, username]);
|
||||
|
||||
const handleEmojiSelect = (emojiName: string) => {
|
||||
if (!textareaRef.current) return;
|
||||
|
||||
const textarea = textareaRef.current;
|
||||
const beforeCursor = message.substring(0, cursorPosition);
|
||||
const afterCursor = message.substring(cursorPosition);
|
||||
|
||||
const match = beforeCursor.match(/:[\w\-+]*$/);
|
||||
if (!match) return;
|
||||
|
||||
const startPos = beforeCursor.lastIndexOf(match[0]);
|
||||
const newBeforeCursor = beforeCursor.substring(0, startPos);
|
||||
|
||||
const newMessage = `${newBeforeCursor}:${emojiName}: ${afterCursor}`;
|
||||
setMessage(newMessage);
|
||||
|
||||
// 3 for colons and space
|
||||
const newCursorPos = newBeforeCursor.length + emojiName.length + 3;
|
||||
|
||||
setTimeout(() => {
|
||||
textarea.focus();
|
||||
textarea.selectionStart = newCursorPos;
|
||||
textarea.selectionEnd = newCursorPos;
|
||||
setCursorPosition(newCursorPos);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const isEmojiSearchOpen = () => {
|
||||
const beforeCursor = message.substring(0, cursorPosition);
|
||||
const match = beforeCursor.match(/:[\w\-+]*$/);
|
||||
return match !== null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="md:border flex flex-col w-full min-w-[350px] h-full bg-mantle">
|
||||
<div ref={scrollRef} className="flex-1 p-4 overflow-y-auto flex flex-col">
|
||||
<div className="space-y-4 flex-1">
|
||||
<div
|
||||
className={`${props.isObsPanel ? 'w-full text-white' : 'md:border-l border-border bg-mantle w-[350px] max-w-[350px]'} flex flex-col h-full`}
|
||||
>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={`flex-1 px-4 py-2 ${props.isObsPanel ? 'scrollbar-hide' : 'scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent'} overflow-y-auto overflow-x-hidden`}
|
||||
>
|
||||
<div className="space-y-1 min-h-full flex flex-col justify-end">
|
||||
{chatMessages.map((msg, i) => (
|
||||
<div key={i} className="flex space-x-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="font-bold shrink-0">{msg.user.username}</div>
|
||||
</div>
|
||||
<div
|
||||
lang="en"
|
||||
className="max-w-[calc(100%-4rem)] break-all whitespace-pre-wrap hyphens-auto"
|
||||
>
|
||||
{msg.message}
|
||||
</div>
|
||||
</div>
|
||||
<Message
|
||||
key={i}
|
||||
user={msg.user}
|
||||
message={msg.message}
|
||||
type={msg.type}
|
||||
emojiMap={emojiMap}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 border-t">
|
||||
<div className="flex space-x-2">
|
||||
<Input
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
sendMessage();
|
||||
}
|
||||
}}
|
||||
placeholder="Type a message"
|
||||
className="flex-1 bg-transparent focus-visible:ring-offset-0"
|
||||
{!props.isObsPanel && (
|
||||
<div className="p-3 border-t border-border relative">
|
||||
<div className="flex gap-2">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={message}
|
||||
onChange={(e) => {
|
||||
setMessage(e.target.value);
|
||||
setCursorPosition(e.target.selectionStart || 0);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !isEmojiSearchOpen()) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
}}
|
||||
onKeyUp={(e) => {
|
||||
setCursorPosition(e.currentTarget.selectionStart || 0);
|
||||
}}
|
||||
onClick={(e) => {
|
||||
setCursorPosition(e.currentTarget.selectionStart || 0);
|
||||
}}
|
||||
placeholder="Send a message..."
|
||||
className="flex-1 bg-background/50 border-border focus-visible:ring-1 focus-visible:ring-primary focus-visible:ring-offset-0 min-h-[40px] max-h-[100px] resize-none py-2 text-sm"
|
||||
rows={1}
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
className="shrink-0 transition-colors"
|
||||
onClick={sendMessage}
|
||||
disabled={!message.trim()}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<EmojiSearch
|
||||
message={message}
|
||||
cursorPosition={cursorPosition}
|
||||
onSelect={handleEmojiSelect}
|
||||
socket={socketRef.current}
|
||||
emojiMap={emojiMap}
|
||||
textareaRef={textareaRef}
|
||||
/>
|
||||
<Button size="icon" className="text-black transition-colors" onClick={sendMessage}>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface User {
|
||||
export interface ChatMessage {
|
||||
user?: User;
|
||||
message: string;
|
||||
type: 'message' | 'systemMsg';
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
pfpUrl: string;
|
||||
isBot: boolean;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
user: User;
|
||||
message: string;
|
||||
interface Props {
|
||||
isObsPanel?: boolean;
|
||||
}
|
||||
|
||||
180
apps/web/src/components/app/ChatPanel/EmojiSearch.tsx
Normal file
180
apps/web/src/components/app/ChatPanel/EmojiSearch.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
// source: ai
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
|
||||
interface EmojiSearchProps {
|
||||
message: string;
|
||||
cursorPosition: number;
|
||||
onSelect: (emoji: string) => void;
|
||||
socket: WebSocket | null;
|
||||
emojiMap: Map<string, string>;
|
||||
textareaRef: React.RefObject<HTMLTextAreaElement>;
|
||||
}
|
||||
|
||||
export function EmojiSearch({
|
||||
message,
|
||||
cursorPosition,
|
||||
onSelect,
|
||||
socket,
|
||||
emojiMap,
|
||||
textareaRef,
|
||||
}: EmojiSearchProps) {
|
||||
const [searchTerm, setSearchTerm] = useState<string | null>(null);
|
||||
const [searchResults, setSearchResults] = useState<string[]>([]);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const resultsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const beforeCursor = message.substring(0, cursorPosition);
|
||||
const match = beforeCursor.match(/:[\w\-+]*$/);
|
||||
|
||||
if (match) {
|
||||
const term = match[0].substring(1);
|
||||
setSearchTerm(term);
|
||||
|
||||
if (term.length > 0) {
|
||||
const localResults = Array.from(emojiMap.keys())
|
||||
.filter((name) => name.toLowerCase().includes(term.toLowerCase()))
|
||||
.slice(0, 5);
|
||||
|
||||
if (localResults.length > 0) {
|
||||
setSearchResults(localResults);
|
||||
}
|
||||
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: 'emojiSearch',
|
||||
searchTerm: term,
|
||||
})
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setSearchResults([]);
|
||||
}
|
||||
} else {
|
||||
setSearchTerm(null);
|
||||
setSearchResults([]);
|
||||
}
|
||||
}, [message, cursorPosition, socket, emojiMap]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
const handleEmojiSearchResponse = (event: MessageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'emojiSearchResponse') {
|
||||
const serverResults = data.results || [];
|
||||
|
||||
const localResults = Array.from(emojiMap.keys())
|
||||
.filter((name) => searchTerm && name.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
.slice(0, 5);
|
||||
|
||||
const combinedResults = [...serverResults];
|
||||
|
||||
localResults.forEach((name) => {
|
||||
if (!combinedResults.includes(name)) {
|
||||
combinedResults.push(name);
|
||||
}
|
||||
});
|
||||
|
||||
setSearchResults(combinedResults.slice(0, 10));
|
||||
setSelectedIndex(0);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('error processing emoji search response:', e);
|
||||
}
|
||||
};
|
||||
|
||||
socket.addEventListener('message', handleEmojiSearchResponse);
|
||||
return () => {
|
||||
socket.removeEventListener('message', handleEmojiSearchResponse);
|
||||
};
|
||||
}, [socket, searchTerm, emojiMap]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!textareaRef.current) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!searchTerm || searchResults.length === 0) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (prev + 1) % searchResults.length);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (prev - 1 + searchResults.length) % searchResults.length);
|
||||
break;
|
||||
case 'Enter':
|
||||
if (searchResults[selectedIndex]) {
|
||||
e.preventDefault();
|
||||
onSelect(searchResults[selectedIndex]);
|
||||
}
|
||||
break;
|
||||
case 'Tab':
|
||||
if (searchResults[selectedIndex]) {
|
||||
e.preventDefault();
|
||||
onSelect(searchResults[selectedIndex]);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
setSearchTerm(null);
|
||||
setSearchResults([]);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const textarea = textareaRef.current;
|
||||
textarea.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
textarea.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [searchTerm, searchResults, selectedIndex, onSelect, textareaRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (resultsRef.current) {
|
||||
const selectedElement = resultsRef.current.children[selectedIndex] as HTMLElement;
|
||||
if (selectedElement) {
|
||||
selectedElement.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
|
||||
if (!searchTerm || searchResults.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-full left-0 right-0 mb-2 mx-0 bg-mantle border border-border rounded-lg shadow-lg max-h-60 overflow-y-auto z-10">
|
||||
<div ref={resultsRef} className="py-1">
|
||||
{searchResults.map((emojiName, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
const emojiUrl = emojiMap.get(emojiName);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={emojiName}
|
||||
className={`px-3 py-2 flex items-center gap-3 cursor-pointer transition-colors ${
|
||||
isSelected ? 'bg-primary/10' : 'hover:bg-primary/5'
|
||||
}`}
|
||||
onClick={() => onSelect(emojiName)}
|
||||
>
|
||||
{emojiUrl && (
|
||||
<Image src={emojiUrl} alt={emojiName} width={24} height={24} className="w-6 h-6" />
|
||||
)}
|
||||
<span className="flex-grow text-sm font-medium">{emojiName}</span>
|
||||
{isSelected && <Check className="h-4 w-4 text-primary" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
apps/web/src/components/app/ChatPanel/message.tsx
Normal file
89
apps/web/src/components/app/ChatPanel/message.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { User } from './ChatPanel';
|
||||
import React from 'react';
|
||||
import Image from 'next/image';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Bot } from 'lucide-react';
|
||||
|
||||
export function Message({ user, message, type, emojiMap }: MessageProps) {
|
||||
if (type === 'systemMsg') {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-1">
|
||||
<span className="text-xs text-muted-foreground italic">{message}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group hover:bg-primary/5 rounded px-2 py-1 -mx-2 transition-colors">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="font-semibold text-primary shrink-0 flex items-center gap-1">
|
||||
{user?.isBot && <Bot className="size-4 text-muted-foreground" />}
|
||||
<span>{user?.displayName || user?.username}</span>
|
||||
</span>
|
||||
<span
|
||||
lang="en"
|
||||
className="text-foreground break-words overflow-wrap-anywhere min-w-0 flex-1"
|
||||
style={{ overflowWrap: 'anywhere', wordBreak: 'break-word' }}
|
||||
>
|
||||
<EmojiRenderer text={message} emojiMap={emojiMap} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmojiRenderer({ text, emojiMap }: EmojiRendererProps) {
|
||||
if (!text) return null;
|
||||
|
||||
const parts = text.split(/(:[\w\-+]+:)/g);
|
||||
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, index) => {
|
||||
if (part.match(/^:[\w\-+]+:$/)) {
|
||||
const emojiName = part.replaceAll(':', '');
|
||||
const emojiUrl = emojiMap.get(emojiName);
|
||||
|
||||
if (emojiUrl) {
|
||||
return (
|
||||
<TooltipProvider key={index}>
|
||||
<Tooltip delayDuration={250}>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex items-center align-middle mx-0.5">
|
||||
<Image
|
||||
src={emojiUrl}
|
||||
alt={part}
|
||||
width={20}
|
||||
height={20}
|
||||
className="inline-block"
|
||||
/>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{part}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve text as-is, handling whitespace properly
|
||||
if (part) {
|
||||
return <span key={index}>{part}</span>;
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface MessageProps {
|
||||
user?: User;
|
||||
message: string;
|
||||
type: 'message' | 'systemMsg';
|
||||
emojiMap: Map<string, string>;
|
||||
}
|
||||
|
||||
interface EmojiRendererProps {
|
||||
text: string;
|
||||
emojiMap: Map<string, string>;
|
||||
}
|
||||
@@ -1,17 +1,16 @@
|
||||
import { validateRequest } from '@/lib/auth/validate';
|
||||
import { prisma } from '@hctv/db';
|
||||
import EditLivestreamDialog from './dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default async function EditLivestream() {
|
||||
const { user } = await validateRequest();
|
||||
if (!user?.hasOnboarded) {
|
||||
return null;
|
||||
}
|
||||
const ownedChannels = await prisma.channel.findMany({
|
||||
where: {
|
||||
OR: [{ ownerId: user.id }, { managers: { some: { id: user.id } } }],
|
||||
},
|
||||
});
|
||||
|
||||
return <EditLivestreamDialog ownedChannels={ownedChannels} />;
|
||||
return (
|
||||
<Link href={`/settings/channel?tab=stream`}>
|
||||
<Button variant="outline">Edit Livestream</Button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
'use client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { StreamInfo } from '@hctv/db';
|
||||
import { UniversalForm } from '../UniversalForm/UniversalForm';
|
||||
import { editStreamInfo } from '@/lib/form/actions';
|
||||
import RegenerateKey from '../RegenerateKey/RegenerateKey';
|
||||
import * as React from 'react';
|
||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { Channel } from '@hctv/db';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import useSWR, { Fetcher } from 'swr';
|
||||
import { fetcher } from '@/lib/services/swr';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function EditLivestreamDialog(props: Props) {
|
||||
const [selectedChannel, setSelectedChannel] = React.useState('');
|
||||
const [streamInfo, setStreamInfo] = React.useState<StreamInfo>();
|
||||
|
||||
const { data, error } = useSWR(
|
||||
selectedChannel ? '/api/stream/info?owned=true' : null,
|
||||
fetcher as Fetcher<StreamInfo[]>
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (data && selectedChannel) {
|
||||
const stream = data.find((stream) => stream.username === selectedChannel);
|
||||
setStreamInfo(stream);
|
||||
}
|
||||
}, [data, selectedChannel]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (props.ownedChannels.length > 0 && !selectedChannel) {
|
||||
setSelectedChannel(props.ownedChannels[0].name);
|
||||
}
|
||||
}, [props.ownedChannels, selectedChannel]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
onOpenChange={(op) => {
|
||||
if (op) {
|
||||
setSelectedChannel('');
|
||||
setStreamInfo(undefined);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">Edit Livestream</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit livestream</DialogTitle>
|
||||
<DialogDescription>Regenerate a key or edit your stream metadata</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Link
|
||||
href="https://gist.github.com/SrIzan10/ebd89ced6b21b016d4d389e6711a94e9"
|
||||
target="_blank"
|
||||
>
|
||||
HOW TO STREAM (github gist link)
|
||||
</Link>
|
||||
<ChannelSelect
|
||||
channelList={props.ownedChannels}
|
||||
onSelect={setSelectedChannel}
|
||||
value={selectedChannel}
|
||||
/>
|
||||
{streamInfo && data ? (
|
||||
<Form username={selectedChannel} streamInfo={streamInfo} />
|
||||
) : (
|
||||
<FormSkeleton />
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function Form(props: FormProps) {
|
||||
return (
|
||||
<UniversalForm
|
||||
fields={[
|
||||
{ name: 'username', label: 'Username', value: props.username, type: 'hidden' },
|
||||
{ name: 'title', label: 'Title', type: 'text', value: props.streamInfo?.title },
|
||||
{ name: 'category', label: 'Category', type: 'text', value: props.streamInfo?.category },
|
||||
{ name: 'enableNotifications', label: 'Enable livestream notifications', type: 'hidden', value: props.streamInfo?.enableNotifications },
|
||||
]}
|
||||
schemaName="streamInfoEdit"
|
||||
action={editStreamInfo}
|
||||
submitButtonDivClassname="float-right"
|
||||
submitText="Save"
|
||||
otherSubmitButton={<RegenerateKey channel={props.username} />}
|
||||
key={props.streamInfo?.id}
|
||||
/>
|
||||
)
|
||||
}
|
||||
function FormSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Title field */}
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-12" /> {/* Label */}
|
||||
<Skeleton className="h-10 w-full" /> {/* Input */}
|
||||
</div>
|
||||
|
||||
{/* Category field */}
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-16" /> {/* Label */}
|
||||
<Skeleton className="h-10 w-full" /> {/* Input */}
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="float-right flex gap-2 py-2">
|
||||
<Skeleton className="h-10 w-32" /> {/* RegenerateKey button */}
|
||||
<Skeleton className="h-10 w-24" /> {/* Save button */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChannelSelect(props: SelectProps) {
|
||||
const { channelList } = props;
|
||||
return (
|
||||
<Select onValueChange={props.onSelect} value={props.value}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Channel" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{channelList.map((channel) => (
|
||||
<SelectItem key={channel.id} value={channel.name}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={channel.pfpUrl} alt={channel.name} />
|
||||
<AvatarFallback>{channel.name[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="font-medium">{channel.name}</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
ownedChannels: Channel[];
|
||||
}
|
||||
interface FormProps {
|
||||
username: string;
|
||||
streamInfo: StreamInfo;
|
||||
}
|
||||
interface SelectProps {
|
||||
channelList: Channel[];
|
||||
value?: string;
|
||||
onSelect: (value: string) => void;
|
||||
}
|
||||
@@ -1,80 +1,42 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Play, Users, MessageCircle, Code, Zap, Heart } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function LandingPage() {
|
||||
return (
|
||||
<>
|
||||
<main className="flex-1">
|
||||
<section className="w-full py-12 md:py-24 lg:py-32 xl:py-48">
|
||||
<div className="container px-4 md:px-6">
|
||||
<div className="flex flex-col items-center space-y-4 text-center">
|
||||
<div className="flex items-center rounded-lg px-3 py-1 text-sm bg-mantle text-red-500">
|
||||
THIS IS A PLACEHOLDER WITH NO "HACK CLUB" VIBE, I'M REALLY BAD AT LANDING PAGES SORRY
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl lg:text-6xl/none">
|
||||
This is hackclub.tv
|
||||
</h1>
|
||||
<p className="mx-auto max-w-[700px] text-gray-500 md:text-xl dark:text-gray-400">
|
||||
The streaming service made by and for Hack Clubbers. Share your coding sessions, workshops, and hackathon moments live!
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-x-4">
|
||||
<Link href="/login">
|
||||
<Button>Start Streaming Now!</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<section className="relative w-full py-20 md:py-32 lg:py-40 xl:py-48 overflow-hidden">
|
||||
<div className="absolute inset-0"></div>
|
||||
<div className="container px-4 md:px-6 relative">
|
||||
<div className="flex flex-col items-center space-y-8 text-center">
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl md:text-6xl lg:text-7xl text-primary">
|
||||
hackclub.tv
|
||||
</h1>
|
||||
<p className="mx-auto max-w-[600px] text-lg text-muted-foreground md:text-xl">
|
||||
The streaming website for Hack Clubbers, by Hack Clubbers.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row">
|
||||
<Link href="/login" className="space-x-4">
|
||||
<Button size="lg" className="px-8 py-3 text-lg font-semibold">
|
||||
<Play className="w-5 h-5 mr-2" />
|
||||
Start Streaming
|
||||
</Button>
|
||||
<Button variant="outline" size="lg" className="px-8 py-3 text-lg font-semibold">
|
||||
<Users className="w-5 h-5 mr-2" />
|
||||
Watch Streams
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="w-full py-12 md:py-24 lg:py-32 bg-mantle" id="features">
|
||||
<div className="container px-4 md:px-6">
|
||||
<div className="flex flex-col items-center justify-center space-y-4 text-center">
|
||||
<div className="space-y-2">
|
||||
<div className="inline-block rounded-lg bg-gray-100 px-3 py-1 text-sm dark:bg-gray-800">
|
||||
Platform Features
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold tracking-tighter sm:text-5xl">
|
||||
Built for Hack Clubbers, by Hack Clubbers
|
||||
</h2>
|
||||
<p className="max-w-[900px] text-gray-500 md:text-xl/relaxed lg:text-base/relaxed xl:text-xl/relaxed dark:text-gray-400">
|
||||
Everything you need to have a closer relationship with the Hack Club community!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto max-w-5xl items-center gap-6 py-12 lg:grid-cols-2 lg:gap-12">
|
||||
<div className="flex flex-col justify-center items-center text-center space-y-4">
|
||||
<ul className="grid gap-6">
|
||||
<li>
|
||||
<div className="grid gap-1">
|
||||
<h3 className="text-xl font-bold">Live Streaming</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Stream your coding sessions with low-latency and high quality
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div className="grid gap-1">
|
||||
<h3 className="text-xl font-bold">Chat Integration</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Interact with viewers in real-time through the built-in chat system
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div className="grid gap-1">
|
||||
<h3 className="text-xl font-bold">Community Features</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Follow other streamers and build your network of Hack Club friends!
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +1,77 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import StreamPlayer from '../StreamPlayer/StreamPlayer';
|
||||
import UserInfoCard from '../UserInfoCard/UserInfoCard';
|
||||
import ChatPanel from '../ChatPanel/ChatPanel';
|
||||
import type { StreamInfo, User } from '@hctv/db';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { StreamInfo, Channel } from '@hctv/db';
|
||||
import { useIsMobile } from '@/lib/hooks/useMobile';
|
||||
import { useAllChannels } from '@/lib/hooks/useUserList';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
|
||||
export default function LiveStream(props: Props) {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const { channels, refresh } = useAllChannels(5000);
|
||||
const [isRestricted, setIsRestricted] = useState(false);
|
||||
const [restrictionExpiresAt, setRestrictionExpiresAt] = useState<string | null>(null);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const currentStream = channels.find((s) => s.username === props.username);
|
||||
if (currentStream?.channel?.isRestricted) {
|
||||
setIsRestricted(true);
|
||||
setRestrictionExpiresAt(currentStream.channel.restrictionExpiresAt || null);
|
||||
} else if (isRestricted && currentStream && !currentStream.channel?.isRestricted) {
|
||||
setIsRestricted(false);
|
||||
setRestrictionExpiresAt(null);
|
||||
}
|
||||
}, [channels, props.username, isRestricted]);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await refresh();
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isRestricted) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-[calc(100vh-64px)] p-4">
|
||||
<h1 className="text-2xl font-bold text-destructive mb-2">Channel Restricted</h1>
|
||||
<p className="text-muted-foreground text-center max-w-md mb-4">
|
||||
This channel has been restricted by a moderator and is no longer available for viewing.
|
||||
</p>
|
||||
{restrictionExpiresAt && (
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Restriction lifts: {format(new Date(restrictionExpiresAt), 'PPP p')}
|
||||
</p>
|
||||
)}
|
||||
<Button variant="outline" onClick={handleRefresh} disabled={isRefreshing}>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
{isRefreshing ? 'Checking...' : 'Check again'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${isMobile ? 'flex flex-col' : 'flex'} h-[calc(100vh-64px)] w-full`}>
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
<StreamPlayer />
|
||||
{isMobile && (
|
||||
<div className="h-[300px]">
|
||||
<div className="flex-1 min-h-[250px] max-h-[400px] border-t border-border">
|
||||
<ChatPanel />
|
||||
</div>
|
||||
)}
|
||||
<UserInfoCard streamInfo={props.streamInfo} />
|
||||
</div>
|
||||
|
||||
|
||||
{!isMobile && (
|
||||
<div>
|
||||
<div className="h-full shrink-0">
|
||||
<ChatPanel />
|
||||
</div>
|
||||
)}
|
||||
@@ -32,5 +81,5 @@ export default function LiveStream(props: Props) {
|
||||
|
||||
interface Props {
|
||||
username: string;
|
||||
streamInfo: StreamInfo & { ownedBy: User };
|
||||
}
|
||||
streamInfo: StreamInfo & { channel: Channel };
|
||||
}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import Link from "next/link";
|
||||
import { links } from "../NavBar/NavBar";
|
||||
|
||||
export default function MobileNavbarLinks() {
|
||||
return (
|
||||
<div className="flex md:hidden">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button>Menu</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56">
|
||||
<DropdownMenuLabel>stack</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
{links.map((link) => (
|
||||
<Link key={link.href} href={link.href}>
|
||||
<DropdownMenuItem>{link.name}</DropdownMenuItem>
|
||||
</Link>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,23 +15,9 @@ import { logout } from '@/lib/auth/actions';
|
||||
import { useSession } from '@/lib/providers/SessionProvider';
|
||||
import Link from 'next/link';
|
||||
import { ThemeSwitcher } from '../ThemeSwitcher/ThemeSwitcher';
|
||||
import { Slack } from 'lucide-react';
|
||||
import { IdCard, Shield } from 'lucide-react';
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||
|
||||
export const links = [{ href: '/', name: 'home (placeholder link)' }];
|
||||
|
||||
function NavbarLinks() {
|
||||
return (
|
||||
<>
|
||||
{links.map((link) => (
|
||||
<Link key={link.href} href={link.href}>
|
||||
<Button variant={'link'}>{link.name}</Button>
|
||||
</Link>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Navbar(props: Props) {
|
||||
const { user } = useSession();
|
||||
return (
|
||||
@@ -46,10 +32,6 @@ export default function Navbar(props: Props) {
|
||||
<SidebarTrigger />
|
||||
</div>
|
||||
|
||||
<div className="hidden md:flex">
|
||||
<NavbarLinks />
|
||||
</div>
|
||||
|
||||
{/* Right Side Items */}
|
||||
<div className="flex items-center gap-1 md:gap-3 shrink-0">
|
||||
{props.editLivestream && <div className="hidden sm:block">{props.editLivestream}</div>}
|
||||
@@ -69,6 +51,33 @@ export default function Navbar(props: Props) {
|
||||
<Link href={`/settings/follows`}>
|
||||
<DropdownMenuItem className="cursor-pointer">Follows</DropdownMenuItem>
|
||||
</Link>
|
||||
<Link href={`/settings/channel/create`}>
|
||||
<DropdownMenuItem className="cursor-pointer">Create channel</DropdownMenuItem>
|
||||
</Link>
|
||||
<Link href={`/settings/bot`}>
|
||||
<DropdownMenuItem className="cursor-pointer">Bot accounts</DropdownMenuItem>
|
||||
</Link>
|
||||
{user.isAdmin && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<Link href={`/admin`}>
|
||||
<DropdownMenuItem className="cursor-pointer text-primary">
|
||||
<Shield className="w-4 h-4 mr-2" />
|
||||
Admin Panel
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<Link href={'https://docs.hackclub.tv'} target="_blank" rel="noreferrer">
|
||||
<DropdownMenuItem className="cursor-pointer">API Docs</DropdownMenuItem>
|
||||
</Link>
|
||||
<Link href={'https://github.com/SrIzan10/hctv'} target="_blank" rel="noreferrer">
|
||||
<DropdownMenuItem className="cursor-pointer">Github</DropdownMenuItem>
|
||||
</Link>
|
||||
<Link href={'https://github.com/sponsors/SrIzan10'} target="_blank" rel="noreferrer">
|
||||
<DropdownMenuItem className="cursor-pointer">Sponsor</DropdownMenuItem>
|
||||
</Link>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
@@ -85,12 +94,23 @@ export default function Navbar(props: Props) {
|
||||
<DropdownMenuGroup>
|
||||
<ThemeSwitcher />
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuGroup>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm px-2">
|
||||
v{process.env.version}-{process.env.NODE_ENV === 'development' ? 'dev' : 'prod'}, commit{' '}
|
||||
<Link
|
||||
href={`https://github.com/SrIzan10/hctv/commit/${process.env.commit}`}
|
||||
target="_blank"
|
||||
>
|
||||
{process.env.commit}
|
||||
</Link>
|
||||
</p>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<Link href="/auth/slack">
|
||||
<Link href="/auth/hackclub">
|
||||
<Button variant="outline" size="sm" className="gap-1 md:gap-2 text-xs md:text-sm">
|
||||
<Slack className="w-3 h-3 md:w-4 md:h-4" />
|
||||
<IdCard className="w-3 h-3 md:w-4 md:h-4" />
|
||||
<span className="hidden sm:inline">Sign in</span>
|
||||
<span className="sm:hidden">Login</span>
|
||||
</Button>
|
||||
|
||||
@@ -1,135 +1,162 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import {
|
||||
Sidebar as UISidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { StreamInfoResponse, useStreams } from '@/lib/providers/StreamInfoProvider';
|
||||
import { StreamInfoResponse } from '@/lib/providers/StreamInfoProvider';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useAllChannels } from '@/lib/hooks/useUserList';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
|
||||
export default function Sidebar({ ...props }: React.ComponentProps<typeof UISidebar>) {
|
||||
const { stream, isLoading } = useStreams();
|
||||
const [followedExpanded, setFollowedExpanded] = React.useState(true);
|
||||
const { channels: stream, isLoading } = useAllChannels(5000);
|
||||
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">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={streamer.channel.pfpUrl}
|
||||
alt={streamer.username}
|
||||
className="h-8 w-8 rounded-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{streamer.isLive && (
|
||||
<span className="absolute -bottom-0.5 -right-0.5 flex h-3 w-3 items-center justify-center rounded-full bg-background ring-2 ring-background">
|
||||
<span className="h-2 w-2 rounded-full bg-red-500 animate-pulse" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isCollapsed && (
|
||||
<div className="flex flex-1 flex-col items-start overflow-hidden">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span className="truncate font-medium text-sm leading-none">
|
||||
{streamer.username}
|
||||
</span>
|
||||
{streamer.isLive && (
|
||||
<div className="flex items-center gap-1 text-xs text-red-500">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-red-500" />
|
||||
<span>{streamer.viewers}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="truncate text-xs text-muted-foreground w-full text-left">
|
||||
{streamer.isLive ? streamer.title || streamer.category || 'Live' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium truncate">{streamer.username}</p>
|
||||
<p className="text-sm truncate">{streamer.category}</p>
|
||||
{streamer.isLive && (
|
||||
<p className="text-sm">
|
||||
{streamer.viewers} viewer{streamer.viewers === 1 ? '' : 's'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarSkeleton({ ...props }: React.ComponentProps<typeof UISidebar>) {
|
||||
const { state } = useSidebar();
|
||||
const isCollapsed = state === 'collapsed';
|
||||
|
||||
return (
|
||||
<UISidebar {...props}>
|
||||
<UISidebar collapsible="icon" {...props}>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel asChild>
|
||||
<button className="w-full flex items-center justify-between">
|
||||
<span>Live Channels</span>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</button>
|
||||
<SidebarGroupLabel className="px-2 py-1.5">
|
||||
<Skeleton className="h-4 w-24 group-data-[collapsible=icon]:opacity-0 transition-opacity duration-200" />
|
||||
</SidebarGroupLabel>
|
||||
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{Array(3).fill(0).map((_, i) => (
|
||||
<StreamerItemSkeleton key={i} />
|
||||
<StreamerItemSkeleton key={i} isCollapsed={isCollapsed} />
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
<Separator className="group-data-[collapsible=icon]:block hidden" />
|
||||
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Offline Channels</SidebarGroupLabel>
|
||||
<SidebarGroupLabel className="px-2 py-1.5">
|
||||
<Skeleton className="h-4 w-24 group-data-[collapsible=icon]:opacity-0 transition-opacity duration-200" />
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{Array(5).fill(0).map((_, i) => (
|
||||
<StreamerItemSkeleton key={i} />
|
||||
<StreamerItemSkeleton key={i} isCollapsed={isCollapsed} />
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
@@ -139,16 +166,18 @@ function SidebarSkeleton({ ...props }: React.ComponentProps<typeof UISidebar>) {
|
||||
);
|
||||
}
|
||||
|
||||
function StreamerItemSkeleton() {
|
||||
function StreamerItemSkeleton({ isCollapsed }: { isCollapsed: boolean }) {
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton className="flex items-center gap-3 h-full">
|
||||
<div className="relative">
|
||||
<Skeleton className="h-9 w-9 rounded-full" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<SidebarMenuButton className="h-12">
|
||||
<div className="flex w-full items-center gap-3">
|
||||
<Skeleton className="h-8 w-8 rounded-full flex-shrink-0" />
|
||||
{!isCollapsed && (
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Skeleton className="h-3.5 w-24" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { toast } from 'sonner';
|
||||
import { fetcher } from '@/lib/services/swr';
|
||||
|
||||
|
||||
export default function SonnerNewVersion() {
|
||||
const [initialCommitId, setInitialCommitId] = useState<string | null>(null);
|
||||
const [hasShownToast, setHasShownToast] = useState(false);
|
||||
|
||||
const { data } = useSWR('/api/commit', fetcher, {
|
||||
refreshInterval: 5000,
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.commit && !initialCommitId) {
|
||||
setInitialCommitId(data.commit);
|
||||
}
|
||||
}, [data?.commit, initialCommitId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
initialCommitId &&
|
||||
data?.commit &&
|
||||
data.commit !== initialCommitId &&
|
||||
!hasShownToast
|
||||
) {
|
||||
toast('New version available!', {
|
||||
action: {
|
||||
label: 'Refresh',
|
||||
onClick: () => window.location.reload(),
|
||||
},
|
||||
duration: Infinity,
|
||||
position: 'top-center'
|
||||
});
|
||||
setHasShownToast(true);
|
||||
}
|
||||
}, [data?.commit, initialCommitId, hasShownToast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasShownToast && data?.commit !== initialCommitId) {
|
||||
setInitialCommitId(data.commit);
|
||||
setHasShownToast(false);
|
||||
}
|
||||
}, [data?.commit, initialCommitId, hasShownToast]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,44 +1,70 @@
|
||||
'use client';
|
||||
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useRef, useEffect } from 'react';
|
||||
import {
|
||||
MediaController,
|
||||
MediaLoadingIndicator,
|
||||
MediaControlBar,
|
||||
MediaPlayButton,
|
||||
MediaSeekBackwardButton,
|
||||
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';
|
||||
import { useUserStreamInfo } from '@/lib/hooks/useUserList';
|
||||
import { getMediamtxClientEnvs } from '@/lib/utils/mediamtx/client';
|
||||
|
||||
export default function StreamPlayer() {
|
||||
const { username } = useParams();
|
||||
const { session } = useSession();
|
||||
const { streamInfo: userInfo } = useUserStreamInfo(username!.toString());
|
||||
|
||||
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',
|
||||
backBufferLength: 90,
|
||||
enableWorker: true,
|
||||
maxLiveSyncPlaybackRate: 1.5,
|
||||
liveSyncDurationCount: 2,
|
||||
liveMaxLatencyDurationCount: 4,
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
video.src = `${getMediamtxClientEnvs(userInfo?.streamRegion!).publicUrl}/${username}/index.m3u8`;
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (video) {
|
||||
// @ts-ignore
|
||||
video.src = '';
|
||||
}
|
||||
};
|
||||
}, [username, session]);
|
||||
|
||||
return (
|
||||
<MediaController className='w-full aspect-video'>
|
||||
<MediaController className="w-full aspect-video">
|
||||
<HlsVideo
|
||||
src={`/api/rtmp/hls/${username}.m3u8`}
|
||||
ref={videoRef}
|
||||
slot="media"
|
||||
crossOrigin="anonymous"
|
||||
autoplay
|
||||
config={{
|
||||
lowLatencyMode: true,
|
||||
liveSyncDurationCount: 2, // Use only 1 segment for sync
|
||||
liveMaxLatencyDurationCount: 3, // Maximum latency allowed
|
||||
liveDurationInfinity: true,
|
||||
enableWorker: true,
|
||||
backBufferLength: 0, // No back buffer
|
||||
startLevel: -1, // Auto level selection
|
||||
maxBufferLength: 4, // Maximum buffer length in seconds
|
||||
maxMaxBufferLength: 6,
|
||||
debug: false,
|
||||
}}
|
||||
/>
|
||||
<MediaLoadingIndicator slot="centered-chrome" noAutohide />
|
||||
<MediaControlBar className='w-full px-2'>
|
||||
<MediaControlBar className="w-full px-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<MediaPlayButton />
|
||||
<MediaMuteButton />
|
||||
|
||||
@@ -19,11 +19,24 @@ import React from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { onboardSchema, streamInfoEditSchema } from '@/lib/form/zod';
|
||||
import {
|
||||
createBotSchema,
|
||||
createChannelSchema,
|
||||
changeUsernameSchema,
|
||||
editBotSchema,
|
||||
onboardSchema,
|
||||
streamInfoEditSchema,
|
||||
updateChannelSettingsSchema,
|
||||
} from '@/lib/form/zod';
|
||||
|
||||
export const schemaDb = [
|
||||
{ name: 'streamInfoEdit', zod: streamInfoEditSchema },
|
||||
{ name: 'onboard', zod: onboardSchema }
|
||||
{ name: 'onboard', zod: onboardSchema },
|
||||
{ name: 'createChannel', zod: createChannelSchema },
|
||||
{ name: 'updateChannelSettings', zod: updateChannelSettingsSchema },
|
||||
{ name: 'createBot', zod: createBotSchema },
|
||||
{ name: 'editBot', zod: editBotSchema },
|
||||
{ name: 'changeUsername', zod: changeUsernameSchema },
|
||||
] as const;
|
||||
|
||||
export function UniversalForm<T extends z.ZodType>({
|
||||
@@ -37,7 +50,7 @@ export function UniversalForm<T extends z.ZodType>({
|
||||
otherSubmitButton,
|
||||
submitButtonDivClassname,
|
||||
}: UniversalFormProps<T>) {
|
||||
// @ts-ignore idk why this error is happening, first apprearing on the react 19 update.
|
||||
// @ts-expect-error - idk
|
||||
const [state, formAction] = useActionState<{ success: boolean; error?: string }>(action, null);
|
||||
const schema = schemaDb.find((s) => s.name === schemaName)?.zod;
|
||||
|
||||
@@ -54,9 +67,11 @@ export function UniversalForm<T extends z.ZodType>({
|
||||
return { ...values, ...defaultValues };
|
||||
}, [fields, defaultValues]);
|
||||
|
||||
const form = useForm<z.infer<T>>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: initialValues as z.infer<T>,
|
||||
type FormData = z.infer<T>;
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(schema as any),
|
||||
defaultValues: initialValues as FormData,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -75,38 +90,50 @@ 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>}
|
||||
<FormItem className={field.type === 'hidden' ? 'hidden' : undefined}>
|
||||
{field.type !== 'hidden' && field.label && <FormLabel>{field.label}</FormLabel>}
|
||||
<FormControl>
|
||||
{field.textArea ? (
|
||||
{field.component ? (
|
||||
field.component({ field: formField, ...field.componentProps })
|
||||
) : field.textArea ? (
|
||||
<Textarea
|
||||
placeholder={field.placeholder}
|
||||
{...formField}
|
||||
value={formField.value ?? ''}
|
||||
rows={field.textAreaRows ?? 5}
|
||||
maxLength={field.maxChars}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type={field.type || 'text'}
|
||||
placeholder={field.placeholder}
|
||||
{...formField}
|
||||
onChange={(e) => {
|
||||
if (field.inputFilter) {
|
||||
e.target.value = e.target.value.replace(field.inputFilter, '');
|
||||
}
|
||||
formField.onChange(e);
|
||||
}}
|
||||
value={formField.value ?? ''}
|
||||
maxLength={field.maxChars}
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
{field.description && <FormDescription>{field.description}</FormDescription>}
|
||||
<FormMessage />
|
||||
{field.type !== 'hidden' && field.description && (
|
||||
<FormDescription>{field.description}</FormDescription>
|
||||
)}
|
||||
{field.type !== 'hidden' && <FormMessage />}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
<div className={cn("flex gap-2 py-2", submitButtonDivClassname)}>
|
||||
<div className={cn('flex gap-2 py-2', submitButtonDivClassname)}>
|
||||
{otherSubmitButton}
|
||||
<SubmitButton buttonText={submitText} className={submitClassname} />
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,29 @@
|
||||
import { z } from 'zod';
|
||||
import { HTMLInputTypeAttribute } from 'react';
|
||||
import { ControllerRenderProps } from 'react-hook-form';
|
||||
import { schemaDb } from './UniversalForm';
|
||||
|
||||
export type FormFieldConfig = {
|
||||
name: string;
|
||||
label: string;
|
||||
label?: string;
|
||||
type?: HTMLInputTypeAttribute;
|
||||
placeholder?: string;
|
||||
description?: string;
|
||||
value?: any;
|
||||
textArea?: boolean;
|
||||
textAreaRows?: number;
|
||||
maxChars?: number;
|
||||
inputFilter?: RegExp;
|
||||
component?: (props: { field: ControllerRenderProps<any, any> } & any) => React.ReactNode;
|
||||
componentProps?: Record<string, any>;
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
export type UniversalFormProps<T extends z.ZodType> = {
|
||||
fields: FormFieldConfig[];
|
||||
schemaName: (typeof schemaDb)[number]['name'];
|
||||
action: (prev: any, formData: FormData) => void;
|
||||
onActionComplete?: (result: unknown) => void;
|
||||
onActionComplete?: (result: any) => void;
|
||||
defaultValues?: Partial<z.infer<T>>;
|
||||
submitText?: string;
|
||||
submitClassname?: string;
|
||||
|
||||
108
apps/web/src/components/app/UserCombobox/UserCombobox.tsx
Normal file
108
apps/web/src/components/app/UserCombobox/UserCombobox.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import useSWR from 'swr';
|
||||
import { fetcher } from '@/lib/services/swr';
|
||||
import { Channel, StreamInfo } from '@hctv/db';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
|
||||
export function UserCombobox(props: Props) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [internalValue, setInternalValue] = React.useState('');
|
||||
|
||||
// Use external value if provided, otherwise use internal state
|
||||
const value = props.value ?? internalValue;
|
||||
const setValue = props.onValueChange ?? setInternalValue;
|
||||
const {
|
||||
data: fetchedUsers,
|
||||
error,
|
||||
isLoading,
|
||||
} = useSWR<APIResponse>(
|
||||
props.users ? null : '/api/stream/info?personal=true',
|
||||
fetcher
|
||||
);
|
||||
|
||||
const users = props.users || fetchedUsers;
|
||||
|
||||
if (!props.users && error) return <div>Error loading users</div>;
|
||||
if (!props.users && isLoading) return <div>Loading...</div>;
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen} modal={props.modal}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-[200px] justify-between"
|
||||
>
|
||||
{value
|
||||
? (
|
||||
<div className='flex items-center gap-2'>
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={users?.find((user) => user.username === value)?.channel.pfpUrl} alt={value} />
|
||||
<AvatarFallback>{value[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span>{users?.find((user) => user.username === value)?.username}</span>
|
||||
</div>
|
||||
)
|
||||
: 'Select user...'}
|
||||
<ChevronsUpDown className="opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search user..." className="h-9" />
|
||||
<CommandList>
|
||||
<CommandEmpty>No user found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{users?.filter(user => !props.filter?.some(filterStr => user.userId === filterStr)).map((user) => (
|
||||
<CommandItem
|
||||
key={user.channelId}
|
||||
value={user.username}
|
||||
onSelect={(currentValue) => {
|
||||
setValue(currentValue === value ? '' : currentValue);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={user.channel.pfpUrl} alt={user.username} />
|
||||
<AvatarFallback>{user.username[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
{user.username}
|
||||
<Check
|
||||
className={cn(
|
||||
'ml-auto',
|
||||
value === user.username ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
type APIResponse = (StreamInfo & { channel: Channel })[];
|
||||
type Props = {
|
||||
users?: APIResponse;
|
||||
value?: string;
|
||||
filter?: string[];
|
||||
modal?: boolean;
|
||||
onValueChange?: (value: string) => void;
|
||||
}
|
||||
@@ -1,16 +1,17 @@
|
||||
import { Avatar, AvatarImage } from '@/components/ui/avatar';
|
||||
import type { StreamInfo, User } from '@hctv/db';
|
||||
import type { StreamInfo, Channel } from '@hctv/db';
|
||||
import FollowButton from './follow';
|
||||
import FollowCountText from './followCount';
|
||||
import ViewerCount from './viewerCount';
|
||||
import { Preview } from '@/components/ui/channel-desc-fancy-area/preview';
|
||||
|
||||
export default function UserInfoCard(props: Props) {
|
||||
return (
|
||||
<div className="bg-mantle p-4 border-b">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="bg-mantle p-4 border-b h-48 flex flex-col">
|
||||
<div className="flex items-start justify-between mb-4 flex-shrink-0">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Avatar className="h-16 w-16">
|
||||
<AvatarImage src={props.streamInfo.ownedBy.pfpUrl} alt={props.streamInfo.username} />
|
||||
<AvatarImage src={props.streamInfo.channel.pfpUrl} alt={props.streamInfo.username} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{props.streamInfo.title}</h1>
|
||||
@@ -23,11 +24,13 @@ export default function UserInfoCard(props: Props) {
|
||||
<FollowButton channel={props.streamInfo.username} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="mb-4">markdown description here</p>
|
||||
<div className="max-h-32 overflow-y-auto">
|
||||
<Preview textValue={props.streamInfo.channel.description} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
streamInfo: StreamInfo & { ownedBy: User };
|
||||
streamInfo: StreamInfo & { channel: Channel };
|
||||
}
|
||||
298
apps/web/src/components/examples/ChannelListExample.tsx
Normal file
298
apps/web/src/components/examples/ChannelListExample.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
// Example component demonstrating production-ready useUserList hook
|
||||
// filepath: /home/srizan/Documents/Development/hclive/apps/web/src/components/examples/ChannelListExample.tsx
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import { useUserList, useLiveChannels, channelCacheUtils, type StreamInfoError } from '@/lib/hooks/useUserList'
|
||||
|
||||
// Error display component
|
||||
function ErrorDisplay({ error, onRetry }: { error: StreamInfoError; onRetry: () => void }) {
|
||||
const getErrorMessage = (error: StreamInfoError) => {
|
||||
if (error.status === 401) {
|
||||
return 'Authentication required. Please log in.'
|
||||
}
|
||||
if (error.status === 403) {
|
||||
return 'Access denied. You don\'t have permission to view this content.'
|
||||
}
|
||||
if (error.status && error.status >= 500) {
|
||||
return 'Server error. Please try again later.'
|
||||
}
|
||||
return error.message || 'An unknown error occurred.'
|
||||
}
|
||||
|
||||
const shouldShowRetry = error.status !== 401 && error.status !== 403
|
||||
|
||||
return (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-red-800 font-medium">Error Loading Channels</h3>
|
||||
<p className="text-red-600 text-sm mt-1">{getErrorMessage(error)}</p>
|
||||
{error.status && (
|
||||
<p className="text-red-500 text-xs mt-1">Status: {error.status}</p>
|
||||
)}
|
||||
</div>
|
||||
{shouldShowRetry && (
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="px-3 py-1 text-sm bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Loading skeleton component
|
||||
function ChannelSkeleton() {
|
||||
return (
|
||||
<div className="animate-pulse">
|
||||
<div className="bg-gray-200 h-4 rounded mb-2"></div>
|
||||
<div className="bg-gray-200 h-3 rounded w-3/4 mb-2"></div>
|
||||
<div className="bg-gray-200 h-3 rounded w-1/2"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Individual channel card component
|
||||
function ChannelCard({ channel }: { channel: any }) {
|
||||
return (
|
||||
<div className="border rounded-lg p-4 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="font-medium text-gray-900">{channel.username}</h3>
|
||||
{channel.isLive && (
|
||||
<span className="px-2 py-1 text-xs bg-red-500 text-white rounded-full">
|
||||
LIVE
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm mb-2">{channel.title}</p>
|
||||
<div className="flex items-center justify-between text-xs text-gray-500">
|
||||
<span>{channel.category}</span>
|
||||
{channel.isLive && (
|
||||
<span>{channel.viewers} viewers</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Main channel list component with comprehensive error handling and loading states
|
||||
export function ChannelListExample() {
|
||||
const [filter, setFilter] = useState<'all' | 'live' | 'owned'>('all')
|
||||
|
||||
// Use different hooks based on filter
|
||||
const allChannelsResult = useUserList({
|
||||
refreshInterval: 60000,
|
||||
revalidateOnFocus: true,
|
||||
isPaused: filter !== 'all'
|
||||
})
|
||||
|
||||
const liveChannelsResult = useLiveChannels()
|
||||
const ownedChannelsResult = useUserList({
|
||||
owned: true,
|
||||
isPaused: filter !== 'owned'
|
||||
})
|
||||
|
||||
// Select the appropriate result based on filter
|
||||
const result = filter === 'live'
|
||||
? liveChannelsResult
|
||||
: filter === 'owned'
|
||||
? ownedChannelsResult
|
||||
: allChannelsResult
|
||||
|
||||
const {
|
||||
channels,
|
||||
isLoading,
|
||||
error,
|
||||
isValidating,
|
||||
isBackgroundFetching,
|
||||
totalChannels,
|
||||
liveChannels,
|
||||
lastUpdated,
|
||||
refresh,
|
||||
clearCache
|
||||
} = result
|
||||
|
||||
// Cache management handlers
|
||||
const handleClearCache = useCallback(async () => {
|
||||
try {
|
||||
await clearCache()
|
||||
console.log('Cache cleared successfully')
|
||||
} catch (error) {
|
||||
console.error('Failed to clear cache:', error)
|
||||
}
|
||||
}, [clearCache])
|
||||
|
||||
const handleInvalidateLive = useCallback(async () => {
|
||||
try {
|
||||
await channelCacheUtils.invalidateLive()
|
||||
console.log('Live channels cache invalidated')
|
||||
} catch (error) {
|
||||
console.error('Failed to invalidate live cache:', error)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleWarmUpCache = useCallback(async () => {
|
||||
try {
|
||||
await channelCacheUtils.warmUp()
|
||||
console.log('Cache warmed up successfully')
|
||||
} catch (error) {
|
||||
console.error('Failed to warm up cache:', error)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Render loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">Loading Channels...</h2>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<ChannelSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Render error state
|
||||
if (error) {
|
||||
return <ErrorDisplay error={error} onRetry={refresh} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header with stats and controls */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">
|
||||
Channels
|
||||
{isBackgroundFetching && (
|
||||
<span className="ml-2 text-blue-500 animate-spin">🔄</span>
|
||||
)}
|
||||
</h2>
|
||||
<div className="text-sm text-gray-600 mt-1">
|
||||
{totalChannels} total • {liveChannels} live
|
||||
{lastUpdated && (
|
||||
<span className="ml-2">
|
||||
• Updated {lastUpdated.toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={refresh}
|
||||
disabled={isValidating}
|
||||
className="px-3 py-1 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isValidating ? 'Refreshing...' : 'Refresh'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleClearCache}
|
||||
className="px-3 py-1 text-sm bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
Clear Cache
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter tabs */}
|
||||
<div className="flex gap-2 border-b">
|
||||
{[
|
||||
{ key: 'all', label: 'All Channels' },
|
||||
{ key: 'live', label: 'Live' },
|
||||
{ key: 'owned', label: 'My Channels' }
|
||||
].map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setFilter(key as any)}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
filter === key
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Cache management tools (development only) */}
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div className="bg-gray-50 p-3 rounded-lg">
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-2">
|
||||
Cache Management (Dev Tools)
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleInvalidateLive}
|
||||
className="px-2 py-1 text-xs bg-yellow-500 text-white rounded hover:bg-yellow-600"
|
||||
>
|
||||
Invalidate Live
|
||||
</button>
|
||||
<button
|
||||
onClick={handleWarmUpCache}
|
||||
className="px-2 py-1 text-xs bg-green-500 text-white rounded hover:bg-green-600"
|
||||
>
|
||||
Warm Up Cache
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Channel grid */}
|
||||
{channels.length > 0 ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{channels.map((channel) => (
|
||||
<ChannelCard key={channel.id} channel={channel} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<p>No channels found.</p>
|
||||
{filter !== 'all' && (
|
||||
<button
|
||||
onClick={() => setFilter('all')}
|
||||
className="mt-2 text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
View all channels
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Debug info in development */}
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<details className="bg-gray-50 p-3 rounded-lg">
|
||||
<summary className="text-sm font-medium text-gray-700 cursor-pointer">
|
||||
Debug Information
|
||||
</summary>
|
||||
<pre className="mt-2 text-xs text-gray-600 overflow-auto">
|
||||
{JSON.stringify({
|
||||
filter,
|
||||
totalChannels,
|
||||
liveChannels,
|
||||
isLoading,
|
||||
isValidating,
|
||||
isBackgroundFetching,
|
||||
cacheKey: result.cacheKey,
|
||||
lastUpdated: lastUpdated?.toISOString(),
|
||||
hasError: !!error
|
||||
}, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChannelListExample
|
||||
36
apps/web/src/components/ui/badge.tsx
Normal file
36
apps/web/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -26,7 +26,8 @@ const buttonVariants = cva(
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10"
|
||||
icon: "h-10 w-10",
|
||||
smicon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user