mirror of
https://github.com/rzmk/learnhouse.git
synced 2025-12-19 04:19:25 +00:00
feat: implement script edition and loading on the frontend
This commit is contained in:
parent
1b35e1cbb3
commit
cc1894cd9c
7 changed files with 544 additions and 3 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
'use client'
|
'use client'
|
||||||
import BreadCrumbs from '@components/Dashboard/Misc/BreadCrumbs'
|
import BreadCrumbs from '@components/Dashboard/Misc/BreadCrumbs'
|
||||||
import { getUriWithOrg } from '@services/config/config'
|
import { getUriWithOrg } from '@services/config/config'
|
||||||
import { ImageIcon, Info, LockIcon, SearchIcon, TextIcon, LucideIcon, Share2Icon, LayoutDashboardIcon } from 'lucide-react'
|
import { ImageIcon, Info, LockIcon, SearchIcon, TextIcon, LucideIcon, Share2Icon, LayoutDashboardIcon, CodeIcon } from 'lucide-react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import React, { useEffect, use } from 'react';
|
import React, { useEffect, use } from 'react';
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
|
|
@ -9,6 +9,7 @@ import OrgEditGeneral from '@components/Dashboard/Pages/Org/OrgEditGeneral/OrgEd
|
||||||
import OrgEditImages from '@components/Dashboard/Pages/Org/OrgEditImages/OrgEditImages'
|
import OrgEditImages from '@components/Dashboard/Pages/Org/OrgEditImages/OrgEditImages'
|
||||||
import OrgEditSocials from '@components/Dashboard/Pages/Org/OrgEditSocials/OrgEditSocials'
|
import OrgEditSocials from '@components/Dashboard/Pages/Org/OrgEditSocials/OrgEditSocials'
|
||||||
import OrgEditLanding from '@components/Dashboard/Pages/Org/OrgEditLanding/OrgEditLanding'
|
import OrgEditLanding from '@components/Dashboard/Pages/Org/OrgEditLanding/OrgEditLanding'
|
||||||
|
import OrgEditOther from '@components/Dashboard/Pages/Org/OrgEditOther/OrgEditOther'
|
||||||
|
|
||||||
export type OrgParams = {
|
export type OrgParams = {
|
||||||
subpage: string
|
subpage: string
|
||||||
|
|
@ -26,6 +27,7 @@ const SETTING_TABS: TabItem[] = [
|
||||||
{ id: 'landing', label: 'Landing Page', icon: LayoutDashboardIcon },
|
{ id: 'landing', label: 'Landing Page', icon: LayoutDashboardIcon },
|
||||||
{ id: 'previews', label: 'Images & Previews', icon: ImageIcon },
|
{ id: 'previews', label: 'Images & Previews', icon: ImageIcon },
|
||||||
{ id: 'socials', label: 'Socials', icon: Share2Icon },
|
{ id: 'socials', label: 'Socials', icon: Share2Icon },
|
||||||
|
{ id: 'other', label: 'Other', icon: CodeIcon },
|
||||||
]
|
]
|
||||||
|
|
||||||
function TabLink({ tab, isActive, orgslug }: {
|
function TabLink({ tab, isActive, orgslug }: {
|
||||||
|
|
@ -67,6 +69,9 @@ function OrgPage(props: { params: Promise<OrgParams> }) {
|
||||||
} else if (params.subpage == 'landing') {
|
} else if (params.subpage == 'landing') {
|
||||||
setH1Label('Landing Page')
|
setH1Label('Landing Page')
|
||||||
setH2Label('Customize your organization landing page')
|
setH2Label('Customize your organization landing page')
|
||||||
|
} else if (params.subpage == 'other') {
|
||||||
|
setH1Label('Other')
|
||||||
|
setH2Label('Manage additional organization settings')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -111,6 +116,7 @@ function OrgPage(props: { params: Promise<OrgParams> }) {
|
||||||
{params.subpage == 'previews' ? <OrgEditImages /> : ''}
|
{params.subpage == 'previews' ? <OrgEditImages /> : ''}
|
||||||
{params.subpage == 'socials' ? <OrgEditSocials /> : ''}
|
{params.subpage == 'socials' ? <OrgEditSocials /> : ''}
|
||||||
{params.subpage == 'landing' ? <OrgEditLanding /> : ''}
|
{params.subpage == 'landing' ? <OrgEditLanding /> : ''}
|
||||||
|
{params.subpage == 'other' ? <OrgEditOther /> : ''}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import NextTopLoader from 'nextjs-toploader';
|
||||||
import Toast from '@components/Objects/StyledElements/Toast/Toast'
|
import Toast from '@components/Objects/StyledElements/Toast/Toast'
|
||||||
import '@styles/globals.css'
|
import '@styles/globals.css'
|
||||||
import Onboarding from '@components/Objects/Onboarding/Onboarding';
|
import Onboarding from '@components/Objects/Onboarding/Onboarding';
|
||||||
|
import Footer from "@components/Footer/Footer";
|
||||||
|
|
||||||
export default function RootLayout(
|
export default function RootLayout(
|
||||||
props: {
|
props: {
|
||||||
|
|
@ -25,6 +26,7 @@ export default function RootLayout(
|
||||||
<Toast />
|
<Toast />
|
||||||
<Onboarding />
|
<Onboarding />
|
||||||
{children}
|
{children}
|
||||||
|
<Footer />
|
||||||
</OrgProvider>
|
</OrgProvider>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,282 @@
|
||||||
|
'use client'
|
||||||
|
import React from 'react'
|
||||||
|
import { Form, Formik } from 'formik'
|
||||||
|
import * as Yup from 'yup'
|
||||||
|
import { updateOrganization } from '@services/settings/org'
|
||||||
|
import { revalidateTags } from '@services/utils/ts/requests'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { useOrg } from '@components/Contexts/OrgContext'
|
||||||
|
import { useLHSession } from '@components/Contexts/LHSessionContext'
|
||||||
|
import { toast } from 'react-hot-toast'
|
||||||
|
import { Button } from "@components/ui/button"
|
||||||
|
import { Label } from "@components/ui/label"
|
||||||
|
import { Textarea } from "@components/ui/textarea"
|
||||||
|
import { Code2, Plus, Trash2, PencilLine, AlertTriangle } from "lucide-react"
|
||||||
|
import { mutate } from 'swr'
|
||||||
|
import { getAPIUrl } from '@services/config/config'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip"
|
||||||
|
|
||||||
|
interface Script {
|
||||||
|
name: string
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrganizationScripts {
|
||||||
|
scripts: Script[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const validationSchema = Yup.object().shape({
|
||||||
|
name: Yup.string().required('Script name is required'),
|
||||||
|
content: Yup.string().required('Script content is required')
|
||||||
|
})
|
||||||
|
|
||||||
|
const OrgEditOther: React.FC = () => {
|
||||||
|
const router = useRouter()
|
||||||
|
const session = useLHSession() as any
|
||||||
|
const access_token = session?.data?.tokens?.access_token
|
||||||
|
const org = useOrg() as any
|
||||||
|
const [selectedView, setSelectedView] = React.useState<'list' | 'edit'>('list')
|
||||||
|
const [scripts, setScripts] = React.useState<Script[]>([])
|
||||||
|
const [currentScript, setCurrentScript] = React.useState<Script | null>(null)
|
||||||
|
|
||||||
|
// Initialize scripts from org
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (org?.scripts?.scripts) {
|
||||||
|
setScripts(Array.isArray(org.scripts.scripts) ? org.scripts.scripts : [])
|
||||||
|
} else {
|
||||||
|
setScripts([])
|
||||||
|
}
|
||||||
|
}, [org])
|
||||||
|
|
||||||
|
const updateOrg = async (values: Script) => {
|
||||||
|
const loadingToast = toast.loading('Updating organization...')
|
||||||
|
try {
|
||||||
|
let updatedScripts: Script[]
|
||||||
|
|
||||||
|
if (currentScript) {
|
||||||
|
// Edit existing script
|
||||||
|
updatedScripts = scripts.map(script =>
|
||||||
|
script.name === currentScript.name ? values : script
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Add new script
|
||||||
|
updatedScripts = [...scripts, values]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new organization object with scripts array wrapped in an object
|
||||||
|
const updateData = {
|
||||||
|
id: org.id,
|
||||||
|
scripts: {
|
||||||
|
scripts: updatedScripts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateOrganization(org.id, updateData, access_token)
|
||||||
|
await revalidateTags(['organizations'], org.slug)
|
||||||
|
mutate(`${getAPIUrl()}orgs/slug/${org.slug}`)
|
||||||
|
setScripts(updatedScripts)
|
||||||
|
setSelectedView('list')
|
||||||
|
setCurrentScript(null)
|
||||||
|
toast.success('Script saved successfully', { id: loadingToast })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error updating organization:', err)
|
||||||
|
toast.error('Failed to save script', { id: loadingToast })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteScript = async (scriptToDelete: Script) => {
|
||||||
|
const loadingToast = toast.loading('Deleting script...')
|
||||||
|
try {
|
||||||
|
const updatedScripts = scripts.filter(script => script.name !== scriptToDelete.name)
|
||||||
|
|
||||||
|
// Create a new organization object with scripts array wrapped in an object
|
||||||
|
const updateData = {
|
||||||
|
id: org.id,
|
||||||
|
scripts: {
|
||||||
|
scripts: updatedScripts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateOrganization(org.id, updateData, access_token)
|
||||||
|
await revalidateTags(['organizations'], org.slug)
|
||||||
|
mutate(`${getAPIUrl()}orgs/slug/${org.slug}`)
|
||||||
|
setScripts(updatedScripts)
|
||||||
|
toast.success('Script deleted successfully', { id: loadingToast })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error deleting script:', err)
|
||||||
|
toast.error('Failed to delete script', { id: loadingToast })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sm:mx-10 mx-0 bg-white rounded-xl nice-shadow">
|
||||||
|
<div className="pt-0.5">
|
||||||
|
<div className="flex flex-col bg-gray-50 -space-y-1 px-5 py-3 mx-3 my-3 rounded-md">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="font-bold text-xl text-gray-800 flex items-center space-x-2">
|
||||||
|
<Code2 className="h-5 w-5" />
|
||||||
|
<span>Scripts</span>
|
||||||
|
<TooltipProvider delayDuration={100}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<AlertTriangle className="h-4 w-4 text-orange-500 hover:text-orange-600 transition-colors" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
className="max-w-[400px] bg-orange-50 border-orange-100 text-orange-900 [&>p]:text-orange-800 data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1"
|
||||||
|
sideOffset={8}
|
||||||
|
>
|
||||||
|
<p className="p-2 leading-relaxed">For your organization's safety, please ensure you trust and understand any scripts before adding them. Scripts can interact with your organization's pages.</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</h1>
|
||||||
|
<h2 className="text-gray-500 text-md">
|
||||||
|
Add custom JavaScript scripts to your organization
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
{selectedView === 'list' && (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentScript(null)
|
||||||
|
setSelectedView('edit')
|
||||||
|
}}
|
||||||
|
className="bg-black text-white hover:bg-black/90"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Add Script
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 pt-1">
|
||||||
|
{selectedView === 'list' ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{(!scripts || scripts.length === 0) ? (
|
||||||
|
<div className="text-center py-8 px-4 text-gray-500 bg-gray-50/50 rounded-lg border border-dashed border-gray-200">
|
||||||
|
<Code2 className="h-8 w-8 mx-auto mb-2 text-gray-400" />
|
||||||
|
<p className="text-sm font-medium">No scripts added yet</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">Add your first script to get started</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
scripts.map((script, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="group p-4 rounded-lg bg-gray-50/50 hover:bg-gray-100/80 transition-colors duration-150 border border-gray-200"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="space-y-2 min-w-0 flex-1">
|
||||||
|
<div className="flex items-baseline space-x-2">
|
||||||
|
<h4 className="text-sm font-medium text-gray-800 truncate">{script.name}</h4>
|
||||||
|
</div>
|
||||||
|
<pre className="text-sm text-gray-600 font-mono bg-white/80 p-2 rounded border border-gray-200 overflow-x-auto">
|
||||||
|
{script.content.length > 100
|
||||||
|
? script.content.substring(0, 100) + '...'
|
||||||
|
: script.content}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
<div className="ml-4 flex space-x-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-gray-400 hover:text-blue-500 hover:bg-blue-50"
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentScript(script)
|
||||||
|
setSelectedView('edit')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PencilLine className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-gray-400 hover:text-red-500 hover:bg-red-50"
|
||||||
|
onClick={() => deleteScript(script)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Formik
|
||||||
|
initialValues={currentScript || { name: '', content: '' }}
|
||||||
|
validationSchema={validationSchema}
|
||||||
|
onSubmit={(values, { setSubmitting }) => {
|
||||||
|
setSubmitting(false)
|
||||||
|
updateOrg(values)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{({ values, handleChange, handleSubmit, errors, touched, isSubmitting }) => (
|
||||||
|
<Form onSubmit={handleSubmit}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="name">Script Name</Label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
value={values.name}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="mt-1 w-full px-3 py-2 border rounded-md"
|
||||||
|
placeholder="Enter script name"
|
||||||
|
/>
|
||||||
|
{touched.name && errors.name && (
|
||||||
|
<p className="text-red-500 text-sm mt-1">{errors.name}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="content">Script Content</Label>
|
||||||
|
<Textarea
|
||||||
|
id="content"
|
||||||
|
name="content"
|
||||||
|
value={values.content}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="mt-1 font-mono"
|
||||||
|
placeholder="Enter JavaScript code"
|
||||||
|
rows={10}
|
||||||
|
/>
|
||||||
|
{touched.content && errors.content && (
|
||||||
|
<p className="text-red-500 text-sm mt-1">{errors.content}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end space-x-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedView('list')
|
||||||
|
setCurrentScript(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="bg-black text-white hover:bg-black/90"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Saving...' : 'Save Script'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OrgEditOther
|
||||||
20
apps/web/components/Footer/Footer.tsx
Normal file
20
apps/web/components/Footer/Footer.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import OrgScripts from '@/components/OrgScripts/OrgScripts'
|
||||||
|
import { usePathname } from 'next/navigation'
|
||||||
|
import { OrgProvider } from '@/components/Contexts/OrgContext'
|
||||||
|
|
||||||
|
const Footer: React.FC = () => {
|
||||||
|
const pathname = usePathname()
|
||||||
|
const isDashboard = pathname?.startsWith('/dashboard')
|
||||||
|
|
||||||
|
// Don't run scripts in dashboard pages
|
||||||
|
if (isDashboard) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return <OrgScripts />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Footer
|
||||||
202
apps/web/components/OrgScripts/OrgScripts.tsx
Normal file
202
apps/web/components/OrgScripts/OrgScripts.tsx
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useEffect } from 'react'
|
||||||
|
import { useOrg } from '@/components/Contexts/OrgContext'
|
||||||
|
import DOMPurify from 'dompurify'
|
||||||
|
|
||||||
|
const OrgScripts: React.FC = () => {
|
||||||
|
const org = useOrg() as any
|
||||||
|
|
||||||
|
// Function to cleanup existing scripts
|
||||||
|
const cleanupExistingScript = (scriptId: string) => {
|
||||||
|
const existingScript = document.getElementById(scriptId)
|
||||||
|
if (existingScript) {
|
||||||
|
const parent = existingScript.parentNode
|
||||||
|
if (parent) {
|
||||||
|
let node = existingScript.previousSibling
|
||||||
|
while (node && node.nodeType === Node.COMMENT_NODE) {
|
||||||
|
const prevNode = node.previousSibling
|
||||||
|
parent.removeChild(node)
|
||||||
|
node = prevNode
|
||||||
|
}
|
||||||
|
node = existingScript.nextSibling
|
||||||
|
while (node && node.nodeType === Node.COMMENT_NODE) {
|
||||||
|
const nextNode = node.nextSibling
|
||||||
|
parent.removeChild(node)
|
||||||
|
node = nextNode
|
||||||
|
}
|
||||||
|
parent.removeChild(existingScript)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to check if script is already loaded
|
||||||
|
const isScriptLoaded = (scriptName: string): boolean => {
|
||||||
|
const scripts = document.querySelectorAll(`script[data-script-name="${scriptName}"]`)
|
||||||
|
return scripts.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to sanitize script content using DOMPurify
|
||||||
|
const sanitizeScriptContent = (content: string): string => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
DOMPurify.addHook('afterSanitizeAttributes', function(node) {
|
||||||
|
if (node.nodeName === 'SCRIPT') {
|
||||||
|
node.setAttribute('type', 'text/javascript');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const purifyConfig = {
|
||||||
|
ALLOWED_TAGS: ['script'],
|
||||||
|
ALLOWED_ATTR: [
|
||||||
|
'src', 'async', 'defer', 'crossorigin',
|
||||||
|
'integrity', 'type', 'nonce', 'id',
|
||||||
|
'data-*', 'referrerpolicy'
|
||||||
|
],
|
||||||
|
ADD_TAGS: ['script'],
|
||||||
|
WHOLE_DOCUMENT: false,
|
||||||
|
RETURN_DOM: false,
|
||||||
|
RETURN_DOM_FRAGMENT: false,
|
||||||
|
FORCE_BODY: true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content.trim().toLowerCase().startsWith('<script')) {
|
||||||
|
return DOMPurify.sanitize(content, purifyConfig)
|
||||||
|
} else {
|
||||||
|
return DOMPurify.sanitize(content, {
|
||||||
|
ALLOWED_TAGS: [],
|
||||||
|
ALLOWED_ATTR: [],
|
||||||
|
WHOLE_DOCUMENT: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to safely load and execute a script
|
||||||
|
const loadScript = (scriptContent: string, scriptName: string) => {
|
||||||
|
try {
|
||||||
|
if (isScriptLoaded(scriptName) || !scriptContent.trim()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeScriptId = `learnhouse-org-script-${scriptName.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${Math.random().toString(36).substr(2, 9)}`
|
||||||
|
|
||||||
|
cleanupExistingScript(safeScriptId)
|
||||||
|
|
||||||
|
if (scriptContent.trim().toLowerCase().startsWith('<script')) {
|
||||||
|
const sanitizedHtml = sanitizeScriptContent(scriptContent.trim())
|
||||||
|
const div = document.createElement('div')
|
||||||
|
div.innerHTML = sanitizedHtml
|
||||||
|
const scriptTag = div.querySelector('script')
|
||||||
|
|
||||||
|
if (!scriptTag) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const scriptElement = document.createElement('script')
|
||||||
|
Array.from(scriptTag.attributes).forEach(attr => {
|
||||||
|
scriptElement.setAttribute(attr.name, attr.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (scriptTag.src) {
|
||||||
|
try {
|
||||||
|
new URL(scriptTag.src)
|
||||||
|
scriptElement.async = true
|
||||||
|
scriptElement.onload = () => {
|
||||||
|
scriptElement.dataset.loaded = 'true'
|
||||||
|
}
|
||||||
|
scriptElement.onerror = (error) => {
|
||||||
|
console.error(`Failed to load external script "${scriptName}":`, error)
|
||||||
|
cleanupExistingScript(safeScriptId)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Invalid script URL in "${scriptName}":`, error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const sanitizedContent = sanitizeScriptContent(scriptTag.textContent || '')
|
||||||
|
scriptElement.textContent = `
|
||||||
|
/* LearnHouse Organization Script - ${scriptName} */
|
||||||
|
try {
|
||||||
|
(function() {
|
||||||
|
'use strict';
|
||||||
|
${sanitizedContent}
|
||||||
|
})();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Script error in ${scriptName}:", error);
|
||||||
|
}
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
scriptElement.id = safeScriptId
|
||||||
|
scriptElement.dataset.scriptName = scriptName
|
||||||
|
scriptElement.dataset.loadTime = new Date().toISOString()
|
||||||
|
scriptElement.dataset.type = scriptTag.src ? 'external' : 'inline'
|
||||||
|
scriptElement.dataset.orgId = org?.id
|
||||||
|
scriptElement.dataset.orgSlug = org?.slug
|
||||||
|
|
||||||
|
const comment = document.createComment(` LearnHouse Organization Script - ${scriptName} (${safeScriptId}) `)
|
||||||
|
document.body.appendChild(comment)
|
||||||
|
document.body.appendChild(scriptElement)
|
||||||
|
} else {
|
||||||
|
const scriptElement = document.createElement('script')
|
||||||
|
scriptElement.type = 'text/javascript'
|
||||||
|
|
||||||
|
const sanitizedContent = sanitizeScriptContent(scriptContent)
|
||||||
|
scriptElement.textContent = `
|
||||||
|
/* LearnHouse Organization Script - ${scriptName} */
|
||||||
|
try {
|
||||||
|
(function() {
|
||||||
|
'use strict';
|
||||||
|
${sanitizedContent}
|
||||||
|
})();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Script error in ${scriptName}:", error)
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
scriptElement.id = safeScriptId
|
||||||
|
scriptElement.dataset.scriptName = scriptName
|
||||||
|
scriptElement.dataset.loadTime = new Date().toISOString()
|
||||||
|
scriptElement.dataset.type = 'raw'
|
||||||
|
scriptElement.dataset.orgId = org?.id
|
||||||
|
scriptElement.dataset.orgSlug = org?.slug
|
||||||
|
|
||||||
|
const comment = document.createComment(` LearnHouse Organization Script - ${scriptName} (${safeScriptId}) `)
|
||||||
|
document.body.appendChild(comment)
|
||||||
|
document.body.appendChild(scriptElement)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to load script ${scriptName}:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!org || !org?.scripts?.scripts || !Array.isArray(org.scripts.scripts)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadedScripts = new Map()
|
||||||
|
|
||||||
|
org.scripts.scripts.forEach((script: { content: string, name: string }, index: number) => {
|
||||||
|
const scriptName = script.name || `Script ${index + 1}`
|
||||||
|
|
||||||
|
if (!loadedScripts.has(scriptName) && script.content) {
|
||||||
|
loadedScripts.set(scriptName, true)
|
||||||
|
loadScript(script.content, scriptName)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
const scripts = document.querySelectorAll('script[id^="learnhouse-org-script-"]')
|
||||||
|
scripts.forEach(script => {
|
||||||
|
cleanupExistingScript(script.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [org])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OrgScripts
|
||||||
29
apps/web/components/ui/tooltip.tsx
Normal file
29
apps/web/components/ui/tooltip.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const TooltipProvider = TooltipPrimitive.Provider
|
||||||
|
|
||||||
|
const Tooltip = TooltipPrimitive.Root
|
||||||
|
|
||||||
|
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||||
|
|
||||||
|
const TooltipContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { clsx, type ClassValue } from 'clsx'
|
import { type ClassValue, clsx } from "clsx"
|
||||||
import { twMerge } from 'tailwind-merge'
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue