Updated list of commands to v4.3. Added groups & permissions. Added connections. Updated redemptions and actions to v4.3.

This commit is contained in:
Tom
2024-08-14 20:33:40 +00:00
parent 6548ce33e0
commit b92529d8c0
51 changed files with 3910 additions and 799 deletions

View File

@ -0,0 +1,82 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "../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 axios from "axios";
export interface ConnectionDefault {
type: string,
connections: { name: string, clientId: string, token: string, type: string, scope: string, expiresAt: Date }[]
}
export const ConnectionDefaultElement = ({
type,
connections,
}: ConnectionDefault) => {
const [connection, setConnection] = useState<{ name: string, clientId: string, token: string, type: string, scope: string, expiresAt: Date } | undefined>(undefined)
const [open, setOpen] = useState(false)
const OnDefaultConnectionUpdate = function (con: { name: string, clientId: string, token: string, type: string, scope: string, expiresAt: Date }) {
if (connection && con.name == connection.name)
return;
axios.put('/api/connection/default', { name: con.name, type: con.type })
.then(d => {
setConnection(con)
})
}
useEffect(() => {
const con = connections.filter((c: any) => c.type == type && c.default)
if (con.length > 0)
OnDefaultConnectionUpdate(con[0])
console.log('default', type, connections.filter(c => c.type == type).length > 0)
}, [])
return (
<div
className={"bg-green-200 p-4 rounded-lg block m-5 max-w-[230px] " + (connections.filter(c => c.type == type).length > 0 ? 'visible' : 'hidden')}>
<Popover
open={open}
onOpenChange={setOpen}>
<PopoverTrigger asChild>
<div className="flex flex-col flex-1">
<Label className="text-base text-black">{type.charAt(0).toUpperCase() + type.substring(1)}</Label>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={"w-[200px] justify-between"}
>{!connection ? "Select " + type.charAt(0).toUpperCase() + type.substring(1) + " connection..." : connection.name}</Button>
</div>
</PopoverTrigger>
<PopoverContent>
<Command>
<CommandInput
placeholder="Filter connections..."
autoFocus={true} />
<CommandList>
<CommandEmpty>No action found.</CommandEmpty>
<CommandGroup>
{connections.filter(c => c.type == type).map(c => (
<CommandItem
value={c.name}
key={c.name}
onSelect={(value) => {
OnDefaultConnectionUpdate(c)
setOpen(false)
}}>
{c.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
);
}

View File

@ -0,0 +1,223 @@
"use client";
import axios from "axios";
import { useState } from "react";
import { Button } from "../ui/button";
import { useRouter } from "next/navigation";
import { v4 } from "uuid";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Input } from "../ui/input";
import { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime";
import { env } from "process";
export interface Connection {
name: string
type: string
clientId: string
scope: string
expiresAt: Date
remover: (name: string) => void
}
const AUTHORIZATION_DATA: { [service: string]: { type: string, endpoint: string, grantType: string, scopes: string[], redirect: string } } = {
'nightbot': {
type: 'nightbot',
endpoint: 'https://api.nightbot.tv/oauth2/authorize',
grantType: 'token',
scopes: ['song_requests', 'song_requests_queue', 'song_requests_playlist'],
redirect: 'https://tomtospeech.com/connection/authorize'
},
'twitch': {
type: 'twitch',
endpoint: 'https://id.twitch.tv/oauth2/authorize',
grantType: 'token',
scopes: [
'chat:read',
'bits:read',
'channel:read:polls',
'channel:read:predictions',
'channel:read:subscriptions',
'channel:read:vips',
'moderator:read:blocked_terms',
'chat:read',
'channel:moderate',
'channel:read:redemptions',
'channel:manage:redemptions',
'channel:manage:predictions',
'user:read:chat',
'channel:bot',
'moderator:read:followers',
'channel:read:ads',
'moderator:read:chatters',
],
redirect: 'https://tomtospeech.com/connection/authorize'
},
// 'twitch tts bot': {
// type: 'twitch',
// endpoint: 'https://id.twitch.tv/oauth2/authorize',
// grantType: 'token',
// scopes: [
// 'chat:read',
// 'bits:read',
// 'channel:read:polls',
// 'channel:read:predictions',
// 'channel:read:subscriptions',
// 'channel:read:vips',
// 'moderator:read:blocked_terms',
// 'chat:read',
// 'channel:moderate',
// 'channel:read:redemptions',
// 'channel:manage:redemptions',
// 'channel:manage:predictions',
// 'user:read:chat',
// 'channel:bot',
// 'moderator:read:followers',
// 'channel:read:ads',
// 'moderator:read:chatters',
// ],
// redirect: 'https://tomtospeech.com/connection/authorize'
// }
}
function AddOrRenew(name: string, type: string | undefined, clientId: string, router: AppRouterInstance) {
if (type === undefined)
return
if (!(type in AUTHORIZATION_DATA))
return
console.log(type)
const data = AUTHORIZATION_DATA[type]
const state = v4()
const clientIdUpdated = type == 'twitch tts bot' ? process.env.NEXT_PUBLIC_TWITCH_TTS_CLIENT_ID : clientId
axios.post("/api/connection/prepare", {
name: name,
type: data.type,
clientId: clientIdUpdated,
grantType: data.grantType,
state: state
}).then(_ => {
const url = data.endpoint + '?client_id=' + clientIdUpdated + '&redirect_uri=' + data.redirect + '&response_type=' + data.grantType
+ '&scope=' + data.scopes.join('%20') + '&state=' + state + '&force_verify=true'
router.push(url)
})
}
export const ConnectionElement = ({
name,
type,
clientId,
expiresAt,
remover,
}: Connection) => {
const router = useRouter()
const expirationHours = (new Date(expiresAt).getTime() - new Date().getTime()) / 1000 / 60 / 60
const expirationDays = expirationHours / 24
function Delete() {
axios.delete("/api/connection?name=" + name)
.then(d => {
remover(d.data.data.name)
})
}
return (
<div
className="bg-green-300 p-3 border-2 border-green-400 rounded-lg flex text-black m-1">
<div
className="justify-between flex-1 font-bold text-xl">
{name}
<div className="text-base font-normal">
{expirationDays > 1 && Math.floor(expirationDays) + " days - " + type}
{expirationDays <= 1 && Math.floor(expirationHours) + " hours - " + type}
</div>
</div>
<div
className="float-right align-middle flex flex-row items-center">
<Button
className="bg-blue-500 mr-3"
onClick={() => AddOrRenew(name, type, clientId, router)}>
Renew
</Button>
<Button
className="bg-red-500"
onClick={Delete}>
Delete
</Button>
</div>
</div>
);
}
export const ConnectionAdderElement = () => {
const router = useRouter()
const [name, setName] = useState<string>('')
const [type, setType] = useState<string | undefined>(undefined)
const [clientId, setClientId] = useState('')
const [open, setOpen] = useState(false)
return (
<div
className="bg-green-300 p-3 border-2 border-green-300 rounded-lg flex m-1">
<div
className="justify-between flex-1">
<Popover
open={open}
onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-[120px] justify-between"
>{!type ? "Select service..." : type}</Button>
</PopoverTrigger>
<PopoverContent>
<Command>
<CommandInput
placeholder="Filter services..."
autoFocus={true} />
<CommandList>
<CommandEmpty>No action found.</CommandEmpty>
<CommandGroup>
{Object.keys(AUTHORIZATION_DATA).map((authType: string) => (
<CommandItem
value={authType}
key={authType}
onSelect={(value) => {
setType(authType)
setOpen(false)
}}>
{authType}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<Input
className='w-[200px] inline m-1'
placeholder="Name"
value={name}
onChange={e => setName(e.target.value.toLowerCase())} />
{!!type && type != 'twitch tts bot' &&
<Input
className='w-[250px] m-1'
placeholder="Client Id"
value={clientId}
onChange={e => setClientId(e.target.value)} />
}
</div>
<div
className="float-right flex flex-row items-center">
<Button
className="bg-green-500"
onClick={() => AddOrRenew(name, type, clientId, router)}>
Add
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,211 @@
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"
import { Checkbox } from "../ui/checkbox";
interface Permission {
id: string|undefined
path: string
allow: boolean|null
groupId: string
edit: boolean
showEdit: boolean
isNew: boolean
permissionPaths: { path: string, description: string }[]
adder: (id: string, path: string, allow: boolean|null) => void
remover: (redemption: { id: string, path: string, allow: boolean|null }) => void
}
const GroupPermission = ({
id,
path,
allow,
groupId,
edit,
showEdit,
isNew,
permissionPaths,
adder,
remover
}: Permission) => {
const [pathOpen, setPathOpen] = useState(false)
const [isEditable, setIsEditable] = useState(edit)
const [oldData, setOldData] = useState<{ path: string, allow: boolean|null } | undefined>(undefined)
const [permission, setPermission] = useState<{ id: string|undefined, path: string, allow: boolean|null }>({ id, path, allow });
function Save() {
if (!permission || !permission.path)
return
if (isNew) {
axios.post("/api/settings/groups/permissions", {
path: permission.path,
allow: permission.allow,
groupId: groupId
}).then(d => {
if (!d || !d.data)
return
adder(d.data.id, permission.path, permission.allow)
setPermission({ id: undefined, path: "", allow: true })
})
} else {
axios.put("/api/settings/groups/permissions", {
id: permission.id,
path: permission.path,
allow: permission.allow
}).then(d => {
setIsEditable(false)
})
}
}
function Cancel() {
if (!oldData)
return
setPermission({ ...oldData, id: permission.id })
setIsEditable(false)
setOldData(undefined)
}
function Delete() {
axios.delete("/api/settings/groups/permissions?id=" + permission.id)
.then(d => {
remover(d.data)
})
}
return (
<div
className="bg-green-400 p-2 border-2 border-green-500 rounded-lg grid grid-flow-row">
<div
className="pb-3 flex grow">
{!isEditable &&
<Input
className="flex grow ml-1"
id="path"
value={permission.path}
readOnly />
|| isEditable &&
<Popover
open={pathOpen}
onOpenChange={setPathOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
id="path"
role="combobox"
className="flex grow justify-between"
>{!permission.path ? "Select a permission" : permission.path}</Button>
</PopoverTrigger>
<PopoverContent>
<Command>
<CommandInput
placeholder="Search..."
inputMode="search"
autoFocus={true} />
<CommandList>
<CommandEmpty>No permission found.</CommandEmpty>
<CommandGroup>
{permissionPaths.map((p) => (
<CommandItem
value={p.path}
key={p.path}
onSelect={(value) => {
setPermission({ ...permission, path: permissionPaths.find(v => v.path.toLowerCase() == value.toLowerCase())?.path ?? value.toLowerCase()})
setPathOpen(false)
}}>
<div>
<div className="text-lg">
{p.path}
</div>
<div className="text-xs text-green-200">
{p.description}
</div>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
}
</div>
<div
className="grid grid-cols-2 gap-1">
<Label>
Inherit from parent
</Label>
<Checkbox
checked={permission.allow === null}
onCheckedChange={(e) => {
if (permission.allow === null)
setPermission({ ...permission, allow: false })
else
setPermission({ ...permission, allow: null })
}}
disabled={!isEditable} />
<Label
htmlFor="inherit">
Allow
</Label>
<Checkbox
id="inherit"
checked={permission.allow === true}
onCheckedChange={(e) => {
setPermission({ ...permission, allow: !permission.allow })
}}
disabled={!isEditable || permission === undefined} />
</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({ ...permission })
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 GroupPermission;

View File

@ -0,0 +1,276 @@
import axios from "axios";
import { useState } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Label } from "../ui/label";
import { Maximize2, Minimize2, Trash2Icon } from "lucide-react";
import GroupPermission from "./group-permission";
import { z } from "zod";
import UserList from "./user-list-group";
interface Group {
id: string | undefined
name: string
priority: number
permissionsLoaded: { id: string, path: string, allow: boolean | null }[]
edit: boolean
showEdit: boolean
isNewGroup: boolean
permissionPaths: { path: string, description: string }[]
specialGroups: string[]
adder: (id: string, name: string, priority: number) => void
remover: (group: { id: string, name: string, priority: number }) => void
}
const GroupElement = ({
id,
name,
priority,
permissionsLoaded,
edit,
showEdit,
isNewGroup,
permissionPaths,
specialGroups,
adder,
remover
}: Group) => {
const [isEditable, setIsEditable] = useState(edit)
const [isNew, setIsNew] = useState(isNewGroup)
const [isMinimized, setIsMinimized] = useState(true)
const [oldData, setOldData] = useState<{ name: string, priority: number } | undefined>(undefined)
const [group, setGroup] = useState<{ id: string | undefined, name: string, priority: number }>({ id, name, priority })
const [permissions, setPermissions] = useState<{ id: string, path: string, allow: boolean | null }[]>(permissionsLoaded);
const isSpecial = (isEditable || oldData === undefined) && !!group && specialGroups.includes(group?.name)
const [error, setError] = useState<string | undefined>(undefined)
function addPermission(id: string, path: string, allow: boolean | null) {
setPermissions([...permissions, { id, path, allow }])
}
function removePermission(permission: { id: string, path: string, allow: boolean | null }) {
setPermissions(permissions.filter(p => p.id != permission.id))
}
const nameSchema = z.string({
required_error: "Name is required.",
invalid_type_error: "Name must be a string"
}).regex(/^[\w\-\s]{1,20}$/, "Name must contain only letters, numbers, dashes, and underscores.")
const prioritySchema = z.string().regex(/^-?\d{1,5}$/, "Priority must be a valid number.")
function Save() {
setError(undefined)
if (!isNew && !id)
return
const nameValidation = nameSchema.safeParse(group.name)
if (!nameValidation.success) {
setError(JSON.parse(nameValidation.error['message'])[0].message)
return
}
const priorityValidation = prioritySchema.safeParse(group.priority.toString())
if (!priorityValidation.success) {
setError(JSON.parse(priorityValidation.error['message'])[0].message)
return
}
if (isNew || group.id?.startsWith('$')) {
axios.post("/api/settings/groups", {
name: group.name,
priority: group.priority
}).then(d => {
if (!d) {
setError("Something went wrong.")
return
}
console.log("DATA", d.data)
if (specialGroups.includes(group.name)) {
setIsNew(false)
setIsEditable(false)
setGroup({ id: d.data.id, name: d.data.name, priority: d.data.priority })
} else {
adder(d.data.id, group.name.toLowerCase(), group.priority)
setGroup({ id: undefined, name: "", priority: 0 })
}
}).catch(() => {
setError("Potential group name duplicate.")
})
} else {
axios.put("/api/settings/groups", {
id: group.id,
name: group.name,
priority: group.priority
}).then(d => {
console.log("DATA", d.data)
setIsEditable(false)
}).catch(() => {
setError("Potential group name duplicate.")
})
}
}
function Cancel() {
setError(undefined)
if (!oldData)
return
setGroup({ ...oldData, id: group.id })
setIsEditable(false)
setOldData(undefined)
}
function Delete() {
axios.delete("/api/settings/groups?id=" + group.id)
.then(d => {
if (specialGroups.includes(group.name)) {
setPermissions([])
setIsMinimized(true)
setOldData(undefined)
setIsNew(true)
setIsEditable(true)
} else
remover(d.data)
})
}
return (
<div
className="bg-green-300 p-5 border-2 border-green-400 rounded-lg">
<div
className="pb-4">
<div
className="justify-between">
<Label
className="mr-2 text-black"
htmlFor="path">
Group Name
</Label>
{isSpecial &&
<div className="bg-white text-muted text-xs p-1 rounded m-1 inline-block">
auto-generated
</div>
}
<Input
value={group.name}
id="path"
onChange={e => setGroup({ ...group, name: e.target.value })}
readOnly={isSpecial || !isEditable} />
</div>
<div
className="justify-between">
<Label
className="mr-2 text-black"
htmlFor="priority">
TTS Priority
</Label>
<Input
name="priority"
value={group.priority}
onChange={e => setGroup(d => {
let temp = { ...group }
const v = parseInt(e.target.value)
if (e.target.value.length == 0) {
temp.priority = 0
} else if (!Number.isNaN(v) && Number.isSafeInteger(v)) {
temp.priority = v
} else if (Number.isNaN(v)) {
temp.priority = 0
}
return temp
})}
readOnly={!isEditable} />
</div>
</div>
<p className="w-[380px] text-red-500 text-wrap text-sm">
{error}
</p>
<div>
{isEditable &&
<Button
className="ml-1 mr-1 align-middle"
onClick={() => Save()}>
{isNew ? "Add" : "Save"}
</Button>
}
{isEditable && !isNew &&
<Button
className="ml-1 mr-1 align-middle"
onClick={() => Cancel()}>
Cancel
</Button>
}
{showEdit && !isEditable &&
<Button
className="ml-1 mr-1 align-middle"
onClick={() => {
setOldData({ ...group })
setIsEditable(true)
}}>
Edit
</Button>
}
{!isEditable && !isNew &&
<Button
className="ml-1 mr-1 align-middle bg-red-500 hover:bg-red-600"
onClick={() => Delete()}>
<Trash2Icon />
</Button>
}
{!isNew && !group?.id?.startsWith('$') &&
<Button
className="ml-1 mr-1 align-middle"
onClick={e => setIsMinimized(!isMinimized)}>
{isMinimized ? <Maximize2 /> : <Minimize2 />}
</Button>
}
{!isNew && !isSpecial &&
<UserList
groupId={group.id!}
groupName={group.name} />
}
</div>
{!isNew && !isMinimized &&
<div>
{permissions.map(permission =>
<div
className="m-3 mb-0"
key={permission.id}>
<GroupPermission
id={permission.id}
path={permission.path}
allow={permission.allow}
groupId={group.id!}
edit={false}
showEdit={true}
isNew={false}
permissionPaths={permissionPaths}
adder={addPermission}
remover={removePermission} />
</div>
)}
<div
className="m-3 mb-0">
<GroupPermission
id={undefined}
path={""}
allow={true}
groupId={group.id!}
edit={true}
showEdit={false}
isNew={true}
permissionPaths={permissionPaths}
adder={addPermission}
remover={removePermission} />
</div>
</div>
}
</div>
);
}
export default GroupElement;

View File

@ -7,24 +7,218 @@ 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";
import { boolean } from "zod";
const actionTypes = [
{
"name": "Overwrite local file content",
"value": ActionType.WRITE_TO_FILE
"value": ActionType.WRITE_TO_FILE,
"inputs": [
{
"type": "text",
"label": "File path",
"key": "file_path",
"placeholder": "Enter local file path, relative or full."
},
{
"type": "text",
"label": "File content",
"key": "file_content",
"placeholder": "Enter the text to write to the file."
}
]
},
{
"name": "Append to local file",
"value": ActionType.APPEND_TO_FILE
"value": ActionType.APPEND_TO_FILE,
"inputs": [
{
"type": "text",
"label": "File path",
"key": "file_path",
"placeholder": "Enter local file path, relative or full."
},
{
"type": "text",
"label": "File content",
"key": "file_content",
"placeholder": "Enter the text to append to the file."
}
]
},
{
"name": "Cause a transformation on OBS scene item",
"value": ActionType.OBS_TRANSFORM
"value": ActionType.OBS_TRANSFORM,
"inputs": []
},
{
"name": "Play an audio file locally",
"value": ActionType.AUDIO_FILE
"value": ActionType.AUDIO_FILE,
"inputs": [
{
"type": "text",
"label": "File path",
"key": "file_path",
"placeholder": "Enter local file path, relative or full."
}
]
},
{
"name": "User gets a random TTS voice that is enabled",
"value": ActionType.RANDOM_TTS_VOICE,
"inputs": []
},
{
"name": "User gets a specific TTS voice",
"value": ActionType.SPECIFIC_TTS_VOICE,
"inputs": [
{
"type": "text",
"label": "TTS Voice",
"key": "tts_voice",
"placeholder": "Name of an enabled TTS voice",
}
]
},
{
"name": "Toggle OBS scene item visibility",
"value": ActionType.TOGGLE_OBS_VISIBILITY,
"inputs": [
{
"type": "text",
"label": "Scene Name",
"key": "scene_name",
"placeholder": "Name of the OBS scene"
},
{
"type": "text",
"label": "Scene Item Name",
"key": "scene_item_name",
"placeholder": "Name of the OBS scene item / source"
}
]
},
{
"name": "Set OBS scene item visibility",
"value": ActionType.SPECIFIC_OBS_VISIBILITY,
"inputs": [
{
"type": "text",
"label": "Scene Name",
"key": "scene_name",
"placeholder": "Name of the OBS scene"
},
{
"type": "text",
"label": "Scene Item Name",
"key": "scene_item_name",
"placeholder": "Name of the OBS scene item / source"
},
{
"type": "text-values",
"label": "Visible",
"key": "obs_visible",
"placeholder": "true for visible; false otherwise",
"values": ["true", "false"]
}
]
},
{
"name": "Set OBS scene item's index",
"value": ActionType.SPECIFIC_OBS_INDEX,
"inputs": [
{
"type": "text",
"label": "Scene Name",
"key": "scene_name",
"placeholder": "Name of the OBS scene"
},
{
"type": "text",
"label": "Scene Item Name",
"key": "scene_item_name",
"placeholder": "Name of the OBS scene item / source"
},
{
"type": "number",
"label": "Index",
"key": "obs_index",
"placeholder": "index, starting from 0."
}
]
},
{
"name": "Sleep - do nothing",
"value": ActionType.SLEEP,
"inputs": [
{
"type": "number",
"label": "Sleep",
"key": "sleep",
"placeholder": "Time in milliseconds to do nothing",
}
]
},
{
"name": "Nightbot - Play",
"value": ActionType.NIGHTBOT_PLAY,
"inputs": [
{
"type": "oauth.nightbot.play",
"label": "nightbot.play",
"key": "nightbot_play",
"placeholder": "",
}
]
},
{
"name": "Nightbot - Pause",
"value": ActionType.NIGHTBOT_PAUSE,
"inputs": [
{
"type": "oauth.nightbot.pause",
"label": "nightbot.pause",
"key": "nightbot_pause",
"placeholder": "",
}
]
},
{
"name": "Nightbot - Skip",
"value": ActionType.NIGHTBOT_SKIP,
"inputs": [
{
"type": "oauth.nightbot.skip",
"label": "nightbot.skip",
"key": "nightbot_skip",
"placeholder": "",
}
]
},
{
"name": "Nightbot - Clear Playlist",
"value": ActionType.NIGHTBOT_CLEAR_PLAYLIST,
"inputs": [
{
"type": "oauth.nightbot.clear_playlist",
"label": "nightbot.clear_playlist",
"key": "nightbot_clear_playlist",
"placeholder": "",
}
]
},
{
"name": "Nightbot - Clear Queue",
"value": ActionType.NIGHTBOT_CLEAR_QUEUE,
"inputs": [
{
"type": "oauth.nightbot.clear_queue",
"label": "nightbot.clear_queue",
"key": "nightbot_clear_queue",
"placeholder": "",
}
]
},
]
@ -37,6 +231,7 @@ interface RedeemableAction {
showEdit?: boolean
isNew: boolean
obsTransformations: { label: string, placeholder: string, description: string }[]
connections: { name: string, type: string }[]
adder: (name: string, type: ActionType, data: { [key: string]: string }) => void
remover: (action: { name: string, type: string, data: any }) => void
}
@ -50,12 +245,13 @@ const RedemptionAction = ({
showEdit = true,
isNew = false,
obsTransformations = [],
connections = [],
adder,
remover
}: RedeemableAction) => {
const [open, setOpen] = useState(false)
const [open, setOpen] = useState<{ [key: string]: boolean }>({ 'actions': false, 'oauth': false, 'oauth.nightbot': false, 'oauth.twitch': false })
const [actionName, setActionName] = useState(name)
const [actionType, setActionType] = useState<{ name: string, value: ActionType } | undefined>(actionTypes.find(a => a.value == type?.toUpperCase()))
const [actionType, setActionType] = useState<{ name: string, value: ActionType, inputs: any[] } | 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)
@ -66,6 +262,7 @@ const RedemptionAction = ({
if (!name) {
return
}
console.log('typeeee', type)
if (!type) {
return
}
@ -96,7 +293,7 @@ const RedemptionAction = ({
}
}
function Cancel(data: { n: string, t: ActionType | undefined, d: { [k: string]: string } } | undefined) {
function Cancel(data: { n: string, t: ActionType | undefined, d: { [k: string]: any } } | undefined) {
if (!data)
return
@ -126,7 +323,7 @@ const RedemptionAction = ({
{actionName}
</Label>
<Button
className="flex inline-block self-end"
className="flex self-end"
onClick={e => setIsMinimized(!isMinimized)}>
{isMinimized ? <Maximize2 /> : <Minimize2 />}
</Button>
@ -160,13 +357,13 @@ const RedemptionAction = ({
readOnly />
|| isEditable &&
<Popover
open={open}
onOpenChange={setOpen}>
open={open['actions']}
onOpenChange={() => setOpen({ ...open, 'actions': !open['actions'] })}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
aria-expanded={open['actions']}
className="w-[300px] justify-between"
>{!actionType ? "Select one..." : actionType.name}</Button>
</PopoverTrigger>
@ -184,7 +381,7 @@ const RedemptionAction = ({
key={action.value}
onSelect={(value) => {
setActionType(actionTypes.find(v => v.name.toLowerCase() == value.toLowerCase()))
setOpen(false)
setOpen({ ...open, 'actions': false })
}}>
{action.name}
</CommandItem>
@ -197,38 +394,138 @@ const RedemptionAction = ({
}
</div>
<div>
{actionType && (actionType.value == ActionType.WRITE_TO_FILE || actionType.value == ActionType.APPEND_TO_FILE) &&
{actionType &&
<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} />
{actionType.inputs.map(i => {
if (i.type == "text") {
return <div key={i.key} className="mt-3">
<Label
className="mr-2"
htmlFor={i.key}>
{i.label}
</Label>
<Input
className="w-[300px] justify-between inline-block"
name={i.key}
placeholder={i.placeholder}
value={actionData[i.key]}
onChange={e => setActionData(d => {
let abc = { ...actionData }
abc[i.key] = e.target.value;
return abc
})}
readOnly={!isEditable} />
</div>
} else if (i.type == "number") {
return <div key={i.key} className="mt-3">
<Label
className="mr-2"
htmlFor={i.key}>
{i.label}
</Label>
<Input
className="w-[300px] justify-between inline-block"
name={i.key}
placeholder={i.placeholder}
value={actionData[i.key]}
onChange={e => setActionData(d => {
let abc = { ...actionData }
const v = parseInt(e.target.value)
if (e.target.value.length == 0) {
abc[i.key] = "0"
} else if (!Number.isNaN(v) && Number.isSafeInteger(v)) {
abc[i.key] = v.toString()
} else if (Number.isNaN(v)) {
abc[i.key] = "0"
}
return abc
})}
readOnly={!isEditable} />
</div>
} else if (i.type == "text-values") {
return <div key={i.key} className="mt-3">
<Label
className="mr-2"
htmlFor={i.key}>
{i.label}
</Label>
<Input
className="w-[300px] justify-between inline-block"
name={i.key}
placeholder={i.placeholder}
value={actionData[i.key]}
onChange={e => setActionData(d => {
let abc = { ...actionData }
abc[i.key] = i.values.map((v: string) => v.startsWith(e.target.value)).some((v: boolean) => v) ? e.target.value : abc[i.key]
return abc
})}
readOnly={!isEditable} />
</div>
} else {
return <div key={i.key}>
<Label
className="mr-2"
htmlFor={i.key}>
Connection
</Label>
<Popover
open={open[i.type]}
onOpenChange={() => { const temp = { ...open }; temp[i.type] = !temp[i.type]; setOpen(temp) }}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open[i.type]}
className="w-[300px] justify-between"
>{!('oauth_name' in actionData) ? "Select connection..." : actionData.oauth_name}</Button>
</PopoverTrigger>
<PopoverContent>
<Command>
<CommandInput
placeholder="Search connections..."
autoFocus={true} />
<CommandList>
<CommandEmpty>No connection found.</CommandEmpty>
<CommandGroup>
{connections.filter(c => !i.type.includes('.') || c.type == i.type.split('.')[1])
.map((connection) => (
<CommandItem
value={connection.name}
key={connection.name}
onSelect={(value) => {
const connection = connections.find(v => v.name.toLowerCase() == value.toLowerCase())
if (!!connection) {
setActionData({
'oauth_name': connection.name,
'oauth_type' : connection.type
})
}
else
setActionData({})
const temp = { ...open }
temp[i.type] = false
setOpen(temp)
}}>
{connection.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
}
return <div key={i.key}></div>
})}
</div>
}
{actionType && actionType.value == ActionType.OBS_TRANSFORM &&
<div>
{obsTransformations.map(t =>
<div
key={t.label.toLowerCase()}
className="mt-3">
<Label
className="mr-2"
@ -250,22 +547,6 @@ const RedemptionAction = ({
)}
</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 &&

View File

@ -17,6 +17,7 @@ interface Redemption {
id: string | undefined
redemptionId: string | undefined
actionName: string
numbering: number,
edit: boolean
showEdit: boolean
isNew: boolean
@ -30,6 +31,7 @@ const OBSRedemption = ({
id,
redemptionId,
actionName,
numbering,
edit,
showEdit,
isNew,
@ -42,12 +44,11 @@ const OBSRedemption = ({
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 [order, setOrder] = useState<number>(numbering)
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))
}, [])
@ -65,7 +66,7 @@ const OBSRedemption = ({
order: order,
state: true
}).then(d => {
adder(d.data.id, action, twitchRedemption.id, 0)
adder(d.data.id, action, twitchRedemption.id, order)
setAction(undefined)
setTwitchRedemption(undefined)
setOrder(0)

View File

@ -0,0 +1,268 @@
import axios from "axios";
import { useEffect, useState } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet"
import { z } from "zod";
import { Trash2 } from "lucide-react";
import RoleGate from "@/components/auth/role-gate";
interface UsersGroup {
groupId: string
groupName: string
//userList: { id: number, username: string }[]
//knownUsers: { id: number, username: string }[]
}
const ITEMS_PER_PAGE: number = 10;
const UserList = ({
groupId,
groupName,
//userList,
//knownUsers
}: UsersGroup) => {
const [usersListOpen, setUsersListOpen] = useState(false)
const [users, setUsers] = useState<{ id: number, username: string }[]>([])
const [addedUsers, setAddedUsers] = useState<{ id: number, username: string }[]>([])
const [deletedUsers, setDeletedUsers] = useState<{ id: number, username: string }[]>([])
const [newUser, setNewUser] = useState<string>("")
const [knownUsers, setKnownUsers] = useState<{ id: number, username: string }[]>([])
const [error, setError] = useState<string | undefined>(undefined)
const [page, setPage] = useState<number>(0)
const [maxPages, setMaxPages] = useState<number>(1)
useEffect(() => {
axios.get('/api/settings/groups/chatters', {
params: {
groupId,
page
}
}).then(d => {
setUsers(d.data)
setKnownUsers(d.data)
setMaxPages(Math.ceil(d.data.length / ITEMS_PER_PAGE))
})
}, [groupId, page])
function close() {
setUsers([...users.filter(u => !addedUsers.find(a => a.id == u.id)), ...deletedUsers])
setUsersListOpen(false)
}
const usernameSchema = z.string({
required_error: "Name is required.",
invalid_type_error: "Name must be a string"
}).regex(/^[\w\-]{4,25}$/, "Invalid Twitch username.")
function AddUsername() {
setError(undefined)
const nameValidation = usernameSchema.safeParse(newUser)
if (!nameValidation.success) {
setError(JSON.parse(nameValidation.error['message'])[0].message)
return
}
if (users.find(u => u.username == newUser.toLowerCase())) {
setError("Username is already in this group.")
return;
}
let user = knownUsers.find(u => u.username == newUser.toLowerCase())
if (!user) {
axios.get('/api/settings/groups/twitchchatters', {
params: {
logins: newUser
}
}).then(d => {
if (!d.data)
return
user = d.data[0]
if (!user)
return
if (deletedUsers.find(u => u.id == user!.id))
setDeletedUsers(deletedUsers.filter(u => u.id != user!.id))
else
setAddedUsers([...addedUsers, user])
setUsers([...users, user])
setKnownUsers([...users, user])
setNewUser("")
setMaxPages(Math.ceil((users.length + 1) / ITEMS_PER_PAGE))
}).catch(e => {
setError("Username does not exist.")
})
return
}
if (deletedUsers.find(u => u.id == user!.id))
setDeletedUsers(deletedUsers.filter(u => u.id != user!.id))
else
setAddedUsers([...addedUsers, user])
setUsers([...users, user])
setNewUser("")
setMaxPages(Math.ceil((users.length + 1) / ITEMS_PER_PAGE))
if (deletedUsers.find(u => u.id == user!.id)) {
setAddedUsers(addedUsers.filter(u => u.username != newUser.toLowerCase()))
}
}
function DeleteUser(user: { id: number, username: string }) {
if (addedUsers.find(u => u.id == user.id)) {
setAddedUsers(addedUsers.filter(u => u.id != user.id))
} else {
setDeletedUsers([...deletedUsers, user])
}
setUsers(users.filter(u => u.id != user.id))
}
function save() {
setError(undefined)
if (addedUsers.length > 0) {
axios.post("/api/settings/groups/chatters", {
groupId,
users: addedUsers
}).then(d => {
setAddedUsers([])
if (deletedUsers.length > 0)
axios.delete("/api/settings/groups/chatters", {
params: {
groupId,
ids: deletedUsers.map(i => i.id.toString()).reduce((a, b) => a + ',' + b)
}
}).then(d => {
setDeletedUsers([])
}).catch(() => {
setError("Something went wrong.")
})
}).catch(() => {
setError("Something went wrong.")
})
return
}
if (deletedUsers.length > 0)
axios.delete("/api/settings/groups/chatters", {
params: {
groupId,
ids: deletedUsers.map(i => i.id.toString()).reduce((a, b) => a + ',' + b)
}
}).then(d => {
setDeletedUsers([])
}).catch(() => {
setError("Something went wrong.")
})
}
return (
<Sheet>
<SheetTrigger asChild>
<Button
className="ml-3 mr-3 align-middle"
onClick={() => setUsersListOpen(true)}>
Users
</Button>
</SheetTrigger>
<SheetContent className="w-[700px]">
<SheetHeader>
<SheetTitle>Edit group - {groupName}</SheetTitle>
<SheetDescription>
Make changes to this group&#39;s list of users.
</SheetDescription>
</SheetHeader>
{!!error &&
<p className="text-red-500">{error}</p>
}
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">
Username
</Label>
<Input
id="name"
value={newUser}
type="text"
onChange={e => setNewUser(e.target.value)}
className="col-span-3" />
<Button
className="bg-white"
onClick={() => AddUsername()}>
Add
</Button>
</div>
</div>
<hr className="mt-4" />
<Table>
<TableHeader>
<TableRow>
<RoleGate roles={['ADMIN']}><TableHead>Id</TableHead></RoleGate>
<TableHead>Username</TableHead>
<TableHead>Delete</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.length ? (
users.slice(ITEMS_PER_PAGE * page, ITEMS_PER_PAGE * (page + 1)).map((user) => (
<TableRow
key={user.id}>
<RoleGate roles={['ADMIN']}><TableCell colSpan={1} className="text-xs">{user.id}</TableCell></RoleGate>
<TableCell colSpan={1}>{user.username}</TableCell>
<TableCell>
<Button
className="bg-red-500 h-9"
onClick={() => DeleteUser(user)}>
<Trash2 />
</Button>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={3}
className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<SheetFooter>
<SheetClose asChild>
<Button onClick={() => save()} type="submit">Save changes</Button>
</SheetClose>
<SheetClose asChild>
<Button onClick={() => close()} type="submit">Close</Button>
</SheetClose>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
export default UserList;

View File

@ -0,0 +1,80 @@
'use client';
import Link from "next/link";
import RoleGate from "@/components/auth/role-gate";
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
navigationMenuTriggerStyle,
} from "@/components/ui/navigation-menu"
const components: { title: string; href: string; description: string }[] = [
{
title: "Alert Dialog",
href: "/docs/primitives/alert-dialog",
description:
"A modal dialog that interrupts the user with important content and expects a response.",
},
]
const MenuNavigation = () => {
return (
<NavigationMenu
className="absolute top-0 flex justify-center left-auto z-51 flex-wrap">
<p className="w-[300px] text-3xl text-center align-middle invisible md:visible">Tom To Speech</p>
<NavigationMenuList>
{/* <NavigationMenuItem>
<NavigationMenuTrigger>Getting started</NavigationMenuTrigger>
<NavigationMenuContent>
<ul className="grid gap-3 p-6 md:w-[400px] lg:w-[500px] lg:grid-cols-[.75fr_1fr]">
<li className="row-span-3">
<NavigationMenuLink asChild>
<a
className="flex h-full w-full select-none flex-col justify-end rounded-md bg-gradient-to-b from-muted/50 to-muted p-6 no-underline outline-none focus:shadow-md"
href="/">
<div className="mb-2 mt-4 text-lg font-medium">
Tom-to-Speech
</div>
<p className="text-sm leading-tight text-muted-foreground">
Text to speech software
</p>
</a>
</NavigationMenuLink>
</li>
</ul>
</NavigationMenuContent>
</NavigationMenuItem> */}
<NavigationMenuItem>
<Link href="/commands" legacyBehavior passHref>
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
Commands
</NavigationMenuLink>
</Link>
</NavigationMenuItem>
<NavigationMenuItem>
<Link href="/settings" legacyBehavior passHref>
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
Settings
</NavigationMenuLink>
</Link>
</NavigationMenuItem>
<RoleGate
roles={["ADMIN"]}>
<NavigationMenuItem>
<Link href="/admin" legacyBehavior passHref>
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
Admin
</NavigationMenuLink>
</Link>
</NavigationMenuItem>
</RoleGate>
</NavigationMenuList>
</NavigationMenu>
);
}
export default MenuNavigation;

View File

@ -5,73 +5,78 @@ import AdminProfile from "./adminprofile";
import RoleGate from "@/components/auth/role-gate";
const SettingsNavigation = async () => {
return (
<div>
<div className="text-4xl flex pl-[15px] pb-[33px]">Hermes</div>
return (
<div className="mt-[100px]">
<div className="w-full pl-[30px] pr-[30px] pb-[50px]">
<UserProfile />
<RoleGate roles={["ADMIN"]}>
<AdminProfile />
</RoleGate>
</div>
<div className="w-full pl-[30px] pr-[30px] pb-[50px]">
<UserProfile />
<RoleGate roles={["ADMIN"]}>
<AdminProfile />
</RoleGate>
</div>
<div className="flex flex-grow h-full w-full">
<ul className="rounded-lg shadow-md flex flex-col w-full justify-between text-center align-center">
<li className="text-xs text-gray-200">
Settings
</li>
<li>
<Link href={"/settings/connections"} className="m-0 p-0 gap-0">
<Button variant="ghost" className="w-full text-lg">
Connections
</Button>
</Link>
</li>
<div className="flex flex-grow h-full w-full">
<ul className="rounded-lg shadow-md flex flex-col w-full justify-between text-center align-center">
<li className="text-xs text-gray-200">
Settings
</li>
<li>
<Link href={"/settings/connections"} className="m-0 p-0 gap-0">
<Button variant="ghost" className="w-full text-lg">
Connections
</Button>
</Link>
</li>
<li className="text-xs text-gray-200">
Text to Speech
</li>
<li className="">
<Link href={"/settings/tts/voices"}>
<Button variant="ghost" className="w-full text-lg">
Voices
</Button>
</Link>
</li>
<li className="">
<Link href={"/settings/tts/filters"}>
<Button variant="ghost" className="w-full text-lg">
Filters
</Button>
</Link>
</li>
<li className="">
<Link href={"/settings/groups/permissions"}>
<Button variant="ghost" className="w-full text-lg">
Permissions
</Button>
</Link>
</li>
<li className="text-xs text-gray-200">
Text to Speech
</li>
<li className="">
<Link href={"/settings/tts/voices"}>
<Button variant="ghost" className="w-full text-lg">
Voices
</Button>
</Link>
</li>
<li className="">
<Link href={"/settings/tts/filters"}>
<Button variant="ghost" className="w-full text-lg">
Filters
</Button>
</Link>
</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">
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">
API
</li>
<li className="">
<Link href={"/settings/api/keys"}>
<Button variant="ghost" className="w-full text-lg">
Keys
</Button>
</Link>
</li>
</ul>
</div>
</div>
);
<li className="text-xs text-gray-200">
API
</li>
<li className="">
<Link href={"/settings/api/keys"}>
<Button variant="ghost" className="w-full text-lg">
Keys
</Button>
</Link>
</li>
</ul>
</div>
</div>
);
}
export default SettingsNavigation;

View File

@ -18,7 +18,6 @@ const NavigationMenu = React.forwardRef<
{...props}
>
{children}
<NavigationMenuViewport />
</NavigationMenuPrimitive.Root>
))
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
@ -30,7 +29,7 @@ const NavigationMenuList = React.forwardRef<
<NavigationMenuPrimitive.List
ref={ref}
className={cn(
"group flex flex-1 list-none items-center justify-center space-x-1",
"group/navList flex flex-1 list-none items-center justify-center space-x-1",
className
)}
{...props}
@ -41,7 +40,7 @@ NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
const NavigationMenuItem = NavigationMenuPrimitive.Item
const navigationMenuTriggerStyle = cva(
"group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50"
"group/navTrig inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50"
)
const NavigationMenuTrigger = React.forwardRef<
@ -50,12 +49,12 @@ const NavigationMenuTrigger = React.forwardRef<
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Trigger
ref={ref}
className={cn(navigationMenuTriggerStyle(), "group", className)}
className={cn(navigationMenuTriggerStyle(), "group/navTrig", className)}
{...props}
>
{children}{" "}
<ChevronDown
className="relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180"
className="relative top-[1px] ml-1 h-3 w-3 transition duration-300 group-data-[state=open]/navTrig:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
@ -69,7 +68,8 @@ const NavigationMenuContent = React.forwardRef<
<NavigationMenuPrimitive.Content
ref={ref}
className={cn(
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ",
"bg-background rounded-xl mt-1 top-full w-auto data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto",
+ "origin-top-center relative mt-2.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
className
)}
{...props}
@ -83,10 +83,10 @@ const NavigationMenuViewport = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
>(({ className, ...props }, ref) => (
<div className={cn("absolute left-0 top-full flex justify-center")}>
<div className={cn("absolute left-0 z-30 top-full flex justify-center")}>
<NavigationMenuPrimitive.Viewport
className={cn(
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
"z-10 origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
className
)}
ref={ref}

140
components/ui/sheet.tsx Normal file
View File

@ -0,0 +1,140 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
}
)
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}