Updated list of commands to v4.3. Added groups & permissions. Added connections. Updated redemptions and actions to v4.3.
This commit is contained in:
82
components/elements/connection-default.tsx
Normal file
82
components/elements/connection-default.tsx
Normal 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>
|
||||
);
|
||||
}
|
223
components/elements/connection.tsx
Normal file
223
components/elements/connection.tsx
Normal 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>
|
||||
);
|
||||
}
|
211
components/elements/group-permission.tsx
Normal file
211
components/elements/group-permission.tsx
Normal 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;
|
276
components/elements/group.tsx
Normal file
276
components/elements/group.tsx
Normal 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;
|
@ -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 &&
|
||||
|
@ -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)
|
||||
|
268
components/elements/user-list-group.tsx
Normal file
268
components/elements/user-list-group.tsx
Normal 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'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;
|
80
components/navigation/menu.tsx
Normal file
80
components/navigation/menu.tsx
Normal 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;
|
@ -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;
|
@ -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
140
components/ui/sheet.tsx
Normal 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,
|
||||
}
|
Reference in New Issue
Block a user