feat: add one-click channel approval via email
Replace manual Threadfin lookup instructions with a signed approve button in admin notification emails. Clicking it activates channels in Threadfin via WebSocket API and sends a confirmation email to the requester. - Add /api/approve-channels endpoint with HMAC-signed tokens - Add lib/threadfin.ts for WebSocket-based channel activation - Add lib/token.ts for signed token creation/verification - Add ws dependency for Threadfin WebSocket communication - Update docker-compose with TOKEN_SECRET and THREADFIN_* env vars Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
cee87aef10
commit
8c79b97ef7
|
|
@ -6,3 +6,10 @@ SMTP_PASS=your-mailbox-password
|
||||||
|
|
||||||
# Admin email to receive access request notifications
|
# Admin email to receive access request notifications
|
||||||
ADMIN_EMAIL=jeff@jeffemmett.com
|
ADMIN_EMAIL=jeff@jeffemmett.com
|
||||||
|
|
||||||
|
# Channel approval token signing secret (generate with: openssl rand -hex 32)
|
||||||
|
TOKEN_SECRET=your-random-secret-here
|
||||||
|
|
||||||
|
# Threadfin credentials for one-click channel activation
|
||||||
|
THREADFIN_USER=your-threadfin-username
|
||||||
|
THREADFIN_PASS=your-threadfin-password
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,163 @@
|
||||||
|
import { NextRequest } from 'next/server'
|
||||||
|
import nodemailer from 'nodemailer'
|
||||||
|
import { verifyApproveToken } from '@/lib/token'
|
||||||
|
import { activateChannels } from '@/lib/threadfin'
|
||||||
|
|
||||||
|
function htmlPage(title: string, body: string): Response {
|
||||||
|
return new Response(
|
||||||
|
`<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>${title} — Jefflix</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 600px; margin: 60px auto; padding: 0 20px; color: #1a1a1a; background: #0a0a0a; color: #e5e5e5; }
|
||||||
|
.card { background: #1a1a1a; border: 1px solid #333; border-radius: 12px; padding: 32px; }
|
||||||
|
.icon { font-size: 48px; margin-bottom: 16px; }
|
||||||
|
h1 { margin: 0 0 16px; font-size: 24px; }
|
||||||
|
ul { padding-left: 20px; line-height: 1.8; }
|
||||||
|
code { background: #2a2a2a; padding: 2px 6px; border-radius: 4px; font-size: 13px; }
|
||||||
|
.success { color: #4ade80; }
|
||||||
|
.warning { color: #facc15; }
|
||||||
|
.error { color: #f87171; }
|
||||||
|
.muted { color: #888; font-size: 13px; margin-top: 24px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
${body}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`,
|
||||||
|
{ status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const token = request.nextUrl.searchParams.get('token')
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return htmlPage('Invalid Link', `
|
||||||
|
<div class="icon">❌</div>
|
||||||
|
<h1 class="error">Invalid Link</h1>
|
||||||
|
<p>No approval token provided.</p>
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = verifyApproveToken(token)
|
||||||
|
if (!payload) {
|
||||||
|
return htmlPage('Expired or Invalid', `
|
||||||
|
<div class="icon">⏳</div>
|
||||||
|
<h1 class="error">Link Expired or Invalid</h1>
|
||||||
|
<p>This approval link has expired or is invalid. Channel requests expire after 7 days.</p>
|
||||||
|
<p>Ask the user to submit a new request.</p>
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await activateChannels(
|
||||||
|
payload.channels.map((ch) => ch.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Send confirmation email to the requester
|
||||||
|
await sendConfirmationEmail(payload.email, result.activated, result.notFound, payload.channels)
|
||||||
|
|
||||||
|
const activatedHtml = result.activated.length > 0
|
||||||
|
? `<h2 class="success">Activated (${result.activated.length})</h2>
|
||||||
|
<ul>${result.activated.map((id) => {
|
||||||
|
const ch = payload.channels.find((c) => c.id === id)
|
||||||
|
return `<li><strong>${ch?.name ?? id}</strong> — <code>${id}</code></li>`
|
||||||
|
}).join('')}</ul>`
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const notFoundHtml = result.notFound.length > 0
|
||||||
|
? `<h2 class="warning">Not Found in Playlists (${result.notFound.length})</h2>
|
||||||
|
<ul>${result.notFound.map((id) => {
|
||||||
|
const ch = payload.channels.find((c) => c.id === id)
|
||||||
|
return `<li><strong>${ch?.name ?? id}</strong> — <code>${id}</code></li>`
|
||||||
|
}).join('')}</ul>
|
||||||
|
<p class="muted">These channels aren't in the current M3U playlists (english/news/sports). Add them to Threadfin's playlist sources first.</p>`
|
||||||
|
: ''
|
||||||
|
|
||||||
|
return htmlPage('Channels Approved', `
|
||||||
|
<div class="icon">✅</div>
|
||||||
|
<h1 class="success">Channels Approved</h1>
|
||||||
|
<p>Request from <strong>${escapeHtml(payload.email)}</strong> has been processed.</p>
|
||||||
|
${activatedHtml}
|
||||||
|
${notFoundHtml}
|
||||||
|
<p class="muted">A confirmation email has been sent to the requester.</p>
|
||||||
|
`)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Channel activation error:', err)
|
||||||
|
return htmlPage('Activation Failed', `
|
||||||
|
<div class="icon">❌</div>
|
||||||
|
<h1 class="error">Activation Failed</h1>
|
||||||
|
<p>Could not connect to Threadfin or activate channels.</p>
|
||||||
|
<p><code>${escapeHtml(err instanceof Error ? err.message : String(err))}</code></p>
|
||||||
|
<p>Check Threadfin is running and credentials are correct.</p>
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendConfirmationEmail(
|
||||||
|
to: string,
|
||||||
|
activated: string[],
|
||||||
|
notFound: string[],
|
||||||
|
channels: { id: string; name: string }[],
|
||||||
|
) {
|
||||||
|
const smtpHost = process.env.SMTP_HOST
|
||||||
|
const smtpUser = process.env.SMTP_USER
|
||||||
|
const smtpPass = process.env.SMTP_PASS
|
||||||
|
if (!smtpHost || !smtpUser || !smtpPass) return
|
||||||
|
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host: smtpHost,
|
||||||
|
port: Number(process.env.SMTP_PORT) || 587,
|
||||||
|
secure: false,
|
||||||
|
auth: { user: smtpUser, pass: smtpPass },
|
||||||
|
tls: { rejectUnauthorized: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
const activatedList = activated.map((id) => {
|
||||||
|
const ch = channels.find((c) => c.id === id)
|
||||||
|
return `<li>✅ <strong>${escapeHtml(ch?.name ?? id)}</strong></li>`
|
||||||
|
}).join('')
|
||||||
|
|
||||||
|
const notFoundList = notFound.map((id) => {
|
||||||
|
const ch = channels.find((c) => c.id === id)
|
||||||
|
return `<li>❌ <strong>${escapeHtml(ch?.name ?? id)}</strong> — not available in current playlists</li>`
|
||||||
|
}).join('')
|
||||||
|
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: `Jefflix <${smtpUser}>`,
|
||||||
|
to,
|
||||||
|
subject: `[Jefflix] Your channel request has been processed`,
|
||||||
|
html: `
|
||||||
|
<h2>Your Channel Request Update</h2>
|
||||||
|
${activated.length > 0 ? `
|
||||||
|
<p>The following channels have been activated and should appear in your guide shortly:</p>
|
||||||
|
<ul style="line-height: 1.8;">${activatedList}</ul>
|
||||||
|
` : ''}
|
||||||
|
${notFound.length > 0 ? `
|
||||||
|
<p>The following channels were not found in the current playlists:</p>
|
||||||
|
<ul style="line-height: 1.8;">${notFoundList}</ul>
|
||||||
|
<p style="color: #666; font-size: 13px;">These may become available when new playlist sources are added.</p>
|
||||||
|
` : ''}
|
||||||
|
${activated.length > 0 ? '<p>It may take a few minutes for the channels to appear in your TV guide. If you don\'t see them after 15 minutes, try refreshing your IPTV app.</p>' : ''}
|
||||||
|
<hr style="margin: 20px 0; border: none; border-top: 1px solid #ddd;" />
|
||||||
|
<p style="color: #666; font-size: 12px;">Jefflix · ${new Date().toLocaleString()}</p>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": ''',
|
||||||
|
}
|
||||||
|
return text.replace(/[&<>"']/g, (char) => map[char])
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import nodemailer from 'nodemailer'
|
import nodemailer from 'nodemailer'
|
||||||
|
import { createApproveToken } from '@/lib/token'
|
||||||
|
|
||||||
interface ChannelSelection {
|
interface ChannelSelection {
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -75,6 +76,14 @@ export async function POST(request: NextRequest) {
|
||||||
? `[Jefflix] Channel Request: ${escapeHtml(channels[0].name)}`
|
? `[Jefflix] Channel Request: ${escapeHtml(channels[0].name)}`
|
||||||
: `[Jefflix] Channel Request: ${channels.length} channels`
|
: `[Jefflix] Channel Request: ${channels.length} channels`
|
||||||
|
|
||||||
|
// Generate approve token for one-click activation
|
||||||
|
const approveToken = createApproveToken(
|
||||||
|
channels.map((ch) => ({ id: ch.id, name: ch.name })),
|
||||||
|
email,
|
||||||
|
)
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'https://jefflix.lol'
|
||||||
|
const approveUrl = `${baseUrl}/api/approve-channels?token=${approveToken}`
|
||||||
|
|
||||||
await transporter.sendMail({
|
await transporter.sendMail({
|
||||||
from: `Jefflix <${smtpUser}>`,
|
from: `Jefflix <${smtpUser}>`,
|
||||||
to: adminEmail,
|
to: adminEmail,
|
||||||
|
|
@ -82,15 +91,17 @@ export async function POST(request: NextRequest) {
|
||||||
html: `
|
html: `
|
||||||
<h2>New Channel Request</h2>
|
<h2>New Channel Request</h2>
|
||||||
<p><strong>${escapeHtml(email)}</strong> requested ${channels.length} channel${channels.length > 1 ? 's' : ''}:</p>
|
<p><strong>${escapeHtml(email)}</strong> requested ${channels.length} channel${channels.length > 1 ? 's' : ''}:</p>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin: 24px 0;">
|
||||||
|
<a href="${approveUrl}" style="display: inline-block; background: #22c55e; color: #fff; padding: 14px 32px; border-radius: 8px; text-decoration: none; font-size: 16px; font-weight: 600;">
|
||||||
|
✅ Approve & Add Channels
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p style="text-align: center; color: #888; font-size: 12px;">One click activates these channels in Threadfin and notifies the requester. Link expires in 7 days.</p>
|
||||||
|
|
||||||
<ul style="line-height: 1.8;">
|
<ul style="line-height: 1.8;">
|
||||||
${channelListHtml}
|
${channelListHtml}
|
||||||
</ul>
|
</ul>
|
||||||
<p><strong>To add these channels:</strong></p>
|
|
||||||
<ol>
|
|
||||||
<li>Search each channel ID in Threadfin / iptv-org</li>
|
|
||||||
<li>Map the streams in Threadfin</li>
|
|
||||||
<li>Reply to <a href="mailto:${escapeHtml(email)}">${escapeHtml(email)}</a> to confirm</li>
|
|
||||||
</ol>
|
|
||||||
<hr style="margin: 20px 0; border: none; border-top: 1px solid #ddd;" />
|
<hr style="margin: 20px 0; border: none; border-top: 1px solid #ddd;" />
|
||||||
<p style="color: #666; font-size: 12px;">Automated message from Jefflix · ${new Date().toLocaleString()}</p>
|
<p style="color: #666; font-size: 12px;">Automated message from Jefflix · ${new Date().toLocaleString()}</p>
|
||||||
`,
|
`,
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,10 @@ services:
|
||||||
- SMTP_USER=${SMTP_USER}
|
- SMTP_USER=${SMTP_USER}
|
||||||
- SMTP_PASS=${SMTP_PASS}
|
- SMTP_PASS=${SMTP_PASS}
|
||||||
- ADMIN_EMAIL=${ADMIN_EMAIL:-jeff@jeffemmett.com}
|
- ADMIN_EMAIL=${ADMIN_EMAIL:-jeff@jeffemmett.com}
|
||||||
|
- TOKEN_SECRET=${TOKEN_SECRET}
|
||||||
|
- THREADFIN_URL=https://threadfin.jefflix.lol
|
||||||
|
- THREADFIN_USER=${THREADFIN_USER}
|
||||||
|
- THREADFIN_PASS=${THREADFIN_PASS}
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.jefflix-website.rule=Host(`jefflix.lol`) || Host(`www.jefflix.lol`)"
|
- "traefik.http.routers.jefflix-website.rule=Host(`jefflix.lol`) || Host(`www.jefflix.lol`)"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
import WebSocket from 'ws'
|
||||||
|
|
||||||
|
interface ThreadfinConfig {
|
||||||
|
url: string
|
||||||
|
user: string
|
||||||
|
pass: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface XepgEntry {
|
||||||
|
'tvg-id': string
|
||||||
|
'x-active': boolean
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
type EpgMapping = Record<string, XepgEntry>
|
||||||
|
|
||||||
|
interface ActivateResult {
|
||||||
|
activated: string[]
|
||||||
|
notFound: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfig(): ThreadfinConfig {
|
||||||
|
const url = process.env.THREADFIN_URL
|
||||||
|
const user = process.env.THREADFIN_USER
|
||||||
|
const pass = process.env.THREADFIN_PASS
|
||||||
|
if (!url || !user || !pass) {
|
||||||
|
throw new Error('THREADFIN_URL, THREADFIN_USER, THREADFIN_PASS must be set')
|
||||||
|
}
|
||||||
|
return { url: url.replace(/\/$/, ''), user, pass }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(): Promise<string> {
|
||||||
|
const { url, user, pass } = getConfig()
|
||||||
|
const res = await fetch(`${url}/api/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ cmd: 'login', username: user, password: pass }),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`Threadfin login failed: ${res.status}`)
|
||||||
|
const data = await res.json()
|
||||||
|
if (!data.token) throw new Error('Threadfin login returned no token')
|
||||||
|
return data.token
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function activateChannels(
|
||||||
|
channelIds: string[],
|
||||||
|
): Promise<ActivateResult> {
|
||||||
|
const { url } = getConfig()
|
||||||
|
const token = await login()
|
||||||
|
|
||||||
|
const wsUrl = url.replace(/^http/, 'ws') + `/data/?Token=${token}`
|
||||||
|
|
||||||
|
return new Promise<ActivateResult>((resolve, reject) => {
|
||||||
|
const ws = new WebSocket(wsUrl)
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
ws.close()
|
||||||
|
reject(new Error('Threadfin WebSocket timed out after 30s'))
|
||||||
|
}, 30_000)
|
||||||
|
|
||||||
|
let currentToken = token
|
||||||
|
|
||||||
|
ws.on('error', (err) => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
reject(err)
|
||||||
|
})
|
||||||
|
|
||||||
|
ws.on('message', (raw) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(raw.toString())
|
||||||
|
|
||||||
|
// Update token if rotated
|
||||||
|
if (msg.token) currentToken = msg.token
|
||||||
|
|
||||||
|
// Initial data response contains the xepg map
|
||||||
|
if (msg.xepg) {
|
||||||
|
const epgMapping: EpgMapping = msg.xepg
|
||||||
|
|
||||||
|
const activated: string[] = []
|
||||||
|
const idSet = new Set(channelIds)
|
||||||
|
const foundIds = new Set<string>()
|
||||||
|
|
||||||
|
// Find and activate matching channels
|
||||||
|
for (const [key, entry] of Object.entries(epgMapping)) {
|
||||||
|
const tvgId = entry['tvg-id']
|
||||||
|
if (tvgId && idSet.has(tvgId)) {
|
||||||
|
entry['x-active'] = true
|
||||||
|
activated.push(tvgId)
|
||||||
|
foundIds.add(tvgId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const notFound = channelIds.filter((id) => !foundIds.has(id))
|
||||||
|
|
||||||
|
if (activated.length === 0) {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
ws.close()
|
||||||
|
resolve({ activated, notFound })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must send the ENTIRE map back
|
||||||
|
const saveMsg = JSON.stringify({
|
||||||
|
cmd: 'saveEpgMapping',
|
||||||
|
epgMapping,
|
||||||
|
token: currentToken,
|
||||||
|
})
|
||||||
|
ws.send(saveMsg, (err) => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
if (err) {
|
||||||
|
ws.close()
|
||||||
|
reject(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Give Threadfin a moment to process, then close
|
||||||
|
setTimeout(() => {
|
||||||
|
ws.close()
|
||||||
|
resolve({ activated, notFound })
|
||||||
|
}, 1000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
ws.close()
|
||||||
|
reject(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { createHmac } from 'crypto'
|
||||||
|
|
||||||
|
interface ApprovePayload {
|
||||||
|
channels: { id: string; name: string }[]
|
||||||
|
email: string
|
||||||
|
exp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
function getSecret(): string {
|
||||||
|
const secret = process.env.TOKEN_SECRET
|
||||||
|
if (!secret) throw new Error('TOKEN_SECRET env var is not set')
|
||||||
|
return secret
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createApproveToken(
|
||||||
|
channels: { id: string; name: string }[],
|
||||||
|
email: string,
|
||||||
|
): string {
|
||||||
|
const payload: ApprovePayload = {
|
||||||
|
channels,
|
||||||
|
email,
|
||||||
|
exp: Date.now() + SEVEN_DAYS_MS,
|
||||||
|
}
|
||||||
|
const data = Buffer.from(JSON.stringify(payload)).toString('base64url')
|
||||||
|
const sig = createHmac('sha256', getSecret()).update(data).digest('hex')
|
||||||
|
return `${data}.${sig}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyApproveToken(token: string): ApprovePayload | null {
|
||||||
|
const parts = token.split('.')
|
||||||
|
if (parts.length !== 2) return null
|
||||||
|
|
||||||
|
const [data, sig] = parts
|
||||||
|
const expected = createHmac('sha256', getSecret()).update(data).digest('hex')
|
||||||
|
|
||||||
|
// Constant-time comparison
|
||||||
|
if (sig.length !== expected.length) return null
|
||||||
|
let mismatch = 0
|
||||||
|
for (let i = 0; i < sig.length; i++) {
|
||||||
|
mismatch |= sig.charCodeAt(i) ^ expected.charCodeAt(i)
|
||||||
|
}
|
||||||
|
if (mismatch !== 0) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload: ApprovePayload = JSON.parse(
|
||||||
|
Buffer.from(data, 'base64url').toString('utf-8'),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!payload.exp || !payload.channels || !payload.email) return null
|
||||||
|
if (Date.now() > payload.exp) return null
|
||||||
|
|
||||||
|
return payload
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -36,7 +36,6 @@
|
||||||
"@radix-ui/react-toggle": "1.1.1",
|
"@radix-ui/react-toggle": "1.1.1",
|
||||||
"@radix-ui/react-toggle-group": "1.1.1",
|
"@radix-ui/react-toggle-group": "1.1.1",
|
||||||
"@radix-ui/react-tooltip": "1.1.6",
|
"@radix-ui/react-tooltip": "1.1.6",
|
||||||
"@vercel/analytics": "latest",
|
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|
@ -58,6 +57,7 @@
|
||||||
"tailwind-merge": "^2.5.5",
|
"tailwind-merge": "^2.5.5",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
|
"ws": "^8.20.0",
|
||||||
"zod": "3.25.76"
|
"zod": "3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -66,6 +66,7 @@
|
||||||
"@types/nodemailer": "^7.0.9",
|
"@types/nodemailer": "^7.0.9",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
"postcss": "^8.5",
|
"postcss": "^8.5",
|
||||||
"tailwindcss": "^4.1.9",
|
"tailwindcss": "^4.1.9",
|
||||||
"tw-animate-css": "1.3.3",
|
"tw-animate-css": "1.3.3",
|
||||||
|
|
@ -2506,42 +2507,14 @@
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vercel/analytics": {
|
"node_modules/@types/ws": {
|
||||||
"version": "1.6.1",
|
"version": "8.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-1.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
"integrity": "sha512-oH9He/bEM+6oKlv3chWuOOcp8Y6fo6/PSro8hEkgCW3pu9/OiCXiUpRUogDh3Fs3LH2sosDrx8CxeOLBEE+afg==",
|
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||||
"license": "MPL-2.0",
|
"dev": true,
|
||||||
"peerDependencies": {
|
"license": "MIT",
|
||||||
"@remix-run/react": "^2",
|
"dependencies": {
|
||||||
"@sveltejs/kit": "^1 || ^2",
|
"@types/node": "*"
|
||||||
"next": ">= 13",
|
|
||||||
"react": "^18 || ^19 || ^19.0.0-rc",
|
|
||||||
"svelte": ">= 4",
|
|
||||||
"vue": "^3",
|
|
||||||
"vue-router": "^4"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@remix-run/react": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@sveltejs/kit": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"next": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"react": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"svelte": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"vue": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"vue-router": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/aria-hidden": {
|
"node_modules/aria-hidden": {
|
||||||
|
|
@ -4006,6 +3979,27 @@
|
||||||
"d3-timer": "^3.0.1"
|
"d3-timer": "^3.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
||||||
|
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "3.25.76",
|
"version": "3.25.76",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@
|
||||||
"tailwind-merge": "^2.5.5",
|
"tailwind-merge": "^2.5.5",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
|
"ws": "^8.20.0",
|
||||||
"zod": "3.25.76"
|
"zod": "3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -66,6 +67,7 @@
|
||||||
"@types/nodemailer": "^7.0.9",
|
"@types/nodemailer": "^7.0.9",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
"postcss": "^8.5",
|
"postcss": "^8.5",
|
||||||
"tailwindcss": "^4.1.9",
|
"tailwindcss": "^4.1.9",
|
||||||
"tw-animate-css": "1.3.3",
|
"tw-animate-css": "1.3.3",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue