mirror of
https://github.com/SrIzan10/hctv.git
synced 2026-06-06 00:56:56 +00:00
Compare commits
63 Commits
emoji-impo
...
feat/proto
| Author | SHA1 | Date | |
|---|---|---|---|
| 0581cc6a61 | |||
| 18025ced9d | |||
| 044221f147 | |||
| 0cabbd8720 | |||
| 5fdb6921d9 | |||
| 312ad480a2 | |||
| a37554d205 | |||
| 5244275264 | |||
| 5275e8cb2a | |||
| 1ff51fad61 | |||
| 440eb407dd | |||
| 4ab1756230 | |||
| f9d11476bf | |||
| 8e8c58e195 | |||
| 6fcbeaa2a7 | |||
| caef4e428a | |||
| cf49fea907 | |||
| 7683f765b0 | |||
| 4d91f15a43 | |||
| 3b49f8d25a | |||
| c99ace0ef5 | |||
| a834b63ac8 | |||
| 09871d3fae | |||
| 2a0a7abe1a | |||
| 2a15a6367a | |||
| 6fad756bd2 | |||
| 0bb44960b4 | |||
| ac2276b112 | |||
| 1adb9be6cc | |||
| a9625f3505 | |||
| 3611e23869 | |||
| f8aa1454ff | |||
| 6e8539a8d1 | |||
| 592524eedb | |||
| 989462e639 | |||
| a7e9115587 | |||
| 934f589b5f | |||
| 27a6838d9f | |||
| 75085630be | |||
| 642270ee91 | |||
| f543061672 | |||
| 3a89f07a6f | |||
| fb40d87736 | |||
| 34d7afd03d | |||
| 495027ca7e | |||
| 55df22341e | |||
| 17f550ce9d | |||
| ebd0a8bffd | |||
| 2298b8bc0c | |||
| 7dd9bf765e | |||
| 8b3df28f1e | |||
| ecca138257 | |||
| cb0f75dfb9 | |||
| 747af0aeb3 | |||
| 64c7a80883 | |||
| e8fdfa8f49 | |||
| 9e965c779f | |||
| 9ce6770115 | |||
| 6d413bc53e | |||
| 93ae6bd73e | |||
| ae99121af6 | |||
| 331a0185af | |||
| d223902a9f |
58
.github/workflows/docker.yml
vendored
58
.github/workflows/docker.yml
vendored
@@ -47,7 +47,7 @@ jobs:
|
||||
mkdir -p apps/web/src/lib/instrumentation/
|
||||
|
||||
export SLACK_TOKEN=${{ secrets.SLACK_TOKEN }}
|
||||
./slack-import-emojis-bin
|
||||
./slack-import-emojis-bin default
|
||||
|
||||
cp emojis.json apps/web/
|
||||
|
||||
@@ -63,41 +63,6 @@ jobs:
|
||||
secrets: |
|
||||
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
|
||||
db:
|
||||
name: Push db to Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
|
||||
with:
|
||||
images: srizan10/hclive-db
|
||||
tags: latest
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./packages/db/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64
|
||||
secrets: |
|
||||
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
|
||||
chat:
|
||||
name: Push chat module to Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
@@ -134,21 +99,10 @@ jobs:
|
||||
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
|
||||
deploy:
|
||||
name: Deploy to server
|
||||
name: Deploy to Coolify
|
||||
runs-on: ubuntu-latest
|
||||
needs: [frontend, db, chat]
|
||||
needs: [frontend, chat]
|
||||
steps:
|
||||
# source https://github.com/taciturnaxolotl/cachet/blob/main/.github/workflows/deploy.yaml
|
||||
- name: Deploy with Docker
|
||||
uses: appleboy/ssh-action@v1
|
||||
with:
|
||||
host: hackclub.app
|
||||
username: srizan
|
||||
key: ${{ secrets.SSH_KEY }}
|
||||
port: 22
|
||||
script: |
|
||||
cd ~/compose/hctv
|
||||
docker compose pull
|
||||
docker compose up -d --remove-orphans
|
||||
# for some reason, without the restart, the rtmp container stops working
|
||||
docker compose restart
|
||||
- name: Send coolify redeploy webhook
|
||||
run: |
|
||||
curl -X POST -H "Authorization: Bearer ${{ secrets.COOLIFY_API_KEY }}" https://coolify.srizan.dev/api/v1/deploy?uuid=${{ secrets.COOLIFY_APP_UUID }}&force=true
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -45,4 +45,6 @@ packages/db/generated/client
|
||||
*dist
|
||||
|
||||
slack-import-emojis/target
|
||||
**/*/emojis.json
|
||||
**/*/emojis.json
|
||||
|
||||
.idea
|
||||
@@ -1,6 +1,6 @@
|
||||
# hackclub.tv
|
||||
|
||||
This is the source code for [hackclub.tv (hctv.srizan.dev)](https://hctv.srizan.dev), a livestreaming website for hackclubbers.
|
||||
This is the source code for [hackclub.tv (hackclub.tv)](https://hackclub.tv), a livestreaming website for hackclubbers.
|
||||
|
||||
Development has been ongoing for a few months, and the site is now live! There are some half-baked features, but I'm all ears for feedback.
|
||||
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
```
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
```
|
||||
open http://localhost:3000
|
||||
```
|
||||
@@ -5,7 +5,7 @@ import { readFile } from 'node:fs/promises';
|
||||
import { lucia } from '@hctv/auth';
|
||||
import { getCookie } from 'hono/cookie';
|
||||
import { getPersonalChannel } from './utils/personalChannel.js';
|
||||
import { getRedisConnection, prisma, type User } from '@hctv/db';
|
||||
import { getRedisConnection, prisma, type BotAccount, type BotApiKey, type User } from '@hctv/db';
|
||||
import uFuzzy from '@leeoniya/ufuzzy';
|
||||
import { randomString } from './utils/randomString.js';
|
||||
|
||||
@@ -29,71 +29,92 @@ app.get('/up', async (c) => {
|
||||
app.get(
|
||||
'/ws/:username',
|
||||
upgradeWebSocket((c) => ({
|
||||
// https://hono.dev/helpers/websocket
|
||||
async onOpen(evt, ws) {
|
||||
const token = getCookie(c, 'auth_session');
|
||||
const grant = c.req.query('grant');
|
||||
console.log({
|
||||
token,
|
||||
grant,
|
||||
})
|
||||
const authHeader = c.req.header('Authorization');
|
||||
|
||||
// random checks that actually make sense if you read trust me bro
|
||||
if (!token && !grant) {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
if (!token && grant === 'null') {
|
||||
if (!token && (!grant || grant === 'null') && !authHeader) {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
let user: User | null = null
|
||||
let chatUser: ChatUser | null = null;
|
||||
let personalChannel: any = null;
|
||||
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
const apiKey = authHeader.substring(7);
|
||||
const botAccount = await prisma.botApiKey.findUnique({
|
||||
where: { key: apiKey },
|
||||
include: { botAccount: true }
|
||||
});
|
||||
|
||||
if (botAccount) {
|
||||
chatUser = {
|
||||
id: botAccount.botAccount.id,
|
||||
username: botAccount.botAccount.slug,
|
||||
pfpUrl: botAccount.botAccount.pfpUrl,
|
||||
displayName: botAccount.botAccount.displayName,
|
||||
isBot: true
|
||||
};
|
||||
|
||||
personalChannel = {
|
||||
id: botAccount.botAccount.id,
|
||||
name: botAccount.botAccount.slug
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!chatUser && token) {
|
||||
const session = await lucia.validateSession(token);
|
||||
if (session.user) {
|
||||
const userChannel = await getPersonalChannel(session.user.id);
|
||||
if (userChannel) {
|
||||
chatUser = {
|
||||
id: session.user.id,
|
||||
username: userChannel.name,
|
||||
pfpUrl: session.user.pfpUrl,
|
||||
isBot: false
|
||||
};
|
||||
personalChannel = userChannel;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dbGrant = await prisma.channel.findFirst({
|
||||
where: {
|
||||
obsChatGrantToken: grant,
|
||||
}
|
||||
where: { obsChatGrantToken: grant }
|
||||
});
|
||||
if (token) {
|
||||
user = (await lucia.validateSession(token)).user;
|
||||
const personalChannel = await getPersonalChannel(user!.id);
|
||||
if (!personalChannel) {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
ws.personalChannel = personalChannel;
|
||||
}
|
||||
if (!user && !dbGrant) {
|
||||
|
||||
if (!chatUser && !dbGrant) {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const { username } = c.req.param();
|
||||
if (dbGrant && dbGrant?.name !== username) {
|
||||
if (dbGrant && dbGrant.name !== username) {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
ws.targetUsername = username;
|
||||
ws.user = user;
|
||||
ws.chatUser = chatUser;
|
||||
ws.personalChannel = personalChannel;
|
||||
ws.viewerId = randomString(10);
|
||||
|
||||
if (ws.raw) {
|
||||
ws.raw.targetUsername = username;
|
||||
// @ts-ignore
|
||||
ws.raw.user = user;
|
||||
ws.raw.personalChannel = ws.personalChannel;
|
||||
ws.raw.chatUser = chatUser;
|
||||
ws.raw.personalChannel = personalChannel;
|
||||
}
|
||||
|
||||
const redis = getRedisConnection();
|
||||
const channelKey = `chat:history:${username}`;
|
||||
const messages = await redis.zrange(channelKey, 0, MESSAGE_HISTORY_SIZE - 1);
|
||||
|
||||
if (messages.length > 0) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'history',
|
||||
messages: messages.map((msg) => JSON.parse(msg)),
|
||||
})
|
||||
);
|
||||
ws.send(JSON.stringify({
|
||||
type: 'history',
|
||||
messages: messages.map((msg) => JSON.parse(msg)),
|
||||
}));
|
||||
}
|
||||
},
|
||||
async onClose(evt, ws) {
|
||||
@@ -116,33 +137,34 @@ app.get(
|
||||
},
|
||||
async onMessage(evt, ws) {
|
||||
const msg = JSON.parse(evt.data.toString());
|
||||
|
||||
if (msg.type === 'ping') {
|
||||
await redis.setex(`viewer:${ws.targetUsername}:${ws.viewerId}`, 30, '1');
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'pong',
|
||||
})
|
||||
);
|
||||
ws.send(JSON.stringify({ type: 'pong' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'message') {
|
||||
if (!ws.personalChannel) return;
|
||||
if (!ws.chatUser || !ws.personalChannel) return;
|
||||
|
||||
const message = (msg.message as string).trim();
|
||||
const msgObj = {
|
||||
user: {
|
||||
id: ws.user.id,
|
||||
username: ws.personalChannel.name,
|
||||
pfpUrl: ws.user.pfpUrl,
|
||||
id: ws.chatUser.id,
|
||||
username: ws.chatUser.username,
|
||||
pfpUrl: ws.chatUser.pfpUrl,
|
||||
displayName: ws.chatUser.displayName,
|
||||
isBot: ws.chatUser.isBot || false
|
||||
},
|
||||
message,
|
||||
};
|
||||
|
||||
// Save to Redis without the type field to maintain compatibility
|
||||
const redisObj = {
|
||||
user: msgObj.user,
|
||||
message: msgObj.message,
|
||||
type: 'message',
|
||||
};
|
||||
|
||||
const redisStr = JSON.stringify(redisObj);
|
||||
const msgStr = JSON.stringify(msgObj);
|
||||
|
||||
@@ -238,3 +260,11 @@ const server = serve(
|
||||
}
|
||||
);
|
||||
injectWebSocket(server);
|
||||
|
||||
interface ChatUser {
|
||||
id: string;
|
||||
username: string;
|
||||
pfpUrl: string;
|
||||
displayName?: string;
|
||||
isBot: boolean;
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
# Starlight Starter Kit: Basics
|
||||
|
||||
[](https://starlight.astro.build)
|
||||
|
||||
```
|
||||
yarn create astro@latest -- --template starlight
|
||||
```
|
||||
|
||||
> 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun!
|
||||
|
||||
## 🚀 Project Structure
|
||||
|
||||
Inside of your Astro + Starlight project, you'll see the following folders and files:
|
||||
|
||||
```
|
||||
.
|
||||
├── public/
|
||||
├── src/
|
||||
│ ├── assets/
|
||||
│ ├── content/
|
||||
│ │ └── docs/
|
||||
│ └── content.config.ts
|
||||
├── astro.config.mjs
|
||||
├── package.json
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
Starlight looks for `.md` or `.mdx` files in the `src/content/docs/` directory. Each file is exposed as a route based on its file name.
|
||||
|
||||
Images can be added to `src/assets/` and embedded in Markdown with a relative link.
|
||||
|
||||
Static assets, like favicons, can be placed in the `public/` directory.
|
||||
|
||||
## 🧞 Commands
|
||||
|
||||
All commands are run from the root of the project, from a terminal:
|
||||
|
||||
| Command | Action |
|
||||
| :------------------------ | :----------------------------------------------- |
|
||||
| `yarn install` | Installs dependencies |
|
||||
| `yarn dev` | Starts local dev server at `localhost:4321` |
|
||||
| `yarn build` | Build your production site to `./dist/` |
|
||||
| `yarn preview` | Preview your build locally, before deploying |
|
||||
| `yarn astro ...` | Run CLI commands like `astro add`, `astro check` |
|
||||
| `yarn astro -- --help` | Get help using the Astro CLI |
|
||||
|
||||
## 👀 Want to learn more?
|
||||
|
||||
Check out [Starlight’s docs](https://starlight.astro.build/), read [the Astro documentation](https://docs.astro.build), or jump into the [Astro Discord server](https://astro.build/chat).
|
||||
@@ -3,12 +3,19 @@ title: Chat
|
||||
description: Chat websocket
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
The chat system is powered by a websocket server. Please read the entire page before implementing anything, as there are some important notes.
|
||||
|
||||
## Connection and messages
|
||||
|
||||
The websocket server is located at `wss://hctv.srizan.dev/api/chat/ws/:username`, where `:username` is the channel you want to connect to.
|
||||
You'll need to provide authentication, which can be done by providing an `auth_session` cookie, just like the REST API.
|
||||
The websocket server is located at `wss://hackclub.tv/api/chat/ws/:username`, where `:username` is the channel you want to connect to.
|
||||
|
||||
You'll need to provide authentication, which can be done by providing an `auth_session` cookie, just like the REST API.
|
||||
<Aside type="tip">
|
||||
Bot accounts are now supported. You can choose to connect as a bot by providing a bot account's API key on the Authentication header: `Bearer hctvb_xxxxxxx`
|
||||
It is highly advised to use a bot account for any automated task, and to implement anything pointed out in this page.
|
||||
</Aside>
|
||||
|
||||
Once connected, you must implement a subroutine in your code to send ping messages every 5 seconds. This is because of Cloudflare limitations.
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ since this is beta software, the API is subject to change. additionally, many en
|
||||
|
||||
## Base url
|
||||
|
||||
base url for all endpoints is `https://hctv.srizan.dev/api`.
|
||||
base url for all endpoints is `https://hackclub.tv/api`.
|
||||
|
||||
## Authentication
|
||||
|
||||
|
||||
@@ -3,10 +3,6 @@ title: RTMP
|
||||
description: RTMP related endpoint group
|
||||
---
|
||||
|
||||
## GET `/rtmp/hls/:path`
|
||||
gets HLS segments, the backbone of hctv livestreaming. **authentication required**.
|
||||
not really sure why you would need this? but check the browser console when playing a stream for an insight on how it's used.
|
||||
|
||||
## POST `/rtmp/streamKey`
|
||||
regenerates your stream key. **authentication required**.
|
||||
body parameters (json):
|
||||
|
||||
@@ -7,7 +7,7 @@ description: Get started with OBS and streaming on hackclub.tv
|
||||
- open settings
|
||||
- open "Stream"
|
||||
- set service to custom
|
||||
- set url to `rtmp://hackclub.app:45913/live`
|
||||
- set url to `srt://localhost:8890?streamid=publish:CHANNEL_NAME:thisusernameislongonpurposesoyoudontaccidentallyleakyourstreamkey:STREAM_KEY&pkt_size=1316`
|
||||
- on the website, click "Regenerate key"
|
||||
- paste the key into your obs "Stream Key"
|
||||
- start streaming!
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
FROM node:lts-alpine AS base
|
||||
FROM node:lts-slim AS base
|
||||
|
||||
FROM base AS builder
|
||||
RUN apk update
|
||||
RUN apk add --no-cache libc6-compat git
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
# Replace <your-major-version> with the major version installed in your repository. For example:
|
||||
@@ -19,14 +21,16 @@ RUN turbo prune @hctv/web --docker
|
||||
|
||||
# Add lockfile and package.json's of isolated subworkspace
|
||||
FROM base AS installer
|
||||
RUN apk update
|
||||
RUN apk add --no-cache libc6-compat git
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
libvips-dev \
|
||||
python3 \
|
||||
make \
|
||||
g++ \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# Get the commit hash from the builder stage
|
||||
COPY --from=builder /tmp/commit_hash /tmp/commit_hash
|
||||
# Read commit hash and set as build arg
|
||||
ARG COMMIT_HASH_FILE=/tmp/commit_hash
|
||||
RUN COMMIT_HASH=$(cat /tmp/commit_hash 2>/dev/null || echo "unknown") && \
|
||||
echo "COMMIT_HASH=$COMMIT_HASH" > /tmp/build_env
|
||||
WORKDIR /app
|
||||
|
||||
# First install the dependencies (as they change less often)
|
||||
@@ -35,24 +39,36 @@ RUN yarn install --frozen-lockfile
|
||||
|
||||
COPY --from=builder /app/out/full/ .
|
||||
RUN --mount=type=secret,id=TURBO_TOKEN --mount=type=secret,id=TURBO_TEAM \
|
||||
. /tmp/build_env && \
|
||||
export commit=$COMMIT_HASH && \
|
||||
TURBO_TOKEN=$(cat /run/secrets/TURBO_TOKEN) TURBO_TEAM=$(cat /run/secrets/TURBO_TEAM) yarn turbo run build --env-mode=loose
|
||||
COMMIT=$(cat /tmp/commit_hash 2>/dev/null || echo "unknown") && \
|
||||
TURBO_TOKEN=$(cat /run/secrets/TURBO_TOKEN) TURBO_TEAM=$(cat /run/secrets/TURBO_TEAM) \
|
||||
commit=$COMMIT yarn turbo run build --env-mode=loose
|
||||
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache ffmpeg
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ffmpeg \
|
||||
libvips42 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Don't run production as root
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
RUN groupadd --system --gid 1001 nodejs
|
||||
RUN useradd --system --uid 1001 nextjs
|
||||
|
||||
# Copy Prisma files for migrations
|
||||
COPY --from=installer --chown=nextjs:nodejs /app/packages/db/prisma ./packages/db/prisma
|
||||
COPY --from=installer --chown=nextjs:nodejs /app/packages/db/generated ./packages/db/generated
|
||||
COPY --from=installer --chown=nextjs:nodejs /app/packages/db/package.json ./packages/db/package.json
|
||||
COPY --from=installer --chown=nextjs:nodejs /app/node_modules ./node_modules
|
||||
|
||||
# Get the commit hash from the installer stage and create a startup script
|
||||
COPY --from=installer /tmp/commit_hash /tmp/commit_hash
|
||||
RUN COMMIT_VALUE=$(cat /tmp/commit_hash 2>/dev/null || echo "unknown") && \
|
||||
echo "#!/bin/sh" > /usr/local/bin/start.sh && \
|
||||
echo "set -e" >> /usr/local/bin/start.sh && \
|
||||
echo "echo 'Running database migrations...'" >> /usr/local/bin/start.sh && \
|
||||
echo "npx prisma migrate deploy --schema=/app/packages/db/prisma/schema.prisma" >> /usr/local/bin/start.sh && \
|
||||
echo "cd /app" >> /usr/local/bin/start.sh && \
|
||||
echo "export commit=$COMMIT_VALUE" >> /usr/local/bin/start.sh && \
|
||||
echo "echo 'Starting Next.js application...'" >> /usr/local/bin/start.sh && \
|
||||
echo "exec node apps/web/server.js" >> /usr/local/bin/start.sh && \
|
||||
chmod +x /usr/local/bin/start.sh
|
||||
|
||||
|
||||
@@ -33,6 +33,9 @@ const nextConfig = {
|
||||
hostname: 'cdn.jsdelivr.net',
|
||||
pathname: '/npm/emoji-datasource-twitter@15.1.2/img/twitter/64/*',
|
||||
},
|
||||
{
|
||||
hostname: 'eoceqrx2r7.ufs.sh'
|
||||
},
|
||||
],
|
||||
minimumCacheTTL: 120,
|
||||
},
|
||||
@@ -44,6 +47,7 @@ const nextConfig = {
|
||||
reactStrictMode: false,
|
||||
output: 'standalone',
|
||||
outputFileTracingRoot: path.join(__dirname, '../../'),
|
||||
serverExternalPackages: ['bullmq'],
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
@@ -52,6 +56,9 @@ const nextConfig = {
|
||||
},
|
||||
];
|
||||
},
|
||||
logging: {
|
||||
incomingRequests: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default withSentryConfig(nextConfig, {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hctv/web",
|
||||
"version": "0.3.0",
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -12,7 +12,7 @@
|
||||
"lint": "next lint",
|
||||
"ui:add": "shadcn add",
|
||||
"check-types": "tsc --noEmit",
|
||||
"openapi": "next-openapi-gen"
|
||||
"genMtxTypes": "bunx openapi-typescript https://github.com/bluenviron/mediamtx/raw/refs/tags/v1.15.5/api/openapi.yaml -o ./src/lib/types/mediamtx.d.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hctv/auth": "*",
|
||||
@@ -20,6 +20,8 @@
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@lucia-auth/adapter-prisma": "^4.0.1",
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@omit/react-confirm-dialog": "^1.2.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-avatar": "^1.0.4",
|
||||
"@radix-ui/react-checkbox": "^1.1.4",
|
||||
"@radix-ui/react-dialog": "^1.1.5",
|
||||
@@ -29,7 +31,7 @@
|
||||
"@radix-ui/react-popover": "^1.1.5",
|
||||
"@radix-ui/react-select": "^2.1.5",
|
||||
"@radix-ui/react-separator": "^1.1.1",
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.1.3",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
@@ -46,18 +48,17 @@
|
||||
"clsx": "^2.1.0",
|
||||
"cmdk": "1.0.0",
|
||||
"hls-video-element": "^1.5.0",
|
||||
"ioredis": "5.7.0",
|
||||
"lucia": "^3.2.2",
|
||||
"lucide-react": "^0.473.0",
|
||||
"media-chrome": "^4.8.0",
|
||||
"next": "^15.3.4",
|
||||
"next": "^16.1.0",
|
||||
"next-themes": "^0.4.4",
|
||||
"node-cron": "^3.0.3",
|
||||
"nuqs": "^2.4.3",
|
||||
"pg": "^8.14.1",
|
||||
"pg-boss": "^10.1.6",
|
||||
"react": "19",
|
||||
"react-dom": "19",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-react": "^8.0.0",
|
||||
|
||||
@@ -16,4 +16,5 @@ Sentry.init({
|
||||
|
||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||
debug: false,
|
||||
enabled: process.env.NODE_ENV === 'production',
|
||||
});
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
// This file configures the initialization of Sentry on the server.
|
||||
// The config you add here will be used whenever the server handles a request.
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
Sentry.init({
|
||||
dsn: "https://f3c26671c39af48406c6e23702a4f3dd@o4506961023860736.ingest.us.sentry.io/4509895816773632",
|
||||
|
||||
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
|
||||
tracesSampleRate: 1,
|
||||
|
||||
// Enable logs to be sent to Sentry
|
||||
enableLogs: true,
|
||||
|
||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||
debug: false,
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { prisma, getRedisConnection } from '@hctv/db';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { lucia } from '@hctv/auth';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const redis = getRedisConnection();
|
||||
const body = await request.json();
|
||||
|
||||
const parsed = schema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return new Response('invalid request', { status: 400 });
|
||||
}
|
||||
const { action, protocol, path, password } = parsed.data;
|
||||
if (action === 'publish' && protocol === 'srt') {
|
||||
const channelKey = await redis.get(`streamKey:${path}`);
|
||||
|
||||
if (channelKey) {
|
||||
if (channelKey !== password) {
|
||||
return new Response('invalid stream key', { status: 403 });
|
||||
}
|
||||
return new Response('youre in yay', { status: 200 });
|
||||
}
|
||||
} else if (action === 'read' && protocol === 'hls') {
|
||||
if (password === process.env.MEDIAMTX_PUBLISH_KEY) {
|
||||
return new Response('authorized', { status: 200 });
|
||||
}
|
||||
const sessionExists = await redis.exists(`sessions:${password}`);
|
||||
if (!sessionExists) {
|
||||
return new Response('unauthorized', { status: 401 });
|
||||
}
|
||||
return new Response('authorized', { status: 200 });
|
||||
}
|
||||
|
||||
return new Response('uhh', { status: 401 });
|
||||
}
|
||||
|
||||
const schema = z.object({
|
||||
user: z.string(),
|
||||
password: z.string(),
|
||||
token: z.string(),
|
||||
ip: z.string(),
|
||||
action: z.enum(['publish', 'read', 'playback', 'api', 'metrics', 'pprof']),
|
||||
path: z.string(),
|
||||
protocol: z.enum(['rtsp', 'rtmp', 'hls', 'webrtc', 'srt']),
|
||||
id: z.string().nullable(),
|
||||
query: z.string(),
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { validateRequest } from '@/lib/auth/validate';
|
||||
import { prisma } from '@hctv/db';
|
||||
import { NextRequest } from "next/server";
|
||||
import { regenerateStreamKey } from '@/lib/db/streamKey';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const { user } = await validateRequest();
|
||||
@@ -34,20 +35,9 @@ export async function POST(request: NextRequest) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const dbUpdate = await prisma.streamKey.upsert({
|
||||
create: {
|
||||
key: crypto.randomUUID(),
|
||||
channelId: channelInfo.id
|
||||
},
|
||||
update: {
|
||||
key: crypto.randomUUID()
|
||||
},
|
||||
where: {
|
||||
channelId: channelInfo.id
|
||||
}
|
||||
})
|
||||
const streamKey = await regenerateStreamKey(channelInfo.id, channel);
|
||||
|
||||
return new Response(JSON.stringify({ key: dbUpdate.key }), {
|
||||
return new Response(JSON.stringify({ key: streamKey.key }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -51,5 +51,14 @@ export async function GET(request: NextRequest) {
|
||||
},
|
||||
});
|
||||
|
||||
db.forEach((obj) => {
|
||||
if (obj.channel.personalFor) {
|
||||
// @ts-ignore
|
||||
delete obj.channel.personalFor.email;
|
||||
}
|
||||
// @ts-ignore
|
||||
delete obj.channel.obsChatGrantToken;
|
||||
});
|
||||
|
||||
return Response.json(db);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { redirect, RedirectType } from 'next/navigation';
|
||||
export default async function Layout({ children }: { children: React.ReactNode }) {
|
||||
const { user } = await validateRequest();
|
||||
if (!user) {
|
||||
return redirect('/auth/slack');
|
||||
return redirect('/auth/hackclub');
|
||||
}
|
||||
if (!user.hasOnboarded) {
|
||||
return redirect(`/onboarding`, RedirectType.push);
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { fetcher } from '@/lib/services/swr';
|
||||
import { useConfirm } from '@omit/react-confirm-dialog';
|
||||
import { Plus, RefreshCcw, Trash } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import useSWR from 'swr';
|
||||
import useSWRMutation from 'swr/mutation';
|
||||
|
||||
export function ApiKeys({ slug }: { slug: string }) {
|
||||
const confirm = useConfirm();
|
||||
const [newApiKeyName, setNewApiKeyName] = useState('');
|
||||
const { data, error, isLoading, mutate } = useSWR<GetResponse>(
|
||||
`/api/settings/bot/${slug}/apiKey`,
|
||||
fetcher
|
||||
);
|
||||
const { trigger } = useSWRMutation(`/api/settings/bot/${slug}/apiKey`, createApiKey);
|
||||
|
||||
const apiKeyCreate = () => {
|
||||
if (newApiKeyName.trim().length < 3) {
|
||||
toast.error('API Key name must be at least 3 characters long');
|
||||
return;
|
||||
}
|
||||
if (newApiKeyName.trim().length > 50) {
|
||||
toast.error('API Key name must be at most 50 characters long');
|
||||
return;
|
||||
}
|
||||
trigger({ action: 'create', name: newApiKeyName }).then(
|
||||
async (res: PostResponse) => {
|
||||
if (res.success) {
|
||||
setNewApiKeyName('');
|
||||
await navigator.clipboard
|
||||
.writeText(res.apiKey || '')
|
||||
.then(() => toast.success('API key copied to clipboard'))
|
||||
.catch(() => {
|
||||
alert('Failed to copy API key to clipboard, here it is: ' + res.apiKey);
|
||||
});
|
||||
await mutate();
|
||||
} else {
|
||||
toast.error(res.error || 'Error creating API key');
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>API Keys</CardTitle>
|
||||
<CardDescription>Manage your API keys</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading && <ApiKeysSkeleton />}
|
||||
{error && <p>Error loading API keys</p>}
|
||||
{data && !data.success && <p>Error: Could not fetch API keys</p>}
|
||||
{data && (
|
||||
<div className="flex">
|
||||
<Input
|
||||
placeholder="New API Key Name"
|
||||
className="flex-1 mr-2"
|
||||
value={newApiKeyName}
|
||||
onChange={(e) => setNewApiKeyName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
apiKeyCreate();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={apiKeyCreate}
|
||||
>
|
||||
<Plus />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{data && data.success && data.apiKeys.length === 0 && <p>No API keys found</p>}
|
||||
{data && data.success && data.apiKeys.length > 0 && (
|
||||
<ul className="space-y-2 pt-4">
|
||||
{data.apiKeys.map((key) => (
|
||||
<li
|
||||
key={key.id}
|
||||
className="flex items-center justify-between p-3 bg-mantle/50 rounded-md"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<strong className="text-sm font-medium">{key.name}</strong>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
const confirmation = await confirm({
|
||||
title: 'Regenerate API Key',
|
||||
description:
|
||||
'Are you sure you want to regenerate this API key? The old key will stop working.',
|
||||
confirmText: 'Regenerate',
|
||||
cancelText: 'Cancel',
|
||||
});
|
||||
if (!confirmation) return;
|
||||
|
||||
trigger({ action: 'regenerate', name: key.name }).then(
|
||||
async (res: PostResponse) => {
|
||||
if (res.success) {
|
||||
await navigator.clipboard
|
||||
.writeText(res.apiKey || '')
|
||||
.then(() => {
|
||||
toast.success('API key copied to clipboard');
|
||||
})
|
||||
.catch(() => {
|
||||
alert(
|
||||
'Failed to copy API key to clipboard, here it is: ' + res.apiKey
|
||||
);
|
||||
});
|
||||
mutate();
|
||||
} else {
|
||||
toast.error(res.error || 'Error regenerating API key');
|
||||
}
|
||||
}
|
||||
);
|
||||
}}
|
||||
>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
const confirmation = await confirm({
|
||||
title: 'Revoke API Key',
|
||||
description:
|
||||
'Are you sure you want to revoke this API key? This action cannot be undone.',
|
||||
confirmText: 'Revoke',
|
||||
cancelText: 'Cancel',
|
||||
});
|
||||
if (!confirmation) return;
|
||||
trigger({ action: 'revoke', name: key.name }).then(
|
||||
async (res: PostResponse) => {
|
||||
if (res.success) {
|
||||
await mutate();
|
||||
} else {
|
||||
toast.error(res.error || 'Error revoking API key');
|
||||
}
|
||||
}
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ApiKeysSkeleton() {
|
||||
return (
|
||||
<>
|
||||
<div className='flex'>
|
||||
<Skeleton className='h-10 flex-1 mr-2' />
|
||||
<Skeleton className='h-10 w-10' />
|
||||
</div>
|
||||
<div className='space-y-2 pt-4'>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className='flex items-center justify-between p-3 bg-mantle/50 rounded-md'>
|
||||
<div className='flex-1'>
|
||||
<Skeleton className='h-4 w-1/2' />
|
||||
</div>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<Skeleton className='h-8 w-8' />
|
||||
<Skeleton className='h-8 w-8' />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
async function createApiKey(url: string, { arg }: { arg: PostRequest }) {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(arg),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
interface GetResponse {
|
||||
success: boolean;
|
||||
apiKeys: Array<{ id: string; name: string; createdAt: string }>;
|
||||
}
|
||||
|
||||
interface PostRequest {
|
||||
action: 'revoke' | 'regenerate' | 'create';
|
||||
name: string;
|
||||
}
|
||||
interface PostResponse {
|
||||
success: boolean;
|
||||
apiKey?: string;
|
||||
id?: string;
|
||||
error?: string;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
'use client';
|
||||
import { UniversalForm } from '@/components/app/UniversalForm/UniversalForm';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { editBot } from '@/lib/form/actions';
|
||||
import { BotAccount } from '@hctv/db';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export function GeneralSettings(props: BotAccount) {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>General Settings</CardTitle>
|
||||
<CardDescription>Edit your bot settings!</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<UniversalForm
|
||||
fields={[
|
||||
{
|
||||
name: 'from',
|
||||
type: 'hidden',
|
||||
value: props.id,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
label: 'Bot Name',
|
||||
placeholder: 'Enter bot name',
|
||||
required: true,
|
||||
value: props.displayName,
|
||||
},
|
||||
{
|
||||
name: 'slug',
|
||||
type: 'text',
|
||||
label: 'Bot Slug',
|
||||
placeholder: 'Enter bot slug',
|
||||
required: true,
|
||||
value: props.slug
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'textarea',
|
||||
label: 'Description',
|
||||
placeholder: 'Enter bot description',
|
||||
value: props.description,
|
||||
textArea: true,
|
||||
},
|
||||
]}
|
||||
schemaName={'editBot'}
|
||||
action={editBot}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { getBotBySlug } from '@/lib/db/resolve';
|
||||
import { validateRequest } from '@/lib/auth/validate';
|
||||
import { redirect } from 'next/navigation';
|
||||
import Image from 'next/image';
|
||||
import { GeneralSettings } from '@/app/(ui)/(protected)/settings/bot/[slug]/gensettings';
|
||||
import { ApiKeys } from '@/app/(ui)/(protected)/settings/bot/[slug]/apikeys';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { user } = await validateRequest();
|
||||
const { slug } = await params;
|
||||
const bot = await getBotBySlug(slug);
|
||||
|
||||
if (!bot || bot.ownerId !== user?.id) {
|
||||
redirect('/settings/bot');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'container mx-auto py-6 space-y-6'}>
|
||||
<Link href={'/settings/bot'} className="text-sm text-muted-foreground hover:underline flex items-center gap-2">
|
||||
<ArrowLeft className='size-4' /> Back to Bot Accounts
|
||||
</Link>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className={'flex items-center space-x-4'}>
|
||||
<Image
|
||||
src={bot.pfpUrl}
|
||||
alt={'Bot Avatar'}
|
||||
width={48}
|
||||
height={48}
|
||||
className="rounded-full"
|
||||
/>
|
||||
<div className={'flex flex-col'}>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{bot.displayName}</h1>
|
||||
<p className="text-muted-foreground">Manage your bot account settings</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full gap-4 flex-col md:flex-row *:w-1/2">
|
||||
<GeneralSettings {...bot} />
|
||||
<ApiKeys slug={slug} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
'use client';
|
||||
|
||||
import { UniversalForm } from '@/components/app/UniversalForm/UniversalForm';
|
||||
import { createBot } from '@/lib/form/actions';
|
||||
import { Bot } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function Page() {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="flex h-full w-full flex-col items-center justify-center px-4 py-12">
|
||||
<div className="mb-8 text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary shadow-lg">
|
||||
<Bot className="h-8 w-8 text-primary-foreground" />
|
||||
</div>
|
||||
<h1 className="mb-2 text-3xl font-bold text-foreground">Create Bot Account</h1>
|
||||
<p className="text-muted-foreground max-w-xl">
|
||||
Create an automated bot account to provide custom functionality for your community.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-md bg-card rounded-xl p-8 border border-border">
|
||||
<UniversalForm
|
||||
fields={[
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
label: 'Bot Name',
|
||||
placeholder: 'Enter bot name',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'slug',
|
||||
type: 'text',
|
||||
label: 'Bot Slug',
|
||||
placeholder: 'Enter bot slug',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'textarea',
|
||||
label: 'Description',
|
||||
placeholder: 'Enter bot description',
|
||||
},
|
||||
]}
|
||||
schemaName={'createBot'}
|
||||
action={createBot}
|
||||
onActionComplete={(res) => {
|
||||
router.push(`/settings/bot/${res.slug}`);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
<p className="mt-6 text-sm text-muted-foreground text-center max-w-md">
|
||||
Your bot will be created with chat permissions. You can configure advanced settings and
|
||||
permissions after creation.
|
||||
</p>
|
||||
*/}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
apps/web/src/app/(ui)/(protected)/settings/bot/page.tsx
Normal file
88
apps/web/src/app/(ui)/(protected)/settings/bot/page.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { validateRequest } from '@/lib/auth/validate';
|
||||
import { prisma } from '@hctv/db';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import Link from 'next/link';
|
||||
import { Plus, Bot, Calendar, Hash } from 'lucide-react';
|
||||
import { redirect } from 'next/navigation';
|
||||
import Image from 'next/image';
|
||||
|
||||
export default async function Page() {
|
||||
const { user } = await validateRequest();
|
||||
if (!user) {
|
||||
redirect('/');
|
||||
}
|
||||
|
||||
const bots = await prisma.user.findFirst({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
botAccounts: true,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Bot Accounts</h1>
|
||||
<p className="text-muted-foreground">Manage your automated bot accounts</p>
|
||||
</div>
|
||||
<Link href="/settings/bot/create">
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Bot
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{bots?.botAccounts.length ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{bots.botAccounts.map((bot) => (
|
||||
<Link href={`/settings/bot/${bot.slug}`} key={bot.id}>
|
||||
<Card key={bot.id} className="hover:shadow-md transition-shadow">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Image src={bot.pfpUrl} alt={'Bot Avatar'} width={32} height={32} className="rounded-full" />
|
||||
<CardTitle className="text-lg">{bot.displayName}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
|
||||
<Hash className="h-4 w-4" />
|
||||
<span>{bot.slug}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>{new Date(bot.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card className="text-center py-8">
|
||||
<CardContent className="space-y-4">
|
||||
<Bot className="mx-auto h-12 w-12 text-muted-foreground" />
|
||||
<div>
|
||||
<CardTitle>No bot accounts yet</CardTitle>
|
||||
<CardDescription className="mt-2">
|
||||
Get started by creating your first bot account
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Link href="/settings/bot/create">
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Your First Bot
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
Copy,
|
||||
Check,
|
||||
Wrench,
|
||||
Eye,
|
||||
EyeOff,
|
||||
} from 'lucide-react';
|
||||
import { UniversalForm } from '@/components/app/UniversalForm/UniversalForm';
|
||||
import {
|
||||
@@ -50,6 +52,7 @@ import { useOwnedChannels } from '@/lib/hooks/useUserList';
|
||||
import { ChannelSelect } from '@/components/app/ChannelSelect/ChannelSelect';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useConfirm } from '@omit/react-confirm-dialog';
|
||||
|
||||
interface ChannelSettingsClientProps {
|
||||
channel: Channel & {
|
||||
@@ -74,9 +77,13 @@ export default function ChannelSettingsClient({
|
||||
currentUser,
|
||||
isPersonal,
|
||||
}: ChannelSettingsClientProps) {
|
||||
const confirm = useConfirm();
|
||||
const [streamKey, setStreamKey] = useState(channel.streamKey?.key || '');
|
||||
const [keyVisible, setKeyVisible] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [copied, setCopied] = useState({
|
||||
streamKey: false,
|
||||
streamUrl: false,
|
||||
});
|
||||
const [selTab, setSelTab] = useQueryState('tab', parseAsString.withDefault('general'));
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
@@ -98,9 +105,9 @@ export default function ChannelSettingsClient({
|
||||
const copyStreamKey = async () => {
|
||||
if (streamKey) {
|
||||
await navigator.clipboard.writeText(streamKey);
|
||||
setCopied(true);
|
||||
setCopied({ ...copied, streamKey: true });
|
||||
toast.success('Stream key copied to clipboard');
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
setTimeout(() => setCopied({ ...copied, streamKey: false }), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -124,6 +131,24 @@ export default function ChannelSettingsClient({
|
||||
}
|
||||
};
|
||||
|
||||
const generateStreamUrl = () => {
|
||||
if (!streamKey) {
|
||||
toast.error('Stream key not available');
|
||||
return '';
|
||||
}
|
||||
return `srt://${process.env.NEXT_PUBLIC_MEDIAMTX_INGEST_ROUTE}?streamid=publish:${channel.name}:thisusernameislongonpurposesoyoudontaccidentallyleakyourstreamkey:${streamKey}&pkt_size=1316`;
|
||||
};
|
||||
|
||||
const copyStreamUrl = async () => {
|
||||
const url = generateStreamUrl();
|
||||
if (url) {
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopied({ ...copied, streamUrl: true });
|
||||
toast.success('Stream URL copied to clipboard');
|
||||
setTimeout(() => setCopied({ ...copied, streamUrl: false }), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container max-w-4xl mx-auto py-6 px-4">
|
||||
<div className="mb-6 flex">
|
||||
@@ -150,7 +175,7 @@ export default function ChannelSettingsClient({
|
||||
value={channel.name}
|
||||
onSelect={(value) => {
|
||||
if (value === 'create') {
|
||||
router.push(`/create`);
|
||||
router.push(`/settings/channel/create`);
|
||||
} else {
|
||||
router.push(`/settings/channel/${value}?tab=${selTab}`);
|
||||
}
|
||||
@@ -335,36 +360,45 @@ export default function ChannelSettingsClient({
|
||||
onActionComplete={handleChannelSettingsActionComplete}
|
||||
/>
|
||||
|
||||
{false && isOwner && (
|
||||
{isOwner && !isPersonal && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-destructive">Danger Zone</h3>
|
||||
<Card className="border-destructive">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">Delete Channel</CardTitle>
|
||||
<CardDescription>
|
||||
<div className="flex items-center justify-between p-4 border border-destructive/20 rounded-lg bg-destructive/5">
|
||||
<div>
|
||||
<p className="font-medium text-destructive">Delete Channel</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Permanently delete this channel. This action cannot be undone.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm(
|
||||
'Are you sure you want to delete this channel? This action cannot be undone.'
|
||||
)
|
||||
) {
|
||||
deleteChannel(channel.id);
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
if (
|
||||
await confirm({
|
||||
title: 'Delete Channel',
|
||||
description:
|
||||
'Are you sure you want to delete this channel? This action cannot be undone.',
|
||||
confirmText: 'Delete',
|
||||
cancelText: 'Cancel',
|
||||
})
|
||||
) {
|
||||
const result = await deleteChannel(channel.id);
|
||||
if (result.success) {
|
||||
toast.success('Channel deleted successfully');
|
||||
router.push('/settings/channel');
|
||||
} else {
|
||||
toast.error(result.error || 'Failed to delete channel');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete Channel
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -379,51 +413,74 @@ export default function ChannelSettingsClient({
|
||||
<CardDescription>Manage your stream key and streaming configuration.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">Stream Key</h3>
|
||||
<p className="text-sm text-mantle-foreground mb-4">
|
||||
Use this key to start streaming to your channel. Keep it secure!
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
Need help getting started? Check out our{' '}
|
||||
<Link
|
||||
href="https://gist.github.com/SrIzan10/ebd89ced6b21b016d4d389e6711a94e9"
|
||||
className="text-primary hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
streaming guide
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type={keyVisible ? 'text' : 'password'}
|
||||
value={streamKey}
|
||||
readOnly
|
||||
className="w-full px-3 py-2 border rounded-md bg-mantle font-mono text-sm"
|
||||
/>
|
||||
<div>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Stream Key</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type={keyVisible ? 'text' : 'password'}
|
||||
value={streamKey}
|
||||
readOnly
|
||||
className="w-full px-3 py-2 pr-10 border rounded-md bg-mantle font-mono text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setKeyVisible(!keyVisible)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{keyVisible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<Button onClick={regenerateStreamKey} variant="outline" size="smicon">
|
||||
<Key className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="smicon"
|
||||
onClick={copyStreamKey}
|
||||
disabled={!streamKey}
|
||||
>
|
||||
{copied.streamKey ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Stream URL (for OBS)</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={generateStreamUrl()}
|
||||
readOnly
|
||||
className="w-full px-3 py-2 border rounded-md bg-mantle font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="smicon"
|
||||
onClick={copyStreamUrl}
|
||||
disabled={!streamKey}
|
||||
>
|
||||
{copied.streamUrl ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setKeyVisible(!keyVisible)}>
|
||||
{keyVisible ? 'Hide' : 'Show'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={copyStreamKey}
|
||||
disabled={!streamKey}
|
||||
>
|
||||
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={regenerateStreamKey} variant="outline">
|
||||
<Key className="h-4 w-4 mr-2" />
|
||||
Regenerate Stream Key
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Need help getting started? Check out our{' '}
|
||||
<Link
|
||||
href="https://docs.hackclub.tv/guides/start-stream/"
|
||||
className="text-primary hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
streaming guide
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
@@ -536,8 +593,13 @@ export default function ChannelSettingsClient({
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (confirm('Remove this manager?')) {
|
||||
onClick={async () => {
|
||||
if (await confirm({
|
||||
title: 'Remove Manager',
|
||||
description: `Are you sure you want to remove ${personalChannel?.name} as a manager? They will no longer be able to stream or moderate this channel.`,
|
||||
confirmText: 'Remove',
|
||||
cancelText: 'Cancel',
|
||||
})) {
|
||||
removeChannelManager(channel.id, manager.id);
|
||||
}
|
||||
}}
|
||||
@@ -649,7 +711,7 @@ export default function ChannelSettingsClient({
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type={keyVisible ? 'text' : 'password'}
|
||||
value={`https://hctv.srizan.dev/chat/${channel.name}?grant=${channel.obsChatGrantToken}`}
|
||||
value={`https://hackclub.tv/chat/${channel.name}?grant=${channel.obsChatGrantToken}`}
|
||||
readOnly
|
||||
className="w-full px-3 py-2 border rounded-md bg-mantle font-mono text-sm"
|
||||
/>
|
||||
|
||||
@@ -13,7 +13,7 @@ export default async function ChannelSettingsPage({
|
||||
const { user } = await validateRequest();
|
||||
|
||||
if (!user) {
|
||||
redirect('/auth/slack');
|
||||
redirect('/auth/hackclub');
|
||||
}
|
||||
|
||||
const channel = await prisma.channel.findUnique({
|
||||
|
||||
@@ -32,8 +32,7 @@ function CreateChannelPage() {
|
||||
schemaName="createChannel"
|
||||
action={createChannel}
|
||||
onActionComplete={(r) => {
|
||||
// @ts-expect-error
|
||||
const channelName = r?.channel;
|
||||
const channelName = r.channel;
|
||||
if (channelName) {
|
||||
router.push(`/${channelName}`);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { slack, lucia } from '@hctv/auth';
|
||||
import { hackClub, lucia, HCID_TOKEN_URL, HCID_USER_INFO_URL } from '@hctv/auth';
|
||||
import { cookies as nextCookies } from 'next/headers';
|
||||
import { decodeIdToken, OAuth2RequestError } from 'arctic';
|
||||
import { OAuth2RequestError } from 'arctic';
|
||||
import { generateIdFromEntropySize } from 'lucia';
|
||||
import { prisma } from '@hctv/db';
|
||||
import { getRedisConnection } from '@hctv/db';
|
||||
@@ -10,7 +10,7 @@ export async function GET(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const code = url.searchParams.get("code");
|
||||
const state = url.searchParams.get("state");
|
||||
const storedState = cookies.get("slack_oauth_state")?.value ?? null;
|
||||
const storedState = cookies.get("hackclub_oauth_state")?.value ?? null;
|
||||
if (!code || !state || !storedState || state !== storedState) {
|
||||
console.log('invalid state stuff');
|
||||
return new Response(null, {
|
||||
@@ -19,22 +19,38 @@ export async function GET(request: Request): Promise<Response> {
|
||||
}
|
||||
|
||||
try {
|
||||
const tokens = await slack.validateAuthorizationCode(code);
|
||||
const accessToken = tokens.accessToken()
|
||||
const slackUserResponse = await fetch('https://slack.com/api/openid.connect.userInfo', {
|
||||
const tokens = await hackClub.validateAuthorizationCode(HCID_TOKEN_URL, code, null);
|
||||
const accessToken = tokens.accessToken();
|
||||
const userResponse = await fetch(HCID_USER_INFO_URL, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
const slackUser: SlackUserInfo = await slackUserResponse.json();
|
||||
const userResult: HackClubUserResponse = await userResponse.json();
|
||||
const identity = userResult.identity;
|
||||
|
||||
const slackId = identity.slack_id;
|
||||
if (!slackId) {
|
||||
return new Response("Please make sure to have a Slack account before continuing.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
slack_id: slackUser.sub,
|
||||
slack_id: slackId,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
// Update email if it's missing or changed
|
||||
if (existingUser.email !== identity.primary_email) {
|
||||
await prisma.user.update({
|
||||
where: { id: existingUser.id },
|
||||
data: { email: identity.primary_email },
|
||||
});
|
||||
}
|
||||
|
||||
const session = await lucia.createSession(existingUser.id, {});
|
||||
const sessionCookie = lucia.createSessionCookie(session.id);
|
||||
await getRedisConnection().set(`sessions:${session.id}`, '');
|
||||
@@ -52,8 +68,9 @@ export async function GET(request: Request): Promise<Response> {
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: userId,
|
||||
slack_id: slackUser.sub,
|
||||
pfpUrl: `https://cachet.dunkirk.sh/users/${slackUser.sub}/r`,
|
||||
slack_id: slackId,
|
||||
email: identity.primary_email,
|
||||
pfpUrl: identity.slack_id ? `https://cachet.dunkirk.sh/users/${identity.slack_id}/r` : 'https://github.com/hackclub.png',
|
||||
hasOnboarded: false,
|
||||
},
|
||||
});
|
||||
@@ -83,40 +100,15 @@ export async function GET(request: Request): Promise<Response> {
|
||||
}
|
||||
}
|
||||
|
||||
interface SlackUserInfo {
|
||||
// OpenID Connect standard fields
|
||||
ok: boolean;
|
||||
sub: string;
|
||||
email: string;
|
||||
email_verified: boolean;
|
||||
date_email_verified: number;
|
||||
name: string;
|
||||
picture: string;
|
||||
given_name: string;
|
||||
family_name: string;
|
||||
locale: string;
|
||||
|
||||
// Slack-specific fields
|
||||
['https://slack.com/user_id']: string;
|
||||
['https://slack.com/team_id']: string;
|
||||
['https://slack.com/team_name']: string;
|
||||
['https://slack.com/team_domain']: string;
|
||||
|
||||
// User image URLs
|
||||
['https://slack.com/user_image_24']: string;
|
||||
['https://slack.com/user_image_32']: string;
|
||||
['https://slack.com/user_image_48']: string;
|
||||
['https://slack.com/user_image_72']: string;
|
||||
['https://slack.com/user_image_192']: string;
|
||||
['https://slack.com/user_image_512']: string;
|
||||
|
||||
// Team image URLs
|
||||
['https://slack.com/team_image_34']?: string;
|
||||
['https://slack.com/team_image_44']?: string;
|
||||
['https://slack.com/team_image_68']?: string;
|
||||
['https://slack.com/team_image_88']?: string;
|
||||
['https://slack.com/team_image_102']?: string;
|
||||
['https://slack.com/team_image_132']?: string;
|
||||
['https://slack.com/team_image_230']?: string;
|
||||
['https://slack.com/team_image_default']?: boolean;
|
||||
interface HackClubIdentity {
|
||||
id: string;
|
||||
slack_id?: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
primary_email: string;
|
||||
}
|
||||
|
||||
interface HackClubUserResponse {
|
||||
identity: HackClubIdentity;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -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}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
import { cookies } from 'next/headers';
|
||||
import '../globals.css';
|
||||
import Navbar from '@/components/app/NavBar/NavBar';
|
||||
import { SessionProvider } from '@/lib/providers/SessionProvider';
|
||||
@@ -16,6 +17,7 @@ import { extractRouterConfig } from 'uploadthing/server';
|
||||
import { ourFileRouter } from '@/lib/services/uploadthing/fileRouter';
|
||||
import { NuqsAdapter } from 'nuqs/adapters/next/app'
|
||||
import SonnerNewVersion from '@/components/app/SonnerNewVersion/SonnerNewVersion';
|
||||
import ConfirmDialogProvider from '@/lib/providers/ConfirmProvider';
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
|
||||
@@ -30,6 +32,9 @@ export default async function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const sessionData = await validateRequest();
|
||||
const cookieStore = await cookies();
|
||||
const defaultOpen = cookieStore.get('sidebar:state')?.value === 'true';
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={cn('flex flex-col h-screen', inter.className)}>
|
||||
@@ -44,20 +49,26 @@ export default async function RootLayout({
|
||||
<NextSSRPlugin
|
||||
routerConfig={extractRouterConfig(ourFileRouter)}
|
||||
/>
|
||||
<NuqsAdapter>
|
||||
<SidebarProvider>
|
||||
<StreamInfoProvider>
|
||||
{/* this promise is ugly but i'm lazy to fix the type errors */}
|
||||
<Navbar editLivestream={Promise.resolve(<EditLivestream />)} />
|
||||
<div className="flex flex-1 pt-16">
|
||||
{/* pt-16 for navbar height */}
|
||||
<Sidebar className="pt-16" />
|
||||
<main className="flex-1 overflow-auto">{children}</main>
|
||||
</div>
|
||||
<Toaster />
|
||||
</StreamInfoProvider>
|
||||
</SidebarProvider>
|
||||
</NuqsAdapter>
|
||||
<ConfirmDialogProvider defaultOptions={{
|
||||
cancelButton: {
|
||||
variant: 'outline',
|
||||
},
|
||||
}}>
|
||||
<NuqsAdapter>
|
||||
<SidebarProvider defaultOpen={defaultOpen}>
|
||||
<StreamInfoProvider>
|
||||
{/* this promise is ugly but i'm lazy to fix the type errors */}
|
||||
<Navbar editLivestream={Promise.resolve(<EditLivestream />)} />
|
||||
<div className="flex flex-1 pt-16">
|
||||
{/* pt-16 for navbar height */}
|
||||
<Sidebar className="pt-16" />
|
||||
<main className="flex-1 overflow-auto">{children}</main>
|
||||
</div>
|
||||
<Toaster />
|
||||
</StreamInfoProvider>
|
||||
</SidebarProvider>
|
||||
</NuqsAdapter>
|
||||
</ConfirmDialogProvider>
|
||||
</ThemeProvider>
|
||||
</SessionProvider>
|
||||
</body>
|
||||
|
||||
@@ -4,111 +4,125 @@
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 220 23.077% 94.902%;
|
||||
--foreground: 233.793 16.022% 35.49%;
|
||||
/* Light theme - based on your color scheme */
|
||||
|
||||
--muted: 222.857 15.909% 82.745%;
|
||||
--muted-foreground: 233.333 12.796% 41.373%;
|
||||
/* Main background and foreground */
|
||||
--background: 350 59% 98%; /* FDF7F8 - main background */
|
||||
--foreground: 351 34% 30%; /* 5D3A3F - main text */
|
||||
|
||||
--popover: 220 23.077% 94.902%;
|
||||
--popover-foreground: 233.793 16.022% 35.49%;
|
||||
/* Muted elements */
|
||||
--muted: 350 40% 93%; /* F8E8EA - muted background */
|
||||
--muted-foreground: 350 30% 45%; /* Lighter version of main text */
|
||||
|
||||
--card: 220 23.077% 94.902%;
|
||||
--card-foreground: 233.793 16.022% 35.49%;
|
||||
/* Popover and card */
|
||||
--popover: 0 0% 100%; /* FFFFFF - popover background */
|
||||
--popover-foreground: 351 34% 30%; /* 5D3A3F - popover text */
|
||||
--card: 0 0% 100%; /* FFFFFF - card background */
|
||||
--card-foreground: 351 34% 30%; /* 5D3A3F - card text */
|
||||
|
||||
--border: 225 13.559% 76.863%;
|
||||
--input: 225 13.559% 76.863%;
|
||||
/* Border and input */
|
||||
--border: 350 30% 85%; /* Derived border color */
|
||||
--input: 350 30% 85%; /* Input background */
|
||||
|
||||
--primary: 219.907 91.489% 53.922%;
|
||||
--primary-foreground: 220 23.077% 94.902%;
|
||||
/* Primary actions */
|
||||
--primary: 350 70% 50%; /* C8394F - primary button */
|
||||
--primary-foreground: 0 0% 100%; /* FFFFFF - text on primary */
|
||||
|
||||
--secondary: 222.857 15.909% 82.745%;
|
||||
--secondary-foreground: 233.793 16.022% 35.49%;
|
||||
/* Secondary elements */
|
||||
--secondary: 350 40% 93%; /* F8E8EA - secondary background */
|
||||
--secondary-foreground: 351 34% 30%; /* 5D3A3F - text on secondary */
|
||||
|
||||
--accent: 222.857 15.909% 82.745%;
|
||||
--accent-foreground: 233.793 16.022% 35.49%;
|
||||
/* Accent elements */
|
||||
--accent: 350 70% 40%; /* A12D3E - accent color */
|
||||
--accent-foreground: 0 0% 100%; /* FFFFFF - text on accent */
|
||||
|
||||
--destructive: 347.077 86.667% 44.118%;
|
||||
--destructive-foreground: 220 21.951% 91.961%;
|
||||
/* Destructive actions */
|
||||
--destructive: 350 70% 55%; /* D63C56 - error/destroy */
|
||||
--destructive-foreground: 0 0% 100%; /* FFFFFF - text on destructive */
|
||||
|
||||
--ring: 233.793 16.022% 35.49%;
|
||||
/* Focus ring */
|
||||
--ring: 350 70% 50%; /* C8394F - focus ring */
|
||||
|
||||
--surface-1: 225 14% 77%;
|
||||
--surface-2: 227 12% 71%;
|
||||
/* Surface colors */
|
||||
--surface-1: 350 40% 93%; /* F8E8EA - surface 1 */
|
||||
--surface-2: 350 35% 88%; /* Derived surface 2 */
|
||||
|
||||
--mantle: 220 22% 92%;
|
||||
/* Mantle */
|
||||
--mantle: 350 59% 98%; /* FDF7F8 - mantle */
|
||||
|
||||
/* Radius */
|
||||
--radius: 0.5rem;
|
||||
|
||||
--sidebar-background: 0 0% 98%;
|
||||
|
||||
--sidebar-foreground: 240 5.3% 26.1%;
|
||||
|
||||
--sidebar-primary: 240 5.9% 10%;
|
||||
|
||||
--sidebar-primary-foreground: 0 0% 98%;
|
||||
|
||||
--sidebar-accent: 240 4.8% 95.9%;
|
||||
|
||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||
|
||||
--sidebar-border: 220 13% 91%;
|
||||
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
/* Sidebar specific */
|
||||
--sidebar-background: 350 59% 98%; /* FDF7F8 - sidebar bg */
|
||||
--sidebar-foreground: 351 34% 30%; /* 5D3A3F - sidebar text */
|
||||
--sidebar-primary: 350 70% 50%; /* C8394F - sidebar primary */
|
||||
--sidebar-primary-foreground: 0 0% 100%; /* FFFFFF - text on sidebar primary */
|
||||
--sidebar-accent: 350 40% 93%; /* F8E8EA - sidebar accent */
|
||||
--sidebar-accent-foreground: 351 34% 30%; /* 5D3A3F - text on sidebar accent */
|
||||
--sidebar-border: 350 30% 85%; /* Derived border */
|
||||
--sidebar-ring: 350 70% 50%; /* C8394F - sidebar focus ring */
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 240 21.053% 14.902%;
|
||||
--foreground: 226.154 63.934% 88.039%;
|
||||
/* Dark theme - based on your color scheme */
|
||||
|
||||
--muted: 240 12% 19%;
|
||||
--muted-foreground: 240 12% 69%;
|
||||
/* Main background and foreground */
|
||||
--background: 350 20% 15%; /* 2A1F21 - main background */
|
||||
--foreground: 350 30% 92%; /* F5E6E8 - main text */
|
||||
|
||||
--popover: 240 21.053% 14.902%;
|
||||
--popover-foreground: 226.154 63.934% 88.039%;
|
||||
/* Muted elements */
|
||||
--muted: 350 20% 25%; /* 4A2D31 - muted background */
|
||||
--muted-foreground: 350 30% 75%; /* Lighter version of main text */
|
||||
|
||||
--card: 240 21.053% 14.902%;
|
||||
--card-foreground: 226.154 63.934% 88.039%;
|
||||
/* Popover and card */
|
||||
--popover: 350 20% 15%; /* 2A1F21 - popover background */
|
||||
--popover-foreground: 350 30% 92%; /* F5E6E8 - popover text */
|
||||
--card: 350 20% 15%; /* 2A1F21 - card background */
|
||||
--card-foreground: 350 30% 92%; /* F5E6E8 - card text */
|
||||
|
||||
--border: 234.286 13.208% 31.176%;
|
||||
--input: 234.286 13.208% 31.176%;
|
||||
/* Border and input */
|
||||
--border: 350 20% 35%; /* Derived border color */
|
||||
--input: 350 20% 35%; /* Input background */
|
||||
|
||||
--primary: 267 84% 81%;
|
||||
--primary-foreground: 267 84% 21%;
|
||||
/* Primary actions */
|
||||
--primary: 350 100% 75%; /* FF7A8A - primary button */
|
||||
--primary-foreground: 350 20% 15%; /* 2A1F21 - text on primary */
|
||||
|
||||
--secondary: 236.842 16.239% 22.941%;
|
||||
--secondary-foreground: 226.154 63.934% 88.039%;
|
||||
/* Secondary elements */
|
||||
--secondary: 350 20% 25%; /* 4A2D31 - secondary background */
|
||||
--secondary-foreground: 350 30% 92%; /* F5E6E8 - text on secondary */
|
||||
|
||||
--accent: 236.842 16.239% 22.941%;
|
||||
--accent-foreground: 226.154 63.934% 88.039%;
|
||||
/* Accent elements */
|
||||
--accent: 350 100% 80%; /* FF9AAA - accent color */
|
||||
--accent-foreground: 350 20% 15%; /* 2A1F21 - text on accent */
|
||||
|
||||
--destructive: 343.269 81.25% 74.902%;
|
||||
--destructive-foreground: 240 21.311% 11.961%;
|
||||
/* Destructive actions */
|
||||
--destructive: 350 100% 70%; /* FF6B7D - error/destroy */
|
||||
--destructive-foreground: 350 20% 15%; /* 2A1F21 - text on destructive */
|
||||
|
||||
--ring: 226.154 63.934% 88.039%;
|
||||
/* Focus ring */
|
||||
--ring: 350 100% 75%; /* FF7A8A - focus ring */
|
||||
|
||||
--surface-1: 234 13% 31%;
|
||||
--surface-2: 233 12% 39%;
|
||||
/* Surface colors */
|
||||
--surface-1: 350 20% 25%; /* 4A2D31 - surface 1 */
|
||||
--surface-2: 350 20% 35%; /* Derived surface 2 */
|
||||
|
||||
--mantle: 240 21.311% 11.961%;
|
||||
/* Mantle */
|
||||
--mantle: 350 20% 12%; /* 1F1617 - mantle */
|
||||
|
||||
/* Radius */
|
||||
--radius: 0.5rem;
|
||||
|
||||
--sidebar-background: 240 21.311% 11.961%; /* crust - matches mantle var */
|
||||
|
||||
--sidebar-foreground: 226.154 63.934% 88.039%; /* matches main foreground */
|
||||
|
||||
--sidebar-primary: 217.168 91.87% 75.882%; /* matches primary */
|
||||
|
||||
--sidebar-primary-foreground: 240 21.053% 14.902%; /* matches primary-foreground */
|
||||
|
||||
--sidebar-accent: 236.842 16.239% 22.941%; /* matches accent */
|
||||
|
||||
--sidebar-accent-foreground: 226.154 63.934% 88.039%; /* matches accent-foreground */
|
||||
|
||||
--sidebar-border: 234.286 13.208% 31.176%; /* matches border */
|
||||
|
||||
--sidebar-ring: 217.168 91.87% 75.882%; /* matches primary */
|
||||
/* Sidebar specific */
|
||||
--sidebar-background: 350 20% 12%; /* 1F1617 - sidebar bg */
|
||||
--sidebar-foreground: 350 30% 92%; /* F5E6E8 - sidebar text */
|
||||
--sidebar-primary: 350 100% 75%; /* FF7A8A - sidebar primary */
|
||||
--sidebar-primary-foreground: 350 20% 15%; /* 2A1F21 - text on sidebar primary */
|
||||
--sidebar-accent: 350 20% 25%; /* 4A2D31 - sidebar accent */
|
||||
--sidebar-accent-foreground: 350 30% 92%; /* F5E6E8 - text on sidebar accent */
|
||||
--sidebar-border: 350 20% 35%; /* Derived border */
|
||||
--sidebar-ring: 350 100% 75%; /* FF7A8A - sidebar focus ring */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,16 +133,20 @@
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar { display: none; }
|
||||
.scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }
|
||||
}
|
||||
|
||||
h1 {
|
||||
@apply scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl;
|
||||
}
|
||||
|
||||
h2 {
|
||||
@apply scroll-m-20 pb-2 text-3xl font-semibold tracking-tight first:mt-0;
|
||||
}
|
||||
|
||||
/* Media controller styles remain unchanged */
|
||||
media-controller {
|
||||
--media-primary-color: #ffffff;
|
||||
--media-secondary-color: hsla(var(--background), 0.85);
|
||||
@@ -160,7 +178,7 @@ media-time-range {
|
||||
}
|
||||
|
||||
media-time-display {
|
||||
--media-text-color: #ffffff;
|
||||
--media-text-color: #ffffff;
|
||||
}
|
||||
|
||||
media-controller::part(centered-layer) {
|
||||
|
||||
@@ -313,6 +313,8 @@ export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
pfpUrl: string;
|
||||
isBot: boolean;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { User } from './ChatPanel';
|
||||
import React from 'react';
|
||||
import Image from 'next/image';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Bot } from 'lucide-react';
|
||||
|
||||
export function Message({ user, message, type, emojiMap }: MessageProps) {
|
||||
if (type === 'systemMsg') {
|
||||
@@ -18,12 +15,18 @@ export function Message({ user, message, type, emojiMap }: MessageProps) {
|
||||
|
||||
return (
|
||||
<div className="flex">
|
||||
<div
|
||||
lang="en"
|
||||
className="max-w-full break-all whitespace-pre-wrap hyphens-auto"
|
||||
>
|
||||
<p>
|
||||
<span className="font-bold mr-2">{user?.username}</span>
|
||||
<div lang="en" className="max-w-full break-all whitespace-pre-wrap hyphens-auto">
|
||||
<p className="flex flex-wrap items-center">
|
||||
<span className="font-bold mr-2 flex items-center">
|
||||
{user?.isBot && (
|
||||
<span className="text-xs text-muted-foreground flex mr-1">
|
||||
{' '}
|
||||
<Bot className="size-5" />
|
||||
</span>
|
||||
)}
|
||||
{user?.displayName || user?.username}
|
||||
</span>
|
||||
|
||||
<EmojiRenderer text={message} emojiMap={emojiMap} />
|
||||
</p>
|
||||
</div>
|
||||
@@ -45,16 +48,26 @@ export function EmojiRenderer({ text, emojiMap }: EmojiRendererProps) {
|
||||
|
||||
if (emojiUrl) {
|
||||
return (
|
||||
<Tooltip key={index} delayDuration={250}>
|
||||
<TooltipTrigger>
|
||||
<span key={index} className="inline-block align-middle" style={{ height: '1.2em' }}>
|
||||
<Image src={emojiUrl} alt={part} width={20} height={20} className="inline-block" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{part}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<TooltipProvider key={index}>
|
||||
<Tooltip delayDuration={250}>
|
||||
<TooltipTrigger>
|
||||
<span
|
||||
key={index}
|
||||
className="inline-block align-middle"
|
||||
style={{ height: '1.2em' }}
|
||||
>
|
||||
<Image
|
||||
src={emojiUrl}
|
||||
alt={part}
|
||||
width={20}
|
||||
height={20}
|
||||
className="inline-block"
|
||||
/>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{part}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import StreamPlayer from '../StreamPlayer/StreamPlayer';
|
||||
import UserInfoCard from '../UserInfoCard/UserInfoCard';
|
||||
import ChatPanel from '../ChatPanel/ChatPanel';
|
||||
import type { StreamInfo, User, Channel } from '@hctv/db';
|
||||
import type { StreamInfo, Channel } from '@hctv/db';
|
||||
import { useIsMobile } from '@/lib/hooks/useMobile';
|
||||
|
||||
export default function LiveStream(props: Props) {
|
||||
|
||||
@@ -15,7 +15,7 @@ import { logout } from '@/lib/auth/actions';
|
||||
import { useSession } from '@/lib/providers/SessionProvider';
|
||||
import Link from 'next/link';
|
||||
import { ThemeSwitcher } from '../ThemeSwitcher/ThemeSwitcher';
|
||||
import { Slack } from 'lucide-react';
|
||||
import { IdCard, Slack } from 'lucide-react';
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||
|
||||
export default function Navbar(props: Props) {
|
||||
@@ -51,9 +51,22 @@ export default function Navbar(props: Props) {
|
||||
<Link href={`/settings/follows`}>
|
||||
<DropdownMenuItem className="cursor-pointer">Follows</DropdownMenuItem>
|
||||
</Link>
|
||||
<Link href={`/create`}>
|
||||
<Link href={`/settings/channel/create`}>
|
||||
<DropdownMenuItem className="cursor-pointer">Create channel</DropdownMenuItem>
|
||||
</Link>
|
||||
<Link href={`/settings/bot`}>
|
||||
<DropdownMenuItem className="cursor-pointer">Bot accounts</DropdownMenuItem>
|
||||
</Link>
|
||||
<DropdownMenuSeparator />
|
||||
<Link href={'https://docs.hackclub.tv'} target="_blank" rel="noreferrer">
|
||||
<DropdownMenuItem className="cursor-pointer">API Docs</DropdownMenuItem>
|
||||
</Link>
|
||||
<Link href={'https://github.com/SrIzan10/hctv'} target="_blank" rel="noreferrer">
|
||||
<DropdownMenuItem className="cursor-pointer">Github</DropdownMenuItem>
|
||||
</Link>
|
||||
<Link href={'https://github.com/sponsors/SrIzan10'} target="_blank" rel="noreferrer">
|
||||
<DropdownMenuItem className="cursor-pointer">Sponsor</DropdownMenuItem>
|
||||
</Link>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
@@ -84,9 +97,9 @@ export default function Navbar(props: Props) {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<Link href="/auth/slack">
|
||||
<Link href="/auth/hackclub">
|
||||
<Button variant="outline" size="sm" className="gap-1 md:gap-2 text-xs md:text-sm">
|
||||
<Slack className="w-3 h-3 md:w-4 md:h-4" />
|
||||
<IdCard className="w-3 h-3 md:w-4 md:h-4" />
|
||||
<span className="hidden sm:inline">Sign in</span>
|
||||
<span className="sm:hidden">Login</span>
|
||||
</Button>
|
||||
|
||||
@@ -13,124 +13,153 @@ import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { StreamInfoResponse, useStreams } from '@/lib/providers/StreamInfoProvider';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useAllChannels } from '@/lib/hooks/useUserList';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
|
||||
export default function Sidebar({ ...props }: React.ComponentProps<typeof UISidebar>) {
|
||||
const { channels: stream, isLoading } = useAllChannels(5000);
|
||||
const [followedExpanded, setFollowedExpanded] = React.useState(true);
|
||||
const { state } = useSidebar();
|
||||
const isCollapsed = state === 'collapsed';
|
||||
|
||||
if (isLoading) return <SidebarSkeleton />;
|
||||
if (isLoading) return <SidebarSkeleton {...props} />;
|
||||
|
||||
const liveStreamers = stream?.filter((s) => s.isLive) || [];
|
||||
const offlineStreamers = stream?.filter((s) => !s.isLive) || [];
|
||||
|
||||
return (
|
||||
<UISidebar {...props}>
|
||||
<UISidebar collapsible="icon" {...props}>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel asChild>
|
||||
<button
|
||||
onClick={() => setFollowedExpanded(!followedExpanded)}
|
||||
className="w-full flex items-center justify-between"
|
||||
>
|
||||
<span>Live Channels</span>
|
||||
{followedExpanded ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
<SidebarGroupLabel className="flex items-center justify-between px-2 py-1.5">
|
||||
<span className="text-xs font-semibold uppercase text-muted-foreground group-data-[collapsible=icon]:opacity-0 transition-opacity duration-200">
|
||||
Live Channels
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground group-data-[collapsible=icon]:opacity-0 transition-opacity duration-200">
|
||||
{liveStreamers.length}
|
||||
</span>
|
||||
</SidebarGroupLabel>
|
||||
|
||||
{followedExpanded && (
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{liveStreamers.map((streamer) => (
|
||||
<StreamerItem key={streamer.id} streamer={streamer} />
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
)}
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{liveStreamers.length === 0 && !isCollapsed && (
|
||||
<div className="px-4 py-2 text-sm text-muted-foreground">
|
||||
No channels live
|
||||
</div>
|
||||
)}
|
||||
{liveStreamers.map((streamer) => (
|
||||
<StreamerItem key={streamer.id} streamer={streamer} isCollapsed={isCollapsed} />
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
{offlineStreamers.length > 0 && (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Offline Channels</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{offlineStreamers.map((streamer) => (
|
||||
<StreamerItem key={streamer.id} streamer={streamer} />
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)}
|
||||
<Separator className="group-data-[collapsible=icon]:block hidden" />
|
||||
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel className="flex items-center justify-between px-2 py-1.5">
|
||||
<span className="text-xs font-semibold uppercase text-muted-foreground group-data-[collapsible=icon]:opacity-0 transition-opacity duration-200">
|
||||
Offline Channels
|
||||
</span>
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{offlineStreamers.map((streamer) => (
|
||||
<StreamerItem key={streamer.id} streamer={streamer} isCollapsed={isCollapsed} />
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
</UISidebar>
|
||||
);
|
||||
}
|
||||
|
||||
function StreamerItem({ streamer }: { streamer: StreamInfoResponse[0] }) {
|
||||
function StreamerItem({ streamer, isCollapsed }: { streamer: StreamInfoResponse[0], isCollapsed: boolean }) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<SidebarMenuItem key={streamer.id} className={streamer.isLive ? '' : '*:text-muted-foreground'}>
|
||||
<SidebarMenuButton className="flex items-center gap-3 h-full" onClick={() => {
|
||||
router.push(`/${streamer.username}`);
|
||||
}}>
|
||||
<div className="relative">
|
||||
<Avatar className="h-9 w-9">
|
||||
<AvatarImage src={streamer.channel.pfpUrl} alt={streamer.username} />
|
||||
<AvatarFallback>{streamer.username}</AvatarFallback>
|
||||
</Avatar>
|
||||
{streamer.isLive && (
|
||||
<span className="absolute -top-1 -right-1 w-3 h-3 bg-primary rounded-full border-2 border-black" />
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
tooltip={streamer.username}
|
||||
className="h-12"
|
||||
onClick={() => router.push(`/${streamer.username}`)}
|
||||
>
|
||||
<button className="flex w-full items-center gap-3">
|
||||
<div className="relative flex-shrink-0">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={streamer.channel.pfpUrl} alt={streamer.username} className="object-cover" />
|
||||
<AvatarFallback>{streamer.username[0]?.toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
{streamer.isLive && (
|
||||
<span className="absolute -bottom-0.5 -right-0.5 flex h-3 w-3 items-center justify-center rounded-full bg-background ring-2 ring-background">
|
||||
<span className="h-2 w-2 rounded-full bg-red-500 animate-pulse" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isCollapsed && (
|
||||
<div className="flex flex-1 flex-col items-start overflow-hidden">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span className="truncate font-medium text-sm leading-none">
|
||||
{streamer.username}
|
||||
</span>
|
||||
{streamer.isLive && (
|
||||
<div className="flex items-center gap-1 text-xs text-red-500">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-red-500" />
|
||||
<span>{streamer.viewers}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="truncate text-xs text-muted-foreground w-full text-left">
|
||||
{streamer.isLive ? streamer.title || streamer.category || 'Live' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium truncate">{streamer.username}</p>
|
||||
<p className="text-sm truncate">{streamer.category}</p>
|
||||
{streamer.isLive && (
|
||||
<p className="text-sm">
|
||||
{streamer.viewers} viewer{streamer.viewers === 1 ? '' : 's'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarSkeleton({ ...props }: React.ComponentProps<typeof UISidebar>) {
|
||||
const { state } = useSidebar();
|
||||
const isCollapsed = state === 'collapsed';
|
||||
|
||||
return (
|
||||
<UISidebar {...props}>
|
||||
<UISidebar collapsible="icon" {...props}>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel asChild>
|
||||
<button className="w-full flex items-center justify-between">
|
||||
<span>Live Channels</span>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</button>
|
||||
<SidebarGroupLabel className="px-2 py-1.5">
|
||||
<Skeleton className="h-4 w-24 group-data-[collapsible=icon]:opacity-0 transition-opacity duration-200" />
|
||||
</SidebarGroupLabel>
|
||||
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{Array(3).fill(0).map((_, i) => (
|
||||
<StreamerItemSkeleton key={i} />
|
||||
<StreamerItemSkeleton key={i} isCollapsed={isCollapsed} />
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
<Separator className="group-data-[collapsible=icon]:block hidden" />
|
||||
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Offline Channels</SidebarGroupLabel>
|
||||
<SidebarGroupLabel className="px-2 py-1.5">
|
||||
<Skeleton className="h-4 w-24 group-data-[collapsible=icon]:opacity-0 transition-opacity duration-200" />
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{Array(5).fill(0).map((_, i) => (
|
||||
<StreamerItemSkeleton key={i} />
|
||||
<StreamerItemSkeleton key={i} isCollapsed={isCollapsed} />
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
@@ -140,16 +169,18 @@ function SidebarSkeleton({ ...props }: React.ComponentProps<typeof UISidebar>) {
|
||||
);
|
||||
}
|
||||
|
||||
function StreamerItemSkeleton() {
|
||||
function StreamerItemSkeleton({ isCollapsed }: { isCollapsed: boolean }) {
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton className="flex items-center gap-3 h-full">
|
||||
<div className="relative">
|
||||
<Skeleton className="h-9 w-9 rounded-full" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<SidebarMenuButton className="h-12">
|
||||
<div className="flex w-full items-center gap-3">
|
||||
<Skeleton className="h-8 w-8 rounded-full flex-shrink-0" />
|
||||
{!isCollapsed && (
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Skeleton className="h-3.5 w-24" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useRef, useEffect } from 'react';
|
||||
import {
|
||||
MediaController,
|
||||
MediaLoadingIndicator,
|
||||
@@ -13,45 +14,47 @@ import {
|
||||
MediaFullscreenButton,
|
||||
} from 'media-chrome/react';
|
||||
import HlsVideo from 'hls-video-element/react';
|
||||
import { useSession } from '@/lib/providers/SessionProvider';
|
||||
|
||||
export default function StreamPlayer() {
|
||||
const { username } = useParams();
|
||||
const { session } = useSession();
|
||||
const videoRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (video && username && session) {
|
||||
const user = 'skibiditoilet';
|
||||
const credentials = btoa(`${user}:${session.id}`);
|
||||
|
||||
// @ts-ignore
|
||||
video.config = {
|
||||
xhrSetup: (xhr: XMLHttpRequest) => {
|
||||
xhr.setRequestHeader('Authorization', `Basic ${credentials}`);
|
||||
},
|
||||
lowLatencyMode: true,
|
||||
debug: process.env.NODE_ENV === 'development',
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
video.src = `${process.env.NEXT_PUBLIC_MEDIAMTX_URL}/${username}/index.m3u8`;
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (video) {
|
||||
// @ts-ignore
|
||||
video.src = '';
|
||||
}
|
||||
};
|
||||
}, [username, session]);
|
||||
|
||||
return (
|
||||
<MediaController className="w-full aspect-video">
|
||||
<HlsVideo
|
||||
src={`/api/rtmp/hls/${username}.m3u8`}
|
||||
ref={videoRef}
|
||||
slot="media"
|
||||
crossOrigin="anonymous"
|
||||
autoplay
|
||||
config={{
|
||||
lowLatencyMode: true,
|
||||
liveSyncDurationCount: 1,
|
||||
liveMaxLatencyDurationCount: 2,
|
||||
liveDurationInfinity: true,
|
||||
enableWorker: true,
|
||||
backBufferLength: 1,
|
||||
startLevel: -1,
|
||||
maxBufferLength: 2,
|
||||
maxMaxBufferLength: 4,
|
||||
startFragPrefetch: true,
|
||||
testBandwidth: false,
|
||||
progressive: false,
|
||||
maxBufferSize: 10 * 1000 * 1000,
|
||||
maxBufferHole: 0.1,
|
||||
highBufferWatchdogPeriod: 0.5,
|
||||
nudgeOffset: 0.01,
|
||||
nudgeMaxRetry: 3,
|
||||
manifestLoadingTimeOut: 3000,
|
||||
manifestLoadingMaxRetry: 3,
|
||||
levelLoadingTimeOut: 3000,
|
||||
fragLoadingTimeOut: 5000,
|
||||
debug: process.env.NODE_ENV === 'development',
|
||||
liveSyncDuration: 1,
|
||||
liveMaxLatencyDuration: 3,
|
||||
maxLiveSyncPlaybackRate: 1.5,
|
||||
liveBackBufferLength: 0,
|
||||
}}
|
||||
/>
|
||||
<MediaLoadingIndicator slot="centered-chrome" noAutohide />
|
||||
<MediaControlBar className="w-full px-2">
|
||||
|
||||
@@ -19,13 +19,18 @@ import React from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { createChannelSchema, onboardSchema, streamInfoEditSchema, updateChannelSettingsSchema } from '@/lib/form/zod';
|
||||
import {
|
||||
createBotSchema,
|
||||
createChannelSchema, editBotSchema, onboardSchema, streamInfoEditSchema, updateChannelSettingsSchema
|
||||
} from '@/lib/form/zod';
|
||||
|
||||
export const schemaDb = [
|
||||
{ name: 'streamInfoEdit', zod: streamInfoEditSchema },
|
||||
{ name: 'onboard', zod: onboardSchema },
|
||||
{ name: 'createChannel', zod: createChannelSchema },
|
||||
{ name: 'updateChannelSettings', zod: updateChannelSettingsSchema },
|
||||
{ name: 'createBot', zod: createBotSchema },
|
||||
{ name: 'editBot', zod: editBotSchema }
|
||||
] as const;
|
||||
|
||||
export function UniversalForm<T extends z.ZodType>({
|
||||
|
||||
@@ -14,13 +14,14 @@ export type FormFieldConfig = {
|
||||
textAreaRows?: number;
|
||||
component?: (props: { field: ControllerRenderProps<any, any> } & any) => React.ReactNode;
|
||||
componentProps?: Record<string, any>;
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
export type UniversalFormProps<T extends z.ZodType> = {
|
||||
fields: FormFieldConfig[];
|
||||
schemaName: (typeof schemaDb)[number]['name'];
|
||||
action: (prev: any, formData: FormData) => void;
|
||||
onActionComplete?: (result: unknown) => void;
|
||||
onActionComplete?: (result: any) => void;
|
||||
defaultValues?: Partial<z.infer<T>>;
|
||||
submitText?: string;
|
||||
submitClassname?: string;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -37,7 +37,7 @@ export function Mention({ children, handle }: Props) {
|
||||
}
|
||||
|
||||
const fallback = handle.substring(0, 2).toUpperCase();
|
||||
const url = `https://hctv.srizan.dev/${handle}`;
|
||||
const url = `https://hackclub.tv/${handle}`;
|
||||
|
||||
return (
|
||||
<HoverCard>
|
||||
|
||||
@@ -23,7 +23,7 @@ const SIDEBAR_COOKIE_NAME = "sidebar:state"
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||
const SIDEBAR_WIDTH = "16rem"
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||
const SIDEBAR_WIDTH_ICON = "3rem"
|
||||
const SIDEBAR_WIDTH_ICON = "4rem"
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||
|
||||
type SidebarContext = {
|
||||
@@ -512,7 +512,7 @@ const SidebarMenuItem = React.forwardRef<
|
||||
SidebarMenuItem.displayName = "SidebarMenuItem"
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-12 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -14,6 +14,7 @@ Sentry.init({
|
||||
|
||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||
debug: false,
|
||||
enabled: process.env.NODE_ENV === 'production'
|
||||
});
|
||||
|
||||
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
|
||||
@@ -1,7 +1,12 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
export const onRequestError = Sentry.captureRequestError;
|
||||
|
||||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
||||
await (await import('@/lib/instrumentation/streamInfo')).default();
|
||||
await (await import('@/lib/instrumentation/writeSessions')).default();
|
||||
await (await import('@/lib/instrumentation/syncStreamKeys')).default();
|
||||
}
|
||||
|
||||
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
||||
@@ -40,4 +45,21 @@ export async function register() {
|
||||
await viewerCountSync();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
process.env.NODE_ENV === 'production' && Sentry.init({
|
||||
dsn: "https://f3c26671c39af48406c6e23702a4f3dd@o4506961023860736.ingest.us.sentry.io/4509895816773632",
|
||||
|
||||
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
|
||||
tracesSampleRate: 1,
|
||||
|
||||
// Enable logs to be sent to Sentry
|
||||
enableLogs: true,
|
||||
|
||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||
debug: false,
|
||||
|
||||
integrations: [
|
||||
Sentry.extraErrorDataIntegration(),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { prisma } from '@hctv/db';
|
||||
import {Prisma, prisma} from '@hctv/db';
|
||||
import {validateRequest} from "@/lib/auth/validate";
|
||||
|
||||
export async function resolveChannelNameId(channelName: string) {
|
||||
const channel = await prisma.channel.findUnique({
|
||||
@@ -28,4 +29,14 @@ export async function resolveUserPersonalChannel(userId: string) {
|
||||
}
|
||||
|
||||
return channel;
|
||||
}
|
||||
|
||||
export async function getBotBySlug(slug: string) {
|
||||
const bot = await prisma.botAccount.findFirst({
|
||||
where: {
|
||||
slug,
|
||||
},
|
||||
});
|
||||
|
||||
return bot;
|
||||
}
|
||||
35
apps/web/src/lib/db/streamKey.ts
Normal file
35
apps/web/src/lib/db/streamKey.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { prisma, getRedisConnection } from '@hctv/db';
|
||||
|
||||
export async function generateStreamKey(channelId: string, channelName: string) {
|
||||
const streamKey = await prisma.streamKey.create({
|
||||
data: {
|
||||
key: crypto.randomUUID(),
|
||||
channelId,
|
||||
},
|
||||
});
|
||||
|
||||
const redis = getRedisConnection();
|
||||
await redis.set(`streamKey:${channelName}`, streamKey.key);
|
||||
|
||||
return streamKey;
|
||||
}
|
||||
|
||||
export async function regenerateStreamKey(channelId: string, channelName: string) {
|
||||
const streamKey = await prisma.streamKey.upsert({
|
||||
create: {
|
||||
key: crypto.randomUUID(),
|
||||
channelId,
|
||||
},
|
||||
update: {
|
||||
key: crypto.randomUUID(),
|
||||
},
|
||||
where: {
|
||||
channelId,
|
||||
},
|
||||
});
|
||||
|
||||
const redis = getRedisConnection();
|
||||
await redis.set(`streamKey:${channelName}`, streamKey.key);
|
||||
|
||||
return streamKey;
|
||||
}
|
||||
@@ -4,10 +4,22 @@ import { revalidatePath } from 'next/cache';
|
||||
import { validateRequest } from '@/lib/auth/validate';
|
||||
import { prisma } from '@hctv/db';
|
||||
import zodVerify from '../zodVerify';
|
||||
import { createChannelSchema, onboardSchema, streamInfoEditSchema, updateChannelSettingsSchema } from './zod';
|
||||
import {
|
||||
createBotSchema,
|
||||
createChannelSchema,
|
||||
editBotSchema,
|
||||
onboardSchema,
|
||||
streamInfoEditSchema,
|
||||
updateChannelSettingsSchema,
|
||||
} from './zod';
|
||||
import { initializeStreamInfo } from '../instrumentation/streamInfo';
|
||||
import { resolveFollowedChannels, resolveStreamInfo, resolveUserFromPersonalChannelName } from '../auth/resolve';
|
||||
import {
|
||||
resolveFollowedChannels,
|
||||
resolveStreamInfo,
|
||||
resolveUserFromPersonalChannelName,
|
||||
} from '../auth/resolve';
|
||||
import { genIdenticonUpload } from '../utils/genIdenticonUpload';
|
||||
import { generateStreamKey } from '../db/streamKey';
|
||||
|
||||
export async function editStreamInfo(prev: any, formData: FormData) {
|
||||
const { user } = await validateRequest();
|
||||
@@ -78,8 +90,8 @@ export async function onboard(prev: any, formData: FormData) {
|
||||
ownerId: user.id,
|
||||
personalFor: { connect: { id: user.id } },
|
||||
pfpUrl: user.pfpUrl,
|
||||
}
|
||||
})
|
||||
},
|
||||
});
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
@@ -91,13 +103,15 @@ export async function onboard(prev: any, formData: FormData) {
|
||||
});
|
||||
await initializeStreamInfo(createdChannel.id);
|
||||
|
||||
await generateStreamKey(createdChannel.id, createdChannel.name);
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
await fetch(process.env.WELCOME_WORKFLOW_URL!, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
username: zod.data.username,
|
||||
}),
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
@@ -150,11 +164,13 @@ export async function createChannel(prev: any, formData: FormData) {
|
||||
name: zod.data.name,
|
||||
ownerId: user.id,
|
||||
pfpUrl: identicon,
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
await initializeStreamInfo(createdChannel.id);
|
||||
|
||||
await generateStreamKey(createdChannel.id, createdChannel.name);
|
||||
|
||||
return { success: true, channel: createdChannel.name };
|
||||
}
|
||||
|
||||
@@ -163,9 +179,10 @@ export async function updateChannelSettings(prev: any, formData: FormData) {
|
||||
if (!user) {
|
||||
return { success: false, error: 'Unauthorized' };
|
||||
}
|
||||
|
||||
|
||||
const zod = await zodVerify(updateChannelSettingsSchema, formData);
|
||||
const urlRegex = /(?:http[s]?:\/\/.)?(?:www\.)?[-a-zA-Z0-9@%._\+~#=]{2,256}\.[a-z]{2,6}\b(?:[-a-zA-Z0-9@:%_\+.~#?&\/\/=]*)/gm;
|
||||
const urlRegex =
|
||||
/(?:http[s]?:\/\/.)?(?:www\.)?[-a-zA-Z0-9@%._\+~#=]{2,256}\.[a-z]{2,6}\b(?:[-a-zA-Z0-9@:%_\+.~#?&\/\/=]*)/gm;
|
||||
if (!zod.success) {
|
||||
return zod;
|
||||
}
|
||||
@@ -186,7 +203,7 @@ export async function updateChannelSettings(prev: any, formData: FormData) {
|
||||
}
|
||||
|
||||
const isOwner = channel.ownerId === user.id;
|
||||
const isManager = channel.managers.some(manager => manager.id === user.id);
|
||||
const isManager = channel.managers.some((manager) => manager.id === user.id);
|
||||
|
||||
if (!isOwner && !isManager) {
|
||||
return { success: false, error: 'Unauthorized' };
|
||||
@@ -230,7 +247,7 @@ export async function addChannelManager(channelId: string, userChannel: string)
|
||||
}
|
||||
|
||||
if (channel.ownerId === userChannel) {
|
||||
return { success: false, error: 'Owner can\'t add themselves as managers' };
|
||||
return { success: false, error: "Owner can't add themselves as managers" };
|
||||
}
|
||||
|
||||
const userDb = await resolveUserFromPersonalChannelName(userChannel);
|
||||
@@ -312,8 +329,8 @@ export async function toggleGlobalChannelNotifs(channelId: string) {
|
||||
},
|
||||
data: {
|
||||
enableNotifications: !streamInfo.enableNotifications,
|
||||
}
|
||||
})
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath(`/settings/channel/${channel.name}`);
|
||||
|
||||
@@ -321,15 +338,14 @@ export async function toggleGlobalChannelNotifs(channelId: string) {
|
||||
}
|
||||
|
||||
export async function deleteChannel(channelId: string) {
|
||||
return { success: false, error: 'disabled atm. dm @eth0 if you want to request a deletion.' }
|
||||
/* const { user } = await validateRequest();
|
||||
const { user } = await validateRequest();
|
||||
if (!user) {
|
||||
return { success: false, error: 'Unauthorized' };
|
||||
}
|
||||
|
||||
const channel = await prisma.channel.findUnique({
|
||||
where: { id: channelId },
|
||||
include: {
|
||||
include: {
|
||||
owner: true,
|
||||
personalFor: true,
|
||||
},
|
||||
@@ -343,7 +359,6 @@ export async function deleteChannel(channelId: string) {
|
||||
return { success: false, error: 'Only channel owners can delete channels' };
|
||||
}
|
||||
|
||||
// Prevent deletion of personal channels
|
||||
if (channel.personalFor) {
|
||||
return { success: false, error: 'Cannot delete personal channels' };
|
||||
}
|
||||
@@ -352,5 +367,77 @@ export async function deleteChannel(channelId: string) {
|
||||
where: { id: channelId },
|
||||
});
|
||||
|
||||
return { success: true }; */
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function createBot(prev: any, formData: FormData) {
|
||||
const { user } = await validateRequest();
|
||||
if (!user) {
|
||||
return { success: false, error: 'Unauthorized' };
|
||||
}
|
||||
const zod = await zodVerify(createBotSchema, formData);
|
||||
if (!zod.success) {
|
||||
return zod;
|
||||
}
|
||||
|
||||
const botExists = await prisma.botAccount.findFirst({
|
||||
where: { slug: zod.data.slug },
|
||||
});
|
||||
if (botExists) {
|
||||
return { success: false, error: 'Bot slug already exists' };
|
||||
}
|
||||
|
||||
const createdBot = await prisma.botAccount.create({
|
||||
data: {
|
||||
displayName: zod.data.name,
|
||||
slug: zod.data.slug,
|
||||
ownerId: user.id,
|
||||
description: zod.data.description,
|
||||
pfpUrl: await genIdenticonUpload(zod.data.slug, 'botpfp'),
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, slug: createdBot.slug };
|
||||
}
|
||||
|
||||
export async function editBot(prev: any, formData: FormData) {
|
||||
const { user } = await validateRequest();
|
||||
if (!user) {
|
||||
return { success: false, error: 'Unauthorized' };
|
||||
}
|
||||
const zod = await zodVerify(editBotSchema, formData);
|
||||
if (!zod.success) {
|
||||
return zod;
|
||||
}
|
||||
|
||||
const bot = await prisma.botAccount.findUnique({
|
||||
where: { id: zod.data.from },
|
||||
});
|
||||
if (!bot) {
|
||||
return { success: false, error: 'Bot not found' };
|
||||
}
|
||||
if (bot.ownerId !== user.id) {
|
||||
return { success: false, error: 'Unauthorized' };
|
||||
}
|
||||
if (bot.slug !== zod.data.slug) {
|
||||
const botExists = await prisma.botAccount.findFirst({
|
||||
where: { slug: zod.data.slug },
|
||||
});
|
||||
if (botExists) {
|
||||
return { success: false, error: 'Bot slug already exists' };
|
||||
}
|
||||
}
|
||||
|
||||
const updatedBot = await prisma.botAccount.update({
|
||||
where: { id: zod.data.from },
|
||||
data: {
|
||||
displayName: zod.data.name,
|
||||
slug: zod.data.slug,
|
||||
description: zod.data.description,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath(`/settings/bot/${updatedBot.slug}`);
|
||||
|
||||
return { success: true, slug: updatedBot.slug };
|
||||
}
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const disallowedUsernames = [
|
||||
'admin',
|
||||
'administrator',
|
||||
'settings',
|
||||
'create',
|
||||
// i hope this doesn't age well tbh
|
||||
'zrl',
|
||||
];
|
||||
const username = z
|
||||
.string()
|
||||
.min(1)
|
||||
.regex(/^[a-z0-9_-]+$/, { message: 'Only characters from a-z, 0-9, underscores and dashes' });
|
||||
.regex(/^[a-z0-9_-]+$/, { message: 'Only characters from a-z, 0-9, underscores and dashes' })
|
||||
.refine((val) => !disallowedUsernames.includes(val.toLowerCase()), {
|
||||
message: 'This username is reserved',
|
||||
});
|
||||
|
||||
export const streamInfoEditSchema = z.object({
|
||||
username: z.string().min(1),
|
||||
@@ -25,4 +36,16 @@ export const updateChannelSettingsSchema = z.object({
|
||||
pfpUrl: z.string(),
|
||||
description: z.string().min(1).max(500),
|
||||
is247: z.boolean(),
|
||||
});
|
||||
});
|
||||
|
||||
export const createBotSchema = z.object({
|
||||
name: z.string().min(1, { message: 'Name is required' }),
|
||||
slug: username.refine((val) => val !== 'settings', { message: 'This slug is reserved' }),
|
||||
description: z.string().max(300).optional(),
|
||||
});
|
||||
|
||||
export const editBotSchema = createBotSchema.and(
|
||||
z.object({
|
||||
from: z.string().min(1),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { prisma } from '@hctv/db';
|
||||
import { HttpFlv } from '../types/liveBackendJson';
|
||||
import { getNotificationQueue } from '../workers';
|
||||
import client from '../services/slackNotifier';
|
||||
import type { paths } from '../types/mediamtx.d.ts';
|
||||
|
||||
export default async function runner() {
|
||||
// if there are no users it explodes so yeah
|
||||
@@ -48,28 +49,21 @@ export async function initializeStreamInfo(channelId?: string) {
|
||||
|
||||
export async function syncStream() {
|
||||
try {
|
||||
const response = await fetch(`${process.env.LIVE_SERVER_URL}/stat`, {
|
||||
headers: {
|
||||
Authorization: process.env.STAT_AUTH!,
|
||||
},
|
||||
});
|
||||
const response = await fetch(`${process.env.MEDIAMTX_API}/v3/paths/list?itemsPerPage=1000`);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`Failed to fetch stream stats: ${response.status} ${response.statusText}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const httpFlv = data['http-flv'] as HttpFlv;
|
||||
type ResponseType = paths['/v3/paths/list']['get']['responses']['200']['content']['application/json'];
|
||||
const data = await response.json() as ResponseType;
|
||||
|
||||
if (!httpFlv?.servers?.[0]?.applications) {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const channelLiveApp = httpFlv.servers[0].applications.find(
|
||||
(app) => app.name === 'channel-live'
|
||||
);
|
||||
const activeStreams = channelLiveApp?.live?.streams || [];
|
||||
const activeStreams = data.items!;
|
||||
|
||||
const currentLiveStreams = await prisma.streamInfo.findMany({
|
||||
where: { isLive: true },
|
||||
@@ -78,8 +72,7 @@ export async function syncStream() {
|
||||
const activeStreamMap = new Map();
|
||||
for (const stream of activeStreams) {
|
||||
activeStreamMap.set(stream.name, {
|
||||
isLive: stream.active,
|
||||
viewers: stream.clients.filter((c) => !c.publishing).length,
|
||||
isLive: stream.ready,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -99,7 +92,7 @@ export async function syncStream() {
|
||||
}
|
||||
|
||||
for (const stream of activeStreams) {
|
||||
if (stream.active) {
|
||||
if (stream.ready) {
|
||||
const existingStream = await prisma.streamInfo.findUnique({
|
||||
where: { username: stream.name },
|
||||
include: { channel: true },
|
||||
@@ -128,7 +121,7 @@ export async function syncStream() {
|
||||
|
||||
if (!existingStream.channel.is247) {
|
||||
queue.add(`streamStartChannel:${existingStream.username}`, {
|
||||
text: `${existingStream.username} is now *live*, streaming *${existingStream.title}* (${existingStream.category})!\n<https://hctv.srizan.dev/${existingStream.username}|Go check them out>`,
|
||||
text: `${existingStream.username} is now *live*, streaming *${existingStream.title}* (${existingStream.category})!\n<https://hackclub.tv/${existingStream.username}|Go check them out>`,
|
||||
channel: process.env.NOTIFICATION_CHANNEL_ID!,
|
||||
unfurl_links: true,
|
||||
});
|
||||
@@ -136,7 +129,7 @@ export async function syncStream() {
|
||||
if (existingStream.enableNotifications && !existingStream.channel.is247) {
|
||||
for (const follower of subscribedFollowers) {
|
||||
queue.add(`streamStartDm:${follower.user.id}`, {
|
||||
text: `${existingStream.username} is now *live*, streaming *${existingStream.title}* (${existingStream.category})!\n<https://hctv.srizan.dev/${existingStream.username}|Go check them out>\n_Stream notifications are enabled for this user. If you want to disable them, you can do so in \`Profile > Follows\`._`,
|
||||
text: `${existingStream.username} is now *live*, streaming *${existingStream.title}* (${existingStream.category})!\n<https://hackclub.tv/${existingStream.username}|Go check them out>\n_Stream notifications are enabled for this user. If you want to disable them, you can do so in \`Profile > Follows\`._`,
|
||||
channel: follower.user.slack_id,
|
||||
unfurl_links: true,
|
||||
});
|
||||
|
||||
31
apps/web/src/lib/instrumentation/syncStreamKeys.ts
Normal file
31
apps/web/src/lib/instrumentation/syncStreamKeys.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { prisma, getRedisConnection } from '@hctv/db';
|
||||
|
||||
export default async function syncStreamKeys() {
|
||||
console.log('Syncing stream keys to Redis...');
|
||||
try {
|
||||
const keys = await prisma.streamKey.findMany({
|
||||
include: {
|
||||
channel: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (keys.length === 0) {
|
||||
console.log('No stream keys found to sync.');
|
||||
return;
|
||||
}
|
||||
|
||||
const redis = getRedisConnection();
|
||||
const pipeline = redis.pipeline();
|
||||
|
||||
for (const key of keys) {
|
||||
if (key.channel && key.channel.name) {
|
||||
pipeline.set(`streamKey:${key.channel.name}`, key.key);
|
||||
}
|
||||
}
|
||||
|
||||
await pipeline.exec();
|
||||
console.log(`Synced ${keys.length} stream keys to Redis`);
|
||||
} catch (error) {
|
||||
console.error('Failed to sync stream keys to Redis:', error);
|
||||
}
|
||||
}
|
||||
21
apps/web/src/lib/providers/ConfirmProvider.tsx
Normal file
21
apps/web/src/lib/providers/ConfirmProvider.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
ConfirmDialogProvider as BaseConfirmDialogProvider,
|
||||
ConfirmOptions,
|
||||
} from '@omit/react-confirm-dialog';
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
defaultOptions?: ConfirmOptions;
|
||||
}
|
||||
|
||||
export const ConfirmDialogProvider = ({ children, defaultOptions }: Props) => {
|
||||
return (
|
||||
<BaseConfirmDialogProvider defaultOptions={defaultOptions}>
|
||||
{children}
|
||||
</BaseConfirmDialogProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfirmDialogProvider;
|
||||
3313
apps/web/src/lib/types/mediamtx.d.ts
vendored
Normal file
3313
apps/web/src/lib/types/mediamtx.d.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,9 @@
|
||||
import { Worker } from 'bullmq';
|
||||
import { getRedisConnection } from '@hctv/db';
|
||||
import { exec } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { existsSync } from 'node:fs';
|
||||
const pExec = promisify(exec);
|
||||
import { exec as execCallback } from 'node:child_process';
|
||||
const pExec = promisify(execCallback);
|
||||
|
||||
const globalForWorker = global as unknown as {
|
||||
thumbnailWorker: Worker | null;
|
||||
@@ -26,28 +26,24 @@ export async function registerThumbnailWorker(): Promise<void> {
|
||||
try {
|
||||
// this is totally unnecessary, but i'll keep it for security purposes.
|
||||
const name = job.data.name.replace(/[^a-zA-Z0-9]/g, '_');
|
||||
const m3u8location = `/dev/shm/hls/${name}.m3u8`;
|
||||
const m3u8location = `${process.env.NEXT_PUBLIC_MEDIAMTX_URL}/${name}/index.m3u8`;
|
||||
const thumbDir = '/dev/shm/hctv-thumb';
|
||||
|
||||
if (!existsSync(m3u8location)) return;
|
||||
if (!existsSync(thumbDir)) {
|
||||
await pExec(`mkdir -p ${thumbDir}`);
|
||||
}
|
||||
// unnecessary for development, but maybe docker volumes mess with permissions in prod
|
||||
// also ik it's not the best practice to use 777, but it'll be fiiiiiine
|
||||
// await pExec('chown -R 777 /dev/shm/hctv-thumb');
|
||||
|
||||
exec(
|
||||
`ffmpeg -i ${m3u8location} -vframes 1 -an -y -f image2 ${thumbDir}/${name}.webp`,
|
||||
(error) => {
|
||||
if (error) {
|
||||
console.error(`Error: ${error.message}`);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
const header = `-headers "Authorization: Basic ${Buffer.from(`skibiditoilet:${process.env.MEDIAMTX_PUBLISH_KEY}`).toString('base64')}\r\n" `;
|
||||
|
||||
try {
|
||||
await pExec(
|
||||
`ffmpeg ${header} -i ${m3u8location} -vframes 1 -an -y -f image2 ${thumbDir}/${name}.webp`
|
||||
);
|
||||
return { success: true };
|
||||
} catch (ffmpegError) {
|
||||
console.error(`FFmpeg error for ${name}:`, ffmpegError);
|
||||
return { success: false, error: ffmpegError instanceof Error ? ffmpegError.message : String(ffmpegError) };
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Slack notification failed:', e);
|
||||
// @ts-ignore e is unknown
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Config } from "tailwindcss"
|
||||
import { withUt } from "uploadthing/tw";
|
||||
import { uploadthingPlugin } from 'uploadthing/tw'
|
||||
import * as tan from 'tailwindcss-animate'
|
||||
|
||||
const config = {
|
||||
darkMode: ["class"],
|
||||
@@ -102,7 +103,7 @@ const config = {
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
plugins: [tan, uploadthingPlugin],
|
||||
} satisfies Config
|
||||
|
||||
export default withUt(config)
|
||||
export default config
|
||||
@@ -14,7 +14,7 @@
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
@@ -32,7 +32,9 @@
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts"
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"tailwind.config.mts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
|
||||
@@ -15,38 +15,11 @@ services:
|
||||
- ./redis:/data
|
||||
ports:
|
||||
- 6379:6379
|
||||
nginx-rtmp:
|
||||
# ports:
|
||||
# - 1935:1935
|
||||
# - 8888:8888
|
||||
network_mode: host
|
||||
environment:
|
||||
UID: 1000
|
||||
GID: 1000
|
||||
API_AUTH: skibiditoilet
|
||||
mediamtx:
|
||||
image: bluenviron/mediamtx:latest
|
||||
ports:
|
||||
- 8890:8890/udp
|
||||
- 8891:8888
|
||||
- 9997:9997
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/templates/nginx.conf.template
|
||||
- ./html:/var/www/html
|
||||
- /dev/shm/hls:/dev/shm/hls
|
||||
image: srizan10/flv-module
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
# Process the template file
|
||||
mkdir -p /usr/local/nginx/conf
|
||||
envsubst '$${API_AUTH}' < /etc/nginx/templates/nginx.conf.template > /usr/local/nginx/conf/nginx.conf
|
||||
|
||||
echo "Setting UID to $${UID} and GID to $${GID}"
|
||||
usermod -u $${UID} nginx || echo "failed to change uid"
|
||||
groupmod -g $${GID} nginx || echo "failed to change gid"
|
||||
|
||||
mkdir -p /usr/local/nginx/proxy_temp /usr/local/nginx/client_body_temp
|
||||
chown -R nginx:nginx /usr/local/nginx
|
||||
mkdir -p /var/www/html
|
||||
chown -R nginx:nginx /var/www/html
|
||||
|
||||
echo "testing nginx config..."
|
||||
/usr/local/nginx/sbin/nginx -t
|
||||
|
||||
/usr/local/nginx/sbin/nginx -g 'daemon off;'
|
||||
- ./mediamtx.yml:/mediamtx.yml
|
||||
13
dev/mediamtx.yml
Normal file
13
dev/mediamtx.yml
Normal file
@@ -0,0 +1,13 @@
|
||||
paths:
|
||||
all:
|
||||
source: publisher
|
||||
|
||||
srt: yes
|
||||
srtAddress: :8890
|
||||
|
||||
hls: yes
|
||||
|
||||
authMethod: http
|
||||
authHTTPAddress: http://192.168.1.47:3000/api/mediamtx/publish
|
||||
|
||||
api: yes
|
||||
@@ -28,8 +28,8 @@ rtmp {
|
||||
hls on;
|
||||
hls_type live;
|
||||
hls_path /dev/shm/hls;
|
||||
hls_fragment 1s;
|
||||
hls_playlist_length 3s;
|
||||
hls_fragment 2s;
|
||||
hls_playlist_length 10s;
|
||||
hls_cleanup on;
|
||||
|
||||
hls_fragment_naming timestamp;
|
||||
@@ -54,7 +54,7 @@ http {
|
||||
|
||||
map $http_authorization $is_authorized {
|
||||
default 0;
|
||||
$API_AUTH 1;
|
||||
${API_AUTH} 1;
|
||||
}
|
||||
|
||||
server {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM alpine:3.19 as builder
|
||||
FROM alpine:3.19 AS builder
|
||||
|
||||
RUN apk add --no-cache \
|
||||
build-base \
|
||||
@@ -37,20 +37,8 @@ RUN mkdir -p /etc/nginx/templates
|
||||
|
||||
EXPOSE 80 1935 8888
|
||||
|
||||
# Create an entrypoint script to handle environment variable substitution
|
||||
RUN echo '#!/bin/sh \n\
|
||||
# Replace environment variables in configuration templates \n\
|
||||
for template in /etc/nginx/templates/*.conf.template; do \n\
|
||||
if [ -f "$template" ]; then \n\
|
||||
output_file="/usr/local/nginx/conf/$(basename $template .template)" \n\
|
||||
echo "Processing template: $template -> $output_file" \n\
|
||||
envsubst "$(env | awk -F= "{printf \\\"\\\$%s \\\",\\\$1}")" < $template > $output_file \n\
|
||||
fi \n\
|
||||
done \n\
|
||||
\n\
|
||||
# Start Nginx \n\
|
||||
exec "$@"' > /docker-entrypoint.sh && \
|
||||
chmod +x /docker-entrypoint.sh
|
||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||
RUN chmod +x /docker-entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
CMD ["/usr/local/nginx/sbin/nginx", "-g", "daemon off;"]
|
||||
27
flv-module/docker-entrypoint.sh
Normal file
27
flv-module/docker-entrypoint.sh
Normal file
@@ -0,0 +1,27 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
UID=${UID:-1000}
|
||||
GID=${GID:-1000}
|
||||
|
||||
echo "Setting UID to $UID and GID to $GID"
|
||||
usermod -u $UID nginx 2>/dev/null || echo "Failed to change UID"
|
||||
groupmod -g $GID nginx 2>/dev/null || echo "Failed to change GID"
|
||||
|
||||
mkdir -p /usr/local/nginx/conf
|
||||
for template in /etc/nginx/templates/*.conf.template; do
|
||||
if [ -f "$template" ]; then
|
||||
output_file="/usr/local/nginx/conf/$(basename $template .template)"
|
||||
echo "Processing template: $template -> $output_file"
|
||||
envsubst '${API_AUTH}' < $template > $output_file
|
||||
fi
|
||||
done
|
||||
|
||||
mkdir -p /usr/local/nginx/proxy_temp /usr/local/nginx/client_body_temp
|
||||
mkdir -p /var/www/html
|
||||
chown -R nginx:nginx /usr/local/nginx /var/www/html
|
||||
|
||||
echo "Testing nginx configuration..."
|
||||
/usr/local/nginx/sbin/nginx -t
|
||||
|
||||
exec "$@"
|
||||
@@ -20,6 +20,7 @@
|
||||
"r:rtmp": "docker compose -f dev/docker-compose.yml restart nginx-rtmp -t 0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"prettier": "^3.6.2",
|
||||
"turbo": "^2.4.4"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { PrismaAdapter } from '@lucia-auth/adapter-prisma';
|
||||
import { Lucia } from 'lucia';
|
||||
import { prisma } from '@hctv/db';
|
||||
import { Slack } from 'arctic';
|
||||
import { OAuth2Client } from 'arctic';
|
||||
|
||||
const adapter = new PrismaAdapter(prisma.session, prisma.user);
|
||||
export const slack = new Slack(process.env.SLACK_ID!, process.env.SLACK_SECRET!, process.env.SLACK_REDIRECT_URI!);
|
||||
export const hackClub = new OAuth2Client(
|
||||
process.env.HCID_CLIENT!,
|
||||
process.env.HCID_SECRET!,
|
||||
process.env.HCID_REDIRECT_URI!
|
||||
);
|
||||
|
||||
export const HCID_AUTH_URL = "https://account.hackclub.com/oauth/authorize";
|
||||
export const HCID_TOKEN_URL = "https://account.hackclub.com/oauth/token";
|
||||
export const HCID_USER_INFO_URL = "https://account.hackclub.com/api/v1/me";
|
||||
|
||||
export const lucia = new Lucia(adapter, {
|
||||
sessionCookie: {
|
||||
@@ -19,6 +27,7 @@ export const lucia = new Lucia(adapter, {
|
||||
getUserAttributes: (attributes) => {
|
||||
return {
|
||||
slack_id: attributes.slack_id,
|
||||
email: attributes.email,
|
||||
pfpUrl: attributes.pfpUrl,
|
||||
hasOnboarded: attributes.hasOnboarded,
|
||||
personalChannelId: attributes.personalChannelId,
|
||||
@@ -35,6 +44,7 @@ declare module 'lucia' {
|
||||
|
||||
interface DatabaseUserAttributes {
|
||||
slack_id: string;
|
||||
email: string | null;
|
||||
pfpUrl: string;
|
||||
hasOnboarded: boolean;
|
||||
personalChannelId: string | null;
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
FROM node:lts-alpine AS base
|
||||
|
||||
FROM base AS builder
|
||||
RUN apk update
|
||||
RUN apk add --no-cache libc6-compat
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
RUN yarn global add turbo@^2
|
||||
COPY . .
|
||||
|
||||
# Generate a partial monorepo with a pruned lockfile for the db package
|
||||
RUN turbo prune @hctv/db --docker
|
||||
|
||||
FROM base AS installer
|
||||
RUN apk update
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
# First install the dependencies
|
||||
COPY --from=builder /app/out/json/ .
|
||||
RUN yarn install --frozen-lockfile
|
||||
|
||||
COPY --from=builder /app/out/full/ .
|
||||
RUN --mount=type=secret,id=TURBO_TOKEN --mount=type=secret,id=TURBO_TEAM TURBO_TOKEN=$(cat /run/secrets/TURBO_TOKEN) TURBO_TEAM=$(cat /run/secrets/TURBO_TEAM) yarn turbo run build --filter=@hctv/db
|
||||
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 prisma
|
||||
USER prisma
|
||||
|
||||
COPY --from=installer --chown=prisma:nodejs /app/packages ./packages
|
||||
COPY --from=installer --chown=prisma:nodejs /app/node_modules ./node_modules
|
||||
COPY --from=installer --chown=prisma:nodejs /app/package.json ./package.json
|
||||
|
||||
# Set environment variables for database connection
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Set the working directory to the db package
|
||||
WORKDIR /app/packages/db
|
||||
|
||||
# Run Prisma migrations as the entrypoint
|
||||
ENTRYPOINT ["npx", "prisma", "migrate", "deploy"]
|
||||
@@ -0,0 +1,42 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "BotAccounts" (
|
||||
"id" TEXT NOT NULL,
|
||||
"displayName" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL,
|
||||
"pfpUrl" TEXT NOT NULL,
|
||||
"channelId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "BotAccounts_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ApiKey" (
|
||||
"id" TEXT NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"botAccountId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ApiKey_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "BotAccounts_slug_key" ON "BotAccounts"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "BotAccounts_channelId_idx" ON "BotAccounts"("channelId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "BotAccounts_slug_idx" ON "BotAccounts"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ApiKey_key_key" ON "ApiKey"("key");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ApiKey_botAccountId_idx" ON "ApiKey"("botAccountId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "BotAccounts" ADD CONSTRAINT "BotAccounts_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "Channel"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_botAccountId_fkey" FOREIGN KEY ("botAccountId") REFERENCES "BotAccounts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `channelId` on the `BotAccounts` table. All the data in the column will be lost.
|
||||
- Added the required column `ownerId` to the `BotAccounts` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "BotAccounts" DROP CONSTRAINT "BotAccounts_channelId_fkey";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "BotAccounts_channelId_idx";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "BotAccounts" DROP COLUMN "channelId",
|
||||
ADD COLUMN "ownerId" TEXT NOT NULL;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "BotAccounts_ownerId_idx" ON "BotAccounts"("ownerId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "BotAccounts" ADD CONSTRAINT "BotAccounts_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,9 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- Added the required column `updatedAt` to the `BotAccounts` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "BotAccounts" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL;
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the `BotAccounts` table. If the table is not empty, all the data it contains will be lost.
|
||||
|
||||
*/
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "ApiKey" DROP CONSTRAINT "ApiKey_botAccountId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "BotAccounts" DROP CONSTRAINT "BotAccounts_ownerId_fkey";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "BotAccounts";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "BotAccount" (
|
||||
"id" TEXT NOT NULL,
|
||||
"displayName" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL,
|
||||
"pfpUrl" TEXT NOT NULL,
|
||||
"ownerId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "BotAccount_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "BotAccount_slug_key" ON "BotAccount"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "BotAccount_ownerId_idx" ON "BotAccount"("ownerId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "BotAccount_slug_idx" ON "BotAccount"("slug");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "BotAccount" ADD CONSTRAINT "BotAccount_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_botAccountId_fkey" FOREIGN KEY ("botAccountId") REFERENCES "BotAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "BotAccount" ALTER COLUMN "description" SET DEFAULT 'A hctv bot account';
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the `ApiKey` table. If the table is not empty, all the data it contains will be lost.
|
||||
|
||||
*/
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "ApiKey" DROP CONSTRAINT "ApiKey_botAccountId_fkey";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "ApiKey";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "BotApiKey" (
|
||||
"id" TEXT NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"botAccountId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "BotApiKey_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "BotApiKey_key_key" ON "BotApiKey"("key");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "BotApiKey_botAccountId_idx" ON "BotApiKey"("botAccountId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "BotApiKey" ADD CONSTRAINT "BotApiKey_botAccountId_fkey" FOREIGN KEY ("botAccountId") REFERENCES "BotAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- Added the required column `name` to the `BotApiKey` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "BotApiKey" ADD COLUMN "name" TEXT NOT NULL;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "email" TEXT;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "StreamInfo" DROP CONSTRAINT "StreamInfo_channelId_fkey";
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "StreamInfo" ADD CONSTRAINT "StreamInfo_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "Channel"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "StreamKey" DROP CONSTRAINT "StreamKey_channelId_fkey";
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "StreamKey" ADD CONSTRAINT "StreamKey_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "Channel"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -5,55 +5,57 @@
|
||||
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
output = "../generated/client"
|
||||
binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
|
||||
provider = "prisma-client-js"
|
||||
output = "../generated/client"
|
||||
binaryTargets = ["native", "debian-openssl-3.0.x"]
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
directUrl = env("DATABASE_DIRECT_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
slack_id String
|
||||
pfpUrl String
|
||||
hasOnboarded Boolean @default(false)
|
||||
id String @id @default(cuid())
|
||||
slack_id String
|
||||
email String?
|
||||
pfpUrl String
|
||||
hasOnboarded Boolean @default(false)
|
||||
|
||||
personalChannel Channel? @relation("PersonalChannel", fields: [personalChannelId], references: [id])
|
||||
personalChannelId String? @unique
|
||||
personalChannel Channel? @relation("PersonalChannel", fields: [personalChannelId], references: [id])
|
||||
personalChannelId String? @unique
|
||||
|
||||
ownedChannels Channel[] @relation("ChannelOwner")
|
||||
managedChannels Channel[] @relation("ChannelManagers")
|
||||
sessions Session[]
|
||||
streams StreamInfo[]
|
||||
followers Follow[] @relation("UserFollows")
|
||||
ownedChannels Channel[] @relation("ChannelOwner")
|
||||
managedChannels Channel[] @relation("ChannelManagers")
|
||||
sessions Session[]
|
||||
streams StreamInfo[]
|
||||
followers Follow[] @relation("UserFollows")
|
||||
botAccounts BotAccount[]
|
||||
|
||||
@@index([personalChannelId])
|
||||
}
|
||||
|
||||
model Channel {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
description String @default("A hctv channel")
|
||||
pfpUrl String
|
||||
pfpUrl String
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
personalFor User? @relation("PersonalChannel")
|
||||
|
||||
owner User @relation("ChannelOwner", fields: [ownerId], references: [id])
|
||||
ownerId String
|
||||
managers User[] @relation("ChannelManagers")
|
||||
streamInfo StreamInfo[]
|
||||
followers Follow[] @relation("ChannelFollowers")
|
||||
streamKey StreamKey?
|
||||
obsChatGrantToken String @unique @default(cuid())
|
||||
is247 Boolean @default(false)
|
||||
|
||||
owner User @relation("ChannelOwner", fields: [ownerId], references: [id])
|
||||
ownerId String
|
||||
managers User[] @relation("ChannelManagers")
|
||||
streamInfo StreamInfo[]
|
||||
followers Follow[] @relation("ChannelFollowers")
|
||||
streamKey StreamKey?
|
||||
obsChatGrantToken String @unique @default(cuid())
|
||||
is247 Boolean @default(false)
|
||||
|
||||
@@index([ownerId])
|
||||
}
|
||||
|
||||
@@ -65,20 +67,20 @@ model Session {
|
||||
}
|
||||
|
||||
model StreamInfo {
|
||||
id String @id @default(cuid())
|
||||
username String @unique
|
||||
id String @id @default(cuid())
|
||||
username String @unique
|
||||
title String
|
||||
thumbnail String
|
||||
viewers Int
|
||||
category String
|
||||
startedAt DateTime
|
||||
isLive Boolean
|
||||
|
||||
channelId String
|
||||
channel Channel @relation(fields: [channelId], references: [id])
|
||||
|
||||
ownedBy User @relation(fields: [userId], references: [id])
|
||||
userId String
|
||||
channelId String
|
||||
channel Channel @relation(fields: [channelId], references: [id], onDelete: Cascade)
|
||||
|
||||
ownedBy User @relation(fields: [userId], references: [id])
|
||||
userId String
|
||||
|
||||
enableNotifications Boolean @default(true)
|
||||
|
||||
@@ -88,13 +90,13 @@ model StreamInfo {
|
||||
model Follow {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation("UserFollows", fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
|
||||
channel Channel @relation("ChannelFollowers", fields: [channelId], references: [id], onDelete: Cascade)
|
||||
|
||||
user User @relation("UserFollows", fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
|
||||
channel Channel @relation("ChannelFollowers", fields: [channelId], references: [id], onDelete: Cascade)
|
||||
channelId String
|
||||
|
||||
|
||||
notifyStream Boolean @default(false)
|
||||
|
||||
@@unique([userId, channelId])
|
||||
@@ -103,9 +105,37 @@ model Follow {
|
||||
}
|
||||
|
||||
model StreamKey {
|
||||
id String @id @default(cuid())
|
||||
key String @unique
|
||||
id String @id @default(cuid())
|
||||
key String @unique
|
||||
|
||||
channelId String @unique
|
||||
channel Channel @relation(fields: [channelId], references: [id])
|
||||
}
|
||||
channelId String @unique
|
||||
channel Channel @relation(fields: [channelId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model BotAccount {
|
||||
id String @id @default(cuid())
|
||||
displayName String
|
||||
slug String @unique
|
||||
description String @default("A hctv bot account")
|
||||
pfpUrl String
|
||||
owner User @relation(fields: [ownerId], references: [id])
|
||||
ownerId String
|
||||
apiKeys BotApiKey[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([ownerId])
|
||||
@@index([slug])
|
||||
}
|
||||
|
||||
model BotApiKey {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
key String @unique
|
||||
botAccount BotAccount @relation(fields: [botAccountId], references: [id], onDelete: Cascade)
|
||||
botAccountId String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([botAccountId])
|
||||
}
|
||||
|
||||
@@ -249,12 +249,15 @@ interface ModifiedWSContext extends WSContext<ModifiedWebSocket> {
|
||||
user?: any;
|
||||
personalChannel?: any;
|
||||
viewerId?: string;
|
||||
botUsername?: string;
|
||||
chatUser?: ChatUser | null;
|
||||
}
|
||||
|
||||
export interface ModifiedWebSocket extends WebSocket {
|
||||
targetUsername?: string;
|
||||
user?: User;
|
||||
personalChannel?: Channel;
|
||||
chatUser?: ChatUser | null;
|
||||
}
|
||||
|
||||
interface CloseEventInit extends EventInit {
|
||||
@@ -262,3 +265,11 @@ interface CloseEventInit extends EventInit {
|
||||
reason?: string;
|
||||
wasClean?: boolean;
|
||||
}
|
||||
|
||||
interface ChatUser {
|
||||
id: string;
|
||||
username: string;
|
||||
pfpUrl: string;
|
||||
displayName?: string;
|
||||
isBot: boolean;
|
||||
}
|
||||
2
slack-import-emojis/Cargo.lock
generated
2
slack-import-emojis/Cargo.lock
generated
@@ -827,7 +827,7 @@ checksum = "04dc19736151f35336d325007ac991178d504a119863a2fcb3758cdb5e52c50d"
|
||||
|
||||
[[package]]
|
||||
name = "slack-import-emojis"
|
||||
version = "0.2.1"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"reqwest",
|
||||
"serde",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "slack-import-emojis"
|
||||
version = "0.2.1"
|
||||
version = "0.3.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -6,12 +6,12 @@ use std::io::Write;
|
||||
use std::env;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SlackEmojiResponse {
|
||||
emoji: HashMap<String, String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[allow(dead_code)]
|
||||
error: Option<String>,
|
||||
struct SlackEmojiItem {
|
||||
name: String,
|
||||
#[serde(rename = "imageUrl")]
|
||||
image_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DefaultEmojiResponse {
|
||||
emoji: HashMap<String, String>,
|
||||
@@ -35,41 +35,41 @@ async fn main() {
|
||||
let mut slack_emojis = slack_request()
|
||||
.await
|
||||
.expect("Failed to fetch slack_emojis from Slack API");
|
||||
println!("{:?} slack_emojis fetched", slack_emojis.emoji.len());
|
||||
println!("{:?} slack_emojis fetched", slack_emojis.len());
|
||||
|
||||
if args.len() > 1 && args[1] == "default" {
|
||||
let default_emojis = default_request()
|
||||
.await
|
||||
.expect("Failed to fetch default_emojis from GitHub");
|
||||
println!("{:?} default_emojis fetched", default_emojis.emoji.len());
|
||||
slack_emojis.emoji.extend(default_emojis.emoji);
|
||||
slack_emojis.extend(default_emojis.emoji);
|
||||
}
|
||||
|
||||
let mut file = File::create("emojis.json").expect("failed to create file for some reason");
|
||||
let json_data =
|
||||
serde_json::to_string(&slack_emojis.emoji).expect("failed to serialize emojis wtf");
|
||||
serde_json::to_string(&slack_emojis).expect("failed to serialize emojis wtf");
|
||||
file
|
||||
.write_all(json_data.as_bytes())
|
||||
.expect("failed to write emojis to file");
|
||||
println!("saved :yay:");
|
||||
}
|
||||
|
||||
async fn slack_request() -> Result<SlackEmojiResponse, Box<dyn std::error::Error>> {
|
||||
async fn slack_request() -> Result<HashMap<String, String>, Box<dyn std::error::Error>> {
|
||||
let client = reqwest::Client::new();
|
||||
let res = client
|
||||
.get("https://slack.com/api/emoji.list")
|
||||
.header(
|
||||
"Authorization",
|
||||
format!(
|
||||
"Bearer {}",
|
||||
std::env::var("SLACK_TOKEN").expect("SLACK_TOKEN not set")
|
||||
),
|
||||
)
|
||||
.get("https://cachet.dunkirk.sh/emojis")
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(response) => Ok(response.json().await?),
|
||||
Ok(response) => {
|
||||
let items: Vec<SlackEmojiItem> = response.json().await?;
|
||||
let map: HashMap<String, String> = items
|
||||
.into_iter()
|
||||
.map(|item| (item.name, item.image_url))
|
||||
.collect();
|
||||
Ok(map)
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("Error: {:?}", err);
|
||||
Err(Box::new(err))
|
||||
|
||||
426
yarn.lock
426
yarn.lock
@@ -747,7 +747,7 @@
|
||||
"@emnapi/wasi-threads" "1.0.1"
|
||||
tslib "^2.4.0"
|
||||
|
||||
"@emnapi/runtime@^1.2.0", "@emnapi/runtime@^1.4.4":
|
||||
"@emnapi/runtime@^1.2.0", "@emnapi/runtime@^1.4.4", "@emnapi/runtime@^1.5.0":
|
||||
version "1.5.0"
|
||||
resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.5.0.tgz#9aebfcb9b17195dce3ab53c86787a6b7d058db73"
|
||||
integrity sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==
|
||||
@@ -1130,6 +1130,11 @@
|
||||
local-pkg "^1.0.0"
|
||||
mlly "^1.7.4"
|
||||
|
||||
"@img/colour@^1.0.0":
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@img/colour/-/colour-1.0.0.tgz#d2fabb223455a793bf3bf9c70de3d28526aa8311"
|
||||
integrity sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==
|
||||
|
||||
"@img/sharp-darwin-arm64@0.33.5":
|
||||
version "0.33.5"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz#ef5b5a07862805f1e8145a377c8ba6e98813ca08"
|
||||
@@ -1151,6 +1156,13 @@
|
||||
optionalDependencies:
|
||||
"@img/sharp-libvips-darwin-arm64" "1.2.0"
|
||||
|
||||
"@img/sharp-darwin-arm64@0.34.4":
|
||||
version "0.34.4"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.4.tgz#8a0dcac9e621ff533fbf2e830f6a977b38d67a0c"
|
||||
integrity sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA==
|
||||
optionalDependencies:
|
||||
"@img/sharp-libvips-darwin-arm64" "1.2.3"
|
||||
|
||||
"@img/sharp-darwin-x64@0.33.5":
|
||||
version "0.33.5"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz#e03d3451cd9e664faa72948cc70a403ea4063d61"
|
||||
@@ -1172,6 +1184,13 @@
|
||||
optionalDependencies:
|
||||
"@img/sharp-libvips-darwin-x64" "1.2.0"
|
||||
|
||||
"@img/sharp-darwin-x64@0.34.4":
|
||||
version "0.34.4"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.4.tgz#0ba2bd9dbf07f7300fab73305b787e66156f7752"
|
||||
integrity sha512-rZheupWIoa3+SOdF/IcUe1ah4ZDpKBGWcsPX6MT0lYniH9micvIU7HQkYTfrx5Xi8u+YqwLtxC/3vl8TQN6rMg==
|
||||
optionalDependencies:
|
||||
"@img/sharp-libvips-darwin-x64" "1.2.3"
|
||||
|
||||
"@img/sharp-libvips-darwin-arm64@1.0.4":
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz#447c5026700c01a993c7804eb8af5f6e9868c07f"
|
||||
@@ -1187,6 +1206,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.0.tgz#e20e9041031acde1de19da121dc5162c7d2cf251"
|
||||
integrity sha512-sBZmpwmxqwlqG9ueWFXtockhsxefaV6O84BMOrhtg/YqbTaRdqDE7hxraVE3y6gVM4eExmfzW4a8el9ArLeEiQ==
|
||||
|
||||
"@img/sharp-libvips-darwin-arm64@1.2.3":
|
||||
version "1.2.3"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.3.tgz#f43c9aa3b74fd307e4318da63ebbe0ed4c34e744"
|
||||
integrity sha512-QzWAKo7kpHxbuHqUC28DZ9pIKpSi2ts2OJnoIGI26+HMgq92ZZ4vk8iJd4XsxN+tYfNJxzH6W62X5eTcsBymHw==
|
||||
|
||||
"@img/sharp-libvips-darwin-x64@1.0.4":
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz#e0456f8f7c623f9dbfbdc77383caa72281d86062"
|
||||
@@ -1202,6 +1226,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.0.tgz#918ca81c5446f31114834cb908425a7532393185"
|
||||
integrity sha512-M64XVuL94OgiNHa5/m2YvEQI5q2cl9d/wk0qFTDVXcYzi43lxuiFTftMR1tOnFQovVXNZJ5TURSDK2pNe9Yzqg==
|
||||
|
||||
"@img/sharp-libvips-darwin-x64@1.2.3":
|
||||
version "1.2.3"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.3.tgz#c42ff786d4a1f42ef8929dba4a989dd5df6417f0"
|
||||
integrity sha512-Ju+g2xn1E2AKO6YBhxjj+ACcsPQRHT0bhpglxcEf+3uyPY+/gL8veniKoo96335ZaPo03bdDXMv0t+BBFAbmRA==
|
||||
|
||||
"@img/sharp-libvips-linux-arm64@1.0.4":
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz#979b1c66c9a91f7ff2893556ef267f90ebe51704"
|
||||
@@ -1217,6 +1246,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.0.tgz#1a5beafc857b43f378c3030427aa981ee3edbc54"
|
||||
integrity sha512-RXwd0CgG+uPRX5YYrkzKyalt2OJYRiJQ8ED/fi1tq9WQW2jsQIn0tqrlR5l5dr/rjqq6AHAxURhj2DVjyQWSOA==
|
||||
|
||||
"@img/sharp-libvips-linux-arm64@1.2.3":
|
||||
version "1.2.3"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.3.tgz#c9073e5c4b629ee417f777db21c552910d84ed77"
|
||||
integrity sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==
|
||||
|
||||
"@img/sharp-libvips-linux-arm@1.0.5":
|
||||
version "1.0.5"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz#99f922d4e15216ec205dcb6891b721bfd2884197"
|
||||
@@ -1232,6 +1266,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.0.tgz#bff51182d5238ca35c5fe9e9f594a18ad6a5480d"
|
||||
integrity sha512-mWd2uWvDtL/nvIzThLq3fr2nnGfyr/XMXlq8ZJ9WMR6PXijHlC3ksp0IpuhK6bougvQrchUAfzRLnbsen0Cqvw==
|
||||
|
||||
"@img/sharp-libvips-linux-arm@1.2.3":
|
||||
version "1.2.3"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.3.tgz#3cbc333fd6b8f224a14d69b03a1dd11df897c799"
|
||||
integrity sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==
|
||||
|
||||
"@img/sharp-libvips-linux-ppc64@1.1.0":
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.1.0.tgz#682334595f2ca00e0a07a675ba170af165162802"
|
||||
@@ -1242,6 +1281,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.0.tgz#10c53ccf6f2d47d71fb3fa282697072c8fe9e40e"
|
||||
integrity sha512-Xod/7KaDDHkYu2phxxfeEPXfVXFKx70EAFZ0qyUdOjCcxbjqyJOEUpDe6RIyaunGxT34Anf9ue/wuWOqBW2WcQ==
|
||||
|
||||
"@img/sharp-libvips-linux-ppc64@1.2.3":
|
||||
version "1.2.3"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.3.tgz#68e0e0076299f43d838468675674fabcc7161d16"
|
||||
integrity sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==
|
||||
|
||||
"@img/sharp-libvips-linux-s390x@1.0.4":
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz#f8a5eb1f374a082f72b3f45e2fb25b8118a8a5ce"
|
||||
@@ -1257,6 +1301,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.0.tgz#392fd7557ddc5c901f1bed7ab3c567c08833ef3b"
|
||||
integrity sha512-eMKfzDxLGT8mnmPJTNMcjfO33fLiTDsrMlUVcp6b96ETbnJmd4uvZxVJSKPQfS+odwfVaGifhsB07J1LynFehw==
|
||||
|
||||
"@img/sharp-libvips-linux-s390x@1.2.3":
|
||||
version "1.2.3"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.3.tgz#7da9ab11a50c0ca905979f0aae14a4ccffab27b2"
|
||||
integrity sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w==
|
||||
|
||||
"@img/sharp-libvips-linux-x64@1.0.4":
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz#d4c4619cdd157774906e15770ee119931c7ef5e0"
|
||||
@@ -1272,6 +1321,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.0.tgz#9315cf90a2fdcdc0e29ea7663cbd8b0f15254400"
|
||||
integrity sha512-ZW3FPWIc7K1sH9E3nxIGB3y3dZkpJlMnkk7z5tu1nSkBoCgw2nSRTFHI5pB/3CQaJM0pdzMF3paf9ckKMSE9Tg==
|
||||
|
||||
"@img/sharp-libvips-linux-x64@1.2.3":
|
||||
version "1.2.3"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.3.tgz#3b162d6b190cf77926819040e09fb15eec42135e"
|
||||
integrity sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-arm64@1.0.4":
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz#166778da0f48dd2bded1fa3033cee6b588f0d5d5"
|
||||
@@ -1287,6 +1341,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.0.tgz#705e03e67d477f6f842f37eb7f66285b1150dc06"
|
||||
integrity sha512-UG+LqQJbf5VJ8NWJ5Z3tdIe/HXjuIdo4JeVNADXBFuG7z9zjoegpzzGIyV5zQKi4zaJjnAd2+g2nna8TZvuW9Q==
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-arm64@1.2.3":
|
||||
version "1.2.3"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.3.tgz#ac99576630dd8e33cb598d7c4586f6e0655912ea"
|
||||
integrity sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw==
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-x64@1.0.4":
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz#93794e4d7720b077fcad3e02982f2f1c246751ff"
|
||||
@@ -1302,6 +1361,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.0.tgz#ec905071cc538df64848d5900e0d386d77c55f13"
|
||||
integrity sha512-SRYOLR7CXPgNze8akZwjoGBoN1ThNZoqpOgfnOxmWsklTGVfJiGJoC/Lod7aNMGA1jSsKWM1+HRX43OP6p9+6Q==
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-x64@1.2.3":
|
||||
version "1.2.3"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.3.tgz#93e9495af7bf6c4e0d41dd71d0196c35c3753a1c"
|
||||
integrity sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g==
|
||||
|
||||
"@img/sharp-linux-arm64@0.33.5":
|
||||
version "0.33.5"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz#edb0697e7a8279c9fc829a60fc35644c4839bb22"
|
||||
@@ -1323,6 +1387,13 @@
|
||||
optionalDependencies:
|
||||
"@img/sharp-libvips-linux-arm64" "1.2.0"
|
||||
|
||||
"@img/sharp-linux-arm64@0.34.4":
|
||||
version "0.34.4"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.4.tgz#0570ff1a4fa6e1d6779456fca8b5e8c18a6a9cf2"
|
||||
integrity sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ==
|
||||
optionalDependencies:
|
||||
"@img/sharp-libvips-linux-arm64" "1.2.3"
|
||||
|
||||
"@img/sharp-linux-arm@0.33.5":
|
||||
version "0.33.5"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz#422c1a352e7b5832842577dc51602bcd5b6f5eff"
|
||||
@@ -1344,6 +1415,13 @@
|
||||
optionalDependencies:
|
||||
"@img/sharp-libvips-linux-arm" "1.2.0"
|
||||
|
||||
"@img/sharp-linux-arm@0.34.4":
|
||||
version "0.34.4"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.4.tgz#5f020d933f54f3fc49203d32c3b7dd0ec11ffcdb"
|
||||
integrity sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA==
|
||||
optionalDependencies:
|
||||
"@img/sharp-libvips-linux-arm" "1.2.3"
|
||||
|
||||
"@img/sharp-linux-ppc64@0.34.3":
|
||||
version "0.34.3"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.3.tgz#6a7cd4c608011333a0ddde6d96e03ac042dd9079"
|
||||
@@ -1351,6 +1429,13 @@
|
||||
optionalDependencies:
|
||||
"@img/sharp-libvips-linux-ppc64" "1.2.0"
|
||||
|
||||
"@img/sharp-linux-ppc64@0.34.4":
|
||||
version "0.34.4"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.4.tgz#8d5775f6dc7e30ea3a1efa43798b7690bb5cb344"
|
||||
integrity sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ==
|
||||
optionalDependencies:
|
||||
"@img/sharp-libvips-linux-ppc64" "1.2.3"
|
||||
|
||||
"@img/sharp-linux-s390x@0.33.5":
|
||||
version "0.33.5"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz#f5c077926b48e97e4a04d004dfaf175972059667"
|
||||
@@ -1372,6 +1457,13 @@
|
||||
optionalDependencies:
|
||||
"@img/sharp-libvips-linux-s390x" "1.2.0"
|
||||
|
||||
"@img/sharp-linux-s390x@0.34.4":
|
||||
version "0.34.4"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.4.tgz#740aa5b369188ee2c1913b1015e7f830f4dfdb50"
|
||||
integrity sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw==
|
||||
optionalDependencies:
|
||||
"@img/sharp-libvips-linux-s390x" "1.2.3"
|
||||
|
||||
"@img/sharp-linux-x64@0.33.5":
|
||||
version "0.33.5"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz#d806e0afd71ae6775cc87f0da8f2d03a7c2209cb"
|
||||
@@ -1393,6 +1485,13 @@
|
||||
optionalDependencies:
|
||||
"@img/sharp-libvips-linux-x64" "1.2.0"
|
||||
|
||||
"@img/sharp-linux-x64@0.34.4":
|
||||
version "0.34.4"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.4.tgz#573ce4196b2d0771bba32acc13a37b7adc9b6212"
|
||||
integrity sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A==
|
||||
optionalDependencies:
|
||||
"@img/sharp-libvips-linux-x64" "1.2.3"
|
||||
|
||||
"@img/sharp-linuxmusl-arm64@0.33.5":
|
||||
version "0.33.5"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz#252975b915894fb315af5deea174651e208d3d6b"
|
||||
@@ -1414,6 +1513,13 @@
|
||||
optionalDependencies:
|
||||
"@img/sharp-libvips-linuxmusl-arm64" "1.2.0"
|
||||
|
||||
"@img/sharp-linuxmusl-arm64@0.34.4":
|
||||
version "0.34.4"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.4.tgz#3c91bc8348cc3b42b43c6fca14f9dbb5cb47bd0d"
|
||||
integrity sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA==
|
||||
optionalDependencies:
|
||||
"@img/sharp-libvips-linuxmusl-arm64" "1.2.3"
|
||||
|
||||
"@img/sharp-linuxmusl-x64@0.33.5":
|
||||
version "0.33.5"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz#3f4609ac5d8ef8ec7dadee80b560961a60fd4f48"
|
||||
@@ -1435,6 +1541,13 @@
|
||||
optionalDependencies:
|
||||
"@img/sharp-libvips-linuxmusl-x64" "1.2.0"
|
||||
|
||||
"@img/sharp-linuxmusl-x64@0.34.4":
|
||||
version "0.34.4"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.4.tgz#33de7d476ac9e2db7ef654331b54cc679b806bda"
|
||||
integrity sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg==
|
||||
optionalDependencies:
|
||||
"@img/sharp-libvips-linuxmusl-x64" "1.2.3"
|
||||
|
||||
"@img/sharp-wasm32@0.33.5":
|
||||
version "0.33.5"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz#6f44f3283069d935bb5ca5813153572f3e6f61a1"
|
||||
@@ -1456,6 +1569,13 @@
|
||||
dependencies:
|
||||
"@emnapi/runtime" "^1.4.4"
|
||||
|
||||
"@img/sharp-wasm32@0.34.4":
|
||||
version "0.34.4"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-wasm32/-/sharp-wasm32-0.34.4.tgz#d617f7b3f851f899802298f360667c20605c0198"
|
||||
integrity sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA==
|
||||
dependencies:
|
||||
"@emnapi/runtime" "^1.5.0"
|
||||
|
||||
"@img/sharp-win32-arm64@0.34.2":
|
||||
version "0.34.2"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.2.tgz#f37bee0f60c167f825a09d2b8de6849b823e8b30"
|
||||
@@ -1466,6 +1586,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.3.tgz#3e8654e368bb349d45799a0d7aeb29db2298628e"
|
||||
integrity sha512-MjnHPnbqMXNC2UgeLJtX4XqoVHHlZNd+nPt1kRPmj63wURegwBhZlApELdtxM2OIZDRv/DFtLcNhVbd1z8GYXQ==
|
||||
|
||||
"@img/sharp-win32-arm64@0.34.4":
|
||||
version "0.34.4"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.4.tgz#38e2c8a88826eac647f7c3f99efefb39897a8f5c"
|
||||
integrity sha512-2Q250do/5WXTwxW3zjsEuMSv5sUU4Tq9VThWKlU2EYLm4MB7ZeMwF+SFJutldYODXF6jzc6YEOC+VfX0SZQPqA==
|
||||
|
||||
"@img/sharp-win32-ia32@0.33.5":
|
||||
version "0.33.5"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz#1a0c839a40c5351e9885628c85f2e5dfd02b52a9"
|
||||
@@ -1481,6 +1606,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.3.tgz#9d4c105e8d5074a351a81a0b6d056e0af913bf76"
|
||||
integrity sha512-xuCdhH44WxuXgOM714hn4amodJMZl3OEvf0GVTm0BEyMeA2to+8HEdRPShH0SLYptJY1uBw+SCFP9WVQi1Q/cw==
|
||||
|
||||
"@img/sharp-win32-ia32@0.34.4":
|
||||
version "0.34.4"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.4.tgz#003a7eb0fdaba600790c3007cfd756e41a9cf749"
|
||||
integrity sha512-3ZeLue5V82dT92CNL6rsal6I2weKw1cYu+rGKm8fOCCtJTR2gYeUfY3FqUnIJsMUPIH68oS5jmZ0NiJ508YpEw==
|
||||
|
||||
"@img/sharp-win32-x64@0.33.5":
|
||||
version "0.33.5"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz#56f00962ff0c4e0eb93d34a047d29fa995e3e342"
|
||||
@@ -1496,6 +1626,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.3.tgz#d20c89bd41b1dd3d76d8575714aaaa3c43204b6a"
|
||||
integrity sha512-OWwz05d++TxzLEv4VnsTz5CmZ6mI6S05sfQGEMrNrQcOEERbX46332IvE7pO/EUiw7jUrrS40z/M7kPyjfl04g==
|
||||
|
||||
"@img/sharp-win32-x64@0.34.4":
|
||||
version "0.34.4"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.4.tgz#b19f1f88ace8bfc20784a0ad31767f3438e025d1"
|
||||
integrity sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig==
|
||||
|
||||
"@inquirer/confirm@^5.0.0":
|
||||
version "5.1.12"
|
||||
resolved "https://registry.yarnpkg.com/@inquirer/confirm/-/confirm-5.1.12.tgz#387037889a5a558ceefe52e978228630aa6e7d0e"
|
||||
@@ -1811,10 +1946,10 @@
|
||||
"@emnapi/runtime" "^1.3.1"
|
||||
"@tybys/wasm-util" "^0.9.0"
|
||||
|
||||
"@next/env@15.3.4":
|
||||
version "15.3.4"
|
||||
resolved "https://registry.yarnpkg.com/@next/env/-/env-15.3.4.tgz#5b41485596d5bfea0918db73f90b7a6db734da3f"
|
||||
integrity sha512-ZkdYzBseS6UjYzz6ylVKPOK+//zLWvD6Ta+vpoye8cW11AjiQjGYVibF0xuvT4L0iJfAPfZLFidaEzAOywyOAQ==
|
||||
"@next/env@16.1.0":
|
||||
version "16.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@next/env/-/env-16.1.0.tgz#b5398c47619789f190211e90e3e032b3d84a6458"
|
||||
integrity sha512-Dd23XQeFHmhf3KBW76leYVkejHlCdB7erakC2At2apL1N08Bm+dLYNP+nNHh0tzUXfPQcNcXiQyacw0PG4Fcpw==
|
||||
|
||||
"@next/eslint-plugin-next@15.1.3":
|
||||
version "15.1.3"
|
||||
@@ -1823,45 +1958,45 @@
|
||||
dependencies:
|
||||
fast-glob "3.3.1"
|
||||
|
||||
"@next/swc-darwin-arm64@15.3.4":
|
||||
version "15.3.4"
|
||||
resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.3.4.tgz#cb2849b8374eb6b52376d4e7abed2a21a2ff24d6"
|
||||
integrity sha512-z0qIYTONmPRbwHWvpyrFXJd5F9YWLCsw3Sjrzj2ZvMYy9NPQMPZ1NjOJh4ojr4oQzcGYwgJKfidzehaNa1BpEg==
|
||||
"@next/swc-darwin-arm64@16.1.0":
|
||||
version "16.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.0.tgz#b7bded3911f35f585243292f85f63c3ef7d4fd85"
|
||||
integrity sha512-onHq8dl8KjDb8taANQdzs3XmIqQWV3fYdslkGENuvVInFQzZnuBYYOG2HGHqqtvgmEU7xWzhgndXXxnhk4Z3fQ==
|
||||
|
||||
"@next/swc-darwin-x64@15.3.4":
|
||||
version "15.3.4"
|
||||
resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-15.3.4.tgz#aa7fd968af7e53aa17d4f234cf7722b3899712cf"
|
||||
integrity sha512-Z0FYJM8lritw5Wq+vpHYuCIzIlEMjewG2aRkc3Hi2rcbULknYL/xqfpBL23jQnCSrDUGAo/AEv0Z+s2bff9Zkw==
|
||||
"@next/swc-darwin-x64@16.1.0":
|
||||
version "16.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.0.tgz#93394a41b9fd368e1d12c3d542bd118b5d6a5ccf"
|
||||
integrity sha512-Am6VJTp8KhLuAH13tPrAoVIXzuComlZlMwGr++o2KDjWiKPe3VwpxYhgV6I4gKls2EnsIMggL4y7GdXyDdJcFA==
|
||||
|
||||
"@next/swc-linux-arm64-gnu@15.3.4":
|
||||
version "15.3.4"
|
||||
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.3.4.tgz#5da3d6d6055665d0c3a2dab0bc0471064bc9eece"
|
||||
integrity sha512-l8ZQOCCg7adwmsnFm8m5q9eIPAHdaB2F3cxhufYtVo84pymwKuWfpYTKcUiFcutJdp9xGHC+F1Uq3xnFU1B/7g==
|
||||
"@next/swc-linux-arm64-gnu@16.1.0":
|
||||
version "16.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.0.tgz#e33c4bc206366501c9bf323a9cc8e4b0aaa6ea9b"
|
||||
integrity sha512-fVicfaJT6QfghNyg8JErZ+EMNQ812IS0lmKfbmC01LF1nFBcKfcs4Q75Yy8IqnsCqH/hZwGhqzj3IGVfWV6vpA==
|
||||
|
||||
"@next/swc-linux-arm64-musl@15.3.4":
|
||||
version "15.3.4"
|
||||
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.3.4.tgz#9043ccc397746c94c2452d301e8f95a33aec22e8"
|
||||
integrity sha512-wFyZ7X470YJQtpKot4xCY3gpdn8lE9nTlldG07/kJYexCUpX1piX+MBfZdvulo+t1yADFVEuzFfVHfklfEx8kw==
|
||||
"@next/swc-linux-arm64-musl@16.1.0":
|
||||
version "16.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.0.tgz#aeef7cd46a6b69c986dd08a730d40fe8853c629e"
|
||||
integrity sha512-TojQnDRoX7wJWXEEwdfuJtakMDW64Q7NrxQPviUnfYJvAx5/5wcGE+1vZzQ9F17m+SdpFeeXuOr6v3jbyusYMQ==
|
||||
|
||||
"@next/swc-linux-x64-gnu@15.3.4":
|
||||
version "15.3.4"
|
||||
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.3.4.tgz#49a33f904a51a8c665406ca7e5a748f480bf195d"
|
||||
integrity sha512-gEbH9rv9o7I12qPyvZNVTyP/PWKqOp8clvnoYZQiX800KkqsaJZuOXkWgMa7ANCCh/oEN2ZQheh3yH8/kWPSEg==
|
||||
"@next/swc-linux-x64-gnu@16.1.0":
|
||||
version "16.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.0.tgz#6882bc63ef3566d0f7596ba7945f58f378dbb1eb"
|
||||
integrity sha512-quhNFVySW4QwXiZkZ34SbfzNBm27vLrxZ2HwTfFFO1BBP0OY1+pI0nbyewKeq1FriqU+LZrob/cm26lwsiAi8Q==
|
||||
|
||||
"@next/swc-linux-x64-musl@15.3.4":
|
||||
version "15.3.4"
|
||||
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.3.4.tgz#8beaff35d8f11961ea80d12a10226581df4c5a74"
|
||||
integrity sha512-Cf8sr0ufuC/nu/yQ76AnarbSAXcwG/wj+1xFPNbyNo8ltA6kw5d5YqO8kQuwVIxk13SBdtgXrNyom3ZosHAy4A==
|
||||
"@next/swc-linux-x64-musl@16.1.0":
|
||||
version "16.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.0.tgz#b1b83cc42f8bf32cbc7ba0e97ccf59a4002cf1a2"
|
||||
integrity sha512-6JW0z2FZUK5iOVhUIWqE4RblAhUj1EwhZ/MwteGb//SpFTOHydnhbp3868gxalwea+mbOLWO6xgxj9wA9wNvNw==
|
||||
|
||||
"@next/swc-win32-arm64-msvc@15.3.4":
|
||||
version "15.3.4"
|
||||
resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.3.4.tgz#149d9a35068ecda317af138814539929c9c269af"
|
||||
integrity sha512-ay5+qADDN3rwRbRpEhTOreOn1OyJIXS60tg9WMYTWCy3fB6rGoyjLVxc4dR9PYjEdR2iDYsaF5h03NA+XuYPQQ==
|
||||
"@next/swc-win32-arm64-msvc@16.1.0":
|
||||
version "16.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.0.tgz#8e4dccc755fc285d0455d4fb14e9e7d551d2357c"
|
||||
integrity sha512-+DK/akkAvvXn5RdYN84IOmLkSy87SCmpofJPdB8vbLmf01BzntPBSYXnMvnEEv/Vcf3HYJwt24QZ/s6sWAwOMQ==
|
||||
|
||||
"@next/swc-win32-x64-msvc@15.3.4":
|
||||
version "15.3.4"
|
||||
resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.3.4.tgz#a652327782d838c2b875eaf216187c51b8409775"
|
||||
integrity sha512-4kDt31Bc9DGyYs41FTL1/kNpDeHyha2TC0j5sRRoKCyrhNcfZ/nRQkAUlF27mETwm8QyHqIjHJitfcza2Iykfg==
|
||||
"@next/swc-win32-x64-msvc@16.1.0":
|
||||
version "16.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.0.tgz#288b4803e56e6b15a0b203e39b7ed3b8dd628755"
|
||||
integrity sha512-Tr0j94MphimCCks+1rtYPzQFK+faJuhHWCegU9S9gDlgyOk8Y3kPmO64UcjyzZAlligeBtYZ/2bEyrKq0d2wqQ==
|
||||
|
||||
"@node-rs/argon2-android-arm-eabi@2.0.2":
|
||||
version "2.0.2"
|
||||
@@ -1981,6 +2116,17 @@
|
||||
resolved "https://registry.yarnpkg.com/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz#3dc35ba0f1e66b403c00b39344f870298ebb1c8e"
|
||||
integrity sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==
|
||||
|
||||
"@omit/react-confirm-dialog@^1.2.0":
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/@omit/react-confirm-dialog/-/react-confirm-dialog-1.2.0.tgz#fff9c47570a91411d8cd8a1dc64385b09fbd12b7"
|
||||
integrity sha512-FXfEnOLPQdGwPXe0zTjgVVbS6OZdAZ+Qs+tSBU819CwtXLNjvx+mimvYvnGnm6dIHK4PGV17D7ViSn+KI8HPRg==
|
||||
dependencies:
|
||||
"@radix-ui/react-alert-dialog" "^1.1.13"
|
||||
"@radix-ui/react-slot" "^1.2.2"
|
||||
class-variance-authority "^0.7.1"
|
||||
clsx "^2.1.1"
|
||||
tailwind-merge "^2.6.0"
|
||||
|
||||
"@open-draft/deferred-promise@^2.2.0":
|
||||
version "2.2.0"
|
||||
resolved "https://registry.yarnpkg.com/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz#4a822d10f6f0e316be4d67b4d4f8c9a124b073bd"
|
||||
@@ -2435,6 +2581,23 @@
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-1.1.2.tgz#83f415c4425f21e3d27914c12b3272a32e3dae65"
|
||||
integrity sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==
|
||||
|
||||
"@radix-ui/primitive@1.1.3":
|
||||
version "1.1.3"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-1.1.3.tgz#e2dbc13bdc5e4168f4334f75832d7bdd3e2de5ba"
|
||||
integrity sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==
|
||||
|
||||
"@radix-ui/react-alert-dialog@^1.1.13", "@radix-ui/react-alert-dialog@^1.1.15":
|
||||
version "1.1.15"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz#fa751d0fdd9aa2a90961c9901dba18e638dd4b41"
|
||||
integrity sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==
|
||||
dependencies:
|
||||
"@radix-ui/primitive" "1.1.3"
|
||||
"@radix-ui/react-compose-refs" "1.1.2"
|
||||
"@radix-ui/react-context" "1.1.2"
|
||||
"@radix-ui/react-dialog" "1.1.15"
|
||||
"@radix-ui/react-primitive" "2.1.3"
|
||||
"@radix-ui/react-slot" "1.2.3"
|
||||
|
||||
"@radix-ui/react-arrow@1.1.2":
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-arrow/-/react-arrow-1.1.2.tgz#30c0d574d7bb10eed55cd7007b92d38b03c6b2ab"
|
||||
@@ -2548,6 +2711,26 @@
|
||||
aria-hidden "^1.1.1"
|
||||
react-remove-scroll "2.5.5"
|
||||
|
||||
"@radix-ui/react-dialog@1.1.15":
|
||||
version "1.1.15"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz#1de3d7a7e9a17a9874d29c07f5940a18a119b632"
|
||||
integrity sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==
|
||||
dependencies:
|
||||
"@radix-ui/primitive" "1.1.3"
|
||||
"@radix-ui/react-compose-refs" "1.1.2"
|
||||
"@radix-ui/react-context" "1.1.2"
|
||||
"@radix-ui/react-dismissable-layer" "1.1.11"
|
||||
"@radix-ui/react-focus-guards" "1.1.3"
|
||||
"@radix-ui/react-focus-scope" "1.1.7"
|
||||
"@radix-ui/react-id" "1.1.1"
|
||||
"@radix-ui/react-portal" "1.1.9"
|
||||
"@radix-ui/react-presence" "1.1.5"
|
||||
"@radix-ui/react-primitive" "2.1.3"
|
||||
"@radix-ui/react-slot" "1.2.3"
|
||||
"@radix-ui/react-use-controllable-state" "1.2.2"
|
||||
aria-hidden "^1.2.4"
|
||||
react-remove-scroll "^2.6.3"
|
||||
|
||||
"@radix-ui/react-dialog@^1.1.5":
|
||||
version "1.1.6"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-dialog/-/react-dialog-1.1.6.tgz#65b4465e99ad900f28a98eed9a94bb21ec644bf7"
|
||||
@@ -2601,6 +2784,17 @@
|
||||
"@radix-ui/react-use-callback-ref" "1.1.1"
|
||||
"@radix-ui/react-use-escape-keydown" "1.1.1"
|
||||
|
||||
"@radix-ui/react-dismissable-layer@1.1.11":
|
||||
version "1.1.11"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz#e33ab6f6bdaa00f8f7327c408d9f631376b88b37"
|
||||
integrity sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==
|
||||
dependencies:
|
||||
"@radix-ui/primitive" "1.1.3"
|
||||
"@radix-ui/react-compose-refs" "1.1.2"
|
||||
"@radix-ui/react-primitive" "2.1.3"
|
||||
"@radix-ui/react-use-callback-ref" "1.1.1"
|
||||
"@radix-ui/react-use-escape-keydown" "1.1.1"
|
||||
|
||||
"@radix-ui/react-dismissable-layer@1.1.5":
|
||||
version "1.1.5"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.5.tgz#96dde2be078c694a621e55e047406c58cd5fe774"
|
||||
@@ -2637,6 +2831,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz#8635edd346304f8b42cae86b05912b61aef27afe"
|
||||
integrity sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==
|
||||
|
||||
"@radix-ui/react-focus-guards@1.1.3":
|
||||
version "1.1.3"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz#2a5669e464ad5fde9f86d22f7fdc17781a4dfa7f"
|
||||
integrity sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==
|
||||
|
||||
"@radix-ui/react-focus-scope@1.0.4":
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.0.4.tgz#2ac45fce8c5bb33eb18419cdc1905ef4f1906525"
|
||||
@@ -2656,6 +2855,15 @@
|
||||
"@radix-ui/react-primitive" "2.0.2"
|
||||
"@radix-ui/react-use-callback-ref" "1.1.0"
|
||||
|
||||
"@radix-ui/react-focus-scope@1.1.7":
|
||||
version "1.1.7"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz#dfe76fc103537d80bf42723a183773fd07bfb58d"
|
||||
integrity sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==
|
||||
dependencies:
|
||||
"@radix-ui/react-compose-refs" "1.1.2"
|
||||
"@radix-ui/react-primitive" "2.1.3"
|
||||
"@radix-ui/react-use-callback-ref" "1.1.1"
|
||||
|
||||
"@radix-ui/react-hover-card@^1.1.14":
|
||||
version "1.1.14"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-hover-card/-/react-hover-card-1.1.14.tgz#a557cda6470e214e744e46ede839496e8b291843"
|
||||
@@ -2826,6 +3034,14 @@
|
||||
"@radix-ui/react-compose-refs" "1.1.2"
|
||||
"@radix-ui/react-use-layout-effect" "1.1.1"
|
||||
|
||||
"@radix-ui/react-presence@1.1.5":
|
||||
version "1.1.5"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-presence/-/react-presence-1.1.5.tgz#5d8f28ac316c32f078afce2996839250c10693db"
|
||||
integrity sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==
|
||||
dependencies:
|
||||
"@radix-ui/react-compose-refs" "1.1.2"
|
||||
"@radix-ui/react-use-layout-effect" "1.1.1"
|
||||
|
||||
"@radix-ui/react-primitive@1.0.3":
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-1.0.3.tgz#d49ea0f3f0b2fe3ab1cb5667eb03e8b843b914d0"
|
||||
@@ -2920,20 +3136,27 @@
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/react-compose-refs" "1.0.1"
|
||||
|
||||
"@radix-ui/react-slot@1.1.2", "@radix-ui/react-slot@^1.1.1":
|
||||
"@radix-ui/react-slot@1.1.2":
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.1.2.tgz#daffff7b2bfe99ade63b5168407680b93c00e1c6"
|
||||
integrity sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==
|
||||
dependencies:
|
||||
"@radix-ui/react-compose-refs" "1.1.1"
|
||||
|
||||
"@radix-ui/react-slot@1.2.3":
|
||||
"@radix-ui/react-slot@1.2.3", "@radix-ui/react-slot@^1.2.2":
|
||||
version "1.2.3"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz#502d6e354fc847d4169c3bc5f189de777f68cfe1"
|
||||
integrity sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==
|
||||
dependencies:
|
||||
"@radix-ui/react-compose-refs" "1.1.2"
|
||||
|
||||
"@radix-ui/react-slot@^1.2.4":
|
||||
version "1.2.4"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.2.4.tgz#63c0ba05fdf90cc49076b94029c852d7bac1fb83"
|
||||
integrity sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==
|
||||
dependencies:
|
||||
"@radix-ui/react-compose-refs" "1.1.2"
|
||||
|
||||
"@radix-ui/react-switch@^1.1.3":
|
||||
version "1.1.3"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-switch/-/react-switch-1.1.3.tgz#cb6386909d1d3f65a2b81a3b15da8c91d18f49b0"
|
||||
@@ -4101,11 +4324,6 @@
|
||||
resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.0.0.tgz#f193b73dc316c4170f2e82a881da0f550d551b9c"
|
||||
integrity sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==
|
||||
|
||||
"@swc/counter@0.1.3":
|
||||
version "0.1.3"
|
||||
resolved "https://registry.yarnpkg.com/@swc/counter/-/counter-0.1.3.tgz#cc7463bd02949611c6329596fccd2b0ec782b0e9"
|
||||
integrity sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==
|
||||
|
||||
"@swc/helpers@0.5.15":
|
||||
version "0.5.15"
|
||||
resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.5.15.tgz#79efab344c5819ecf83a43f3f9f811fc84b516d7"
|
||||
@@ -5364,6 +5582,11 @@ base64-js@^1.1.2, base64-js@^1.3.0, base64-js@^1.3.1:
|
||||
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
|
||||
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
|
||||
|
||||
baseline-browser-mapping@^2.8.3:
|
||||
version "2.9.11"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz#53724708c8db5f97206517ecfe362dbe5181deea"
|
||||
integrity sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==
|
||||
|
||||
bcp-47-match@^2.0.0:
|
||||
version "2.0.3"
|
||||
resolved "https://registry.yarnpkg.com/bcp-47-match/-/bcp-47-match-2.0.3.tgz#603226f6e5d3914a581408be33b28a53144b09d0"
|
||||
@@ -5498,13 +5721,6 @@ bundle-require@^5.1.0:
|
||||
dependencies:
|
||||
load-tsconfig "^0.2.3"
|
||||
|
||||
busboy@1.6.0:
|
||||
version "1.6.0"
|
||||
resolved "https://registry.yarnpkg.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893"
|
||||
integrity sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==
|
||||
dependencies:
|
||||
streamsearch "^1.1.0"
|
||||
|
||||
bytes@3.1.2, bytes@^3.1.2:
|
||||
version "3.1.2"
|
||||
resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5"
|
||||
@@ -6486,6 +6702,11 @@ detect-libc@^2.0.3, detect-libc@^2.0.4:
|
||||
resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.4.tgz#f04715b8ba815e53b4d8109655b6508a6865a7e8"
|
||||
integrity sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==
|
||||
|
||||
detect-libc@^2.1.0:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.1.tgz#9f1e511ace6bb525efea4651345beac424dac7b9"
|
||||
integrity sha512-ecqj/sy1jcK1uWrwpR67UhYrIFQ+5WlGxth34WquCbamhFA6hkkwiu37o6J5xCHdo1oixJRfVRw+ywV+Hq/0Aw==
|
||||
|
||||
detect-node-es@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/detect-node-es/-/detect-node-es-1.1.0.tgz#163acdf643330caa0b4cd7c21e7ee7755d6fa493"
|
||||
@@ -9815,28 +10036,27 @@ next-themes@^0.4.4:
|
||||
resolved "https://registry.yarnpkg.com/next-themes/-/next-themes-0.4.6.tgz#8d7e92d03b8fea6582892a50a928c9b23502e8b6"
|
||||
integrity sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==
|
||||
|
||||
next@^15.3.4:
|
||||
version "15.3.4"
|
||||
resolved "https://registry.yarnpkg.com/next/-/next-15.3.4.tgz#7a4863be14c998f1ec1e6d8d4e9e1a1291c8cbe3"
|
||||
integrity sha512-mHKd50C+mCjam/gcnwqL1T1vPx/XQNFlXqFIVdgQdVAFY9iIQtY0IfaVflEYzKiqjeA7B0cYYMaCrmAYFjs4rA==
|
||||
next@^16.1.0:
|
||||
version "16.1.0"
|
||||
resolved "https://registry.yarnpkg.com/next/-/next-16.1.0.tgz#af5941f1c313655ace98b60f26db8de5a2692c42"
|
||||
integrity sha512-Y+KbmDbefYtHDDQKLNrmzE/YYzG2msqo2VXhzh5yrJ54tx/6TmGdkR5+kP9ma7i7LwZpZMfoY3m/AoPPPKxtVw==
|
||||
dependencies:
|
||||
"@next/env" "15.3.4"
|
||||
"@swc/counter" "0.1.3"
|
||||
"@next/env" "16.1.0"
|
||||
"@swc/helpers" "0.5.15"
|
||||
busboy "1.6.0"
|
||||
baseline-browser-mapping "^2.8.3"
|
||||
caniuse-lite "^1.0.30001579"
|
||||
postcss "8.4.31"
|
||||
styled-jsx "5.1.6"
|
||||
optionalDependencies:
|
||||
"@next/swc-darwin-arm64" "15.3.4"
|
||||
"@next/swc-darwin-x64" "15.3.4"
|
||||
"@next/swc-linux-arm64-gnu" "15.3.4"
|
||||
"@next/swc-linux-arm64-musl" "15.3.4"
|
||||
"@next/swc-linux-x64-gnu" "15.3.4"
|
||||
"@next/swc-linux-x64-musl" "15.3.4"
|
||||
"@next/swc-win32-arm64-msvc" "15.3.4"
|
||||
"@next/swc-win32-x64-msvc" "15.3.4"
|
||||
sharp "^0.34.1"
|
||||
"@next/swc-darwin-arm64" "16.1.0"
|
||||
"@next/swc-darwin-x64" "16.1.0"
|
||||
"@next/swc-linux-arm64-gnu" "16.1.0"
|
||||
"@next/swc-linux-arm64-musl" "16.1.0"
|
||||
"@next/swc-linux-x64-gnu" "16.1.0"
|
||||
"@next/swc-linux-x64-musl" "16.1.0"
|
||||
"@next/swc-win32-arm64-msvc" "16.1.0"
|
||||
"@next/swc-win32-x64-msvc" "16.1.0"
|
||||
sharp "^0.34.4"
|
||||
|
||||
nlcst-to-string@^4.0.0:
|
||||
version "4.0.0"
|
||||
@@ -10566,6 +10786,11 @@ prelude-ls@^1.2.1:
|
||||
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
|
||||
integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==
|
||||
|
||||
prettier@^3.6.2:
|
||||
version "3.6.2"
|
||||
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.6.2.tgz#ccda02a1003ebbb2bfda6f83a074978f608b9393"
|
||||
integrity sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==
|
||||
|
||||
pretty-bytes@^6.1.1:
|
||||
version "6.1.1"
|
||||
resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-6.1.1.tgz#38cd6bb46f47afbf667c202cfc754bffd2016a3b"
|
||||
@@ -10719,12 +10944,12 @@ raw-body@^3.0.0:
|
||||
iconv-lite "0.6.3"
|
||||
unpipe "1.0.0"
|
||||
|
||||
react-dom@19:
|
||||
version "19.0.0"
|
||||
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.0.0.tgz#43446f1f01c65a4cd7f7588083e686a6726cfb57"
|
||||
integrity sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==
|
||||
react-dom@^19.2.3:
|
||||
version "19.2.3"
|
||||
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.2.3.tgz#f0b61d7e5c4a86773889fcc1853af3ed5f215b17"
|
||||
integrity sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==
|
||||
dependencies:
|
||||
scheduler "^0.25.0"
|
||||
scheduler "^0.27.0"
|
||||
|
||||
react-hook-form@^7.54.2:
|
||||
version "7.54.2"
|
||||
@@ -10774,10 +10999,10 @@ react-style-singleton@^2.2.1, react-style-singleton@^2.2.2, react-style-singleto
|
||||
get-nonce "^1.0.0"
|
||||
tslib "^2.0.0"
|
||||
|
||||
react@19:
|
||||
version "19.0.0"
|
||||
resolved "https://registry.yarnpkg.com/react/-/react-19.0.0.tgz#6e1969251b9f108870aa4bff37a0ce9ddfaaabdd"
|
||||
integrity sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==
|
||||
react@^19.2.3:
|
||||
version "19.2.3"
|
||||
resolved "https://registry.yarnpkg.com/react/-/react-19.2.3.tgz#d83e5e8e7a258cf6b4fe28640515f99b87cd19b8"
|
||||
integrity sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==
|
||||
|
||||
read-cache@^1.0.0:
|
||||
version "1.0.0"
|
||||
@@ -11398,10 +11623,10 @@ sax@^1.2.4:
|
||||
resolved "https://registry.yarnpkg.com/sax/-/sax-1.4.1.tgz#44cc8988377f126304d3b3fc1010c733b929ef0f"
|
||||
integrity sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==
|
||||
|
||||
scheduler@^0.25.0:
|
||||
version "0.25.0"
|
||||
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.25.0.tgz#336cd9768e8cceebf52d3c80e3dcf5de23e7e015"
|
||||
integrity sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==
|
||||
scheduler@^0.27.0:
|
||||
version "0.27.0"
|
||||
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.27.0.tgz#0c4ef82d67d1e5c1e359e8fc76d3a87f045fe5bd"
|
||||
integrity sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==
|
||||
|
||||
semver@^6.3.1:
|
||||
version "6.3.1"
|
||||
@@ -11548,7 +11773,7 @@ sharp@^0.33.3:
|
||||
"@img/sharp-win32-ia32" "0.33.5"
|
||||
"@img/sharp-win32-x64" "0.33.5"
|
||||
|
||||
sharp@^0.34.1, sharp@^0.34.2:
|
||||
sharp@^0.34.2:
|
||||
version "0.34.2"
|
||||
resolved "https://registry.yarnpkg.com/sharp/-/sharp-0.34.2.tgz#648bd639854dbe48047b0b420213c186d036b32d"
|
||||
integrity sha512-lszvBmB9QURERtyKT2bNmsgxXK0ShJrL/fvqlonCo7e6xBF8nT8xU6pW+PMIbLsz0RxQk3rgH9kd8UmvOzlMJg==
|
||||
@@ -11611,6 +11836,38 @@ sharp@^0.34.3:
|
||||
"@img/sharp-win32-ia32" "0.34.3"
|
||||
"@img/sharp-win32-x64" "0.34.3"
|
||||
|
||||
sharp@^0.34.4:
|
||||
version "0.34.4"
|
||||
resolved "https://registry.yarnpkg.com/sharp/-/sharp-0.34.4.tgz#73c2c5a425e98250b8b927e5537f711da8966e38"
|
||||
integrity sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA==
|
||||
dependencies:
|
||||
"@img/colour" "^1.0.0"
|
||||
detect-libc "^2.1.0"
|
||||
semver "^7.7.2"
|
||||
optionalDependencies:
|
||||
"@img/sharp-darwin-arm64" "0.34.4"
|
||||
"@img/sharp-darwin-x64" "0.34.4"
|
||||
"@img/sharp-libvips-darwin-arm64" "1.2.3"
|
||||
"@img/sharp-libvips-darwin-x64" "1.2.3"
|
||||
"@img/sharp-libvips-linux-arm" "1.2.3"
|
||||
"@img/sharp-libvips-linux-arm64" "1.2.3"
|
||||
"@img/sharp-libvips-linux-ppc64" "1.2.3"
|
||||
"@img/sharp-libvips-linux-s390x" "1.2.3"
|
||||
"@img/sharp-libvips-linux-x64" "1.2.3"
|
||||
"@img/sharp-libvips-linuxmusl-arm64" "1.2.3"
|
||||
"@img/sharp-libvips-linuxmusl-x64" "1.2.3"
|
||||
"@img/sharp-linux-arm" "0.34.4"
|
||||
"@img/sharp-linux-arm64" "0.34.4"
|
||||
"@img/sharp-linux-ppc64" "0.34.4"
|
||||
"@img/sharp-linux-s390x" "0.34.4"
|
||||
"@img/sharp-linux-x64" "0.34.4"
|
||||
"@img/sharp-linuxmusl-arm64" "0.34.4"
|
||||
"@img/sharp-linuxmusl-x64" "0.34.4"
|
||||
"@img/sharp-wasm32" "0.34.4"
|
||||
"@img/sharp-win32-arm64" "0.34.4"
|
||||
"@img/sharp-win32-ia32" "0.34.4"
|
||||
"@img/sharp-win32-x64" "0.34.4"
|
||||
|
||||
shebang-command@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea"
|
||||
@@ -11805,11 +12062,6 @@ stream-replace-string@^2.0.0:
|
||||
resolved "https://registry.yarnpkg.com/stream-replace-string/-/stream-replace-string-2.0.0.tgz#e49fd584bd1c633613e010bc73b9db49cb5024ad"
|
||||
integrity sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w==
|
||||
|
||||
streamsearch@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764"
|
||||
integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==
|
||||
|
||||
strict-event-emitter@^0.5.1:
|
||||
version "0.5.1"
|
||||
resolved "https://registry.yarnpkg.com/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz#1602ece81c51574ca39c6815e09f1a3e8550bd93"
|
||||
@@ -12048,7 +12300,7 @@ tabbable@^6.2.0:
|
||||
resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-6.2.0.tgz#732fb62bc0175cfcec257330be187dcfba1f3b97"
|
||||
integrity sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==
|
||||
|
||||
tailwind-merge@^2.2.2, tailwind-merge@^2.5.5:
|
||||
tailwind-merge@^2.2.2, tailwind-merge@^2.5.5, tailwind-merge@^2.6.0:
|
||||
version "2.6.0"
|
||||
resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-2.6.0.tgz#ac5fb7e227910c038d458f396b7400d93a3142d5"
|
||||
integrity sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==
|
||||
|
||||
Reference in New Issue
Block a user