commit 9863b8ecb53eacf364f48f81ced1941223e929ee Author: balvarez Date: Thu Jan 1 12:47:02 2026 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f300556 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.idea +src/dist/ +src/node_modules/ +src/package-lock.json diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..bc3999b --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1 @@ +nodePipeline(name: 'dites-le-en-chanson-backoffice') diff --git a/src/.env b/src/.env new file mode 100644 index 0000000..a7d2746 --- /dev/null +++ b/src/.env @@ -0,0 +1,2 @@ +VITE_SUPABASE_URL=https://supabase.abpcode.fr +VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzY2Nzc3OTU5LCJleHAiOjE5MjQ0NTc5NTl9.I-qytVb1ef6QMR8IUDePJzESO3bJAnsGE075XQ2xiaI diff --git a/src/.nvmrc b/src/.nvmrc new file mode 100644 index 0000000..7949534 --- /dev/null +++ b/src/.nvmrc @@ -0,0 +1 @@ +20.19.1 diff --git a/src/.version b/src/.version new file mode 100644 index 0000000..b4de394 --- /dev/null +++ b/src/.version @@ -0,0 +1 @@ +11 diff --git a/src/index.html b/src/index.html new file mode 100644 index 0000000..c7be9a1 --- /dev/null +++ b/src/index.html @@ -0,0 +1,15 @@ + + + + + + + + + Backoffice - Dites le en chanson + + +
+ + + \ No newline at end of file diff --git a/src/package.json b/src/package.json new file mode 100644 index 0000000..a1633c3 --- /dev/null +++ b/src/package.json @@ -0,0 +1,51 @@ +{ + "name": "web-app", + "type": "module", + "version": "0.0.0", + "private": true, + "scripts": { + "dev": "vite --host :: --port 3000", + "build": "vite build", + "preview": "vite preview --host :: --port 3000" + }, + "dependencies": { + "@radix-ui/react-alert-dialog": "^1.0.5", + "@radix-ui/react-avatar": "^1.0.3", + "@radix-ui/react-checkbox": "^1.0.4", + "@radix-ui/react-dialog": "^1.0.5", + "@radix-ui/react-dropdown-menu": "^2.0.5", + "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-tabs": "^1.0.4", + "@radix-ui/react-toast": "^1.1.5", + "@radix-ui/react-slider": "^1.1.2", + "@radix-ui/react-select": "^2.0.0", + "@supabase/supabase-js": "^2.39.0", + "class-variance-authority": "^0.7.0", + "clsx": "^2.0.0", + "framer-motion": "^10.16.4", + "lucide-react": "^0.285.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.16.0", + "tailwind-merge": "^1.14.0", + "tailwindcss-animate": "^1.0.7" + }, + "devDependencies": { + "@babel/generator": "^7.27.0", + "@babel/parser": "^7.27.0", + "@babel/traverse": "^7.27.0", + "@babel/types": "^7.27.0", + "@types/node": "^20.8.3", + "@types/react": "^18.2.15", + "@types/react-dom": "^18.2.7", + "@vitejs/plugin-react": "^4.0.3", + "autoprefixer": "^10.4.16", + "postcss": "^8.4.31", + "tailwindcss": "^3.3.3", + "vite": "^4.4.5", + "terser": "^5.39.0", + "eslint": "^8.57.1", + "eslint-config-react-app": "^7.0.1" + } +} \ No newline at end of file diff --git a/src/plugins/visual-editor/edit-mode-script.js b/src/plugins/visual-editor/edit-mode-script.js new file mode 100644 index 0000000..90fddda --- /dev/null +++ b/src/plugins/visual-editor/edit-mode-script.js @@ -0,0 +1,315 @@ +import { POPUP_STYLES } from './plugins/visual-editor/visual-editor-config.js'; + +const PLUGIN_APPLY_EDIT_API_URL = '/api/apply-edit'; + +const ALLOWED_PARENT_ORIGINS = [ + 'https://horizons.hostinger.com', + 'https://horizons.hostinger.dev', + 'https://horizons-frontend-local.hostinger.dev', + 'http://localhost:4000', +]; + +let disabledTooltipElement = null; +let currentDisabledHoverElement = null; + +let translations = { + disabledTooltipText: "This text can be changed only through chat.", + disabledTooltipTextImage: "This image can only be changed through chat." +}; + +let areStylesInjected = false; + +let globalEventHandlers = null; + +let currentEditingInfo = null; + +function injectPopupStyles() { + if (areStylesInjected) return; + + const styleElement = document.createElement('style'); + styleElement.id = 'inline-editor-styles'; + styleElement.textContent = POPUP_STYLES; + document.head.appendChild(styleElement); + areStylesInjected = true; +} + +function findEditableElementAtPoint(event) { + let editableElement = event.target.closest('[data-edit-id]'); + + if (editableElement) { + return editableElement; + } + + const elementsAtPoint = document.elementsFromPoint(event.clientX, event.clientY); + + const found = elementsAtPoint.find(el => el !== event.target && el.hasAttribute('data-edit-id')); + if (found) return found; + + return null; +} + +function findDisabledElementAtPoint(event) { + const direct = event.target.closest('[data-edit-disabled]'); + if (direct) return direct; + const elementsAtPoint = document.elementsFromPoint(event.clientX, event.clientY); + const found = elementsAtPoint.find(el => el !== event.target && el.hasAttribute('data-edit-disabled')); + if (found) return found; + return null; +} + +function showPopup(targetElement, editId, currentContent, isImage = false) { + currentEditingInfo = { editId, targetElement }; + + const parentOrigin = getParentOrigin(); + + if (parentOrigin && ALLOWED_PARENT_ORIGINS.includes(parentOrigin)) { + const eventType = isImage ? 'imageEditEnter' : 'editEnter'; + + window.parent.postMessage({ + type: eventType, + payload: { currentText: currentContent } + }, parentOrigin); + } +} + +function handleGlobalEvent(event) { + if (!document.getElementById('root')?.getAttribute('data-edit-mode-enabled')) { + return; + } + + if (event.target.closest('#inline-editor-popup')) { + return; + } + + const editableElement = findEditableElementAtPoint(event); + + if (editableElement) { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + + if (event.type === 'click') { + const editId = editableElement.getAttribute('data-edit-id'); + if (!editId) { + console.warn('[INLINE EDITOR] Clicked element missing data-edit-id'); + return; + } + + const isImage = editableElement.tagName.toLowerCase() === 'img'; + let currentContent = ''; + + if (isImage) { + currentContent = editableElement.getAttribute('src') || ''; + } else { + currentContent = editableElement.textContent || ''; + } + + showPopup(editableElement, editId, currentContent, isImage); + } + } else { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + } +} + +function getParentOrigin() { + if (window.location.ancestorOrigins && window.location.ancestorOrigins.length > 0) { + return window.location.ancestorOrigins[0]; + } + + if (document.referrer) { + try { + return new URL(document.referrer).origin; + } catch (e) { + console.warn('Invalid referrer URL:', document.referrer); + } + } + + return null; +} + +async function handleEditSave(updatedText) { + const newText = updatedText + // Replacing characters that cause Babel parser to crash + .replace(//g, '>') + .replace(/{/g, '{') + .replace(/}/g, '}') + + const { editId } = currentEditingInfo; + + try { + const response = await fetch(PLUGIN_APPLY_EDIT_API_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + editId: editId, + newFullText: newText + }), + }); + + const result = await response.json(); + if (result.success) { + const parentOrigin = getParentOrigin(); + if (parentOrigin && ALLOWED_PARENT_ORIGINS.includes(parentOrigin)) { + window.parent.postMessage({ + type: 'editApplied', + payload: { + editId: editId, + fileContent: result.newFileContent, + beforeCode: result.beforeCode, + afterCode: result.afterCode, + } + }, parentOrigin); + } else { + console.error('Unauthorized parent origin:', parentOrigin); + } + } else { + console.error(`[vite][visual-editor] Error saving changes: ${result.error}`); + } + } catch (error) { + console.error(`[vite][visual-editor] Error during fetch for ${editId}:`, error); + } +} + +function createDisabledTooltip() { + if (disabledTooltipElement) return; + + disabledTooltipElement = document.createElement('div'); + disabledTooltipElement.id = 'inline-editor-disabled-tooltip'; + document.body.appendChild(disabledTooltipElement); +} + +function showDisabledTooltip(targetElement, isImage = false) { + if (!disabledTooltipElement) createDisabledTooltip(); + + disabledTooltipElement.textContent = isImage ? translations.disabledTooltipTextImage : translations.disabledTooltipText; + + if (!disabledTooltipElement.isConnected) { + document.body.appendChild(disabledTooltipElement); + } + disabledTooltipElement.classList.add('tooltip-active'); + + const tooltipWidth = disabledTooltipElement.offsetWidth; + const tooltipHeight = disabledTooltipElement.offsetHeight; + const rect = targetElement.getBoundingClientRect(); + + // Ensures that tooltip is not off the screen with 5px margin + let newLeft = rect.left + window.scrollX + (rect.width / 2) - (tooltipWidth / 2); + let newTop = rect.bottom + window.scrollY + 5; + + if (newLeft < window.scrollX) { + newLeft = window.scrollX + 5; + } + if (newLeft + tooltipWidth > window.innerWidth + window.scrollX) { + newLeft = window.innerWidth + window.scrollX - tooltipWidth - 5; + } + if (newTop + tooltipHeight > window.innerHeight + window.scrollY) { + newTop = rect.top + window.scrollY - tooltipHeight - 5; + } + if (newTop < window.scrollY) { + newTop = window.scrollY + 5; + } + + disabledTooltipElement.style.left = `${newLeft}px`; + disabledTooltipElement.style.top = `${newTop}px`; +} + +function hideDisabledTooltip() { + if (disabledTooltipElement) { + disabledTooltipElement.classList.remove('tooltip-active'); + } +} + +function handleDisabledElementHover(event) { + const isImage = event.currentTarget.tagName.toLowerCase() === 'img'; + + showDisabledTooltip(event.currentTarget, isImage); +} + +function handleDisabledElementLeave() { + hideDisabledTooltip(); +} + +function handleDisabledGlobalHover(event) { + const disabledElement = findDisabledElementAtPoint(event); + if (disabledElement) { + if (currentDisabledHoverElement !== disabledElement) { + currentDisabledHoverElement = disabledElement; + const isImage = disabledElement.tagName.toLowerCase() === 'img'; + showDisabledTooltip(disabledElement, isImage); + } + } else { + if (currentDisabledHoverElement) { + currentDisabledHoverElement = null; + hideDisabledTooltip(); + } + } +} + +function enableEditMode() { + document.getElementById('root')?.setAttribute('data-edit-mode-enabled', 'true'); + + injectPopupStyles(); + + if (!globalEventHandlers) { + globalEventHandlers = { + mousedown: handleGlobalEvent, + pointerdown: handleGlobalEvent, + click: handleGlobalEvent + }; + + Object.entries(globalEventHandlers).forEach(([eventType, handler]) => { + document.addEventListener(eventType, handler, true); + }); + } + + document.addEventListener('mousemove', handleDisabledGlobalHover, true); + + document.querySelectorAll('[data-edit-disabled]').forEach(el => { + el.removeEventListener('mouseenter', handleDisabledElementHover); + el.addEventListener('mouseenter', handleDisabledElementHover); + el.removeEventListener('mouseleave', handleDisabledElementLeave); + el.addEventListener('mouseleave', handleDisabledElementLeave); + }); +} + +function disableEditMode() { + document.getElementById('root')?.removeAttribute('data-edit-mode-enabled'); + + hideDisabledTooltip(); + + if (globalEventHandlers) { + Object.entries(globalEventHandlers).forEach(([eventType, handler]) => { + document.removeEventListener(eventType, handler, true); + }); + globalEventHandlers = null; + } + + document.removeEventListener('mousemove', handleDisabledGlobalHover, true); + currentDisabledHoverElement = null; + + document.querySelectorAll('[data-edit-disabled]').forEach(el => { + el.removeEventListener('mouseenter', handleDisabledElementHover); + el.removeEventListener('mouseleave', handleDisabledElementLeave); + }); +} + +window.addEventListener("message", function(event) { + if (event.data?.type === "edit-save") { + handleEditSave(event.data?.payload?.newText); + } + if (event.data?.type === "enable-edit-mode") { + if (event.data?.translations) { + translations = { ...translations, ...event.data.translations }; + } + + enableEditMode(); + } + if (event.data?.type === "disable-edit-mode") { + disableEditMode(); + } +}); diff --git a/src/plugins/visual-editor/visual-editor-config.js b/src/plugins/visual-editor/visual-editor-config.js new file mode 100644 index 0000000..8051451 --- /dev/null +++ b/src/plugins/visual-editor/visual-editor-config.js @@ -0,0 +1,137 @@ +export const POPUP_STYLES = ` +#inline-editor-popup { + width: 360px; + position: fixed; + z-index: 10000; + background: #161718; + color: white; + border: 1px solid #4a5568; + border-radius: 16px; + padding: 8px; + box-shadow: 0 4px 12px rgba(0,0,0,0.2); + flex-direction: column; + gap: 10px; + display: none; +} + +@media (max-width: 768px) { + #inline-editor-popup { + width: calc(100% - 20px); + } +} + +#inline-editor-popup.is-active { + display: flex; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + +#inline-editor-popup.is-disabled-view { + padding: 10px 15px; +} + +#inline-editor-popup textarea { + height: 100px; + padding: 4px 8px; + background: transparent; + color: white; + font-family: inherit; + font-size: 0.875rem; + line-height: 1.42; + resize: none; + outline: none; +} + +#inline-editor-popup .button-container { + display: flex; + justify-content: flex-end; + gap: 10px; +} + +#inline-editor-popup .popup-button { + border: none; + padding: 6px 16px; + border-radius: 8px; + cursor: pointer; + font-size: 0.75rem; + font-weight: 700; + height: 34px; + outline: none; +} + +#inline-editor-popup .save-button { + background: #673de6; + color: white; +} + +#inline-editor-popup .cancel-button { + background: transparent; + border: 1px solid #3b3d4a; + color: white; + + &:hover { + background:#474958; + } +} +`; + +export function getPopupHTMLTemplate(saveLabel, cancelLabel) { + return ` + +
+ + +
+ `; +}; + +export const EDIT_MODE_STYLES = ` + #root[data-edit-mode-enabled="true"] [data-edit-id] { + cursor: pointer; + outline: 2px dashed #357DF9; + outline-offset: 2px; + min-height: 1em; + } + #root[data-edit-mode-enabled="true"] img[data-edit-id] { + outline-offset: -2px; + } + #root[data-edit-mode-enabled="true"] { + cursor: pointer; + } + #root[data-edit-mode-enabled="true"] [data-edit-id]:hover { + background-color: #357DF933; + outline-color: #357DF9; + } + + @keyframes fadeInTooltip { + from { + opacity: 0; + transform: translateY(5px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + #inline-editor-disabled-tooltip { + display: none; + opacity: 0; + position: absolute; + background-color: #1D1E20; + color: white; + padding: 4px 8px; + border-radius: 8px; + z-index: 10001; + font-size: 14px; + border: 1px solid #3B3D4A; + max-width: 184px; + text-align: center; + } + + #inline-editor-disabled-tooltip.tooltip-active { + display: block; + animation: fadeInTooltip 0.2s ease-out forwards; + } +`; \ No newline at end of file diff --git a/src/plugins/visual-editor/vite-plugin-edit-mode.js b/src/plugins/visual-editor/vite-plugin-edit-mode.js new file mode 100644 index 0000000..3e39a12 --- /dev/null +++ b/src/plugins/visual-editor/vite-plugin-edit-mode.js @@ -0,0 +1,32 @@ +import { readFileSync } from 'fs'; +import { resolve } from 'path'; +import { fileURLToPath } from 'url'; +import { EDIT_MODE_STYLES } from './visual-editor-config'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = resolve(__filename, '..'); + +export default function inlineEditDevPlugin() { + return { + name: 'vite:inline-edit-dev', + apply: 'serve', + transformIndexHtml() { + const scriptPath = resolve(__dirname, 'edit-mode-script.js'); + const scriptContent = readFileSync(scriptPath, 'utf-8'); + + return [ + { + tag: 'script', + attrs: { type: 'module' }, + children: scriptContent, + injectTo: 'body' + }, + { + tag: 'style', + children: EDIT_MODE_STYLES, + injectTo: 'head' + } + ]; + } + }; +} diff --git a/src/plugins/visual-editor/vite-plugin-react-inline-editor.js b/src/plugins/visual-editor/vite-plugin-react-inline-editor.js new file mode 100644 index 0000000..80fa80d --- /dev/null +++ b/src/plugins/visual-editor/vite-plugin-react-inline-editor.js @@ -0,0 +1,384 @@ +import path from 'path'; +import { fileURLToPath } from 'url'; +import { parse } from '@babel/parser'; +import traverseBabel from '@babel/traverse'; +import generate from '@babel/generator'; +import * as t from '@babel/types'; +import fs from 'fs'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const VITE_PROJECT_ROOT = path.resolve(__dirname, '../..'); +const EDITABLE_HTML_TAGS = ["a", "Button", "button", "p", "span", "h1", "h2", "h3", "h4", "h5", "h6", "label", "Label", "img"]; + +function parseEditId(editId) { + const parts = editId.split(':'); + + if (parts.length < 3) { + return null; + } + + const column = parseInt(parts.at(-1), 10); + const line = parseInt(parts.at(-2), 10); + const filePath = parts.slice(0, -2).join(':'); + + if (!filePath || isNaN(line) || isNaN(column)) { + return null; + } + + return { filePath, line, column }; +} + +function checkTagNameEditable(openingElementNode, editableTagsList) { + if (!openingElementNode || !openingElementNode.name) return false; + const nameNode = openingElementNode.name; + + // Check 1: Direct name (for

, + ); + } + if (order.status === statusProcessing) { + return ( + + ); + } + return null; + }; + + return ( + +

+
+
+ + Commande #{order.id.substring(0,8)}... + + + {getStatusIcon(order.status)} + {order.status} + +
+
+
+ {renderActionButton()} +
+
+ +
+ + + + + + + + + + + + + + + + +
+ + { (order.anecdote1 || order.anecdote2 || order.anecdote3) && +
+

Anecdotes :

+ {order.anecdote1 &&

- {order.anecdote1}

} + {order.anecdote2 &&

- {order.anecdote2}

} + {order.anecdote3 &&

- {order.anecdote3}

} +
+ } + + ); +} \ No newline at end of file diff --git a/src/src/components/dashboard/OrderDetailItem.jsx b/src/src/components/dashboard/OrderDetailItem.jsx new file mode 100644 index 0000000..6dd1d35 --- /dev/null +++ b/src/src/components/dashboard/OrderDetailItem.jsx @@ -0,0 +1,16 @@ +import React from 'react'; + +export function OrderDetailItem({ label, value, isId = false }) { + if (!value && value !== 0) return null; // Allow 0 to be displayed + + return ( +
+ {label}: + {isId ? ( + {value} + ) : ( + {value} + )} +
+ ); +} \ No newline at end of file diff --git a/src/src/components/dashboard/index.js b/src/src/components/dashboard/index.js new file mode 100644 index 0000000..e0c6085 --- /dev/null +++ b/src/src/components/dashboard/index.js @@ -0,0 +1,3 @@ +export * from './MetricCard'; +export * from './OrderCard'; +export * from './OrderDetailItem'; \ No newline at end of file diff --git a/src/src/components/ui/button.jsx b/src/src/components/ui/button.jsx new file mode 100644 index 0000000..d5939c3 --- /dev/null +++ b/src/src/components/ui/button.jsx @@ -0,0 +1,47 @@ +import { cn } from '@/lib/utils'; +import { Slot } from '@radix-ui/react-slot'; +import { cva } from 'class-variance-authority'; +import React from 'react'; + +const buttonVariants = cva( + 'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground hover:bg-primary/90', + destructive: + 'bg-destructive text-destructive-foreground hover:bg-destructive/90', + outline: + 'border border-input bg-background hover:bg-accent hover:text-accent-foreground', + secondary: + 'bg-secondary text-secondary-foreground hover:bg-secondary/80', + ghost: 'hover:bg-accent hover:text-accent-foreground', + link: 'text-primary underline-offset-4 hover:underline', + }, + size: { + default: 'h-10 px-4 py-2', + sm: 'h-9 rounded-md px-3', + lg: 'h-11 rounded-md px-8', + icon: 'h-10 w-10', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }, +); + +const Button = React.forwardRef(({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : 'button'; + return ( + + ); +}); +Button.displayName = 'Button'; + +export { Button, buttonVariants }; \ No newline at end of file diff --git a/src/src/components/ui/input.jsx b/src/src/components/ui/input.jsx new file mode 100644 index 0000000..5827e78 --- /dev/null +++ b/src/src/components/ui/input.jsx @@ -0,0 +1,19 @@ +import { cn } from '@/lib/utils'; +import React from 'react'; + +const Input = React.forwardRef(({ className, type, ...props }, ref) => { + return ( + + ); +}); +Input.displayName = 'Input'; + +export { Input }; \ No newline at end of file diff --git a/src/src/components/ui/label.jsx b/src/src/components/ui/label.jsx new file mode 100644 index 0000000..47fa697 --- /dev/null +++ b/src/src/components/ui/label.jsx @@ -0,0 +1,19 @@ +import { cn } from '@/lib/utils'; +import * as LabelPrimitive from '@radix-ui/react-label'; +import { cva } from 'class-variance-authority'; +import React from 'react'; + +const labelVariants = cva( + 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70' +); + +const Label = React.forwardRef(({ className, ...props }, ref) => ( + +)); +Label.displayName = LabelPrimitive.Root.displayName; + +export { Label }; \ No newline at end of file diff --git a/src/src/components/ui/select.jsx b/src/src/components/ui/select.jsx new file mode 100644 index 0000000..c67e0b4 --- /dev/null +++ b/src/src/components/ui/select.jsx @@ -0,0 +1,135 @@ +import { cn } from '@/lib/utils'; +import * as SelectPrimitive from '@radix-ui/react-select'; +import { Check, ChevronDown, ChevronUp } from 'lucide-react'; +import React from 'react'; + +const Select = SelectPrimitive.Root; + +const SelectGroup = SelectPrimitive.Group; + +const SelectValue = SelectPrimitive.Value; + +const SelectTrigger = React.forwardRef(({ className, children, ...props }, ref) => ( + span]:line-clamp-1', + className + )} + {...props} + > + {children} + + + + +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; + +const SelectScrollUpButton = React.forwardRef(({ className, ...props }, ref) => ( + + + +)); +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; + +const SelectScrollDownButton = React.forwardRef(({ className, ...props }, ref) => ( + + + +)); +SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName; + +const SelectContent = React.forwardRef(({ className, children, position = 'popper', ...props }, ref) => ( + + + + + {children} + + + + +)); +SelectContent.displayName = SelectPrimitive.Content.displayName; + +const SelectLabel = React.forwardRef(({ className, ...props }, ref) => ( + +)); +SelectLabel.displayName = SelectPrimitive.Label.displayName; + +const SelectItem = React.forwardRef(({ className, children, ...props }, ref) => ( + + + + + + + + {children} + +)); +SelectItem.displayName = SelectPrimitive.Item.displayName; + +const SelectSeparator = React.forwardRef(({ className, ...props }, ref) => ( + +)); +SelectSeparator.displayName = SelectPrimitive.Separator.displayName; + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +}; \ No newline at end of file diff --git a/src/src/components/ui/toast.jsx b/src/src/components/ui/toast.jsx new file mode 100644 index 0000000..74f5309 --- /dev/null +++ b/src/src/components/ui/toast.jsx @@ -0,0 +1,101 @@ +import { cn } from '@/lib/utils'; +import * as ToastPrimitives from '@radix-ui/react-toast'; +import { cva } from 'class-variance-authority'; +import { X } from 'lucide-react'; +import React from 'react'; + +const ToastProvider = ToastPrimitives.Provider; + +const ToastViewport = React.forwardRef(({ className, ...props }, ref) => ( + +)); +ToastViewport.displayName = ToastPrimitives.Viewport.displayName; + +const toastVariants = cva( + 'data-[swipe=move]:transition-none group relative pointer-events-auto flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full data-[state=closed]:slide-out-to-right-full', + { + variants: { + variant: { + default: 'bg-background border', + destructive: + 'group destructive border-destructive bg-destructive text-destructive-foreground', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +); + +const Toast = React.forwardRef(({ className, variant, ...props }, ref) => { + return ( + + ); +}); +Toast.displayName = ToastPrimitives.Root.displayName; + +const ToastAction = React.forwardRef(({ className, ...props }, ref) => ( + +)); +ToastAction.displayName = ToastPrimitives.Action.displayName; + +const ToastClose = React.forwardRef(({ className, ...props }, ref) => ( + + + +)); +ToastClose.displayName = ToastPrimitives.Close.displayName; + +const ToastTitle = React.forwardRef(({ className, ...props }, ref) => ( + +)); +ToastTitle.displayName = ToastPrimitives.Title.displayName; + +const ToastDescription = React.forwardRef(({ className, ...props }, ref) => ( + +)); +ToastDescription.displayName = ToastPrimitives.Description.displayName; + +export { + Toast, + ToastAction, + ToastClose, + ToastDescription, + ToastProvider, + ToastTitle, + ToastViewport, +}; \ No newline at end of file diff --git a/src/src/components/ui/toaster.jsx b/src/src/components/ui/toaster.jsx new file mode 100644 index 0000000..d500fe6 --- /dev/null +++ b/src/src/components/ui/toaster.jsx @@ -0,0 +1,34 @@ +import { + Toast, + ToastClose, + ToastDescription, + ToastProvider, + ToastTitle, + ToastViewport, +} from '@/components/ui/toast'; +import { useToast } from '@/components/ui/use-toast'; +import React from 'react'; + +export function Toaster() { + const { toasts } = useToast(); + + return ( + + {toasts.map(({ id, title, description, action, ...props }) => { + return ( + +
+ {title && {title}} + {description && ( + {description} + )} +
+ {action} + +
+ ); + })} + +
+ ); +} \ No newline at end of file diff --git a/src/src/components/ui/use-toast.js b/src/src/components/ui/use-toast.js new file mode 100644 index 0000000..99b4878 --- /dev/null +++ b/src/src/components/ui/use-toast.js @@ -0,0 +1,103 @@ +import { useState, useEffect } from "react" + +const TOAST_LIMIT = 1 + +let count = 0 +function generateId() { + count = (count + 1) % Number.MAX_VALUE + return count.toString() +} + +const toastStore = { + state: { + toasts: [], + }, + listeners: [], + + getState: () => toastStore.state, + + setState: (nextState) => { + if (typeof nextState === 'function') { + toastStore.state = nextState(toastStore.state) + } else { + toastStore.state = { ...toastStore.state, ...nextState } + } + + toastStore.listeners.forEach(listener => listener(toastStore.state)) + }, + + subscribe: (listener) => { + toastStore.listeners.push(listener) + return () => { + toastStore.listeners = toastStore.listeners.filter(l => l !== listener) + } + } +} + +export const toast = ({ ...props }) => { + const id = generateId() + + const update = (props) => + toastStore.setState((state) => ({ + ...state, + toasts: state.toasts.map((t) => + t.id === id ? { ...t, ...props } : t + ), + })) + + const dismiss = () => toastStore.setState((state) => ({ + ...state, + toasts: state.toasts.filter((t) => t.id !== id), + })) + + toastStore.setState((state) => ({ + ...state, + toasts: [ + { ...props, id, dismiss }, + ...state.toasts, + ].slice(0, TOAST_LIMIT), + })) + + return { + id, + dismiss, + update, + } +} + +export function useToast() { + const [state, setState] = useState(toastStore.getState()) + + useEffect(() => { + const unsubscribe = toastStore.subscribe((state) => { + setState(state) + }) + + return unsubscribe + }, []) + + useEffect(() => { + const timeouts = [] + + state.toasts.forEach((toast) => { + if (toast.duration === Infinity) { + return + } + + const timeout = setTimeout(() => { + toast.dismiss() + }, toast.duration || 5000) + + timeouts.push(timeout) + }) + + return () => { + timeouts.forEach((timeout) => clearTimeout(timeout)) + } + }, [state.toasts]) + + return { + toast, + toasts: state.toasts, + } +} \ No newline at end of file diff --git a/src/src/contexts/AuthContext.jsx b/src/src/contexts/AuthContext.jsx new file mode 100644 index 0000000..ad736b2 --- /dev/null +++ b/src/src/contexts/AuthContext.jsx @@ -0,0 +1,74 @@ +import React, { createContext, useContext, useState, useEffect } from 'react'; +import { supabase } from '@/lib/supabaseClient'; + +const AuthContext = createContext(); + +export function useAuth() { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +} + +export function AuthProvider({ children }) { + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [isLoading, setIsLoading] = useState(true); // Keep loading state for initial auth check + + useEffect(() => { + const authToken = localStorage.getItem('admin_auth_token'); + if (authToken) { + // Potentially validate token here if needed in future + setIsAuthenticated(true); + } + setIsLoading(false); // Finished initial check + }, []); + + const login = async (password) => { + setIsLoading(true); + try { + const { data, error } = await supabase.functions.invoke('verify-admin-password', { + body: { password }, + }); + + if (error) { + console.error('Error invoking verify-admin-password function:', error); + setIsLoading(false); + return false; + } + + if (data && data.success) { + const token = btoa(Date.now().toString()); // Simple token for client-side + localStorage.setItem('admin_auth_token', token); + setIsAuthenticated(true); + setIsLoading(false); + return true; + } else { + setIsLoading(false); + return false; + } + } catch (e) { + console.error('Unexpected error during login:', e); + setIsLoading(false); + return false; + } + }; + + const logout = () => { + localStorage.removeItem('admin_auth_token'); + setIsAuthenticated(false); + }; + + const value = { + isAuthenticated, + isLoading, + login, + logout + }; + + return ( + + {children} + + ); +} \ No newline at end of file diff --git a/src/src/index.css b/src/src/index.css new file mode 100644 index 0000000..8bedab3 --- /dev/null +++ b/src/src/index.css @@ -0,0 +1,95 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + --primary: 217.2 91.2% 59.8%; + --primary-foreground: 222.2 84% 4.9%; + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 224.3 76.3% 94.0%; + --radius: 0.75rem; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + font-feature-settings: "rlig" 1, "calt" 1; + } +} + +.gradient-bg { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); +} + +.glass-effect { + background: rgba(255, 255, 255, 0.1); + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.2); +} + +.status-badge { + @apply px-3 py-1 rounded-full text-xs font-medium inline-flex items-center; +} + +.status-preparation { /* En attente de traitement - Jaune */ + @apply bg-yellow-500/20 text-yellow-300 border border-yellow-500/30; +} + +.status-created { /* Traitement en cours - Bleu */ + @apply bg-blue-500/20 text-blue-300 border border-blue-500/30; +} + +.status-completed { /* Commande traitée - Vert */ + @apply bg-green-500/20 text-green-300 border border-green-500/30; +} + +.metric-card { + @apply bg-gradient-to-br from-slate-800/50 to-slate-900/50 backdrop-blur-sm border border-slate-700/50 rounded-xl p-6; +} + +.order-card { + @apply bg-gradient-to-br from-slate-800/30 to-slate-900/30 backdrop-blur-sm border border-slate-700/30 rounded-lg hover:border-slate-600/50 transition-all duration-300; +} + +.login-container { + background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%); + min-height: 100vh; +} + +.floating-animation { + animation: float 6s ease-in-out infinite; +} + +@keyframes float { + 0%, 100% { transform: translateY(0px); } + 50% { transform: translateY(-20px); } +} + +.pulse-glow { + animation: pulse-glow 2s ease-in-out infinite alternate; +} + +@keyframes pulse-glow { + from { box-shadow: 0 0 20px rgba(59, 130, 246, 0.3); } + to { box-shadow: 0 0 30px rgba(59, 130, 246, 0.6); } +} \ No newline at end of file diff --git a/src/src/lib/supabaseClient.js b/src/src/lib/supabaseClient.js new file mode 100644 index 0000000..733b874 --- /dev/null +++ b/src/src/lib/supabaseClient.js @@ -0,0 +1,6 @@ +import { createClient } from '@supabase/supabase-js'; + +const supabaseUrl = import.meta.env.VITE_SUPABASE_URL; +const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY; + +export const supabase = createClient(supabaseUrl, supabaseAnonKey); diff --git a/src/src/lib/utils.js b/src/src/lib/utils.js new file mode 100644 index 0000000..7fe4ab4 --- /dev/null +++ b/src/src/lib/utils.js @@ -0,0 +1,6 @@ +import { clsx } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs) { + return twMerge(clsx(inputs)); +} \ No newline at end of file diff --git a/src/src/main.jsx b/src/src/main.jsx new file mode 100644 index 0000000..a014354 --- /dev/null +++ b/src/src/main.jsx @@ -0,0 +1,10 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from '@/App'; +import '@/index.css'; + +ReactDOM.createRoot(document.getElementById('root')).render( + + + +); \ No newline at end of file diff --git a/src/src/pages/DashboardPage.jsx b/src/src/pages/DashboardPage.jsx new file mode 100644 index 0000000..0a8516f --- /dev/null +++ b/src/src/pages/DashboardPage.jsx @@ -0,0 +1,366 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { motion } from 'framer-motion'; +import { + LogOut, + Music, + Package, + CheckCircle, + Clock, + Users, + RefreshCw, + AlertTriangle, + PlayCircle, + Send, + ChevronLeft, + ChevronRight, + Filter +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { useAuth } from '@/contexts/AuthContext'; +import { toast } from '@/components/ui/use-toast'; +import { supabase } from '@/lib/supabaseClient'; +import { MetricCard } from '@/components/dashboard/MetricCard'; +import { OrderCard } from '@/components/dashboard/OrderCard'; + +const STATUS_PENDING = "En attente de traitement"; +const STATUS_PROCESSING = "Traitement en cours"; +const STATUS_COMPLETED = "Commande traitée"; +const ORDERS_PER_PAGE = 10; + +const statusMapping = { + pending: STATUS_PENDING, + processing: STATUS_PROCESSING, + completed: STATUS_COMPLETED, +}; + +function DashboardPage() { + const { logout } = useAuth(); + const [orders, setOrders] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isUpdating, setIsUpdating] = useState(null); + const [fetchError, setFetchError] = useState(null); + const [activeFilter, setActiveFilter] = useState(null); + const [currentPage, setCurrentPage] = useState(1); + const [totalOrders, setTotalOrders] = useState(0); + const [metrics, setMetrics] = useState({ + total: 0, + pending: 0, + processing: 0, + completed: 0, + totalRevenue: 0, + pendingRevenue: 0, + processingRevenue: 0, + completedRevenue: 0, + }); + + const calculateMetrics = useCallback((allOrders) => { + const pendingOrders = allOrders.filter(o => o.status === STATUS_PENDING); + const processingOrders = allOrders.filter(o => o.status === STATUS_PROCESSING); + const completedOrders = allOrders.filter(o => o.status === STATUS_COMPLETED); + + setMetrics({ + total: allOrders.length, + pending: pendingOrders.length, + processing: processingOrders.length, + completed: completedOrders.length, + totalRevenue: allOrders.reduce((sum, order) => sum + (Number(order.price) || 0), 0), + pendingRevenue: pendingOrders.reduce((sum, order) => sum + (Number(order.price) || 0), 0), + processingRevenue: processingOrders.reduce((sum, order) => sum + (Number(order.price) || 0), 0), + completedRevenue: completedOrders.reduce((sum, order) => sum + (Number(order.price) || 0), 0), + }); + }, []); + + const fetchOrdersAndMetrics = useCallback(async () => { + try { + const { data: allOrdersData, error: metricsError } = await supabase + .from('orders') + .select('status, price'); + + if (metricsError) { + console.error("Error fetching all orders for metrics:", metricsError); + } else if (allOrdersData) { + calculateMetrics(allOrdersData); + } + } catch (err) { + console.error("Unexpected error fetching all orders for metrics:", err); + } + }, [calculateMetrics]); + + const fetchPaginatedOrders = useCallback(async () => { + setIsLoading(true); + setFetchError(null); + + const from = (currentPage - 1) * ORDERS_PER_PAGE; + const to = from + ORDERS_PER_PAGE - 1; + + let query = supabase + .from('orders') + .select('*', { count: 'exact' }) + .order('created_at', { ascending: false }) + .range(from, to); + + if (activeFilter && statusMapping[activeFilter]) { + query = query.eq('status', statusMapping[activeFilter]); + } + + try { + const { data, error, count } = await query; + + if (error) { + console.error("Error fetching orders:", error); + setFetchError(error.message); + toast({ + title: "Erreur de chargement", + description: "Impossible de récupérer les commandes.", + variant: "destructive", + }); + setOrders([]); + setTotalOrders(0); + } else { + setOrders(data || []); + setTotalOrders(count || 0); + } + } catch (err) { + console.error("Unexpected error fetching orders:", err); + setFetchError("Une erreur inattendue est survenue."); + toast({ + title: "Erreur critique", + description: "Une erreur inattendue est survenue lors du chargement des commandes.", + variant: "destructive", + }); + setOrders([]); + setTotalOrders(0); + } finally { + setIsLoading(false); + } + }, [currentPage, activeFilter]); + + useEffect(() => { + fetchOrdersAndMetrics(); + fetchPaginatedOrders(); + }, [fetchPaginatedOrders, fetchOrdersAndMetrics]); + + const handleStatusChange = async (orderId, newStatus) => { + if (!orderId) { + toast({ title: "Erreur interne", description: "ID de commande manquant.", variant: "destructive" }); + return; + } + setIsUpdating(orderId); + try { + const { data: updatedOrder, error: functionError } = await supabase.functions.invoke('update-order-status', { + body: JSON.stringify({ orderId, newStatus }), + }); + + if (functionError) { + console.error('Supabase function error:', functionError); + toast({ title: "Erreur de mise à jour (fonction)", description: `Statut non mis à jour: ${functionError.message}`, variant: "destructive" }); + } else if (updatedOrder && updatedOrder.error) { + // Handle errors returned by the function logic itself + console.error('Error from Edge Function:', updatedOrder.error); + toast({ title: "Erreur de mise à jour (logique fonction)", description: `Statut non mis à jour: ${updatedOrder.error}`, variant: "destructive" }); + } else if (updatedOrder) { + await fetchOrdersAndMetrics(); + await fetchPaginatedOrders(); + toast({ title: "Statut mis à jour !", description: `Commande #${orderId.substring(0,8)}... est maintenant "${newStatus}".` }); + } else { + toast({ title: "Mise à jour incertaine", description: "Aucune donnée de confirmation de la fonction.", variant: "destructive" }); + await fetchOrdersAndMetrics(); + await fetchPaginatedOrders(); + } + } catch (err) { + console.error("Unexpected error invoking Supabase function:", err); + toast({ title: "Erreur critique", description: "Erreur inattendue lors de l'appel de la fonction de mise à jour.", variant: "destructive" }); + } finally { + setIsUpdating(null); + } + }; + + const handleFilterClick = (filter) => { + setActiveFilter(prevFilter => prevFilter === filter ? null : filter); + setCurrentPage(1); + }; + + const handlePageChange = (newPage) => { + if (newPage >= 1 && newPage <= Math.ceil(totalOrders / ORDERS_PER_PAGE)) { + setCurrentPage(newPage); + } + }; + + const formatCurrency = (amount) => { + return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(amount); + }; + + const totalPages = Math.ceil(totalOrders / ORDERS_PER_PAGE); + + return ( +
+
+
+
+
+
+ +
+
+

Backoffice Admin

+

Dites-le en Chanson

+
+
+ +
+
+
+ +
+
+ } + onClick={() => handleFilterClick(null)} + isActive={activeFilter === null} + isLoading={isLoading && metrics.total === 0} + /> + } + onClick={() => handleFilterClick('pending')} + isActive={activeFilter === 'pending'} + isLoading={isLoading && metrics.pending === 0} + statusColor="yellow" + /> + } + onClick={() => handleFilterClick('processing')} + isActive={activeFilter === 'processing'} + isLoading={isLoading && metrics.processing === 0} + statusColor="blue" + /> + } + onClick={() => handleFilterClick('completed')} + isActive={activeFilter === 'completed'} + isLoading={isLoading && metrics.completed === 0} + statusColor="green" + /> +
+ + +
+

+ + {activeFilter ? `Commandes: ${statusMapping[activeFilter]}` : 'Toutes les Commandes'} + {activeFilter && ( + + )} +

+ +
+ + {isLoading && orders.length === 0 ? ( +
+ +
+ ) : fetchError ? ( +
+ +

Erreur de chargement des commandes

+

{fetchError}

+ +
+ ) : !isLoading && orders.length === 0 ? ( +
+ +

Aucune commande {activeFilter ? `avec le statut "${statusMapping[activeFilter]}"` : 'pour le moment'}

+

Les nouvelles commandes apparaîtront ici.

+
+ ) : ( + <> +
+ {orders.map((order, index) => ( + + ))} +
+ {totalPages > 1 && ( +
+ + + Page {currentPage} sur {totalPages} ({totalOrders} commandes) + + +
+ )} + + )} +
+
+
+ ); +} + +export default DashboardPage; \ No newline at end of file diff --git a/src/src/pages/LoginPage.jsx b/src/src/pages/LoginPage.jsx new file mode 100644 index 0000000..169561d --- /dev/null +++ b/src/src/pages/LoginPage.jsx @@ -0,0 +1,136 @@ +import React, { useState } from 'react'; +import { Navigate } from 'react-router-dom'; +import { motion } from 'framer-motion'; +import { Lock, Music, Sparkles } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useAuth } from '@/contexts/AuthContext'; +import { toast } from '@/components/ui/use-toast'; + +function LoginPage() { + const [password, setPassword] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); // Renamed from isLoading to avoid conflict + const { isAuthenticated, login, isLoading: isAuthLoading } = useAuth(); // Get auth loading state + + if (isAuthLoading) { + // Show a loading indicator while auth state is being determined + return ( +
+ +
+ ); + } + + if (isAuthenticated) { + return ; + } + + const handleSubmit = async (e) => { + e.preventDefault(); + setIsSubmitting(true); + + try { + const success = await login(password); // login is now async + if (success) { + toast({ + title: "Connexion réussie !", + description: "Bienvenue dans votre backoffice.", + }); + } else { + toast({ + title: "Erreur de connexion", + description: "Mot de passe incorrect.", + variant: "destructive", + }); + } + } catch (error) { + console.error("Login submit error:", error); + toast({ + title: "Erreur", + description: "Une erreur est survenue lors de la connexion.", + variant: "destructive", + }); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+
+ +
+ + + +

+ Backoffice Admin +

+

+ + Dites-le en Chanson + +

+
+ +
+
+ +
+ + setPassword(e.target.value)} + className="pl-10 bg-slate-800/50 border-slate-600 text-white placeholder-slate-400 focus:border-blue-500" + placeholder="Entrez votre mot de passe" + required + /> +
+
+ + +
+ +
+

+ Accès sécurisé réservé aux administrateurs +

+
+
+
+
+ ); +} + +export default LoginPage; \ No newline at end of file diff --git a/src/tailwind.config.js b/src/tailwind.config.js new file mode 100644 index 0000000..45b741b --- /dev/null +++ b/src/tailwind.config.js @@ -0,0 +1,76 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + darkMode: ['class'], + content: [ + './pages/**/*.{js,jsx}', + './components/**/*.{js,jsx}', + './app/**/*.{js,jsx}', + './src/**/*.{js,jsx}', + ], + theme: { + container: { + center: true, + padding: '2rem', + screens: { + '2xl': '1400px', + }, + }, + extend: { + colors: { + border: 'hsl(var(--border))', + input: 'hsl(var(--input))', + ring: 'hsl(var(--ring))', + background: 'hsl(var(--background))', + foreground: 'hsl(var(--foreground))', + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))', + }, + secondary: { + DEFAULT: 'hsl(var(--secondary))', + foreground: 'hsl(var(--secondary-foreground))', + }, + destructive: { + DEFAULT: 'hsl(var(--destructive))', + foreground: 'hsl(var(--destructive-foreground))', + }, + muted: { + DEFAULT: 'hsl(var(--muted))', + foreground: 'hsl(var(--muted-foreground))', + }, + accent: { + DEFAULT: 'hsl(var(--accent))', + foreground: 'hsl(var(--accent-foreground))', + }, + popover: { + DEFAULT: 'hsl(var(--popover))', + foreground: 'hsl(var(--popover-foreground))', + }, + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))', + }, + }, + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)', + }, + keyframes: { + 'accordion-down': { + from: { height: 0 }, + to: { height: 'var(--radix-accordion-content-height)' }, + }, + 'accordion-up': { + from: { height: 'var(--radix-accordion-content-height)' }, + to: { height: 0 }, + }, + }, + animation: { + 'accordion-down': 'accordion-down 0.2s ease-out', + 'accordion-up': 'accordion-up 0.2s ease-out', + }, + }, + }, + plugins: [require('tailwindcss-animate')], +}; \ No newline at end of file diff --git a/src/tools/generate-llms.js b/src/tools/generate-llms.js new file mode 100644 index 0000000..da7f4f0 --- /dev/null +++ b/src/tools/generate-llms.js @@ -0,0 +1,181 @@ +#!/usr/bin/env node + +import fs from 'fs'; +import path from 'path'; + +const CLEAN_CONTENT_REGEX = { + comments: /\/\*[\s\S]*?\*\/|\/\/.*$/gm, + templateLiterals: /`[\s\S]*?`/g, + strings: /'[^']*'|"[^"]*"/g, + jsxExpressions: /\{.*?\}/g, + htmlEntities: { + quot: /"/g, + amp: /&/g, + lt: /</g, + gt: />/g, + apos: /'/g + } +}; + +const EXTRACTION_REGEX = { + route: /]*>/g, + path: /path=["']([^"']+)["']/, + element: /element=\{<(\w+)[^}]*\/?\s*>\}/, + helmet: /]*?>([\s\S]*?)<\/Helmet>/i, + helmetTest: //i, + title: /]*?>\s*(.*?)\s*<\/title>/i, + description: /') + .replace(CLEAN_CONTENT_REGEX.htmlEntities.apos, "'") + .trim(); +} + +function extractRoutes(appJsxPath) { + if (!fs.existsSync(appJsxPath)) return new Map(); + + try { + const content = fs.readFileSync(appJsxPath, 'utf8'); + const routes = new Map(); + const routeMatches = [...content.matchAll(EXTRACTION_REGEX.route)]; + + for (const match of routeMatches) { + const routeTag = match[0]; + const pathMatch = routeTag.match(EXTRACTION_REGEX.path); + const elementMatch = routeTag.match(EXTRACTION_REGEX.element); + const isIndex = routeTag.includes('index'); + + if (elementMatch) { + const componentName = elementMatch[1]; + let routePath; + + if (isIndex) { + routePath = '/'; + } else if (pathMatch) { + routePath = pathMatch[1].startsWith('/') ? pathMatch[1] : `/${pathMatch[1]}`; + } + + routes.set(componentName, routePath); + } + } + + return routes; + } catch (error) { + return new Map(); + } +} + +function findReactFiles(dir) { + return fs.readdirSync(dir).map(item => path.join(dir, item)); +} + +function extractHelmetData(content, filePath, routes) { + const cleanedContent = cleanContent(content); + + if (!EXTRACTION_REGEX.helmetTest.test(cleanedContent)) { + return null; + } + + const helmetMatch = content.match(EXTRACTION_REGEX.helmet); + if (!helmetMatch) return null; + + const helmetContent = helmetMatch[1]; + const titleMatch = helmetContent.match(EXTRACTION_REGEX.title); + const descMatch = helmetContent.match(EXTRACTION_REGEX.description); + + const title = cleanText(titleMatch?.[1]); + const description = cleanText(descMatch?.[1]); + + const fileName = path.basename(filePath, path.extname(filePath)); + const url = routes.length && routes.has(fileName) + ? routes.get(fileName) + : generateFallbackUrl(fileName); + + return { + url, + title: title || 'Untitled Page', + description: description || 'No description available' + }; +} + +function generateFallbackUrl(fileName) { + const cleanName = fileName.replace(/Page$/, '').toLowerCase(); + return cleanName === 'app' ? '/' : `/${cleanName}`; +} + +function generateLlmsTxt(pages) { + const sortedPages = pages.sort((a, b) => a.title.localeCompare(b.title)); + const pageEntries = sortedPages.map(page => + `- [${page.title}](${page.url}): ${page.description}` + ).join('\n'); + + return `## Pages\n${pageEntries}`; +} + +function ensureDirectoryExists(dirPath) { + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } +} + +function processPageFile(filePath, routes) { + try { + const content = fs.readFileSync(filePath, 'utf8'); + return extractHelmetData(content, filePath, routes); + } catch (error) { + console.error(`❌ Error processing ${filePath}:`, error.message); + return null; + } +} + +function main() { + const pagesDir = path.join(process.cwd(), 'src', 'pages'); + const appJsxPath = path.join(process.cwd(), 'src', 'App.jsx'); + + let pages = []; + + if (!fs.existsSync(pagesDir)) { + pages.push(processPageFile(appJsxPath, [])); + } else { + const routes = extractRoutes(appJsxPath); + const reactFiles = findReactFiles(pagesDir); + + pages = reactFiles + .map(filePath => processPageFile(filePath, routes)) + .filter(Boolean); + + if (pages.length === 0) { + console.error('❌ No pages with Helmet components found!'); + process.exit(1); + } + } + + + const llmsTxtContent = generateLlmsTxt(pages); + const outputPath = path.join(process.cwd(), 'public', 'llms.txt'); + + ensureDirectoryExists(path.dirname(outputPath)); + fs.writeFileSync(outputPath, llmsTxtContent, 'utf8'); +} + +const isMainModule = import.meta.url === `file://${process.argv[1]}`; + +if (isMainModule) { + main(); +} diff --git a/src/vite.config.js b/src/vite.config.js new file mode 100644 index 0000000..e6c8d2b --- /dev/null +++ b/src/vite.config.js @@ -0,0 +1,265 @@ +import path from 'node:path'; +import react from '@vitejs/plugin-react'; +import { createLogger, defineConfig } from 'vite'; +import inlineEditPlugin from './plugins/visual-editor/vite-plugin-react-inline-editor.js'; +import editModeDevPlugin from './plugins/visual-editor/vite-plugin-edit-mode.js'; +import iframeRouteRestorationPlugin from './plugins/vite-plugin-iframe-route-restoration.js'; + +const isDev = process.env.NODE_ENV !== 'production'; + +const configHorizonsViteErrorHandler = ` +const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + for (const addedNode of mutation.addedNodes) { + if ( + addedNode.nodeType === Node.ELEMENT_NODE && + ( + addedNode.tagName?.toLowerCase() === 'vite-error-overlay' || + addedNode.classList?.contains('backdrop') + ) + ) { + handleViteOverlay(addedNode); + } + } + } +}); + +observer.observe(document.documentElement, { + childList: true, + subtree: true +}); + +function handleViteOverlay(node) { + if (!node.shadowRoot) { + return; + } + + const backdrop = node.shadowRoot.querySelector('.backdrop'); + + if (backdrop) { + const overlayHtml = backdrop.outerHTML; + const parser = new DOMParser(); + const doc = parser.parseFromString(overlayHtml, 'text/html'); + const messageBodyElement = doc.querySelector('.message-body'); + const fileElement = doc.querySelector('.file'); + const messageText = messageBodyElement ? messageBodyElement.textContent.trim() : ''; + const fileText = fileElement ? fileElement.textContent.trim() : ''; + const error = messageText + (fileText ? ' File:' + fileText : ''); + + window.parent.postMessage({ + type: 'horizons-vite-error', + error, + }, '*'); + } +} +`; + +const configHorizonsRuntimeErrorHandler = ` +window.onerror = (message, source, lineno, colno, errorObj) => { + const errorDetails = errorObj ? JSON.stringify({ + name: errorObj.name, + message: errorObj.message, + stack: errorObj.stack, + source, + lineno, + colno, + }) : null; + + window.parent.postMessage({ + type: 'horizons-runtime-error', + message, + error: errorDetails + }, '*'); +}; +`; + +const configHorizonsConsoleErrroHandler = ` +const originalConsoleError = console.error; +console.error = function(...args) { + originalConsoleError.apply(console, args); + + let errorString = ''; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg instanceof Error) { + errorString = arg.stack || \`\${arg.name}: \${arg.message}\`; + break; + } + } + + if (!errorString) { + errorString = args.map(arg => typeof arg === 'object' ? JSON.stringify(arg) : String(arg)).join(' '); + } + + window.parent.postMessage({ + type: 'horizons-console-error', + error: errorString + }, '*'); +}; +`; + +const configWindowFetchMonkeyPatch = ` +const originalFetch = window.fetch; + +window.fetch = function(...args) { + const url = args[0] instanceof Request ? args[0].url : args[0]; + + // Skip WebSocket URLs + if (url.startsWith('ws:') || url.startsWith('wss:')) { + return originalFetch.apply(this, args); + } + + return originalFetch.apply(this, args) + .then(async response => { + const contentType = response.headers.get('Content-Type') || ''; + + // Exclude HTML document responses + const isDocumentResponse = + contentType.includes('text/html') || + contentType.includes('application/xhtml+xml'); + + if (!response.ok && !isDocumentResponse) { + const responseClone = response.clone(); + const errorFromRes = await responseClone.text(); + const requestUrl = response.url; + console.error(\`Fetch error from \${requestUrl}: \${errorFromRes}\`); + } + + return response; + }) + .catch(error => { + if (!url.match(/\.html?$/i)) { + console.error(error); + } + + throw error; + }); +}; +`; + +const configNavigationHandler = ` +if (window.navigation && window.self !== window.top) { + window.navigation.addEventListener('navigate', (event) => { + const url = event.destination.url; + + try { + const destinationUrl = new URL(url); + const destinationOrigin = destinationUrl.origin; + const currentOrigin = window.location.origin; + + if (destinationOrigin === currentOrigin) { + return; + } + } catch (error) { + return; + } + + window.parent.postMessage({ + type: 'horizons-navigation-error', + url, + }, '*'); + }); +} +`; + +const addTransformIndexHtml = { + name: 'add-transform-index-html', + transformIndexHtml(html) { + const tags = [ + { + tag: 'script', + attrs: { type: 'module' }, + children: configHorizonsRuntimeErrorHandler, + injectTo: 'head', + }, + { + tag: 'script', + attrs: { type: 'module' }, + children: configHorizonsViteErrorHandler, + injectTo: 'head', + }, + { + tag: 'script', + attrs: {type: 'module'}, + children: configHorizonsConsoleErrroHandler, + injectTo: 'head', + }, + { + tag: 'script', + attrs: { type: 'module' }, + children: configWindowFetchMonkeyPatch, + injectTo: 'head', + }, + { + tag: 'script', + attrs: { type: 'module' }, + children: configNavigationHandler, + injectTo: 'head', + }, + ]; + + if (!isDev && process.env.TEMPLATE_BANNER_SCRIPT_URL && process.env.TEMPLATE_REDIRECT_URL) { + tags.push( + { + tag: 'script', + attrs: { + src: process.env.TEMPLATE_BANNER_SCRIPT_URL, + 'template-redirect-url': process.env.TEMPLATE_REDIRECT_URL, + }, + injectTo: 'head', + } + ); + } + + return { + html, + tags, + }; + }, +}; + +console.warn = () => {}; + +const logger = createLogger() +const loggerError = logger.error + +logger.error = (msg, options) => { + if (options?.error?.toString().includes('CssSyntaxError: [postcss]')) { + return; + } + + loggerError(msg, options); +} + +export default defineConfig({ + customLogger: logger, + plugins: [ + ...(isDev ? [inlineEditPlugin(), editModeDevPlugin(), iframeRouteRestorationPlugin()] : []), + react(), + addTransformIndexHtml + ], + server: { + cors: true, + headers: { + 'Cross-Origin-Embedder-Policy': 'credentialless', + }, + allowedHosts: true, + }, + resolve: { + extensions: ['.jsx', '.js', '.tsx', '.ts', '.json', ], + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + build: { + rollupOptions: { + external: [ + '@babel/parser', + '@babel/traverse', + '@babel/generator', + '@babel/types' + ] + } + } +});