Compare commits

...

31 Commits

Author SHA1 Message Date
renovate[bot]
6fa9fe45b1 chore(deps): update nextjs monorepo to v16.2.7 2026-06-02 01:45:31 +00:00
de25bc5cd1 chore(branding): change desc 2026-05-30 23:11:50 +02:00
184ea9c973 chore(deps): regenerate pnpm-lock.yaml 2026-05-24 15:33:11 +02:00
beec80fee6 fix(styling): userinfocard not able to be seen in certain places 2026-05-17 16:13:17 +02:00
796313348b fix(docker): change pnpm home path 2026-05-15 18:29:55 +02:00
8d13c86159 chore(types): fix type issues and update other packages 2026-05-15 18:19:08 +02:00
a2dfe81265 chore(deps): migrate to tailwind v4 and patch nextjs 2026-05-15 17:58:47 +02:00
ed3ebc9e3a chore(deps): update ALL packages 2026-05-15 17:40:52 +02:00
50d92f6787 fix(settings): the other server aint showing up 2026-05-15 17:38:09 +02:00
renovate[bot]
3ac4f59efd chore(deps): update dependency next to v16.2.3 [security] (#74)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-15 00:35:14 +02:00
renovate[bot]
3de374392c chore(deps): update dependency next to v16.1.7 [security] (#69)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-15 00:28:28 +02:00
61c005a585 Merge pull request #67 from SrIzan10/renovate/configure
chore: Configure Renovate
2026-05-15 00:02:22 +02:00
renovate[bot]
c35e3ae1ba Add renovate.json 2026-05-13 14:22:06 +00:00
7481006dbe chore(bs): filter out whipEnabled on server selection 2026-04-29 23:37:50 +02:00
d5aa3217ac feat(bs): add beforeunload to prevent accidental closings 2026-04-29 23:27:28 +02:00
6701090c7a fix(mediamtx): remake the docker image with alpine 2026-04-29 23:23:49 +02:00
d95b935c7a fix(mediamtx): use my mediamtx image instead 2026-04-29 23:21:01 +02:00
efec8602fc fix(mediamtx): put authhttpfingerprint there 2026-04-29 18:41:31 +02:00
0597cb1157 fix(mediamtx): auth http fingerprint 2026-04-29 18:38:42 +02:00
8a924f2d52 fix(mediamtx): set the ssl cert file 2026-04-29 18:22:50 +02:00
79093c5057 fix(mediamtx): schema wasnt right 2026-04-29 17:56:43 +02:00
2bf452c9ed fix(mediamtx): authentication issues 2026-04-29 17:11:57 +02:00
01b2e88969 fix(mirror): put mediamtx client there 2026-04-29 16:59:31 +02:00
b42b4be2d9 fix(mirror): log level debug again oops 2026-04-29 16:48:24 +02:00
22f3cff3c1 fix(mirror): use cloudflare 2026-04-29 16:47:49 +02:00
d01cc9f68d fix(mirror): log level debug 2026-04-29 16:43:23 +02:00
12617b3d59 fix(mirror): bump traefik to latest instead 2026-04-29 16:38:59 +02:00
aff01be9e1 fix(mirror): bump traefik version 2026-04-29 16:37:39 +02:00
995a14387c fix(ts): region typescript issue 2026-04-29 16:32:16 +02:00
2ce6fea782 fix(bs): server not right 2026-04-29 16:27:23 +02:00
ab6a788b36 Merge pull request #66 from SrIzan10/feat/browser-streaming
feat: #68 feat/browser streaming
2026-04-29 16:16:40 +02:00
48 changed files with 5700 additions and 6207 deletions

View File

@@ -1,4 +1,4 @@
# Agent Guidelines for HackClub.tv
# Agent Guidelines for tv
This document provides essential information for AI coding agents working on the HackClub.tv codebase.

View File

@@ -1,6 +1,6 @@
FROM node:lts-alpine AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
ENV PATH="$PNPM_HOME/bin:$PATH"
RUN corepack enable
FROM base AS builder
@@ -44,4 +44,4 @@ WORKDIR /app/apps/chat
EXPOSE 8000
ENTRYPOINT ["node", "dist/index.js"]
ENTRYPOINT ["node", "dist/index.js"]

View File

@@ -10,16 +10,16 @@
"@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",
"@hono/node-server": "^2.0.1",
"@hono/node-ws": "^1.3.1",
"@leeoniya/ufuzzy": "^1.0.19",
"@oslojs/encoding": "^1.1.0",
"hono": "^4.7.5",
"hono": "^4.12.16",
"prom-client": "^15.1.3"
},
"devDependencies": {
"@types/node": "^20.11.17",
"tsx": "^4.7.1",
"typescript": "^5.8.2"
"@types/node": "^25.6.0",
"tsx": "^4.21.0",
"typescript": "^6.0.3"
}
}

View File

@@ -10,14 +10,14 @@
"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",
"@astrojs/starlight": "^0.38.4",
"@catppuccin/starlight": "^2.0.1",
"astro": "^6.3.3",
"astro-mermaid": "^2.0.1",
"mermaid": "^11.14.0",
"sharp": "^0.34.5",
"starlight-typedoc": "^0.21.5",
"typedoc": "^0.28.16",
"typedoc-plugin-markdown": "^4.9.0"
"typedoc": "^0.28.19",
"typedoc-plugin-markdown": "^4.11.0"
}
}

View File

@@ -1,5 +1,8 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"ignoreDeprecations": "6.0"
},
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"]
}

View File

@@ -23,10 +23,11 @@ MEDIAMTX_API_HQ=http://localhost:9997
NEXT_PUBLIC_MEDIAMTX_INGEST_ROUTE_HQ=localhost:8890
NEXT_PUBLIC_MEDIAMTX_WHIP_ROUTE_HQ=http://localhost:8889
# 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
# optional EU mirror
# NEXT_PUBLIC_MEDIAMTX_URL_ETHANDE=https://hls-eu.example.com
# MEDIAMTX_API_ETHANDE=https://mediamtx-api-eu.example.com
# NEXT_PUBLIC_MEDIAMTX_INGEST_ROUTE_ETHANDE=ingest-eu.example.com:8890
# NEXT_PUBLIC_MEDIAMTX_WHIP_ROUTE_ETHANDE=https://webrtc-eu.example.com
# generate with `openssl rand -base64 20`
MEDIAMTX_PUBLISH_KEY=

View File

@@ -1,6 +1,6 @@
FROM node:22-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
ENV PATH="$PNPM_HOME/bin:$PATH"
RUN corepack enable
FROM base AS builder

View File

@@ -4,7 +4,7 @@
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"config": "tailwind.config.mts",
"css": "src/app/globals.css",
"baseColor": "slate",
"cssVariables": true,
@@ -14,4 +14,4 @@
"components": "@/components",
"utils": "@/lib/utils"
}
}
}

View File

@@ -36,6 +36,9 @@ const nextConfig = {
{
hostname: 'eoceqrx2r7.ufs.sh'
},
{
hostname: 'thesvg.org',
}
],
minimumCacheTTL: 120,
},

View File

@@ -17,82 +17,83 @@
"dependencies": {
"@hctv/auth": "workspace:*",
"@hctv/db": "workspace:*",
"@hookform/resolvers": "^3.9.1",
"@hookform/resolvers": "^5.2.2",
"@lucia-auth/adapter-prisma": "^4.0.1",
"@node-rs/argon2": "^2.0.2",
"@omit/react-confirm-dialog": "^1.2.0",
"@omit/react-confirm-dialog": "^2.0.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-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-tooltip": "^1.2.7",
"@scalar/api-reference-react": "^0.7.42",
"@sentry/nextjs": "^10",
"@slack/web-api": "^7.9.1",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@scalar/api-reference-react": "^0.9.32",
"@sentry/nextjs": "^10.51.0",
"@slack/web-api": "^7.15.1",
"@uidotdev/usehooks": "^2.4.1",
"@uploadthing/react": "^7.3.1",
"ajv": "^8.17.1",
"@uploadthing/react": "^7.3.3",
"ajv": "^8.20.0",
"arctic": "^3.7.0",
"bullmq": "^5.45.2",
"cheerio": "^1.0.0",
"bullmq": "^5.76.4",
"cheerio": "^1.2.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.0",
"cmdk": "1.0.0",
"clsx": "^2.1.1",
"cmdk": "1.1.1",
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
"hls-video-element": "^1.5.0",
"hls.js": "^1.6.15",
"hls-video-element": "^1.5.11",
"hls.js": "^1.6.16",
"lucia": "^3.2.2",
"lucide-react": "^0.473.0",
"media-chrome": "^4.8.0",
"next": "^16.1.0",
"next-themes": "^0.4.4",
"node-cron": "^3.0.3",
"nuqs": "^2.4.3",
"pg": "^8.14.1",
"pg-boss": "^10.1.6",
"lucide-react": "^1.14.0",
"media-chrome": "^4.19.0",
"next": "^16.2.6",
"next-themes": "^0.4.6",
"node-cron": "^4.2.1",
"nuqs": "^2.8.9",
"pg": "^8.20.0",
"pg-boss": "^12.18.1",
"prom-client": "^15.1.3",
"react": "^19.2.3",
"react-day-picker": "^9.13.0",
"react-dom": "^19.2.3",
"react-hook-form": "^7.54.2",
"react": "^19.2.5",
"react-day-picker": "^9.14.0",
"react-dom": "^19.2.5",
"react-hook-form": "^7.74.0",
"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",
"sharp": "^0.34.5",
"sonner": "^2.0.7",
"swr": "^2.4.1",
"tailwind-merge": "^3.5.0",
"unified": "^11.0.5",
"uploadthing": "^7.7.2",
"uploadthing": "^7.7.4",
"util-utils": "^1.0.3",
"valtio": "^2.1.2",
"ws": "^8.18.1",
"zod": "^3.24.1"
"valtio": "^2.3.2",
"ws": "^8.20.0",
"zod": "^4.4.2"
},
"devDependencies": {
"@types/node": "^20",
"@types/node": "^25.6.0",
"@types/node-cron": "^3.0.11",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/ws": "^8.18.0",
"eslint": "^8",
"eslint-config-next": "15.1.3",
"postcss": "^8",
"shadcn": "^2.7.0",
"tailwindcss": "^3.4.1",
"typescript": "^5"
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/ws": "^8.18.1",
"@tailwindcss/postcss": "^4.2.4",
"eslint": "^10.3.0",
"eslint-config-next": "16.2.7",
"postcss": "^8.5.13",
"shadcn": "^4.6.0",
"tailwindcss": "^4.2.4",
"tw-animate-css": "^1.4.0",
"typescript": "^6.0.3"
}
}

View File

@@ -1,7 +1,7 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
'@tailwindcss/postcss': {},
},
};

View File

@@ -22,11 +22,15 @@ export async function POST(request: NextRequest) {
const parsed = schema.safeParse(body);
if (!parsed.success) {
if (process.env.NODE_ENV !== 'production') {
console.error('Invalid MediaMTX auth request:', parsed.error.flatten());
}
return finish('invalid request', 400, 'invalid_request');
}
const { action: parsedAction, protocol: parsedProtocol, path, password } = parsed.data;
const { action: parsedAction, protocol: parsedProtocol, path, password, token } = parsed.data;
action = parsedAction;
protocol = parsedProtocol;
protocol = parsedProtocol || 'none';
if (parsedAction === 'publish' && (parsedProtocol === 'srt' || parsedProtocol === 'webrtc')) {
const channelKey = await redis.get(`streamKey:${path}`);
@@ -81,7 +85,7 @@ export async function POST(request: NextRequest) {
return finish('authorized', 200, 'authorized_read');
}
if (parsedAction === 'api') {
if (password === process.env.MEDIAMTX_API_KEY) {
if (password === process.env.MEDIAMTX_API_KEY || token === process.env.MEDIAMTX_API_KEY) {
return finish('authorized api', 200, 'authorized_api');
}
@@ -91,14 +95,22 @@ export async function POST(request: NextRequest) {
return finish('uhh', 401, 'unauthorized');
}
const emptyableString = z
.string()
.nullish()
.transform((value) => value ?? '');
const schema = z.object({
user: z.string(),
password: z.string(),
token: z.string(),
ip: z.string(),
user: emptyableString,
password: emptyableString,
token: emptyableString,
ip: emptyableString,
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(),
path: emptyableString,
protocol: z
.union([z.enum(['rtsp', 'rtmp', 'hls', 'webrtc', 'srt']), z.literal('')])
.nullish()
.transform((value) => value ?? ''),
id: z.string().nullable().default(null),
query: emptyableString,
});

View File

@@ -19,7 +19,13 @@ export async function POST(request: NextRequest, segmentData: { params: Params }
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 });
return new Response(
JSON.stringify({
success: false,
error: parsedBody.error.issues.map((issue) => issue.message).join(', '),
}),
{ status: 400 }
);
}
const { action, name } = parsedBody.data;
@@ -121,4 +127,4 @@ export async function GET(request: NextRequest, segmentData: { params: Params })
function generateApiKey() {
const uuid = crypto.randomUUID().replace(/-/g, '');
return `hctvb_${uuid}`;
}
}

View File

@@ -80,7 +80,10 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { getMediamtxClientEnvs } from '@/lib/utils/mediamtx/client';
import {
getMediamtxClientEnvs,
getMediamtxClientRegionOptions,
} from '@/lib/utils/mediamtx/client';
import type { MediaMTXRegion } from '@/lib/utils/mediamtx/regions';
import { Textarea } from '@/components/ui/textarea';
@@ -121,6 +124,7 @@ export default function ChannelSettingsClient({
const [selTab, setSelTab] = useQueryState('tab', parseAsString.withDefault('general'));
const [isUploading, setIsUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const serverOptions = getMediamtxClientRegionOptions();
const [region, setRegion] = useState<MediaMTXRegion>('hq');
const channelList = useOwnedChannels();
const {
@@ -587,7 +591,11 @@ export default function ChannelSettingsClient({
<SelectValue placeholder="Select region" />
</SelectTrigger>
<SelectContent>
<SelectItem value="hq">HQ Server A 🇺🇸</SelectItem>
{serverOptions.map((server) => (
<SelectItem key={server.value} value={server.value}>
{server.label} {server.emoji}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
@@ -1110,9 +1118,7 @@ export default function ChannelSettingsClient({
value={
typeof field.value === 'string'
? field.value
: Array.isArray(field.value)
? field.value.join('\n')
: ''
: ''
}
disabled={channel.is247}
rows={4}

View File

@@ -34,10 +34,10 @@ import type { MediaMTXRegion } from '@/lib/utils/mediamtx/regions';
export default function Page() {
const serverOptions = getMediamtxClientRegionOptions();
const whipEnabledServerOptions = serverOptions.filter((server) => server.whipEnabled);
const defaultRegion = whipEnabledServerOptions[0]?.value ?? 'hq';
const [selectedChannel, setSelectedChannel] = useState('');
const [selectedRegion, setSelectedRegion] = useState<MediaMTXRegion>(
serverOptions[0]?.value ?? 'hq'
);
const [selectedRegion, setSelectedRegion] = useState<MediaMTXRegion>(defaultRegion);
const { channels, isLoading: isLoadingChannels } = useOwnedChannels();
const ownedChannels = channels.map(({ channel }) => channel);
const {
@@ -67,12 +67,14 @@ export default function Page() {
});
const hasChannels = ownedChannels.length > 0;
const hasServerOptions = serverOptions.length > 0;
const selectedServer = whipEnabledServerOptions.find((server) => server.value === selectedRegion);
const hasWhipEnabledServerOptions = whipEnabledServerOptions.length > 0;
const canStartPublishing =
!isSessionActive &&
!isPreviewingSource &&
Boolean(selectedChannel) &&
Boolean(streamKey) &&
Boolean(selectedServer?.whipEnabled) &&
!isLoadingStreamKey;
const channelPlaceholder = isLoadingChannels ? 'Loading channels...' : 'Select a channel';
const primaryIssue = issue ?? browserWarning;
@@ -87,6 +89,23 @@ export default function Page() {
}
}, [isSessionActive, ownedChannels, selectedChannel]);
useEffect(() => {
if (!isLive) {
return;
}
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
event.preventDefault();
event.returnValue = '';
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}, [isLive]);
const statusLabel = isLive
? 'LIVE'
: isSwitchingSource
@@ -252,18 +271,14 @@ export default function Page() {
<Select
value={selectedRegion}
onValueChange={(value) => setSelectedRegion(value as MediaMTXRegion)}
disabled={isSessionActive || !hasServerOptions}
disabled={isSessionActive || !hasWhipEnabledServerOptions}
>
<SelectTrigger className="w-44">
<SelectValue placeholder="Select server" />
</SelectTrigger>
<SelectContent>
{serverOptions.map((server) => (
<SelectItem
key={server.value}
value={server.value}
disabled={!server.whipEnabled}
>
{whipEnabledServerOptions.map((server) => (
<SelectItem key={server.value} value={server.value}>
{server.label} {server.emoji}
</SelectItem>
))}

View File

@@ -1,5 +1,4 @@
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';
@@ -9,7 +8,6 @@ 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";
@@ -19,8 +17,6 @@ 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",
@@ -37,7 +33,7 @@ export default async function RootLayout({
return (
<html lang="en">
<body className={cn('flex flex-col h-screen', inter.className)}>
<body className="flex h-screen flex-col">
<SessionProvider value={sessionData}>
<ThemeProvider
attribute="class"

View File

@@ -1,128 +1,163 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import "tailwindcss";
@import "tw-animate-css";
@layer base {
:root {
/* Light theme - based on your color scheme */
@config "../../tailwind.config.mts";
@custom-variant dark (&:is(.dark *));
/* Main background and foreground */
--background: 350 59% 98%; /* FDF7F8 - main background */
--foreground: 351 34% 30%; /* 5D3A3F - main text */
@font-face {
font-family: 'Phantom Sans';
src:
url('https://assets.hackclub.com/fonts/Phantom_Sans_0.7/Regular.woff2') format('woff2'),
url('https://assets.hackclub.com/fonts/Phantom_Sans_0.7/Regular.woff') format('woff');
font-weight: normal;
font-style: normal;
font-display: swap;
}
/* Muted elements */
--muted: 350 40% 93%; /* F8E8EA - muted background */
--muted-foreground: 350 30% 45%; /* Lighter version of main text */
@font-face {
font-family: 'Phantom Sans';
src:
url('https://assets.hackclub.com/fonts/Phantom_Sans_0.7/Italic.woff2') format('woff2'),
url('https://assets.hackclub.com/fonts/Phantom_Sans_0.7/Italic.woff') format('woff');
font-weight: normal;
font-style: italic;
font-display: swap;
}
/* 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 */
@font-face {
font-family: 'Phantom Sans';
src:
url('https://assets.hackclub.com/fonts/Phantom_Sans_0.7/Bold.woff2') format('woff2'),
url('https://assets.hackclub.com/fonts/Phantom_Sans_0.7/Bold.woff') format('woff');
font-weight: bold;
font-style: normal;
font-display: swap;
}
/* Border and input */
--border: 350 30% 85%; /* Derived border color */
--input: 350 30% 85%; /* Input background */
:root {
--background: hsl(350 59% 98%);
--foreground: hsl(351 34% 30%);
--muted: hsl(350 40% 93%);
--muted-foreground: hsl(350 30% 45%);
--popover: hsl(0 0% 100%);
--popover-foreground: hsl(351 34% 30%);
--card: hsl(0 0% 100%);
--card-foreground: hsl(351 34% 30%);
--border: hsl(350 30% 85%);
--input: hsl(350 30% 85%);
--primary: hsl(350 70% 50%);
--primary-foreground: hsl(0 0% 100%);
--secondary: hsl(350 40% 93%);
--secondary-foreground: hsl(351 34% 30%);
--accent: hsl(350 70% 40%);
--accent-foreground: hsl(0 0% 100%);
--destructive: hsl(350 70% 55%);
--destructive-foreground: hsl(0 0% 100%);
--ring: hsl(350 70% 50%);
--surface-1: hsl(350 40% 93%);
--surface-2: hsl(350 35% 88%);
--mantle: hsl(350 59% 98%);
--mantle-foreground: hsl(351 34% 30%);
--radius: 0.5rem;
--sidebar-background: hsl(350 59% 98%);
--sidebar-foreground: hsl(351 34% 30%);
--sidebar-primary: hsl(350 70% 50%);
--sidebar-primary-foreground: hsl(0 0% 100%);
--sidebar-accent: hsl(350 40% 93%);
--sidebar-accent-foreground: hsl(351 34% 30%);
--sidebar-border: hsl(350 30% 85%);
--sidebar-ring: hsl(350 70% 50%);
}
/* Primary actions */
--primary: 350 70% 50%; /* C8394F - primary button */
--primary-foreground: 0 0% 100%; /* FFFFFF - text on primary */
.dark {
--background: hsl(350 20% 15%);
--foreground: hsl(350 30% 92%);
--muted: hsl(350 20% 25%);
--muted-foreground: hsl(350 30% 75%);
--popover: hsl(350 20% 15%);
--popover-foreground: hsl(350 30% 92%);
--card: hsl(350 20% 15%);
--card-foreground: hsl(350 30% 92%);
--border: hsl(350 20% 35%);
--input: hsl(350 20% 35%);
--primary: hsl(350 100% 75%);
--primary-foreground: hsl(350 20% 15%);
--secondary: hsl(350 20% 25%);
--secondary-foreground: hsl(350 30% 92%);
--accent: hsl(350 100% 80%);
--accent-foreground: hsl(350 20% 15%);
--destructive: hsl(350 100% 70%);
--destructive-foreground: hsl(350 20% 15%);
--ring: hsl(350 100% 75%);
--surface-1: hsl(350 20% 25%);
--surface-2: hsl(350 20% 35%);
--mantle: hsl(350 20% 12%);
--mantle-foreground: hsl(350 30% 92%);
--sidebar-background: hsl(350 20% 12%);
--sidebar-foreground: hsl(350 30% 92%);
--sidebar-primary: hsl(350 100% 75%);
--sidebar-primary-foreground: hsl(350 20% 15%);
--sidebar-accent: hsl(350 20% 25%);
--sidebar-accent-foreground: hsl(350 30% 92%);
--sidebar-border: hsl(350 20% 35%);
--sidebar-ring: hsl(350 100% 75%);
}
/* Secondary elements */
--secondary: 350 40% 93%; /* F8E8EA - secondary background */
--secondary-foreground: 351 34% 30%; /* 5D3A3F - text on secondary */
@theme inline {
--font-sans: 'Phantom Sans', ui-sans-serif, system-ui, sans-serif;
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-ring: var(--ring);
--color-surface1: var(--surface-1);
--color-surface2: var(--surface-2);
--color-mantle: var(--mantle);
--color-mantle-foreground: var(--mantle-foreground);
--color-sidebar: var(--sidebar-background);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--animate-accordion-down: accordion-down 0.2s ease-out;
--animate-accordion-up: accordion-up 0.2s ease-out;
}
/* Accent elements */
--accent: 350 70% 40%; /* A12D3E - accent color */
--accent-foreground: 0 0% 100%; /* FFFFFF - text on accent */
/* Destructive actions */
--destructive: 350 70% 55%; /* D63C56 - error/destroy */
--destructive-foreground: 0 0% 100%; /* FFFFFF - text on destructive */
/* Focus ring */
--ring: 350 70% 50%; /* C8394F - focus ring */
/* Surface colors */
--surface-1: 350 40% 93%; /* F8E8EA - surface 1 */
--surface-2: 350 35% 88%; /* Derived surface 2 */
/* Mantle */
--mantle: 350 59% 98%; /* FDF7F8 - mantle */
/* Radius */
--radius: 0.5rem;
/* Sidebar specific */
--sidebar-background: 350 59% 98%; /* FDF7F8 - sidebar bg */
--sidebar-foreground: 351 34% 30%; /* 5D3A3F - sidebar text */
--sidebar-primary: 350 70% 50%; /* C8394F - sidebar primary */
--sidebar-primary-foreground: 0 0% 100%; /* FFFFFF - text on sidebar primary */
--sidebar-accent: 350 40% 93%; /* F8E8EA - sidebar accent */
--sidebar-accent-foreground: 351 34% 30%; /* 5D3A3F - text on sidebar accent */
--sidebar-border: 350 30% 85%; /* Derived border */
--sidebar-ring: 350 70% 50%; /* C8394F - sidebar focus ring */
@keyframes accordion-down {
from {
height: 0;
}
to {
height: var(--radix-accordion-content-height);
}
}
.dark {
/* Dark theme - based on your color scheme */
/* Main background and foreground */
--background: 350 20% 15%; /* 2A1F21 - main background */
--foreground: 350 30% 92%; /* F5E6E8 - main text */
/* Muted elements */
--muted: 350 20% 25%; /* 4A2D31 - muted background */
--muted-foreground: 350 30% 75%; /* Lighter version of main text */
/* Popover and card */
--popover: 350 20% 15%; /* 2A1F21 - popover background */
--popover-foreground: 350 30% 92%; /* F5E6E8 - popover text */
--card: 350 20% 15%; /* 2A1F21 - card background */
--card-foreground: 350 30% 92%; /* F5E6E8 - card text */
/* Border and input */
--border: 350 20% 35%; /* Derived border color */
--input: 350 20% 35%; /* Input background */
/* Primary actions */
--primary: 350 100% 75%; /* FF7A8A - primary button */
--primary-foreground: 350 20% 15%; /* 2A1F21 - text on primary */
/* Secondary elements */
--secondary: 350 20% 25%; /* 4A2D31 - secondary background */
--secondary-foreground: 350 30% 92%; /* F5E6E8 - text on secondary */
/* Accent elements */
--accent: 350 100% 80%; /* FF9AAA - accent color */
--accent-foreground: 350 20% 15%; /* 2A1F21 - text on accent */
/* Destructive actions */
--destructive: 350 100% 70%; /* FF6B7D - error/destroy */
--destructive-foreground: 350 20% 15%; /* 2A1F21 - text on destructive */
/* Focus ring */
--ring: 350 100% 75%; /* FF7A8A - focus ring */
/* Surface colors */
--surface-1: 350 20% 25%; /* 4A2D31 - surface 1 */
--surface-2: 350 20% 35%; /* Derived surface 2 */
/* Mantle */
--mantle: 350 20% 12%; /* 1F1617 - mantle */
/* Radius */
--radius: 0.5rem;
/* Sidebar specific */
--sidebar-background: 350 20% 12%; /* 1F1617 - sidebar bg */
--sidebar-foreground: 350 30% 92%; /* F5E6E8 - sidebar text */
--sidebar-primary: 350 100% 75%; /* FF7A8A - sidebar primary */
--sidebar-primary-foreground: 350 20% 15%; /* 2A1F21 - text on sidebar primary */
--sidebar-accent: 350 20% 25%; /* 4A2D31 - sidebar accent */
--sidebar-accent-foreground: 350 30% 92%; /* F5E6E8 - text on sidebar accent */
--sidebar-border: 350 20% 35%; /* Derived border */
--sidebar-ring: 350 100% 75%; /* FF7A8A - sidebar focus ring */
@keyframes accordion-up {
from {
height: var(--radix-accordion-content-height);
}
to {
height: 0;
}
}
@@ -131,31 +166,30 @@
@apply border-border;
}
body {
font-family: 'Phantom Sans', ui-sans-serif, system-ui, sans-serif;
@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;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
h2 {
@apply scroll-m-20 pb-2 text-3xl font-semibold tracking-tight first:mt-0;
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
}
media-controller {
--media-primary-color: #ffffff;
--media-secondary-color: transparent;
--media-control-background: transparent;
--media-control-hover-background: hsla(var(--primary), 0.4);
--media-control-hover-background: color-mix(in oklab, var(--primary) 40%, transparent);
/* Range colors */
--media-range-track-background: hsla(0, 0%, 100%, 0.3);
--media-range-bar-color: hsl(var(--primary));
--media-range-thumb-background: hsl(var(--primary));
--media-range-bar-color: var(--primary);
--media-range-thumb-background: var(--primary);
--media-range-thumb-border-radius: 50%;
--media-range-thumb-height: 12px;
--media-range-thumb-width: 12px;
@@ -164,7 +198,7 @@ media-controller {
/* Layout & structure */
border-radius: calc(var(--radius) * 1.5);
overflow: hidden;
border: 1px solid hsla(var(--border), 0.2);
border: 1px solid color-mix(in oklab, var(--border) 20%, transparent);
background-color: #000;
box-shadow: 0 10px 30px -10px rgba(0,0,0,0.3);
}
@@ -182,9 +216,9 @@ media-control-bar {
}
media-time-range {
--media-preview-background: hsla(var(--card), 0.95);
--media-preview-background: color-mix(in oklab, var(--card) 95%, transparent);
--media-preview-border-radius: var(--radius);
--media-time-display-color: hsl(var(--foreground));
--media-time-display-color: var(--foreground);
}
media-time-display {
@@ -207,8 +241,8 @@ media-controller:not([mediapaused])[userinactive]::part(centered-layer) {
media-loading-indicator {
--media-loading-icon-width: 56px;
--media-loading-icon-height: 56px;
--media-loading-icon-color: hsl(var(--primary));
filter: drop-shadow(0 0 8px hsla(var(--primary), 0.4));
--media-loading-icon-color: var(--primary);
filter: drop-shadow(0 0 8px color-mix(in oklab, var(--primary) 40%, transparent));
}
media-play-button,
@@ -225,7 +259,7 @@ media-mute-button:hover,
media-fullscreen-button:hover,
media-chrome-button:hover {
transform: scale(1.1);
--media-control-background: hsla(var(--primary), 0.85);
--media-control-background: color-mix(in oklab, var(--primary) 85%, transparent);
--media-button-icon-color: #ffffff;
}
@@ -241,4 +275,4 @@ media-chrome-button:hover {
media-volume-range {
width: 90px;
height: 40px; /* Aligns with standard media button heights */
}
}

View File

@@ -300,7 +300,7 @@ export default function ChatPanel(props: Props) {
return (
<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`}
className={`${props.isObsPanel ? 'w-full text-white' : 'w-full max-w-none md:w-[350px] md:max-w-[350px] md:border-l border-border bg-mantle'} flex flex-col h-full min-w-0`}
>
<div
ref={scrollRef}

View File

@@ -9,7 +9,7 @@ interface EmojiSearchProps {
onSelect: (emoji: string) => void;
socket: WebSocket | null;
emojiMap: Map<string, string>;
textareaRef: React.RefObject<HTMLTextAreaElement>;
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
}
export function EmojiSearch({

View File

@@ -61,7 +61,7 @@ export default function LiveStream(props: Props) {
}
return (
<div className={`${isMobile ? 'flex flex-col' : 'flex'} h-[calc(100vh-64px)] w-full`}>
<div className={`${isMobile ? 'flex flex-col' : 'flex'} h-[calc(100vh-64px)] w-full min-w-0`}>
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
{isRestricted && props.canViewRestrictedStream && (
<div className="flex items-start gap-3 border-b border-amber-500/30 bg-amber-500/10 px-4 py-3 text-foreground">
@@ -80,9 +80,11 @@ export default function LiveStream(props: Props) {
</div>
</div>
)}
<StreamPlayer />
<div className="min-h-0 flex-1 overflow-hidden bg-black">
<StreamPlayer />
</div>
{isMobile && (
<div className="flex-1 min-h-[250px] max-h-[400px] border-t border-border">
<div className="w-full min-w-0 flex-1 min-h-[250px] max-h-[400px] border-t border-border">
<ChatPanel />
</div>
)}

View File

@@ -23,7 +23,6 @@ import {
PenSquare,
LogOut,
Code,
Github,
Heart,
Radio,
} from 'lucide-react';
@@ -31,6 +30,7 @@ import { SidebarTrigger } from '@/components/ui/sidebar';
import Image from 'next/image';
import Logo from '@/lib/assets/logo.webp';
import { usePersonalChannels } from '@/lib/hooks/useUserList';
import { JSX } from 'react';
export default function Navbar(props: Props) {
const { user } = useSession();
@@ -140,8 +140,13 @@ export default function Navbar(props: Props) {
</Link>
<div className="grid grid-cols-2 gap-1">
<Link href={'https://github.com/SrIzan10/hctv'} target="_blank" rel="noreferrer">
<DropdownMenuItem className={`${menuItemClass} justify-center text-xs`}>
<Github className="w-3.5 h-3.5 mr-1.5" />
<DropdownMenuItem className={`${menuItemClass} justify-center text-xs gap-2`}>
<Image
src="https://thesvg.org/icons/github/dark.svg"
alt="GitHub"
width={14}
height={14}
/>
Github
</DropdownMenuItem>
</Link>

View File

@@ -177,8 +177,8 @@ export default function StreamPlayer() {
}, [clearWaitingTimeout, playerKey, triggerRecovery]);
return (
<div className="relative">
<MediaController className="w-full aspect-video">
<div className="relative flex h-full w-full min-w-0 items-center justify-center bg-black">
<MediaController className="h-full w-full">
<HlsVideo
key={playerKey}
ref={videoRef}
@@ -186,6 +186,7 @@ export default function StreamPlayer() {
crossOrigin="anonymous"
playsInline
autoplay
className="h-full w-full object-contain"
/>
<MediaLoadingIndicator slot="centered-chrome" noAutohide />
<MediaControlBar className="w-full px-2 sm:px-4 pb-1">
@@ -200,7 +201,10 @@ export default function StreamPlayer() {
{(process.env.NODE_ENV === 'development' || userInfo?.isLive) && (
<MediaChromeButton onClick={() => triggerRecovery('manual_reload')}>
<span className="flex h-4 w-4 items-center justify-center">
<RefreshCw className={cn("h-5 w-5 shrink-0", isRecovering && "animate-spin")} strokeWidth={2.5} />
<RefreshCw
className={cn('h-5 w-5 shrink-0', isRecovering && 'animate-spin')}
strokeWidth={2.5}
/>
</span>
<span slot="tooltip-content">Retry stream</span>
</MediaChromeButton>

View File

@@ -1,6 +1,6 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { Path, useForm } from 'react-hook-form';
import { FieldValues, Path, useForm } from 'react-hook-form';
import {
Form,
FormControl,
@@ -11,7 +11,6 @@ import {
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { z } from 'zod';
import type { UniversalFormProps } from './types';
import SubmitButton from '../SubmitButton/SubmitButton';
import { useActionState } from 'react';
@@ -43,7 +42,7 @@ export const schemaDb = [
{ name: 'updateNotificationChannels', zod: updateNotificationChannelsSchema },
] as const;
export function UniversalForm<T extends z.ZodType>({
export function UniversalForm({
fields,
schemaName,
action,
@@ -54,7 +53,7 @@ export function UniversalForm<T extends z.ZodType>({
submitClassname,
otherSubmitButton,
submitButtonDivClassname,
}: UniversalFormProps<T>) {
}: UniversalFormProps) {
// @ts-expect-error - idk
const [state, formAction] = useActionState<{ success: boolean; error?: string }>(action, null);
const schema = schemaDb.find((s) => s.name === schemaName)?.zod;
@@ -72,11 +71,9 @@ export function UniversalForm<T extends z.ZodType>({
return { ...values, ...defaultValues };
}, [fields, defaultValues]);
type FormData = z.infer<T>;
const form = useForm<FormData>({
const form = useForm<FieldValues>({
resolver: zodResolver(schema as any),
defaultValues: initialValues as FormData,
defaultValues: initialValues,
});
React.useEffect(() => {
@@ -95,7 +92,7 @@ export function UniversalForm<T extends z.ZodType>({
<FormField
key={field.name}
control={form.control}
name={field.name as Path<FormData>}
name={field.name as Path<FieldValues>}
render={({ field: formField }) => (
<FormItem className={field.type === 'hidden' ? 'hidden' : undefined}>
{field.type !== 'hidden' && field.label && <FormLabel>{field.label}</FormLabel>}

View File

@@ -1,34 +1,35 @@
import type { HTMLInputTypeAttribute, Ref } from 'react';
import { ControllerRenderProps } from 'react-hook-form';
import { z } from 'zod';
import { ControllerRenderProps, FieldValues } from 'react-hook-form';
import { schemaDb } from './UniversalForm';
export type FormFieldConfig<T extends z.ZodType> = {
type FormFieldValue = string | number | boolean | null | undefined;
export type FormFieldConfig = {
name: string;
label?: string;
type?: HTMLInputTypeAttribute;
placeholder?: string;
description?: string;
value?: z.input<T>[keyof z.input<T>];
value?: FormFieldValue;
textArea?: boolean;
textAreaRows?: number;
maxChars?: number;
inputFilter?: RegExp;
component?: (
props: {
field: ControllerRenderProps<z.infer<T>>;
field: ControllerRenderProps<FieldValues>;
} & Record<string, unknown>
) => React.ReactNode;
componentProps?: Record<string, any>;
required?: boolean;
};
export type UniversalFormProps<T extends z.ZodType> = {
fields: FormFieldConfig<T>[];
export type UniversalFormProps = {
fields: FormFieldConfig[];
schemaName: (typeof schemaDb)[number]['name'];
action: (prev: any, formData: FormData) => void;
onActionComplete?: (result: any) => void;
defaultValues?: Partial<z.infer<T>>;
defaultValues?: Partial<FieldValues>;
formRef?: Ref<HTMLFormElement>;
submitText?: string;
submitClassname?: string;

View File

@@ -8,7 +8,7 @@ import { Preview } from '@/components/ui/channel-desc-fancy-area/preview';
export default function UserInfoCard(props: Props) {
return (
<div className="bg-mantle p-4 border-b h-48 flex flex-col">
<div className="bg-mantle p-4 border-b h-48 shrink-0 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">

View File

@@ -26,7 +26,6 @@ export function useProcessor(md: string) {
mention: ["handle"],
},
})
// @ts-expect-error because mention is not valid html-tag
.use(rehypeReact, {
createElement,
Fragment,
@@ -37,7 +36,7 @@ export function useProcessor(md: string) {
},
})
.process(text)
.then((file) => {
.then((file: { result: React.ReactNode }) => {
setContent(file.result);
});
}, [text]);

View File

@@ -141,7 +141,7 @@ const SidebarProvider = React.forwardRef<
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh has-[[data-variant=inset]]:bg-sidebar",
"group/sidebar-wrapper flex min-h-svh flex-col has-[[data-variant=inset]]:bg-sidebar",
className
)}
ref={ref}
@@ -181,7 +181,7 @@ const Sidebar = React.forwardRef<
return (
<div
className={cn(
"flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground",
"flex h-full w-[var(--sidebar-width)] flex-col bg-sidebar text-sidebar-foreground",
className
)}
ref={ref}
@@ -198,7 +198,7 @@ const Sidebar = React.forwardRef<
<SheetContent
data-sidebar="sidebar"
data-mobile="true"
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
className="w-[var(--sidebar-width)] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
@@ -224,24 +224,24 @@ const Sidebar = React.forwardRef<
{/* This is what handles the sidebar gap on desktop */}
<div
className={cn(
"duration-200 relative h-[calc(100vh-4rem)] w-[--sidebar-width] transition-[left,right,width] ease-linear md:flex",
"duration-200 relative h-[calc(100vh-4rem)] w-[var(--sidebar-width)] transition-[left,right,width] ease-linear md:flex",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+var(--spacing)*4)]"
: "group-data-[collapsible=icon]:w-[var(--sidebar-width-icon)]"
)}
/>
<div
className={cn(
"duration-200 fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] ease-linear md:flex",
"duration-200 fixed inset-y-0 z-10 hidden h-svh w-[var(--sidebar-width)] transition-[left,right,width] ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+var(--spacing)*4+2px)]"
: "group-data-[collapsible=icon]:w-[var(--sidebar-width-icon)] group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
@@ -323,7 +323,7 @@ const SidebarInset = React.forwardRef<
ref={ref}
className={cn(
"relative flex min-h-svh flex-1 flex-col bg-background",
"peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
"peer-data-[variant=inset]:min-h-[calc(100svh-var(--spacing)*4)] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
className
)}
{...props}
@@ -518,7 +518,7 @@ const sidebarMenuButtonVariants = cva(
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
"bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]",
},
size: {
default: "h-8 text-sm",
@@ -669,7 +669,7 @@ const SidebarMenuSkeleton = React.forwardRef<
/>
)}
<Skeleton
className="h-4 flex-1 max-w-[--skeleton-width]"
className="h-4 flex-1 max-w-[var(--skeleton-width)]"
data-sidebar="menu-skeleton-text"
style={
{

View File

@@ -306,7 +306,7 @@ function classifyPublisherIssue(error: unknown, context: PublisherIssueContext):
return {
context,
description:
'Use HackClub.tv over HTTPS or localhost in a Chromium-based browser, then try again.',
'Use the platform over HTTPS or localhost in a Chromium-based browser, then try again.',
title: 'This browser or page cannot start screen sharing',
tone: 'destructive',
};

View File

@@ -90,6 +90,11 @@ export async function syncStream() {
for (const r of regions) {
const region = MEDIAMTX_SERVER_REGIONS[r];
if (!region) {
// continuing bc of the next if check
continue;
}
if (!region.apiAuthHeader) {
throw new Error('MEDIAMTX_API_KEY is required when querying the MediaMTX API');
}

View File

@@ -54,5 +54,11 @@ export function getMediamtxClientRegionOptions(): MediaMTXClientRegionOption[] {
label: 'HQ Server A',
whipEnabled: false,
},
{
value: 'ethande',
emoji: '🇩🇪',
label: 'eth0\'s VPS',
whipEnabled: true,
},
];
}

View File

@@ -5,11 +5,9 @@ export interface MediaMTXEnvs {
apiAuthHeader?: string;
}
export const MEDIAMTX_SERVER_REGIONS: Record<MediaMTXRegion, MediaMTXEnvs> = {
hq: {
apiUrl: process.env.MEDIAMTX_API_HQ!,
apiAuthHeader: getMediamtxApiAuthHeader(),
},
export const MEDIAMTX_SERVER_REGIONS: Partial<Record<MediaMTXRegion, MediaMTXEnvs>> = {
hq: createMediamtxEnvs(process.env.MEDIAMTX_API_HQ),
ethande: createMediamtxEnvs(process.env.MEDIAMTX_API_ETHANDE),
};
export function getMediamtxEnvs(region: MediaMTXRegion = 'hq'): MediaMTXEnvs {
@@ -31,3 +29,14 @@ function getMediamtxApiAuthHeader() {
return `Basic ${Buffer.from(`hctv-api:${apiKey}`).toString('base64')}`;
}
function createMediamtxEnvs(apiUrl?: string): MediaMTXEnvs | undefined {
if (!apiUrl) {
return undefined;
}
return {
apiUrl,
apiAuthHeader: getMediamtxApiAuthHeader(),
};
}

View File

@@ -30,8 +30,11 @@ export default async function zodVerify<TSchema extends z.ZodTypeAny>(
const zod = schema.safeParse(obj);
if (!zod.success) {
const [issue] = zod.error.issues;
const path = issue.path[0] === undefined ? 'form' : String(issue.path[0]);
return {
error: `From ${zod.error.errors[0].path[0]}: ${zod.error.errors[0].message}`,
error: `From ${path}: ${issue.message}`,
success: false,
};
}

View File

@@ -1,109 +1,20 @@
import type { Config } from "tailwindcss"
import { uploadthingPlugin } from 'uploadthing/tw'
import * as tan from 'tailwindcss-animate'
import { uploadthingPlugin } from 'uploadthing/tw';
import type { Config } from 'tailwindcss';
const config = {
darkMode: ["class"],
content: [
'./pages/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',
'./app/**/*.{ts,tsx}',
'./src/**/*.{ts,tsx}',
],
prefix: "",
darkMode: 'class',
content: ['./src/**/*.{ts,tsx}'],
prefix: '',
theme: {
container: {
center: true,
padding: '2rem',
screens: {
'2xl': '1400px'
}
},
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))'
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))'
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))'
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))'
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))'
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))'
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))'
},
surface1: {
DEFAULT: 'hsl(var(--surface-1))'
},
surface2: {
DEFAULT: 'hsl(var(--surface-2))'
},
mantle: {
DEFAULT: 'hsl(var(--mantle))'
},
sidebar: {
DEFAULT: 'hsl(var(--sidebar-background))',
foreground: 'hsl(var(--sidebar-foreground))',
primary: 'hsl(var(--sidebar-primary))',
'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
accent: 'hsl(var(--sidebar-accent))',
'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
border: 'hsl(var(--sidebar-border))',
ring: 'hsl(var(--sidebar-ring))'
}
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
},
keyframes: {
'accordion-down': {
from: {
height: '0'
},
to: {
height: 'var(--radix-accordion-content-height)'
}
},
'accordion-up': {
from: {
height: 'var(--radix-accordion-content-height)'
},
to: {
height: '0'
}
}
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out'
}
}
container: {
center: true,
padding: '2rem',
screens: {
'2xl': '1400px',
},
},
},
plugins: [tan, uploadthingPlugin],
} satisfies Config
plugins: [uploadthingPlugin],
} satisfies Config;
export default config
export default config;

View File

@@ -61,6 +61,10 @@ services:
build:
context: .
dockerfile: docker/mediamtx/Dockerfile
environment:
SSL_CERT_FILE: /etc/ssl/certs/ca-certificates.crt
MTX_AUTHHTTPADDRESS: ${MEDIAMTX_AUTH_HTTP_ADDRESS:-http://hctv:3000/api/mediamtx/publish}
MTX_WEBRTCADDITIONALHOSTS: ${MEDIAMTX_WEBRTC_ADDITIONAL_HOSTS:-}
ports:
- '8890:8890/udp'
postgres-exporter:

View File

@@ -1,6 +1,8 @@
FROM bluenviron/mediamtx:1 AS mediamtx
FROM ubuntu:24.04
FROM alpine:3.21
RUN apk add --no-cache ca-certificates
COPY --from=mediamtx /mediamtx /
COPY ./docker/mediamtx/mediamtx.yml /mediamtx.yml

View File

@@ -1,4 +1,5 @@
ACME_EMAIL=ops@hackclub.tv
CF_DNS_API_TOKEN=cloudflare_dns_edit_token
# public hostnames and stuff
MEDIAMTX_HLS_HOST=hls.hackclub.tv
@@ -8,5 +9,5 @@ MEDIAMTX_API_HOST=mmtxapi.hackclub.tv
# public ip for webrtc stuff
MEDIAMTX_WEBRTC_ADDITIONAL_HOSTS=203.0.113.10
# mediamtx publish route on hctv
MEDIAMTX_AUTH_HTTP_ADDRESS=https://hackclub.tv/api/mediamtx/publish
MEDIAMTX_AUTH_HTTP_FINGERPRINT=

View File

@@ -1,6 +1,6 @@
services:
traefik:
image: traefik:v3.5
image: traefik:latest
command:
- --providers.docker=true
- --providers.docker.exposedbydefault=false
@@ -10,8 +10,11 @@ services:
- --entrypoints.webrtc-ice.address=:8189/udp
- --certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}
- --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
- --certificatesresolvers.letsencrypt.acme.httpchallenge=true
- --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web
- --certificatesresolvers.letsencrypt.acme.dnschallenge=true
- --certificatesresolvers.letsencrypt.acme.dnschallenge.provider=cloudflare
- --certificatesresolvers.letsencrypt.acme.dnschallenge.resolvers=1.1.1.1:53,1.0.0.1:53
environment:
CF_DNS_API_TOKEN: ${CF_DNS_API_TOKEN}
ports:
- 80:80
- 443:443
@@ -23,12 +26,14 @@ services:
restart: unless-stopped
mediamtx:
image: bluenviron/mediamtx:1
image: srizan10/hclive-mediamtx
volumes:
- ./mediamtx.yml:/mediamtx.yml:ro
environment:
SSL_CERT_FILE: /etc/ssl/certs/ca-certificates.crt
MTX_WEBRTCADDITIONALHOSTS: ${MEDIAMTX_WEBRTC_ADDITIONAL_HOSTS}
MTX_AUTHHTTPADDRESS: ${MEDIAMTX_AUTH_HTTP_ADDRESS}
MTX_AUTHHTTPFINGERPRINT: ${MEDIAMTX_AUTH_HTTP_FINGERPRINT:-}
labels:
- traefik.enable=true

View File

@@ -23,8 +23,8 @@
"sdk:example": "dotenvx run -f .env.sdk -- pnpm --filter=@hctv/sdk example"
},
"devDependencies": {
"prettier": "^3.6.2",
"turbo": "^2.4.4"
"prettier": "^3.8.3",
"turbo": "^2.9.7"
},
"packageManager": "pnpm@10.6.5"
}

View File

@@ -20,6 +20,6 @@
"lucia": "^3.2.2"
},
"devDependencies": {
"typescript": "^5.8.2"
"typescript": "^6.0.3"
}
}

View File

@@ -3,6 +3,7 @@
"target": "ESNext",
"module": "NodeNext",
"moduleResolution": "nodenext",
"ignoreDeprecations": "6.0",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
@@ -15,4 +16,4 @@
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
}

View File

@@ -26,6 +26,6 @@
"devDependencies": {
"@types/node": "^24.0.1",
"tsx": "^4.7.1",
"typescript": "^5.8.2"
"typescript": "^6.0.3"
}
}

View File

@@ -3,6 +3,7 @@
"target": "ESNext",
"module": "NodeNext",
"moduleResolution": "nodenext",
"ignoreDeprecations": "6.0",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
@@ -15,4 +16,4 @@
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
}

View File

@@ -22,7 +22,9 @@
"license": "MIT",
"homepage": "https://github.com/honojs/middleware",
"devDependencies": {
"@hono/node-server": "^2.0.1",
"@types/ws": "^8",
"hono": "^4.12.16",
"tsup": "^8.0.1"
},
"dependencies": {
@@ -30,8 +32,8 @@
"@hctv/db": "workspace:*"
},
"peerDependencies": {
"@hono/node-server": "^1.11.1",
"hono": "^4.6.0"
"@hono/node-server": "^2.0.1",
"hono": "^4.12.16"
},
"engines": {
"node": ">=18.14.1"

View File

@@ -6,6 +6,7 @@
"module": "NodeNext",
"declaration": true,
"moduleResolution": "nodenext",
"ignoreDeprecations": "6.0",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,

View File

@@ -39,7 +39,7 @@
"@typescript-eslint/parser": "^8.50.1",
"eslint": "^9.39.2",
"tsup": "^8.5.1",
"typescript": "^5.6.2",
"typescript": "^6.0.3",
"vitest": "^4.0.16"
}
}

View File

@@ -28,6 +28,7 @@
"module": "commonjs", /* Specify what module code is generated. */
// "rootDir": "./", /* Specify the root folder within your source files. */
"moduleResolution": "Node", /* Specify how TypeScript looks up a file from a given module specifier. */
"ignoreDeprecations": "6.0",
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
@@ -112,4 +113,4 @@
"node_modules",
"dist"
]
}
}

11024
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

6
renovate.json Normal file
View File

@@ -0,0 +1,6 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended"
]
}