Added redemptions & redeemable actions. Fixed a few bugs.

This commit is contained in:
Tom 2024-06-24 22:16:55 +00:00
parent 68df045c54
commit 6548ce33e0
35 changed files with 1787 additions and 471 deletions

View File

@ -1,22 +1,20 @@
import axios from 'axios' import axios from 'axios'
import { db } from "@/lib/db" import { db } from "@/lib/db"
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import fetchUser from '@/lib/fetch-user';
import fetchUserWithImpersonation from '@/lib/fetch-user-impersonation';
export async function GET(req: Request) { export async function GET(req: Request) {
try { try {
// Verify state against user id in user table. // Verify state against user id in user table.
const key = await db.apiKey.findFirst({ const user = await fetchUserWithImpersonation(req)
where: { if (!user) {
id: req.headers.get('x-api-key') as string return new NextResponse("Unauthorized", { status: 401 });
}
})
if (!key) {
return new NextResponse("Forbidden", { status: 403 });
} }
const connection = await db.twitchConnection.findFirst({ const connection = await db.twitchConnection.findFirst({
where: { where: {
userId: key.userId userId: user.id
} }
}) })
if (!connection) { if (!connection) {
@ -29,8 +27,22 @@ export async function GET(req: Request) {
Authorization: 'OAuth ' + connection.accessToken Authorization: 'OAuth ' + connection.accessToken
} }
})).data; })).data;
if (expires_in > 3600)
return new NextResponse("", { status: 201 }); if (expires_in > 3600) {
let data = await db.twitchConnection.findFirst({
where: {
userId: user.id
}
})
let dataFormatted = {
user_id: user.id,
access_token: data?.accessToken,
refresh_token: data?.refreshToken,
broadcaster_id: connection.broadcasterId
}
return NextResponse.json(dataFormatted, { status: 201 });
}
} catch (error) { } catch (error) {
} }
@ -51,14 +63,22 @@ export async function GET(req: Request) {
await db.twitchConnection.update({ await db.twitchConnection.update({
where: { where: {
userId: key.userId userId: user.id
}, },
data: { data: {
accessToken: access_token accessToken: access_token,
refreshToken: refresh_token
} }
}) })
const data = {
user_id: user.id,
access_token,
refresh_token,
broadcaster_id: connection.broadcasterId
}
return new NextResponse("", { status: 200 }); return NextResponse.json(data)
} catch (error) { } catch (error) {
console.log("[ACCOUNT]", error); console.log("[ACCOUNT]", error);
return new NextResponse("Internal Error", { status: 500 }); return new NextResponse("Internal Error", { status: 500 });

View File

@ -0,0 +1,35 @@
import { db } from "@/lib/db"
import { NextResponse } from "next/server";
import fetchUserWithImpersonation from '@/lib/fetch-user-impersonation';
import axios from "axios";
import { updateTwitchToken } from "@/data/twitch-reauthorize";
export async function GET(req: Request) {
try {
if (!process.env.TWITCH_BOT_CLIENT_ID)
return new NextResponse("Internal Error", { status: 500 });
const user = await fetchUserWithImpersonation(req)
if (!user) {
return new NextResponse("Unauthorized", { status: 401 });
}
const auth = await updateTwitchToken(user.id)
if (!auth)
return new NextResponse("Bad Request", { status: 400 })
const redemptions = await axios.get("https://api.twitch.tv/helix/channel_points/custom_rewards?broadcaster_id=" + auth.broadcaster_id,
{
headers: {
"Client-Id": process.env.TWITCH_BOT_CLIENT_ID,
"Authorization": "Bearer " + auth.access_token
}
}
)
return NextResponse.json(redemptions.data);
} catch (error) {
console.log("[REDEMPTIONS/ACTIONS]", error);
return new NextResponse("Internal Error", { status: 500 });
}
}

View File

@ -6,7 +6,9 @@ import fetchUser from "@/lib/fetch-user";
export async function GET(req: Request) { export async function GET(req: Request) {
try { try {
return NextResponse.json(await fetchUser(req)) const user = await fetchUser(req)
if (!user) return new NextResponse("Internal Error", { status: 401 })
return NextResponse.json(user)
} catch (error) { } catch (error) {
console.log("[ACCOUNT]", error); console.log("[ACCOUNT]", error);
return new NextResponse("Internal Error", { status: 500 }); return new NextResponse("Internal Error", { status: 500 });

View File

@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
export async function GET(req: Request) {
return NextResponse.json({
major_version: 3,
minor_version: 3,
download: "https://drive.proton.me/urls/KVGW0ZKE9C#2Y0WGGt5uHFZ",
changelog: "Revised the redeem system, activated via channel point redeems.\nAdded OBS transformation to redeems.\nLogs changed & writes to logs folder as well."
//changelog: "Added new command for mods: !refresh <username_filters|word_filters|default_voice> - Used to refresh data if done via website.\nAdded new command for mods: !tts <voice_name> <remove|enable|disable> - To delete, enable, or disable a specific voice."
//changelog: "Save TTS voices set by chatters.\nAdded more options for TTS voices." 3.1
//changelog: "Added a message when new updates are available.\nFixed 7tv renames not being applied correctly." 3.0
});
}

View File

@ -0,0 +1,150 @@
import { db } from "@/lib/db"
import { NextResponse } from "next/server";
import fetchUserWithImpersonation from "@/lib/fetch-user-impersonation";
import { ActionType, Prisma } from "@prisma/client";
import { JsonSerializer } from "typescript-json-serializer";
export async function GET(req: Request) {
try {
const user = await fetchUserWithImpersonation(req)
if (!user) {
return new NextResponse("Unauthorized", { status: 401 });
}
const actions = await db.action.findMany({
where: {
userId: user.id
}
})
return NextResponse.json(actions.map(({userId, ...attrs}) => attrs));
} catch (error) {
console.log("[REDEMPTIONS/ACTIONS]", error);
return new NextResponse("Internal Error", { status: 500 });
}
}
export async function POST(req: Request) {
try {
const user = await fetchUserWithImpersonation(req)
if (!user) {
return new NextResponse("Unauthorized", { status: 401 });
}
const { name, type, scene_name, scene_item_name, rotation, position_x, position_y, file_path, file_content }: { name: string, type: ActionType, scene_name: string, scene_item_name: string, rotation: string, position_x: string, position_y: string, file_path: string, file_content: string } = await req.json();
if (!name && !type)
return new NextResponse("Bad Request", { status: 400 });
if (type == ActionType.OBS_TRANSFORM && (!scene_name || !scene_item_name || !rotation && !position_x && !position_y))
return new NextResponse("Bad Request", { status: 400 });
if ((type == ActionType.WRITE_TO_FILE || type == ActionType.APPEND_TO_FILE) && (!file_path || !file_content))
return new NextResponse("Bad Request", { status: 400 });
if (type == ActionType.AUDIO_FILE && !file_path)
return new NextResponse("Bad Request", { status: 400 });
let data:any = { }
if (type == ActionType.WRITE_TO_FILE || type == ActionType.APPEND_TO_FILE) {
data = { file_path, file_content, ...data }
} else if (type == ActionType.OBS_TRANSFORM) {
data = { scene_name, scene_item_name, ...data }
if (!!rotation)
data = { rotation, ...data }
if (!!position_x)
data = { position_x, ...data }
if (!!position_y)
data = { position_y, ...data }
} else if (type == ActionType.AUDIO_FILE) {
data = { file_path, ...data }
}
await db.action.create({
data: {
userId: user.id,
name,
type,
data: data as Prisma.JsonObject
}
});
return new NextResponse("", { status: 200 });
} catch (error) {
console.log("[REDEMPTIONS/ACTIONS]", error);
return new NextResponse("Internal Error", { status: 500 });
}
}
export async function PUT(req: Request) {
try {
const user = await fetchUserWithImpersonation(req)
if (!user) {
return new NextResponse("Unauthorized", { status: 401 });
}
const { name, type, scene_name, scene_item_name, rotation, position_x, position_y, file_path, file_content }: { name: string, type: ActionType, scene_name: string, scene_item_name: string, rotation: string, position_x: string, position_y: string, file_path: string, file_content: string } = await req.json();
if (!name && !type)
return new NextResponse("Bad Request", { status: 400 });
if (type == ActionType.OBS_TRANSFORM && (!scene_name || !scene_item_name || !rotation && !position_x && !position_y))
return new NextResponse("Bad Request", { status: 400 });
if ((type == ActionType.WRITE_TO_FILE || type == ActionType.APPEND_TO_FILE) && (!file_path || !file_content))
return new NextResponse("Bad Request", { status: 400 });
if (type == ActionType.AUDIO_FILE && !file_path)
return new NextResponse("Bad Request", { status: 400 });
let data:any = { }
if (type == ActionType.WRITE_TO_FILE || type == ActionType.APPEND_TO_FILE) {
data = { file_path, file_content, ...data }
} else if (type == ActionType.OBS_TRANSFORM) {
data = { scene_name, scene_item_name, ...data }
if (!!rotation)
data = { rotation, ...data }
if (!!position_x)
data = { position_x, ...data }
if (!!position_y)
data = { position_y, ...data }
} else if (type == ActionType.AUDIO_FILE) {
data = { file_path, ...data }
}
await db.action.update({
where: {
userId_name: {
userId: user.id,
name
}
},
data: {
type,
data: data as Prisma.JsonObject
}
});
return new NextResponse("", { status: 200 });
} catch (error) {
console.log("[REDEMPTIONS/ACTIONS]", error);
return new NextResponse("Internal Error", { status: 500 });
}
}
export async function DELETE(req: Request) {
try {
const user = await fetchUserWithImpersonation(req)
if (!user) {
return new NextResponse("Unauthorized", { status: 401 });
}
const { searchParams } = new URL(req.url)
const name = searchParams.get('action_name') as string
const redemptions = await db.action.delete({
where: {
userId_name: {
userId: user.id,
name
}
}
})
return NextResponse.json(redemptions);
} catch (error) {
console.log("[REDEMPTIONS]", error);
return new NextResponse("Internal Error", { status: 500 });
}
}

View File

@ -0,0 +1,143 @@
import { db } from "@/lib/db"
import { NextResponse } from "next/server";
import fetchUserWithImpersonation from "@/lib/fetch-user-impersonation";
export async function GET(req: Request) {
try {
const user = await fetchUserWithImpersonation(req)
if (!user) {
return new NextResponse("Unauthorized", { status: 401 });
}
const redemptions = await db.redemption.findMany({
where: {
userId: user.id
}
})
return NextResponse.json(redemptions.map(({userId, ...attrs}) => attrs));
} catch (error) {
console.log("[REDEMPTIONS]", error);
return new NextResponse("Internal Error", { status: 500 });
}
}
export async function POST(req: Request) {
try {
const user = await fetchUserWithImpersonation(req)
if (!user) {
return new NextResponse("Unauthorized", { status: 401 });
}
const { actionName, redemptionId, order, state }: { actionName: string, redemptionId: string, order: number, state: boolean } = await req.json();
if (!redemptionId || !actionName && !order && !state)
return new NextResponse("Bad Request", { status: 400 });
const action = await db.action.findFirst({
where: {
name: actionName
}
})
if (!action)
return new NextResponse("Bad Request", { status: 400 });
let data:any = {
actionName,
order,
state
}
if (!data.actionName)
data = { actionName, ...data }
if (!data.order)
data = { order, ...data }
if (!data.state)
data = { state, ...data }
const res = await db.redemption.create({
data: {
userId: user.id,
redemptionId,
order,
state: true,
...data
}
});
return NextResponse.json(res, { status: 200 });
} catch (error) {
console.log("[REDEMPTIONS]", error);
return new NextResponse("Internal Error", { status: 500 });
}
}
export async function PUT(req: Request) {
try {
const user = await fetchUserWithImpersonation(req)
if (!user) {
return new NextResponse("Unauthorized", { status: 401 });
}
const { id, actionName, redemptionId, order, state }: { id: string, actionName: string, redemptionId: string, order: number, state: boolean } = await req.json();
if (!redemptionId || !actionName && !order && !state)
return new NextResponse("Bad Request", { status: 400 });
const action = await db.action.findFirst({
where: {
name: actionName
}
})
if (!action)
return new NextResponse("Bad Request", { status: 400 });
let data:any = {
actionName,
redemptionId,
order,
state
}
if (!data.actionName)
data = { actionName, ...data }
if (!data.order)
data = { order, ...data }
if (!data.state)
data = { state, ...data }
if (!data.redemptionId)
data = { redemptionId, ...data }
const res = await db.redemption.update({
where: {
id
},
data: data
});
return NextResponse.json(res, { status: 200 });
} catch (error) {
console.log("[REDEMPTIONS]", error);
return new NextResponse("Internal Error", { status: 500 });
}
}
export async function DELETE(req: Request) {
try {
const user = await fetchUserWithImpersonation(req)
if (!user) {
return new NextResponse("Unauthorized", { status: 401 });
}
const { searchParams } = new URL(req.url)
const id = searchParams.get('id') as string
const redemptions = await db.redemption.delete({
where: {
id
}
})
return NextResponse.json(redemptions);
} catch (error) {
console.log("[REDEMPTIONS]", error);
return new NextResponse("Internal Error", { status: 500 });
}
}

View File

@ -5,19 +5,16 @@ import voices from "@/data/tts";
export async function GET(req: Request) { export async function GET(req: Request) {
try { try {
if (!voices) {
return new NextResponse("Voices not available.", { status: 500 });
}
const user = await fetchUserWithImpersonation(req) const user = await fetchUserWithImpersonation(req)
if (!user) { if (!user) {
return new NextResponse("Unauthorized", { status: 401 }); return new NextResponse("Unauthorized", { status: 401 });
} }
const u = await db.user.findFirst({ return NextResponse.json(user.ttsDefaultVoice);
where: {
id: user.id
}
});
const voice = voices.find(v => v.value == new String(u?.ttsDefaultVoice))
return NextResponse.json(voice);
} catch (error) { } catch (error) {
console.log("[TTS/FILTER/DEFAULT]", error); console.log("[TTS/FILTER/DEFAULT]", error);
return new NextResponse("Internal Error", { status: 500 }); return new NextResponse("Internal Error", { status: 500 });
@ -26,28 +23,32 @@ export async function GET(req: Request) {
export async function POST(req: Request) { export async function POST(req: Request) {
try { try {
if (!voices) {
return new NextResponse("Voices not available.", { status: 500 });
}
const user = await fetchUserWithImpersonation(req) const user = await fetchUserWithImpersonation(req)
if (!user) { if (!user) {
return new NextResponse("Unauthorized", { status: 401 }); return new NextResponse("Unauthorized", { status: 401 });
} }
const { voice } = await req.json(); const { voice } = await req.json();
if (!voice || !voices.map(v => v.label.toLowerCase()).includes(voice.toLowerCase())) return new NextResponse("Bad Request", { status: 400 }); if (!voice || !voices.map(v => v.toLowerCase()).includes(voice.toLowerCase())) return new NextResponse("Bad Request", { status: 400 });
const v = voices.find(v => v.label.toLowerCase() == voice.toLowerCase()) const v = voices.find(v => v.toLowerCase() == voice.toLowerCase())
await db.user.update({ await db.user.update({
where: { where: {
id: user.id id: user.id
}, },
data: { data: {
ttsDefaultVoice: Number.parseInt(v.value) ttsDefaultVoice: v
} }
}); });
return new NextResponse("", { status: 200 }); return new NextResponse("", { status: 200 });
} catch (error) { } catch (error) {
console.log("[TTS/FILTER/DEFAULT]", error); console.log("[TTS/FILTER/DEFAULT]", error);
return new NextResponse("Internal Error", { status: 500 });
} }
return new NextResponse("Internal Error", { status: 500 });
} }

View File

@ -31,7 +31,7 @@ export async function POST(req: Request) {
const { search, replace } = await req.json(); const { search, replace } = await req.json();
if (!search || search.length < 4 || search.length > 200) return new NextResponse("Bad Request", { status: 400 }); if (!search || search.length < 4 || search.length > 200) return new NextResponse("Bad Request", { status: 400 });
if (!replace) return new NextResponse("Bad Request", { status: 400 }); if (replace == null) return new NextResponse("Bad Request", { status: 400 });
const filter = await db.ttsWordFilter.create({ const filter = await db.ttsWordFilter.create({
data: { data: {
@ -58,7 +58,7 @@ export async function PUT(req: Request) {
const { id, search, replace } = await req.json(); const { id, search, replace } = await req.json();
if (!id || id.length < 1) return new NextResponse("Bad Request", { status: 400 }); if (!id || id.length < 1) return new NextResponse("Bad Request", { status: 400 });
if (!search || search.length < 4 || search.length > 200) return new NextResponse("Bad Request", { status: 400 }); if (!search || search.length < 4 || search.length > 200) return new NextResponse("Bad Request", { status: 400 });
if (!replace) return new NextResponse("Bad Request", { status: 400 }); if (replace == null) return new NextResponse("Bad Request", { status: 400 });
const filter = await db.ttsWordFilter.update({ const filter = await db.ttsWordFilter.update({
where: { where: {

View File

@ -1,7 +1,6 @@
import { db } from "@/lib/db" import { db } from "@/lib/db"
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import fetchUserWithImpersonation from "@/lib/fetch-user-impersonation"; import fetchUserWithImpersonation from "@/lib/fetch-user-impersonation";
import voices from "@/data/tts";
export async function GET(req: Request) { export async function GET(req: Request) {
try { try {
@ -10,23 +9,18 @@ export async function GET(req: Request) {
return new NextResponse("Unauthorized", { status: 401 }); return new NextResponse("Unauthorized", { status: 401 });
} }
let list : { const voiceStates = await db.ttsVoiceState.findMany({
value: string; where: {
label: string; userId: user.id
gender: string; }
language: string; });
}[] = []
const enabled = user.ttsEnabledVoice
for (let v of voices) {
var n = Number.parseInt(v.value) - 1
if ((enabled & (1 << n)) > 0) {
list.push(v)
}
}
return NextResponse.json(list); const voiceNames = await db.ttsVoice.findMany();
const voiceNamesMapped: { [id: string]: string } = Object.assign({}, ...voiceNames.map(v => ({ [v.id]: v.name })))
return NextResponse.json(voiceStates.filter(v => v.state).map(v => voiceNamesMapped[v.ttsVoiceId]));
} catch (error) { } catch (error) {
console.log("[TTS/FILTER/USER]", error); console.log("[TTS]", error);
return new NextResponse("Internal Error", { status: 500 }); return new NextResponse("Internal Error", { status: 500 });
} }
} }
@ -38,16 +32,29 @@ export async function POST(req: Request) {
return new NextResponse("Unauthorized", { status: 401 }); return new NextResponse("Unauthorized", { status: 401 });
} }
let { voice } = await req.json(); const { voice, state }: { voice: string, state: boolean } = await req.json();
voice = voice & ((1 << voices.length) - 1)
await db.user.update({ const voiceIds = await db.ttsVoice.findMany();
where: { const voiceIdsMapped: { [voice: string]: string } = Object.assign({}, ...voiceIds.map(v => ({ [v.name.toLowerCase()]: v.id })));
id: user.id if (!voiceIdsMapped[voice.toLowerCase()]) {
}, return new NextResponse("Bad Request", { status: 400 });
data: { }
ttsEnabledVoice: voice
} await db.ttsVoiceState.upsert({
where: {
userId_ttsVoiceId: {
userId: user.id,
ttsVoiceId: voiceIdsMapped[voice.toLowerCase()]
}
},
update: {
state
},
create: {
userId: user.id,
ttsVoiceId: voiceIdsMapped[voice.toLowerCase()],
state
}
}); });
return new NextResponse("", { status: 200 }); return new NextResponse("", { status: 200 });

View File

@ -0,0 +1,71 @@
import { db } from "@/lib/db"
import { NextResponse } from "next/server";
import fetchUserWithImpersonation from "@/lib/fetch-user-impersonation";
import voices from "@/data/tts";
export async function GET(req: Request) {
try {
const user = await fetchUserWithImpersonation(req)
if (!user) {
return new NextResponse("Unauthorized", { status: 401 });
}
const selected = await db.ttsChatVoice.findMany({
where: {
userId: user.id
}
})
const voices = await db.ttsVoice.findMany();
const voiceNamesMapped: { [id: string]: string } = Object.assign({}, ...voices.map(v => ({ [v.id]: v.name })))
const data = selected.map(s => ({ chatter_id: new Number(s.chatterId), voice: voiceNamesMapped[s.ttsVoiceId] }))
return NextResponse.json(data);
} catch (error) {
console.log("[TTS/SELECTED]", error);
return new NextResponse("Internal Error", { status: 500 });
}
}
export async function POST(req: Request) {
try {
const user = await fetchUserWithImpersonation(req)
if (!user) {
return new NextResponse("Unauthorized", { status: 401 });
}
const { voice, chatterId }: { voice: string, chatterId: number } = await req.json();
if (!voice || !voices.map(v => v.toLowerCase()).includes(voice.toLowerCase())) return new NextResponse("Bad Request", { status: 400 });
const v = voices.find(v => v.toLowerCase() == voice.toLowerCase())
const voiceData = await db.ttsVoice.findFirst({
where: {
name: v
}
})
if (!voiceData)
return new NextResponse("Bad Request", { status: 400 });
await db.ttsChatVoice.upsert({
where: {
userId_chatterId: {
userId: user.id,
chatterId
}
},
create: {
userId: user.id,
chatterId,
ttsVoiceId: voiceData.id
},
update: {
ttsVoiceId: voiceData.id
}
});
return new NextResponse("", { status: 200 });
} catch (error) {
console.log("[TTS/SELECTED]", error);
return new NextResponse("Internal Error", { status: 500 });
}
}

View File

@ -30,7 +30,6 @@ export async function GET(req: Request, { params } : { params: { id: string } })
export async function DELETE(req: Request, { params } : { params: { id: string } }) { export async function DELETE(req: Request, { params } : { params: { id: string } }) {
try { try {
const user = await fetchUserWithImpersonation(req) const user = await fetchUserWithImpersonation(req)
if (!user) { if (!user) {
return new NextResponse("Unauthorized", { status: 401 }); return new NextResponse("Unauthorized", { status: 401 });
} }

View File

@ -4,20 +4,24 @@ import { NextResponse } from "next/server";
export async function GET(req: Request) { export async function GET(req: Request) {
try { try {
console.log("ABC 1")
const user = await fetchUserWithImpersonation(req); const user = await fetchUserWithImpersonation(req);
if (!user) { if (!user) {
return new NextResponse("Unauthorized", { status: 401 }); return new NextResponse("Unauthorized", { status: 401 });
} }
console.log("ABC 2")
const api = await db.twitchConnection.findFirst({ const api = await db.twitchConnection.findFirst({
where: { where: {
userId: user.id userId: user.id
} }
}) })
console.log("ABC 3")
if (!api) { if (!api) {
return new NextResponse("Forbidden", { status: 403 }); return new NextResponse("Forbidden", { status: 403 });
} }
console.log("ABC 4")
const data = { const data = {
client_id: process.env.TWITCH_BOT_CLIENT_ID, client_id: process.env.TWITCH_BOT_CLIENT_ID,
client_secret: process.env.TWITCH_BOT_CLIENT_SECRET, client_secret: process.env.TWITCH_BOT_CLIENT_SECRET,
@ -25,6 +29,7 @@ export async function GET(req: Request) {
refresh_token: api.refreshToken, refresh_token: api.refreshToken,
broadcaster_id: api.broadcasterId broadcaster_id: api.broadcasterId
} }
console.log("ABC 5", data)
return NextResponse.json(data); return NextResponse.json(data);
} catch (error) { } catch (error) {
console.log("[TOKENS/GET]", error); console.log("[TOKENS/GET]", error);

View File

@ -20,11 +20,11 @@ export async function POST(req: Request) {
const id = generateToken() const id = generateToken()
const token = await db.apiKey.create({ const token = await db.apiKey.create({
data: { data: {
id, id,
label, label,
userId: userId as string userId: userId as string
} }
}); });
return NextResponse.json(token); return NextResponse.json(token);
@ -41,16 +41,16 @@ export async function DELETE(req: Request) {
return new NextResponse("Unauthorized", { status: 401 }); return new NextResponse("Unauthorized", { status: 401 });
} }
let { id } = await req.json(); const { id } = await req.json();
if (!id) { if (!id) {
return NextResponse.json(null) return NextResponse.json(null)
} }
const token = await db.apiKey.delete({ const token = await db.apiKey.delete({
where: { where: {
id, id,
userId: user?.id userId: user?.id
} }
}); });
return NextResponse.json(token); return NextResponse.json(token);
@ -60,12 +60,12 @@ export async function DELETE(req: Request) {
} }
} }
export function generateToken() { function generateToken() {
var chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXTZabcdefghiklmnopqrstuvwxyz"; let chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXTZabcdefghiklmnopqrstuvwxyz";
var string_length = 32; let string_length = 32;
var randomstring = ''; let randomstring = '';
for (var i = 0; i < string_length; i++) { for (let i = 0; i < string_length; i++) {
var rnum = Math.floor(Math.random() * chars.length); let rnum = Math.floor(Math.random() * chars.length);
randomstring += chars[rnum]; randomstring += chars[rnum];
} }
return randomstring; return randomstring;

View File

@ -1,28 +1,23 @@
import fetchUser from "@/lib/fetch-user";
import { db } from "@/lib/db" import { db } from "@/lib/db"
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import fetchUserWithImpersonation from "@/lib/fetch-user-impersonation";
export async function GET(req: Request) { export async function GET(req: Request) {
try { try {
const { searchParams } = new URL(req.url) const user = await fetchUserWithImpersonation(req)
let userId = searchParams.get('userId') if (!user) {
return new NextResponse("Unauthorized", { status: 401 });
if (userId == null) {
const user = await fetchUser(req);
if (user != null) {
userId = user.id as string;
}
} }
const tokens = await db.apiKey.findMany({ const tokens = await db.apiKey.findMany({
where: { where: {
userId: userId as string userId: user.id
} }
}); });
return NextResponse.json(tokens); return NextResponse.json(tokens);
} catch (error) { } catch (error) {
console.log("[TOKENS/GET]", error); console.log("[TOKENS/GET]", error);
return new NextResponse("Internal Error", { status: 500}); return new NextResponse("Internal Error", { status: 500 });
} }
} }

View File

@ -24,7 +24,7 @@ export async function GET(req: Request) {
return NextResponse.json(users) return NextResponse.json(users)
} }
if (id) { if (id) {
const users = await db.user.findUnique({ const users = await db.user.findFirst({
where: { where: {
id: id id: id
} }
@ -35,7 +35,7 @@ export async function GET(req: Request) {
const users = await db.user.findMany(); const users = await db.user.findMany();
return NextResponse.json(users) return NextResponse.json(users)
} catch (error) { } catch (error) {
console.log("[AUTH/ACCOUNT/IMPERSONATION]", error); console.log("[USERS]", error);
return new NextResponse("Internal Error", { status: 500 }); return new NextResponse("Internal Error", { status: 500 });
} }
} }

View File

@ -3,63 +3,46 @@
import axios from "axios"; import axios from "axios";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import * as React from 'react'; import * as React from 'react';
import { ApiKey, User } from "@prisma/client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useSession } from "next-auth/react";
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
const SettingsPage = () => { const ApiKeyPage = () => {
const { data: session, status } = useSession(); const [apiKeyViewable, setApiKeyViewable] = useState<number>(-1)
const [apiKeys, setApiKeys] = useState<{ id: string, label: string, userId: string }[]>([])
const [apiKeyViewable, setApiKeyViewable] = useState(0)
const [apiKeyChanges, setApiKeyChanges] = useState(0)
const [apiKeys, setApiKeys] = useState<ApiKey[]>([])
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
try { await axios.get("/api/tokens")
const keys = (await axios.get("/api/tokens")).data ?? {}; .then(d => setApiKeys(d.data ?? []))
setApiKeys(keys) .catch(console.error)
} catch (error) {
console.log("ERROR", error)
}
}; };
fetchData().catch(console.error); fetchData();
}, [apiKeyChanges]); }, []);
const onApiKeyAdd = async () => { const onApiKeyAdd = async (label: string) => {
try { await axios.post("/api/token", { label })
await axios.post("/api/token", { .then(d => setApiKeys(apiKeys.concat([d.data])))
label: "Key label" .catch(console.error)
});
setApiKeyChanges(apiKeyChanges + 1)
} catch (error) {
console.log("ERROR", error)
}
} }
const onApiKeyDelete = async (id: string) => { const onApiKeyDelete = async (id: string) => {
try { await axios.delete("/api/token/" + id)
await axios.delete("/api/token/" + id); .then((d) => setApiKeys(apiKeys.filter(k => k.id != d.data.id)))
setApiKeyChanges(apiKeyChanges - 1) .catch(console.error)
} catch (error) {
console.log("ERROR", error)
}
} }
return ( return (
<div> <div>
<div className="px-10 py-5 mx-5 my-10"> <div className="px-10 py-5 mx-5 my-10">
<div> <div>
<div className="text-xl justify-left mt-10">API Keys</div> <div className="text-xl justify-left mt-10 text-center">API Keys</div>
<Table className="max-w-2xl"> <Table>
<TableCaption>A list of your secret API keys.</TableCaption> <TableCaption>A list of your secret API keys.</TableCaption>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Label</TableHead> <TableHead>Label</TableHead>
<TableHead>Token</TableHead> <TableHead>Token</TableHead>
<TableHead>View</TableHead>
<TableHead>Action</TableHead> <TableHead>Action</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@ -67,20 +50,20 @@ const SettingsPage = () => {
{apiKeys.map((key, index) => ( {apiKeys.map((key, index) => (
<TableRow key={key.id}> <TableRow key={key.id}>
<TableCell className="font-medium">{key.label}</TableCell> <TableCell className="font-medium">{key.label}</TableCell>
<TableCell>{(apiKeyViewable & (1 << index)) > 0 ? key.id : "*".repeat(key.id.length)}</TableCell> <TableCell>{apiKeyViewable == index ? key.id : "*".repeat(key.id.length)}</TableCell>
<TableCell> <TableCell>
<Button onClick={() => setApiKeyViewable((v) => v ^ (1 << index))}> <Button onClick={() => setApiKeyViewable((v) => v != index ? index : -1)}>
{(apiKeyViewable & (1 << index)) > 0 ? "HIDE" : "VIEW"} {apiKeyViewable == index ? "HIDE" : "VIEW"}
</Button> </Button>
<Button onClick={() => onApiKeyDelete(key.id)} className="ml-[10px] bg-red-500 hover:bg-red-700">DELETE</Button>
</TableCell> </TableCell>
<TableCell><Button onClick={() => onApiKeyDelete(key.id)}>DEL</Button></TableCell> <TableCell></TableCell>
</TableRow> </TableRow>
))} ))}
<TableRow key="ADD"> <TableRow key="ADD">
<TableCell className="font-medium"></TableCell> <TableCell className="font-medium"></TableCell>
<TableCell></TableCell> <TableCell></TableCell>
<TableCell></TableCell> <TableCell><Button onClick={() => onApiKeyAdd("Key label")}>ADD</Button></TableCell>
<TableCell><Button onClick={onApiKeyAdd}>ADD</Button></TableCell>
</TableRow> </TableRow>
</TableBody> </TableBody>
</Table> </Table>
@ -90,4 +73,4 @@ const SettingsPage = () => {
); );
} }
export default SettingsPage; export default ApiKeyPage;

View File

@ -10,7 +10,7 @@ import Link from "next/link";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
const SettingsPage = () => { const ConnectionsPage = () => {
const { data: session, status } = useSession(); const { data: session, status } = useSession();
const [previousUsername, setPreviousUsername] = useState<string>() const [previousUsername, setPreviousUsername] = useState<string>()
const [userId, setUserId] = useState<string>() const [userId, setUserId] = useState<string>()
@ -24,7 +24,7 @@ const SettingsPage = () => {
setPreviousUsername(session.user?.name as string) setPreviousUsername(session.user?.name as string)
if (session.user?.name) { if (session.user?.name) {
const fetchData = async () => { const fetchData = async () => {
var connection: User = (await axios.get("/api/account")).data let connection: User = (await axios.get("/api/account")).data
setUserId(connection.id) setUserId(connection.id)
setLoading(false) setLoading(false)
} }
@ -36,7 +36,7 @@ const SettingsPage = () => {
const [twitchUser, setTwitchUser] = useState<TwitchConnection | null>(null) const [twitchUser, setTwitchUser] = useState<TwitchConnection | null>(null)
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
var connection: TwitchConnection = (await axios.get("/api/settings/connections/twitch")).data let connection: TwitchConnection = (await axios.get("/api/settings/connections/twitch")).data
setTwitchUser(connection) setTwitchUser(connection)
} }
@ -97,4 +97,4 @@ const SettingsPage = () => {
); );
} }
export default SettingsPage; export default ConnectionsPage;

View File

@ -3,8 +3,11 @@ import { cn } from "@/lib/utils";
import { headers } from 'next/headers'; import { headers } from 'next/headers';
import React from "react"; import React from "react";
const SettingsLayout = async ( const SettingsLayout = async ({
{ children } : { children:React.ReactNode } ) => { children
} : {
children:React.ReactNode
} ) => {
const headersList = headers(); const headersList = headers();
const header_url = headersList.get('x-url') || ""; const header_url = headersList.get('x-url') || "";
@ -14,7 +17,7 @@ const SettingsLayout = async (
header_url.endsWith("/settings") && "flex h-full w-full md:w-[250px] z-30 flex-col fixed inset-y-0")}> header_url.endsWith("/settings") && "flex h-full w-full md:w-[250px] z-30 flex-col fixed inset-y-0")}>
<SettingsNavigation /> <SettingsNavigation />
</div> </div>
<main className={cn("md:pl-[250px] h-full", header_url.endsWith("/settings") && "hidden")}> <main className={"md:pl-[250px] h-full"}>
{children} {children}
</main> </main>
</div> </div>

View File

@ -0,0 +1,143 @@
"use client";
import axios from "axios";
import * as React from 'react';
import { useEffect, useState } from "react";
import { useSession } from "next-auth/react";
import RedeemptionAction from "@/components/elements/redeemable-action";
import OBSRedemption from "@/components/elements/redemption";
import { ActionType } from "@prisma/client";
import InfoNotice from "@/components/elements/info-notice";
const obsTransformations = [
{ label: "scene_name", description: "", placeholder: "Name of the OBS scene" },
{ label: "scene_item_name", description: "", placeholder: "Name of the OBS scene item / source" },
{ label: "rotation", description: "", placeholder: "An expression using x as the previous value" },
{ label: "position_x", description: "", placeholder: "An expression using x as the previous value" },
{ label: "position_y", description: "", placeholder: "An expression using x as the previous value" }
]
const RedemptionsPage = () => {
const { data: session, status } = useSession();
const [previousUsername, setPreviousUsername] = useState<string | null>()
const [loading, setLoading] = useState<boolean>(true)
const [open, setOpen] = useState(false)
const [actions, setActions] = useState<{ name: string, type: string, data: any }[]>([])
const [twitchRedemptions, setTwitchRedemptions] = useState<{ id: string, title: string }[]>([])
const [redemptions, setRedemptions] = useState<{ id: string, redemptionId: string, actionName: string, order: number }[]>([])
function addAction(name: string, type: ActionType, data: { [key: string]: string }) {
setActions([...actions, { name, type, data }])
}
function removeAction(action: { name: string, type: string, data: any }) {
setActions(actions.filter(a => a.name != action.name))
}
function addRedemption(id: string, actionName: string, redemptionId: string, order: number) {
setRedemptions([...redemptions, { id, redemptionId, actionName, order }])
}
function removeRedemption(redemption: { id: string, redemptionId: string, actionName: string, order: number }) {
setRedemptions(redemptions.filter(r => r.id != redemption.id))
}
useEffect(() => {
if (status !== "authenticated" || previousUsername == session.user?.name) {
return
}
setPreviousUsername(session.user?.name)
axios.get("/api/settings/redemptions/actions")
.then(d => {
setActions(d.data)
})
axios.get("/api/account/redemptions")
.then(d => {
const rs = d.data.data?.map(r => ({ id: r.id, title: r.title })) ?? []
setTwitchRedemptions(rs)
axios.get("/api/settings/redemptions")
.then(d => {
setRedemptions(d.data)
})
})
}, [session])
return (
<div>
<div className="text-2xl text-center pt-[50px]">Redemption Actions</div>
<InfoNotice
message="Redemption actions are activated when specific Twitch channel point redeems have been activated. Aforementioned redeem need to be linked in the redemption part, together with the action, for the action to activate."
hidden={false} />
{actions.map(action =>
<div
className="px-10 py-3 w-full h-full flex-grow inset-y-1/2"
key={action.name}>
<RedeemptionAction
name={action.name}
type={action.type}
data={action.data}
edit={false}
showEdit={true}
isNew={false}
obsTransformations={obsTransformations}
adder={addAction}
remover={removeAction} />
</div>
)}
<div
className="px-10 py-3 w-full h-full flex-grow inset-y-1/2">
<RedeemptionAction
name=""
type={undefined}
data={{}}
edit={true}
showEdit={false}
isNew={true}
obsTransformations={obsTransformations}
adder={addAction}
remover={removeAction} />
</div>
<div className="text-2xl text-center pt-[50px]">Redemptions</div>
<InfoNotice
message="Redemptions are just a way to link specific actions to actual Twitch channel point redeems."
hidden={false} />
{redemptions.map(redemption =>
<div
className="px-10 py-3 w-full h-full flex-grow inset-y-1/2"
key={redemption.id}>
<OBSRedemption
id={redemption.id}
redemptionId={redemption.redemptionId}
actionName={redemption.actionName}
edit={false}
showEdit={true}
isNew={false}
actions={actions.map(a => a.name)}
twitchRedemptions={twitchRedemptions}
adder={addRedemption}
remover={removeRedemption} />
</div>
)}
<div
className="px-10 py-3 w-full h-full flex-grow inset-y-1/2">
<OBSRedemption
id={undefined}
redemptionId={undefined}
actionName=""
edit={true}
showEdit={false}
isNew={true}
actions={actions.map(a => a.name)}
twitchRedemptions={twitchRedemptions}
adder={addRedemption}
remover={removeRedemption} />
</div>
</div>
);
}
export default RedemptionsPage;

View File

@ -230,7 +230,7 @@ const TTSFiltersPage = () => {
<Form {...usernameFilteredForm}> <Form {...usernameFilteredForm}>
<form onSubmit={usernameFilteredForm.handleSubmit(onAdd)}> <form onSubmit={usernameFilteredForm.handleSubmit(onAdd)}>
<div className="flex w-full items-center justify-between rounded-md border px-4 py-2 gap-3 mt-2"> <div className="flex w-full items-center justify-between rounded-md border px-4 py-2 gap-3 mt-2">
<Label className="rounded-lg bg-primary px-2 py-1 text-xs text-primary-foreground "> <Label className="rounded-lg bg-primary px-2 py-1 text-xs text-primary-foreground">
{tag} {tag}
</Label> </Label>
<FormField <FormField

View File

@ -3,10 +3,8 @@
import axios from "axios"; import axios from "axios";
import * as React from 'react'; import * as React from 'react';
import { Check, ChevronsUpDown } from "lucide-react" import { Check, ChevronsUpDown } from "lucide-react"
import { useEffect, useState } from "react"; import { useEffect, useReducer, useState } from "react";
import { useSession } from "next-auth/react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command" import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
@ -16,49 +14,50 @@ import voices from "@/data/tts";
import InfoNotice from "@/components/elements/info-notice"; import InfoNotice from "@/components/elements/info-notice";
const TTSVoiceFiltersPage = () => { const TTSVoiceFiltersPage = () => {
const { data: session, status } = useSession();
const [loading, setLoading] = useState<boolean>(true)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [value, setValue] = useState(0) const [defaultVoice, setDefaultVoice] = useState("")
const [enabled, setEnabled] = useState(0) const [loading, setLoading] = useState(true)
function enabledVoicesReducer(enabledVoices: { [voice: string]: boolean }, action: { type: string, value: string }) {
if (action.type == "enable") {
return { ...enabledVoices, [action.value]: true }
} else if (action.type == "disable") {
return { ...enabledVoices, [action.value]: false }
}
return enabledVoices
}
const [enabledVoices, dispatchEnabledVoices] = useReducer(enabledVoicesReducer, Object.assign({}, ...voices.map(v => ({[v]: false}) )))
useEffect(() => { useEffect(() => {
axios.get("/api/settings/tts/default") axios.get("/api/settings/tts/default")
.then((voice) => { .then((voice) => {
setValue(Number.parseInt(voice.data.value)) setDefaultVoice(voice.data)
}) })
axios.get("/api/settings/tts") axios.get("/api/settings/tts")
.then((d) => { .then((d) => {
const total = d.data.reduce((acc: number, item: {value: number, label: string, gender: string, language: string}) => acc |= 1 << (item.value - 1), 0) const data: string[] = d.data;
setEnabled(total) data.forEach(d => dispatchEnabledVoices({ type: "enable", value: d }))
setLoading(false)
}) })
}, []) }, [])
const onDefaultChange = (voice: string) => { const onDefaultChange = (voice: string) => {
try { try {
axios.post("/api/settings/tts/default", { voice }) axios.post("/api/settings/tts/default", { voice })
.then(d => {
console.log(d)
})
.catch(e => console.error(e)) .catch(e => console.error(e))
} catch (error) { } catch (error) {
console.log("[TTS/DEFAULT]", error); console.log("[TTS/DEFAULT]", error);
return;
} }
} }
const onEnabledChanged = (val: number) => { const onEnabledChanged = (voice: string, state: boolean) => {
try { try {
axios.post("/api/settings/tts", { voice: val }) axios.post("/api/settings/tts", { voice: voice, state: state })
.then(d => {
console.log(d)
})
.catch(e => console.error(e)) .catch(e => console.error(e))
} catch (error) { } catch (error) {
console.log("[TTS]", error); console.log("[TTS/ENABLED]", error);
return;
} }
} }
@ -78,7 +77,7 @@ const TTSVoiceFiltersPage = () => {
role="combobox" role="combobox"
aria-expanded={open} aria-expanded={open}
className="w-[200px] justify-between"> className="w-[200px] justify-between">
{value ? voices.find(v => Number.parseInt(v.value) == value)?.label : "Select voice..."} {defaultVoice ? voices.find(v => v == defaultVoice) : "Select voice..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
@ -89,20 +88,20 @@ const TTSVoiceFiltersPage = () => {
<CommandGroup> <CommandGroup>
{voices.map((voice) => ( {voices.map((voice) => (
<CommandItem <CommandItem
key={voice.value + "-" + voice.label} key={voice}
value={voice.value} value={voice}
onSelect={(currentValue) => { onSelect={(currentVoice) => {
setValue(Number.parseInt(currentValue)) setDefaultVoice(voice)
onDefaultChange(voice.label) onDefaultChange(voice)
setOpen(false) setOpen(false)
}}> }}>
<Check <Check
className={cn( className={cn(
"mr-2 h-4 w-4", "mr-2 h-4 w-4",
value === Number.parseInt(voice.value) ? "opacity-100" : "opacity-0" defaultVoice === voice ? "opacity-100" : "opacity-0"
)} )}
/> />
{voice.label} {voice}
</CommandItem> </CommandItem>
))} ))}
</CommandGroup> </CommandGroup>
@ -116,14 +115,14 @@ const TTSVoiceFiltersPage = () => {
<InfoNotice message="Voices can be disabled from being used. Default voice will always work." hidden={false} /> <InfoNotice message="Voices can be disabled from being used. Default voice will always work." hidden={false} />
<div className="grid grid-cols-4 grid-flow-row gap-4 pt-[20px]"> <div className="grid grid-cols-4 grid-flow-row gap-4 pt-[20px]">
{voices.map((v, i) => ( {voices.map((v, i) => (
<div key={v.label + "-enabled"} className="h-[30px] row-span-1 col-span-1 align-middle flex items-center justify-center"> <div key={v + "-enabled"} className="h-[30px] row-span-1 col-span-1 align-middle flex items-center justify-center">
<Checkbox onClick={() => { <Checkbox onClick={() => {
const newVal = enabled ^ (1 << (Number.parseInt(v.value) - 1)) dispatchEnabledVoices({ type: enabledVoices[v] ? "disable" : "enable", value: v })
setEnabled(newVal) onEnabledChanged(v, !enabledVoices[v])
onEnabledChanged(newVal)
}} }}
checked={(enabled & (1 << (Number.parseInt(v.value) - 1))) > 0} /> disabled={loading}
<div className="pl-[5px]">{v.label}</div> checked={enabledVoices[v]} />
<div className="pl-[5px]">{v}</div>
</div> </div>
))} ))}
</div> </div>

58
app/socket/page.tsx Normal file
View File

@ -0,0 +1,58 @@
"use client"
import { Button } from '@/components/ui/button';
import { v4 } from "uuid"
import { useEffect, useState } from 'react';
import useWebSocket from 'react-use-websocket';
const socketUrl = 'wss://echo.websocket.org';
const SocketPage = () => {
const [mounted, setMounted] = useState(false)
const {
sendMessage,
sendJsonMessage,
lastMessage,
lastJsonMessage,
readyState,
getWebSocket,
} = useWebSocket(socketUrl, {
onOpen: () => console.log('opened'),
onMessage: (e) => console.log("MESSAGE", e),
onError: (e) => console.error(e),
shouldReconnect: (closeEvent) => { console.log("Reconnect"); return true; },
});
const [messageHistory, setMessageHistory] = useState<MessageEvent[]>([]);
useEffect(() => {
if (lastMessage !== null) {
console.log("LAST", lastMessage)
setMessageHistory((prev) => prev.concat(lastMessage));
}
}, [lastMessage, setMessageHistory]);
useEffect(() => {
if (!mounted) {
setMounted(true)
}
}, [])
return (<div className="w-full bg-blue-300">
<p>Hello</p>
<p>{readyState}</p>
<Button onClick={() => sendMessage("uisdhnishdadasdfsd " + v4())}>
Click on me
</Button>
<div>
{lastMessage ? <span>Last message: {lastMessage.data}</span> : null}
<ul>
{messageHistory.map((message, idx) => (
<p className='block' key={idx}>{message ? message.data : null}</p>
))}
</ul>
</div>
</div>)
}
export default SocketPage

View File

@ -0,0 +1,316 @@
import axios from "axios";
import { useState } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Label } from "../ui/label";
import { Maximize2, Minimize2, Trash2Icon } from "lucide-react";
import { ActionType } from "@prisma/client";
const actionTypes = [
{
"name": "Overwrite local file content",
"value": ActionType.WRITE_TO_FILE
},
{
"name": "Append to local file",
"value": ActionType.APPEND_TO_FILE
},
{
"name": "Cause a transformation on OBS scene item",
"value": ActionType.OBS_TRANSFORM
},
{
"name": "Play an audio file locally",
"value": ActionType.AUDIO_FILE
},
]
interface RedeemableAction {
name: string
type: string | undefined
data: { [key: string]: string }
edit?: boolean
showEdit?: boolean
isNew: boolean
obsTransformations: { label: string, placeholder: string, description: string }[]
adder: (name: string, type: ActionType, data: { [key: string]: string }) => void
remover: (action: { name: string, type: string, data: any }) => void
}
const RedemptionAction = ({
name,
type,
data,
edit,
showEdit = true,
isNew = false,
obsTransformations = [],
adder,
remover
}: RedeemableAction) => {
const [open, setOpen] = useState(false)
const [actionName, setActionName] = useState(name)
const [actionType, setActionType] = useState<{ name: string, value: ActionType } | undefined>(actionTypes.find(a => a.value == type?.toUpperCase()))
const [actionData, setActionData] = useState<{ [key: string]: string }>(data)
const [isEditable, setIsEditable] = useState(edit)
const [isMinimized, setIsMinimized] = useState(!isNew)
const [oldData, setOldData] = useState<{ n: string, t: ActionType | undefined, d: { [k: string]: string } } | undefined>(undefined)
function Save(name: string, type: ActionType | undefined, data: { [key: string]: string }, isNew: boolean) {
// TODO: validation
if (!name) {
return
}
if (!type) {
return
}
if (!data) {
return
}
let info: any = {
name,
type
}
info = { ...info, ...data }
if (isNew) {
axios.post("/api/settings/redemptions/actions", info)
.then(d => {
adder(name, type, data)
setActionName("")
setActionType(undefined)
setActionData({})
})
} else {
axios.put("/api/settings/redemptions/actions", info)
.then(d => {
setIsEditable(false)
})
}
}
function Cancel(data: { n: string, t: ActionType | undefined, d: { [k: string]: string } } | undefined) {
if (!data)
return
setActionName(data.n)
setActionType(actionTypes.find(a => a.value == data.t))
setActionData(data.d)
setIsEditable(false)
setOldData(undefined)
}
function Delete() {
axios.delete("/api/settings/redemptions/actions?action_name=" + name)
.then(d => {
remover(d.data)
})
}
return (
<div
className="bg-orange-300 p-3 border-2 border-orange-400 rounded-lg w-[830px]">
{isMinimized &&
<div
className="flex">
<Label
className="mr-2 grow text-lg align-middle"
htmlFor="name">
{actionName}
</Label>
<Button
className="flex inline-block self-end"
onClick={e => setIsMinimized(!isMinimized)}>
{isMinimized ? <Maximize2 /> : <Minimize2 />}
</Button>
</div>
|| !isMinimized &&
<div>
<div
className="pb-3">
<Label
className="mr-2"
htmlFor="name">
Action name
</Label>
<Input
className="inline-block w-[300px]"
id="name"
placeholder="Enter a name for this action"
onChange={e => setActionName(e.target.value)}
value={actionName}
readOnly={!isNew} />
<Label
className="ml-10 mr-2"
htmlFor="type">
Action type
</Label>
{!isEditable &&
<Input
className="inline-block w-[300px] justify-between"
name="type"
value={actionType?.name}
readOnly />
|| isEditable &&
<Popover
open={open}
onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-[300px] justify-between"
>{!actionType ? "Select one..." : actionType.name}</Button>
</PopoverTrigger>
<PopoverContent>
<Command>
<CommandInput
placeholder="Filter actions..."
autoFocus={true} />
<CommandList>
<CommandEmpty>No action found.</CommandEmpty>
<CommandGroup>
{actionTypes.map((action) => (
<CommandItem
value={action.name}
key={action.value}
onSelect={(value) => {
setActionType(actionTypes.find(v => v.name.toLowerCase() == value.toLowerCase()))
setOpen(false)
}}>
{action.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
}
</div>
<div>
{actionType && (actionType.value == ActionType.WRITE_TO_FILE || actionType.value == ActionType.APPEND_TO_FILE) &&
<div>
<Label
className="mr-2"
htmlFor="file_path">
File path
</Label>
<Input
className="w-[300px] justify-between inline-block"
name="file_path"
placeholder={actionType.value == ActionType.WRITE_TO_FILE ? "Enter the local file path to the file to overwrite" : "Enter the local file path to the file to append to"}
value={actionData["file_path"]}
onChange={e => setActionData({ ...actionData, "file_path": e.target.value })}
readOnly={!isEditable} />
<Label
className="ml-10 mr-2"
htmlFor="file_content">
File content
</Label>
<Input
className="w-[300px] justify-between inline-block"
name="file_content"
placeholder="Enter the content that should be written"
value={actionData["file_content"]}
onChange={e => setActionData({ ...actionData, "file_content": e.target.value })}
readOnly={!isEditable} />
</div>
}
{actionType && actionType.value == ActionType.OBS_TRANSFORM &&
<div>
{obsTransformations.map(t =>
<div
className="mt-3">
<Label
className="mr-2"
htmlFor={t.label.toLowerCase()}>
{t.label.split("_").map(w => w.substring(0, 1).toUpperCase() + w.substring(1).toLowerCase()).join(" ")}
</Label>
<Input
className="w-[300px] justify-between inline-block"
name={t.label.toLowerCase()}
placeholder={t.placeholder}
value={actionData[t.label]}
onChange={e => {
let c = { ...actionData }
c[t.label] = e.target.value
setActionData(c)
}}
readOnly={!isEditable} />
</div>
)}
</div>
}
{actionType && actionType.value == ActionType.AUDIO_FILE &&
<div>
<Label
className="mr-2"
htmlFor="file_path">
File path
</Label>
<Input
className="w-[300px] justify-between inline-block"
name="file_path"
placeholder={"Enter the local file path where the audio file is at"}
value={actionData["file_path"]}
onChange={e => setActionData({ ...actionData, "file_path": e.target.value })}
readOnly={!isEditable} />
</div>
}
</div>
<div>
{isEditable &&
<Button
className="m-3"
onClick={() => Save(actionName, actionType?.value, actionData, isNew)}>
{isNew ? "Add" : "Save"}
</Button>
}
{isEditable && !isNew &&
<Button
className="m-3"
onClick={() => Cancel(oldData)}>
Cancel
</Button>
}
{showEdit && !isEditable &&
<Button
className="m-3"
onClick={() => {
setOldData({ n: actionName, t: actionType?.value, d: actionData })
setIsEditable(true)
}}>
Edit
</Button>
}
{!isEditable &&
<Button
className="m-3 bg-red-500 hover:bg-red-600 align-bottom"
onClick={() => Delete()}>
<Trash2Icon />
</Button>
}
{!isNew &&
<Button
className="m-3 align-middle"
onClick={e => setIsMinimized(!isMinimized)}>
{isMinimized ? <Maximize2 /> : <Minimize2 />}
</Button>
}
</div>
</div>
}
</div>
);
}
export default RedemptionAction;

View File

@ -0,0 +1,274 @@
import axios from "axios";
import { useEffect, useState } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Label } from "../ui/label";
import { HelpCircleIcon, Trash2Icon } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "../ui/tooltip"
interface Redemption {
id: string | undefined
redemptionId: string | undefined
actionName: string
edit: boolean
showEdit: boolean
isNew: boolean
actions: string[]
twitchRedemptions: { id: string, title: string }[]
adder: (id: string, actionName: string, redemptionId: string, order: number) => void
remover: (redemption: { id: string, redemptionId: string, actionName: string, order: number }) => void
}
const OBSRedemption = ({
id,
redemptionId,
actionName,
edit,
showEdit,
isNew,
actions,
twitchRedemptions,
adder,
remover
}: Redemption) => {
const [actionOpen, setActionOpen] = useState(false)
const [redemptionOpen, setRedemptionOpen] = useState(false)
const [twitchRedemption, setTwitchRedemption] = useState<{ id: string, title: string } | undefined>(undefined)
const [action, setAction] = useState<string | undefined>(actionName)
const [order, setOrder] = useState<number>(0)
const [isEditable, setIsEditable] = useState(edit)
const [oldData, setOldData] = useState<{ r: { id: string, title: string } | undefined, a: string | undefined, o: number } | undefined>(undefined)
useEffect(() => {
console.log("TR:", twitchRedemptions, redemptionId, twitchRedemptions.find(r => r.id == redemptionId))
setTwitchRedemption(twitchRedemptions.find(r => r.id == redemptionId))
}, [])
function Save() {
// TODO: validation
if (!isNew && !id)
return
if (!action || !twitchRedemption)
return
if (isNew) {
axios.post("/api/settings/redemptions", {
actionName: action,
redemptionId: twitchRedemption?.id,
order: order,
state: true
}).then(d => {
adder(d.data.id, action, twitchRedemption.id, 0)
setAction(undefined)
setTwitchRedemption(undefined)
setOrder(0)
})
} else {
axios.put("/api/settings/redemptions", {
id: id,
actionName: action,
redemptionId: twitchRedemption?.id,
order: order,
state: true
}).then(d => {
setIsEditable(false)
})
}
}
function Cancel() {
if (!oldData)
return
setAction(oldData.a)
setTwitchRedemption(oldData.r)
setOrder(oldData.o)
setIsEditable(false)
setOldData(undefined)
}
function Delete() {
axios.delete("/api/settings/redemptions?id=" + id)
.then(d => {
remover(d.data)
})
}
return (
<div
className="bg-orange-300 p-5 border-2 border-orange-400 rounded-lg w-[830px]">
<div
className="pb-4">
<Label
className="mr-2"
htmlFor="redemption">
Twitch Redemption
</Label>
{!isEditable &&
<Input
className="inline-block w-[290px] justify-between"
name="redemption"
value={twitchRedemption?.title}
readOnly />
|| isEditable &&
<Popover
open={redemptionOpen}
onOpenChange={setRedemptionOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={actionOpen}
className="w-[290px] justify-between"
>{!twitchRedemption ? "Select one..." : twitchRedemption.title}</Button>
</PopoverTrigger>
<PopoverContent>
<Command>
<CommandInput
placeholder="Filter redemptions..."
autoFocus={true} />
<CommandList>
<CommandEmpty>No redemption found.</CommandEmpty>
<CommandGroup>
{twitchRedemptions.map((redemption) => (
<CommandItem
value={redemption.title}
key={redemption.id}
onSelect={(value) => {
setTwitchRedemption(twitchRedemptions.find(v => v.title.toLowerCase() == value.toLowerCase()))
setRedemptionOpen(false)
}}>
{redemption.title}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
}
<Label
className="ml-10 mr-2"
htmlFor="action">
Action
</Label>
{!isEditable &&
<Input
className="inline-block w-[290px] justify-between"
name="action"
value={action}
readOnly />
|| isEditable &&
<Popover
open={actionOpen}
onOpenChange={setActionOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={actionOpen}
className="w-[290px] justify-between">
{!action ? "Select one..." : action}
</Button>
</PopoverTrigger>
<PopoverContent>
<Command>
<CommandInput
placeholder="Filter actions..."
autoFocus={true} />
<CommandList>
<CommandEmpty>No action found.</CommandEmpty>
<CommandGroup>
{actions.map((action) => (
<CommandItem
value={action}
key={action}
onSelect={(value) => {
let a = actions.find(v => v == action)
if (a)
setAction(a)
setActionOpen(false)
}}>
{action}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
}
</div>
<div
className="pb-4">
<Label
className="mr-2"
htmlFor="order">
Order
</Label>
<Input
className="inline-block w-[300px]"
id="name"
placeholder="Enter an order number for this action"
onChange={e => setOrder(e.target.value.length == 0 ? 0 : parseInt(e.target.value))}
value={order}
readOnly={!isEditable} />
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircleIcon
className="inline-block ml-3"/>
</TooltipTrigger>
<TooltipContent>
<p>This decides when this action will be done relative to other actions for this Twitch redemption.<br/>
Action start from lowest to highest order number.<br/>
Equal order numbers cannot be guaranteed proper order.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div>
{isEditable &&
<Button
className="m-3"
onClick={() => Save()}>
{isNew ? "Add" : "Save"}
</Button>
}
{isEditable && !isNew &&
<Button
className="m-3"
onClick={() => Cancel()}>
Cancel
</Button>
}
{showEdit && !isEditable &&
<Button
className="m-3"
onClick={() => {
setOldData({ a: actionName, r: twitchRedemption, o: order })
setIsEditable(true)
}}>
Edit
</Button>
}
{!isEditable &&
<Button
className="m-3 bg-red-500 hover:bg-red-600 align-bottom"
onClick={() => Delete()}>
<Trash2Icon />
</Button>
}
</div>
</div>
);
}
export default OBSRedemption;

View File

@ -13,110 +13,104 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "
const AdminProfile = () => { const AdminProfile = () => {
const session = useSession(); const session = useSession();
const [impersonation, setImpersonation] = useState<string | null>(null) const [impersonation, setImpersonation] = useState<string | null>(null)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [users, setUsers] = useState<User[]>([])
const [users, setUsers] = useState<User[]>([]) useEffect(() => {
const fetch = async (userId: string | undefined) => {
if (!userId) return
useEffect(() => { await axios.get("/api/users?id=" + userId)
const fetch = async (userId: string | undefined) => { .then(u => setImpersonation(u.data?.name))
if (!userId) return }
await axios.get<User>("/api/users?id=" + userId) const fetchUsers = async () => {
.then(u => { await axios.get<User[]>("/api/users")
setImpersonation(u.data?.name) .then((u) => {
}) setUsers(u.data.filter(x => x.id != session.data?.user.id))
})
}
fetch(session?.data?.user?.impersonation?.id)
fetchUsers()
}, [])
const onImpersonationChange = async (userId: string, name: string) => {
console.log("IMPERSONATION", impersonation)
if (impersonation) {
if (impersonation == name) {
await axios.delete("/api/account/impersonate")
.then(() => {
setImpersonation(null)
window.location.reload()
})
} else {
await axios.put("/api/account/impersonate", { targetId: userId })
.then(() => {
setImpersonation(name)
window.location.reload()
})
}
} else {
await axios.post("/api/account/impersonate", { targetId: userId })
.then(() => {
setImpersonation(name)
window.location.reload()
})
}
} }
console.log(session) return (
fetch(session?.data?.user?.impersonation?.id) <div className={"px-5 py-3 rounded-md bg-red-300 overflow-hidden wrap my-[10px] flex flex-grow flex-col gap-y-3"}>
}, []) <div>
<p className="text-xs text-gray-200">Role:</p>
useEffect(() => { <p>{session?.data?.user?.role}</p>
const fetchUsers = async () => { </div>
await axios.get<User[]>("/api/users") <div>
.then((u) => { <p className="text-xs text-gray-200">Impersonation:</p>
setUsers(u.data.filter(x => x.id != session.data?.user.id)) <Popover open={open} onOpenChange={setOpen}>
}) <PopoverTrigger asChild>
} <Button
variant="outline"
fetchUsers() role="combobox"
}, []) aria-expanded={open}
className="flex flex-grow justify-between text-xs">
const onImpersonationChange = async (userId: string, name: string) => { {impersonation ? impersonation : "Select a user"}
if (impersonation) { <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
if (impersonation == session.data?.user.impersonation?.name) { </Button>
await axios.delete("/api/account/impersonate") </PopoverTrigger>
.then(() => { <PopoverContent className="w-[200px] p-0">
setImpersonation(null) <Command>
window.location.reload() <CommandInput placeholder="Search users by name" />
}) <CommandEmpty>No voices found.</CommandEmpty>
} else { <CommandGroup>
await axios.put("/api/account/impersonate", { targetId: userId }) {users.map((user) => (
.then(() => { <CommandItem
setImpersonation(name) key={user.id}
window.location.reload() value={user.name ?? undefined}
}) onSelect={(currentValue) => {
} onImpersonationChange(user.id, user.name ?? "")
} else { setOpen(false)
await axios.post("/api/account/impersonate", { targetId: userId }) }}
.then(() => { >
setImpersonation(name) <Check
window.location.reload() className={cn(
}) "mr-2 h-4 w-4",
} user.name == impersonation ? "opacity-100" : "opacity-0"
} )}
/>
return ( {user.name}
<div className={"px-5 py-3 rounded-md bg-red-300 overflow-hidden wrap my-[10px] flex flex-grow flex-col gap-y-3"}> </CommandItem>
<div> ))}
<p className="text-xs text-gray-200">Role:</p> </CommandGroup>
<p>{session?.data?.user?.role}</p> </Command>
</div> </PopoverContent>
<div> </Popover>
<p className="text-xs text-gray-200">Impersonation:</p> </div>
<Popover open={open} onOpenChange={setOpen}> </div>
<PopoverTrigger asChild> );
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="flex flex-grow justify-between text-xs">
{impersonation ? impersonation : "Select a user"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput placeholder="Search users by name" />
<CommandEmpty>No voices found.</CommandEmpty>
<CommandGroup>
{users.map((user) => (
<CommandItem
key={user.id}
value={user.name ?? undefined}
onSelect={(currentValue) => {
onImpersonationChange(user.id, user.name ?? "")
setOpen(false)
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
user.name == impersonation ? "opacity-100" : "opacity-0"
)}
/>
{user.name}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
);
} }
export default AdminProfile; export default AdminProfile;

View File

@ -47,6 +47,17 @@ const SettingsNavigation = async () => {
</Link> </Link>
</li> </li>
<li className="text-xs text-gray-200">
Twitch
</li>
<li className="">
<Link href={"/settings/redemptions"}>
<Button variant="ghost" className="w-full text-lg">
Channel Redemptions
</Button>
</Link>
</li>
<li className="text-xs text-gray-200"> <li className="text-xs text-gray-200">
API API
</li> </li>

View File

@ -22,7 +22,7 @@ const UserProfile = () => {
const fetchData = async () => { const fetchData = async () => {
if (user) return if (user) return
var userData = (await axios.get("/api/account")).data let userData = (await axios.get("/api/account")).data
setUser(userData) setUser(userData)
} }

30
components/ui/tooltip.tsx Normal file
View File

@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@ -1,126 +1,4 @@
let voices_data = [ const voices_data = ["Filiz", "Astrid", "Tatyana", "Maxim", "Carmen", "Ines", "Cristiano", "Vitoria", "Ricardo", "Maja", "Jan", "Jacek", "Ewa", "Ruben", "Lotte", "Liv", "Seoyeon", "Takumi", "Mizuki", "Giorgio", "Carla", "Bianca", "Karl", "Dora", "Mathieu", "Celine", "Chantal", "Penelope", "Miguel", "Mia", "Enrique", "Conchita", "Geraint", "Salli", "Matthew", "Kimberly", "Kendra", "Justin", "Joey", "Joanna", "Ivy", "Raveena", "Aditi", "Emma", "Brian", "Amy", "Russell", "Nicole", "Vicki", "Marlene", "Hans", "Naja", "Mads", "Gwyneth", "Zhiyu", "es-ES-Standard-A", "it-IT-Standard-A", "it-IT-Wavenet-A", "ja-JP-Standard-A", "ja-JP-Wavenet-A", "ko-KR-Standard-A", "ko-KR-Wavenet-A", "pt-BR-Standard-A", "tr-TR-Standard-A", "sv-SE-Standard-A", "nl-NL-Standard-A", "nl-NL-Wavenet-A", "en-US-Wavenet-A", "en-US-Wavenet-B", "en-US-Wavenet-C", "en-US-Wavenet-D", "en-US-Wavenet-E", "en-US-Wavenet-F", "en-GB-Standard-A", "en-GB-Standard-B", "en-GB-Standard-C", "en-GB-Standard-D", "en-GB-Wavenet-A", "en-GB-Wavenet-B", "en-GB-Wavenet-C", "en-GB-Wavenet-D", "en-US-Standard-B", "en-US-Standard-C", "en-US-Standard-D", "en-US-Standard-E", "de-DE-Standard-A", "de-DE-Standard-B", "de-DE-Wavenet-A", "de-DE-Wavenet-B", "de-DE-Wavenet-C", "de-DE-Wavenet-D", "en-AU-Standard-A", "en-AU-Standard-B", "en-AU-Wavenet-A", "en-AU-Wavenet-B", "en-AU-Wavenet-C", "en-AU-Wavenet-D", "en-AU-Standard-C", "en-AU-Standard-D", "fr-CA-Standard-A", "fr-CA-Standard-B", "fr-CA-Standard-C", "fr-CA-Standard-D", "fr-FR-Standard-C", "fr-FR-Standard-D", "fr-FR-Wavenet-A", "fr-FR-Wavenet-B", "fr-FR-Wavenet-C", "fr-FR-Wavenet-D", "da-DK-Wavenet-A", "pl-PL-Wavenet-A", "pl-PL-Wavenet-B", "pl-PL-Wavenet-C", "pl-PL-Wavenet-D", "pt-PT-Wavenet-A", "pt-PT-Wavenet-B", "pt-PT-Wavenet-C", "pt-PT-Wavenet-D", "ru-RU-Wavenet-A", "ru-RU-Wavenet-B", "ru-RU-Wavenet-C", "ru-RU-Wavenet-D", "sk-SK-Wavenet-A", "tr-TR-Wavenet-A", "tr-TR-Wavenet-B", "tr-TR-Wavenet-C", "tr-TR-Wavenet-D", "tr-TR-Wavenet-E", "uk-UA-Wavenet-A", "ar-XA-Wavenet-A", "ar-XA-Wavenet-B", "ar-XA-Wavenet-C", "cs-CZ-Wavenet-A", "nl-NL-Wavenet-B", "nl-NL-Wavenet-C", "nl-NL-Wavenet-D", "nl-NL-Wavenet-E", "en-IN-Wavenet-A", "en-IN-Wavenet-B", "en-IN-Wavenet-C", "fil-PH-Wavenet-A", "fi-FI-Wavenet-A", "el-GR-Wavenet-A", "hi-IN-Wavenet-A", "hi-IN-Wavenet-B", "hi-IN-Wavenet-C", "hu-HU-Wavenet-A", "id-ID-Wavenet-A", "id-ID-Wavenet-B", "id-ID-Wavenet-C", "it-IT-Wavenet-B", "it-IT-Wavenet-C", "it-IT-Wavenet-D", "ja-JP-Wavenet-B", "ja-JP-Wavenet-C", "ja-JP-Wavenet-D", "cmn-CN-Wavenet-A", "cmn-CN-Wavenet-B", "cmn-CN-Wavenet-C", "cmn-CN-Wavenet-D", "nb-no-Wavenet-E", "nb-no-Wavenet-A", "nb-no-Wavenet-B", "nb-no-Wavenet-C", "nb-no-Wavenet-D", "vi-VN-Wavenet-A", "vi-VN-Wavenet-B", "vi-VN-Wavenet-C", "vi-VN-Wavenet-D", "sr-rs-Standard-A", "lv-lv-Standard-A", "is-is-Standard-A", "bg-bg-Standard-A", "af-ZA-Standard-A", "Tracy", "Danny", "Huihui", "Yaoyao", "Kangkang", "HanHan", "Zhiwei", "Asaf", "An", "Stefanos", "Filip", "Ivan", "Heidi", "Herena", "Kalpana", "Hemant", "Matej", "Andika", "Rizwan", "Lado", "Valluvar", "Linda", "Heather", "Sean", "Michael", "Karsten", "Guillaume", "Pattara", "Jakub", "Szabolcs", "Hoda", "Naayf"]
{ const voices = voices_data.filter(v => !v.includes("-")).sort()
value: "1",
label: "Brian",
gender: "Male",
language: "en"
},
{
value: "2",
label: "Amy",
gender: "Female",
language: "en"
},
{
value: "3",
label: "Emma",
gender: "Female",
language: "en"
},
{
value: "4",
label: "Geraint",
gender: "Male",
language: "en"
},
{
value: "5",
label: "Russel",
gender: "Male",
language: "en"
},
{
value: "6",
label: "Nicole",
gender: "Female",
language: "en"
},
{
value: "7",
label: "Joey",
gender: "Male",
language: "en"
},
{
value: "8",
label: "Justin",
gender: "Male",
language: "en"
},
{
value: "9",
label: "Matthew",
gender: "Male",
language: "en"
},
{
value: "10",
label: "Ivy",
gender: "Female",
language: "en"
},
{
value: "11",
label: "Joanna",
gender: "Female",
language: "en"
},
{
value: "12",
label: "Kendra",
gender: "Female",
language: "en"
},
{
value: "13",
label: "Kimberly",
gender: "Female",
language: "en"
},
{
value: "14",
label: "Salli",
gender: "Female",
language: "en"
},
{
value: "15",
label: "Raveena",
gender: "Female",
language: "en"
},
{
value: "16",
label: "Carter",
gender: "Male",
language: "en"
},
{
value: "17",
label: "Paul",
gender: "Male",
language: "en"
},
{
value: "18",
label: "Evelyn",
gender: "Female",
language: "en"
},
{
value: "19",
label: "Liam",
gender: "Male",
language: "en"
},
{
value: "20",
label: "Jasmine",
gender: "Female",
language: "en"
},
]
const voices = voices_data.sort((a, b) => a.label < b.label ? -1 : a.label > b.label ? 1 : 0)
export default voices export default voices

View File

@ -0,0 +1,72 @@
import axios from 'axios'
import { db } from "@/lib/db"
export async function updateTwitchToken(userId: string) {
const connection = await db.twitchConnection.findFirst({
where: {
userId: userId
}
})
if (!connection) {
return null
}
try {
const { expires_in }: { client_id: string, login: string, scopes: string[], user_id: string, expires_in: number } = (await axios.get("https://id.twitch.tv/oauth2/validate", {
headers: {
Authorization: 'OAuth ' + connection.accessToken
}
})).data;
if (expires_in > 3600) {
let data = await db.twitchConnection.findFirst({
where: {
userId: userId
}
})
let dataFormatted = {
user_id: userId,
access_token: data?.accessToken,
refresh_token: data?.refreshToken,
broadcaster_id: connection.broadcasterId
}
return dataFormatted
}
} catch (error) {
}
// Post to https://id.twitch.tv/oauth2/token
const token: { access_token: string, expires_in: number, refresh_token: string, token_type: string, scope: string[] } = (await axios.post("https://id.twitch.tv/oauth2/token", {
client_id: process.env.TWITCH_BOT_CLIENT_ID,
client_secret: process.env.TWITCH_BOT_CLIENT_SECRET,
grant_type: "refresh_token",
refresh_token: connection.refreshToken
})).data
// Fetch values from token.
const { access_token, expires_in, refresh_token, token_type } = token
if (!access_token || !refresh_token || token_type !== "bearer") {
return null
}
await db.twitchConnection.update({
where: {
userId: userId
},
data: {
accessToken: access_token,
refreshToken: refresh_token
}
})
const data = {
user_id: userId,
access_token,
refresh_token,
broadcaster_id: connection.broadcasterId
}
return data
}

0
hooks/ApiKeyHooks.tsx Normal file
View File

View File

@ -12,7 +12,7 @@ export default async function fetchUserWithImpersonation(req: Request) {
} }
const token = req.headers?.get('x-api-key') const token = req.headers?.get('x-api-key')
if (token === null || token === undefined) if (!token)
return null return null
const key = await db.apiKey.findFirst({ const key = await db.apiKey.findFirst({
@ -21,7 +21,8 @@ export default async function fetchUserWithImpersonation(req: Request) {
} }
}) })
if (!key) return null if (!key)
return null
return fetch(key.userId) return fetch(key.userId)
} }
@ -35,6 +36,7 @@ const fetch = async (userId: string) => {
if (!user) return null if (!user) return null
// Only admins can impersonate others.
if (user.role == "ADMIN") { if (user.role == "ADMIN") {
const impersonation = await db.impersonation.findFirst({ const impersonation = await db.impersonation.findFirst({
where: { where: {

View File

@ -12,7 +12,7 @@ export default async function fetchUser(req: Request) {
} }
const token = req.headers?.get('x-api-key') const token = req.headers?.get('x-api-key')
if (token === null || token === undefined) if (!token)
return null return null
const key = await db.apiKey.findFirst({ const key = await db.apiKey.findFirst({
@ -38,6 +38,6 @@ const fetch = async (userId: string) => {
return { return {
id: user.id, id: user.id,
username: user.name, username: user.name,
role: user.role role: user.role,
} }
} }

View File

@ -1,4 +1,7 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = {} const nextConfig = {
reactStrictMode: false,
output: 'standalone',
}
module.exports = nextConfig module.exports = nextConfig

View File

@ -1,113 +1,222 @@
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
} }
// datasource db {
// provider = "mysql"
// url = env("DATABASE_URL")
// relationMode = "prisma"
// }
datasource db { datasource db {
provider = "mysql" provider = "postgresql"
url = env("DATABASE_URL") url = env("DATABASE_URL")
relationMode = "prisma"
} }
enum UserRole { enum UserRole {
USER USER
ADMIN ADMIN
} }
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
name String? name String?
email String? @unique email String? @unique
emailVerified DateTime? emailVerified DateTime?
role UserRole @default(USER) role UserRole @default(USER)
image String? image String?
ttsDefaultVoice Int @default(1) ttsDefaultVoice String @default("Brian")
ttsEnabledVoice Int @default(1048575)
impersonationSources Impersonation[] @relation(name: "impersonationSources") impersonationSources Impersonation[] @relation(name: "impersonationSources")
impersonationTargets Impersonation[] @relation(name: "impersonationTargets") impersonationTargets Impersonation[] @relation(name: "impersonationTargets")
apiKeys ApiKey[] apiKeys ApiKey[]
accounts Account[] accounts Account[]
twitchConnections TwitchConnection[] twitchConnections TwitchConnection[]
ttsUsernameFilter TtsUsernameFilter[] ttsUsernameFilter TtsUsernameFilter[]
ttsWordFilter TtsWordFilter[] ttsWordFilter TtsWordFilter[]
ttsChatVoices TtsChatVoice[]
ttsVoiceStates TtsVoiceState[]
actions Action[]
redemptions Redemption[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
model Account { model Account {
id String @id @default(cuid()) id String @id @default(cuid())
userId String userId String
type String type String
provider String provider String
providerAccountId String providerAccountId String
refresh_token String? @db.Text refresh_token String? @db.Text
access_token String? @db.Text access_token String? @db.Text
expires_at Int? expires_at Int?
token_type String? token_type String?
scope String? scope String?
id_token String? @db.Text id_token String? @db.Text
session_state String? session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
@@unique([provider, providerAccountId]) @@unique([provider, providerAccountId])
@@index([userId])
} }
model Impersonation { model Impersonation {
sourceId String sourceId String
targetId String targetId String
source User @relation(name: "impersonationSources", fields: [sourceId], references: [id], onDelete: Cascade) source User @relation(name: "impersonationSources", fields: [sourceId], references: [id], onDelete: Cascade, onUpdate: Cascade)
target User @relation(name: "impersonationTargets", fields: [targetId], references: [id], onDelete: Cascade) target User @relation(name: "impersonationTargets", fields: [targetId], references: [id], onDelete: Cascade, onUpdate: Cascade)
@@id([sourceId]) @@id([sourceId])
@@index([sourceId]) @@index([sourceId])
@@index([targetId]) @@index([targetId])
} }
model ApiKey { model ApiKey {
id String @id @default(uuid()) id String @id @default(uuid())
label String label String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId]) userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
} }
model TwitchConnection { model TwitchConnection {
broadcasterId String @unique broadcasterId String @unique
accessToken String accessToken String
refreshToken String refreshToken String
userId String @id userId String @id
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
@@index([userId])
} }
model TtsUsernameFilter { model TtsUsernameFilter {
username String username String
tag String tag String
userId String userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
@@index([userId]) @@id([userId, username])
@@id([userId, username])
} }
model TtsWordFilter { model TtsWordFilter {
id String @id @default(cuid()) id String @id @default(cuid())
search String search String
replace String replace String
userId String userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
@@index([userId]) @@unique([userId, search])
@@unique([userId, search]) }
}
model TtsVoice {
id String @id @default(cuid())
name String
ttsChatVoices TtsChatVoice[]
ttsVoiceStates TtsVoiceState[]
}
model TtsChatVoice {
userId String
chatterId BigInt
ttsVoiceId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
voice TtsVoice @relation(fields: [ttsVoiceId], references: [id], onDelete: Cascade, onUpdate: Cascade)
@@id([userId, chatterId])
@@index([userId])
}
model TtsVoiceState {
userId String
ttsVoiceId String
state Boolean @default(true)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
voice TtsVoice @relation(fields: [ttsVoiceId], references: [id], onDelete: Cascade, onUpdate: Cascade)
@@id([userId, ttsVoiceId])
}
model Chatter {
id BigInt
name String
ban DateTime @default(dbgenerated("'1970-01-01 00:00:00.000'"))
//history EmoteUsageHistory[]
@@id([id])
}
model Emote {
id String
name String
//history EmoteUsageHistory[]
@@id([id])
}
model EmoteUsageHistory {
timestamp DateTime
broadcasterId BigInt
emoteId String
chatterId BigInt
//emote Emote @relation(fields: [emoteId], references: [id], onDelete: Cascade, onUpdate: Cascade)
//chatter Chatter @relation(fields: [chatterId], references: [id], onDelete: Cascade, onUpdate: Cascade)
@@id([timestamp, emoteId, chatterId])
}
enum ActionType {
WRITE_TO_FILE
APPEND_TO_FILE
AUDIO_FILE
OBS_TRANSFORM
}
model Action {
userId String
name String @unique
type ActionType
data Json
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([userId, name])
}
model Redemption {
id String @id @default(uuid()) @db.Uuid
userId String
redemptionId String
actionName String
order Int
state Boolean
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model Quest {
id Int @id @default(autoincrement())
type Int
target Int
start DateTime
end DateTime
@@unique([type, start])
}
model QuestProgression {
chatterId BigInt
questId Int
counter Int
@@id([chatterId, questId])
}