Initial commit
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
.idea
|
||||||
|
src/dist/
|
||||||
|
src/node_modules/
|
||||||
|
src/package-lock.json
|
||||||
1
Jenkinsfile
vendored
Normal file
1
Jenkinsfile
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
nodePipeline(name: 'dites-le-en-chanson-backoffice')
|
||||||
2
src/.env
Normal file
2
src/.env
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
VITE_SUPABASE_URL=https://supabase.abpcode.fr
|
||||||
|
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzY2Nzc3OTU5LCJleHAiOjE5MjQ0NTc5NTl9.I-qytVb1ef6QMR8IUDePJzESO3bJAnsGE075XQ2xiaI
|
||||||
1
src/.nvmrc
Normal file
1
src/.nvmrc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
20.19.1
|
||||||
1
src/.version
Normal file
1
src/.version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
11
|
||||||
15
src/index.html
Normal file
15
src/index.html
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/music-icon.svg" />
|
||||||
|
<meta name="generator" content="Hostinger Horizons" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Backoffice - Dites le en chanson</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
51
src/package.json
Normal file
51
src/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
315
src/plugins/visual-editor/edit-mode-script.js
Normal file
315
src/plugins/visual-editor/edit-mode-script.js
Normal file
@@ -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, '{')
|
||||||
|
.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();
|
||||||
|
}
|
||||||
|
});
|
||||||
137
src/plugins/visual-editor/visual-editor-config.js
Normal file
137
src/plugins/visual-editor/visual-editor-config.js
Normal file
@@ -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 `
|
||||||
|
<textarea></textarea>
|
||||||
|
<div class="button-container">
|
||||||
|
<button class="popup-button cancel-button">${cancelLabel}</button>
|
||||||
|
<button class="popup-button save-button">${saveLabel}</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
`;
|
||||||
32
src/plugins/visual-editor/vite-plugin-edit-mode.js
Normal file
32
src/plugins/visual-editor/vite-plugin-edit-mode.js
Normal file
@@ -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'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
384
src/plugins/visual-editor/vite-plugin-react-inline-editor.js
Normal file
384
src/plugins/visual-editor/vite-plugin-react-inline-editor.js
Normal file
@@ -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 <p>, <Button>)
|
||||||
|
if (nameNode.type === 'JSXIdentifier' && editableTagsList.includes(nameNode.name)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check 2: Property name of a member expression (for <motion.h1>, check if "h1" is in editableTagsList)
|
||||||
|
if (nameNode.type === 'JSXMemberExpression' && nameNode.property && nameNode.property.type === 'JSXIdentifier' && editableTagsList.includes(nameNode.property.name)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateImageSrc(openingNode) {
|
||||||
|
if (!openingNode || !openingNode.name || openingNode.name.name !== 'img') {
|
||||||
|
return { isValid: true, reason: null }; // Not an image, skip validation
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPropsSpread = openingNode.attributes.some(attr =>
|
||||||
|
t.isJSXSpreadAttribute(attr) &&
|
||||||
|
attr.argument &&
|
||||||
|
t.isIdentifier(attr.argument) &&
|
||||||
|
attr.argument.name === 'props'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasPropsSpread) {
|
||||||
|
return { isValid: false, reason: 'props-spread' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const srcAttr = openingNode.attributes.find(attr =>
|
||||||
|
t.isJSXAttribute(attr) &&
|
||||||
|
attr.name &&
|
||||||
|
attr.name.name === 'src'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!srcAttr) {
|
||||||
|
return { isValid: false, reason: 'missing-src' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!t.isStringLiteral(srcAttr.value)) {
|
||||||
|
return { isValid: false, reason: 'dynamic-src' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!srcAttr.value.value || srcAttr.value.value.trim() === '') {
|
||||||
|
return { isValid: false, reason: 'empty-src' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isValid: true, reason: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function inlineEditPlugin() {
|
||||||
|
return {
|
||||||
|
name: 'vite-inline-edit-plugin',
|
||||||
|
enforce: 'pre',
|
||||||
|
|
||||||
|
transform(code, id) {
|
||||||
|
if (!/\.(jsx|tsx)$/.test(id) || !id.startsWith(VITE_PROJECT_ROOT) || id.includes('node_modules')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const relativeFilePath = path.relative(VITE_PROJECT_ROOT, id);
|
||||||
|
const webRelativeFilePath = relativeFilePath.split(path.sep).join('/');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const babelAst = parse(code, {
|
||||||
|
sourceType: 'module',
|
||||||
|
plugins: ['jsx', 'typescript'],
|
||||||
|
errorRecovery: true
|
||||||
|
});
|
||||||
|
|
||||||
|
let attributesAdded = 0;
|
||||||
|
|
||||||
|
traverseBabel.default(babelAst, {
|
||||||
|
enter(path) {
|
||||||
|
if (path.isJSXOpeningElement()) {
|
||||||
|
const openingNode = path.node;
|
||||||
|
const elementNode = path.parentPath.node; // The JSXElement itself
|
||||||
|
|
||||||
|
if (!openingNode.loc) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const alreadyHasId = openingNode.attributes.some(
|
||||||
|
(attr) => t.isJSXAttribute(attr) && attr.name.name === 'data-edit-id'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (alreadyHasId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Condition 1: Is the current element tag type editable?
|
||||||
|
const isCurrentElementEditable = checkTagNameEditable(openingNode, EDITABLE_HTML_TAGS);
|
||||||
|
if (!isCurrentElementEditable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageValidation = validateImageSrc(openingNode);
|
||||||
|
if (!imageValidation.isValid) {
|
||||||
|
const disabledAttribute = t.jsxAttribute(
|
||||||
|
t.jsxIdentifier('data-edit-disabled'),
|
||||||
|
t.stringLiteral('true')
|
||||||
|
);
|
||||||
|
openingNode.attributes.push(disabledAttribute);
|
||||||
|
attributesAdded++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let shouldBeDisabledDueToChildren = false;
|
||||||
|
|
||||||
|
// Condition 2: Does the element have dynamic or editable children
|
||||||
|
if (t.isJSXElement(elementNode) && elementNode.children) {
|
||||||
|
// Check if element has {...props} spread attribute - disable editing if it does
|
||||||
|
const hasPropsSpread = openingNode.attributes.some(attr => t.isJSXSpreadAttribute(attr)
|
||||||
|
&& attr.argument
|
||||||
|
&& t.isIdentifier(attr.argument)
|
||||||
|
&& attr.argument.name === 'props'
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasDynamicChild = elementNode.children.some(child =>
|
||||||
|
t.isJSXExpressionContainer(child)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasDynamicChild || hasPropsSpread) {
|
||||||
|
shouldBeDisabledDueToChildren = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shouldBeDisabledDueToChildren && t.isJSXElement(elementNode) && elementNode.children) {
|
||||||
|
const hasEditableJsxChild = elementNode.children.some(child => {
|
||||||
|
if (t.isJSXElement(child)) {
|
||||||
|
return checkTagNameEditable(child.openingElement, EDITABLE_HTML_TAGS);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hasEditableJsxChild) {
|
||||||
|
shouldBeDisabledDueToChildren = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldBeDisabledDueToChildren) {
|
||||||
|
const disabledAttribute = t.jsxAttribute(
|
||||||
|
t.jsxIdentifier('data-edit-disabled'),
|
||||||
|
t.stringLiteral('true')
|
||||||
|
);
|
||||||
|
|
||||||
|
openingNode.attributes.push(disabledAttribute);
|
||||||
|
attributesAdded++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Condition 3: Parent is non-editable if AT LEAST ONE child JSXElement is a non-editable type.
|
||||||
|
if (t.isJSXElement(elementNode) && elementNode.children && elementNode.children.length > 0) {
|
||||||
|
let hasNonEditableJsxChild = false;
|
||||||
|
for (const child of elementNode.children) {
|
||||||
|
if (t.isJSXElement(child)) {
|
||||||
|
if (!checkTagNameEditable(child.openingElement, EDITABLE_HTML_TAGS)) {
|
||||||
|
hasNonEditableJsxChild = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hasNonEditableJsxChild) {
|
||||||
|
const disabledAttribute = t.jsxAttribute(
|
||||||
|
t.jsxIdentifier('data-edit-disabled'),
|
||||||
|
t.stringLiteral("true")
|
||||||
|
);
|
||||||
|
openingNode.attributes.push(disabledAttribute);
|
||||||
|
attributesAdded++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Condition 4: Is any ancestor JSXElement also editable?
|
||||||
|
let currentAncestorCandidatePath = path.parentPath.parentPath;
|
||||||
|
while (currentAncestorCandidatePath) {
|
||||||
|
const ancestorJsxElementPath = currentAncestorCandidatePath.isJSXElement()
|
||||||
|
? currentAncestorCandidatePath
|
||||||
|
: currentAncestorCandidatePath.findParent(p => p.isJSXElement());
|
||||||
|
|
||||||
|
if (!ancestorJsxElementPath) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checkTagNameEditable(ancestorJsxElementPath.node.openingElement, EDITABLE_HTML_TAGS)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentAncestorCandidatePath = ancestorJsxElementPath.parentPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const line = openingNode.loc.start.line;
|
||||||
|
const column = openingNode.loc.start.column + 1;
|
||||||
|
const editId = `${webRelativeFilePath}:${line}:${column}`;
|
||||||
|
|
||||||
|
const idAttribute = t.jsxAttribute(
|
||||||
|
t.jsxIdentifier('data-edit-id'),
|
||||||
|
t.stringLiteral(editId)
|
||||||
|
);
|
||||||
|
|
||||||
|
openingNode.attributes.push(idAttribute);
|
||||||
|
attributesAdded++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (attributesAdded > 0) {
|
||||||
|
const generateFunction = generate.default || generate;
|
||||||
|
const output = generateFunction(babelAst, {
|
||||||
|
sourceMaps: true,
|
||||||
|
sourceFileName: webRelativeFilePath
|
||||||
|
}, code);
|
||||||
|
|
||||||
|
return { code: output.code, map: output.map };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[vite][visual-editor] Error transforming ${id}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
// Updates source code based on the changes received from the client
|
||||||
|
configureServer(server) {
|
||||||
|
server.middlewares.use('/api/apply-edit', async (req, res, next) => {
|
||||||
|
if (req.method !== 'POST') return next();
|
||||||
|
|
||||||
|
let body = '';
|
||||||
|
req.on('data', chunk => { body += chunk.toString(); });
|
||||||
|
|
||||||
|
req.on('end', async () => {
|
||||||
|
let absoluteFilePath = '';
|
||||||
|
try {
|
||||||
|
const { editId, newFullText } = JSON.parse(body);
|
||||||
|
|
||||||
|
if (!editId || typeof newFullText === 'undefined') {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
return res.end(JSON.stringify({ error: 'Missing editId or newFullText' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedId = parseEditId(editId);
|
||||||
|
if (!parsedId) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
return res.end(JSON.stringify({ error: 'Invalid editId format (filePath:line:column)' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { filePath, line, column } = parsedId;
|
||||||
|
|
||||||
|
absoluteFilePath = path.resolve(VITE_PROJECT_ROOT, filePath);
|
||||||
|
if (filePath.includes('..') || !absoluteFilePath.startsWith(VITE_PROJECT_ROOT) || absoluteFilePath.includes('node_modules')) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
return res.end(JSON.stringify({ error: 'Invalid path' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalContent = fs.readFileSync(absoluteFilePath, 'utf-8');
|
||||||
|
|
||||||
|
const babelAst = parse(originalContent, {
|
||||||
|
sourceType: 'module',
|
||||||
|
plugins: ['jsx', 'typescript'],
|
||||||
|
errorRecovery: true
|
||||||
|
});
|
||||||
|
|
||||||
|
let targetNodePath = null;
|
||||||
|
const visitor = {
|
||||||
|
JSXOpeningElement(path) {
|
||||||
|
const node = path.node;
|
||||||
|
if (node.loc && node.loc.start.line === line && node.loc.start.column + 1 === column) {
|
||||||
|
targetNodePath = path;
|
||||||
|
path.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
traverseBabel.default(babelAst, visitor);
|
||||||
|
|
||||||
|
if (!targetNodePath) {
|
||||||
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||||
|
return res.end(JSON.stringify({ error: 'Target node not found by line/column', editId }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateFunction = generate.default || generate;
|
||||||
|
const targetOpeningElement = targetNodePath.node;
|
||||||
|
const parentElementNode = targetNodePath.parentPath?.node;
|
||||||
|
|
||||||
|
const isImageElement = targetOpeningElement.name && targetOpeningElement.name.name === 'img';
|
||||||
|
|
||||||
|
let beforeCode = '';
|
||||||
|
let afterCode = '';
|
||||||
|
let modified = false;
|
||||||
|
|
||||||
|
if (isImageElement) {
|
||||||
|
// Handle image src attribute update
|
||||||
|
const beforeOutput = generateFunction(targetOpeningElement, {});
|
||||||
|
beforeCode = beforeOutput.code;
|
||||||
|
|
||||||
|
const srcAttr = targetOpeningElement.attributes.find(attr =>
|
||||||
|
t.isJSXAttribute(attr) && attr.name && attr.name.name === 'src'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (srcAttr && t.isStringLiteral(srcAttr.value)) {
|
||||||
|
srcAttr.value = t.stringLiteral(newFullText);
|
||||||
|
modified = true;
|
||||||
|
|
||||||
|
const afterOutput = generateFunction(targetOpeningElement, {});
|
||||||
|
afterCode = afterOutput.code;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (parentElementNode && t.isJSXElement(parentElementNode)) {
|
||||||
|
const beforeOutput = generateFunction(parentElementNode, {});
|
||||||
|
beforeCode = beforeOutput.code;
|
||||||
|
|
||||||
|
parentElementNode.children = [];
|
||||||
|
if (newFullText && newFullText.trim() !== '') {
|
||||||
|
const newTextNode = t.jsxText(newFullText);
|
||||||
|
parentElementNode.children.push(newTextNode);
|
||||||
|
}
|
||||||
|
modified = true;
|
||||||
|
|
||||||
|
const afterOutput = generateFunction(parentElementNode, {});
|
||||||
|
afterCode = afterOutput.code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!modified) {
|
||||||
|
res.writeHead(409, { 'Content-Type': 'application/json' });
|
||||||
|
return res.end(JSON.stringify({ error: 'Could not apply changes to AST.' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = generateFunction(babelAst, {});
|
||||||
|
const newContent = output.code;
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
newFileContent: newContent,
|
||||||
|
beforeCode,
|
||||||
|
afterCode,
|
||||||
|
}));
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'Internal server error during edit application.' }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
125
src/plugins/vite-plugin-iframe-route-restoration.js
Normal file
125
src/plugins/vite-plugin-iframe-route-restoration.js
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
export default function iframeRouteRestorationPlugin() {
|
||||||
|
return {
|
||||||
|
name: 'vite:iframe-route-restoration',
|
||||||
|
apply: 'serve',
|
||||||
|
transformIndexHtml() {
|
||||||
|
const script = `
|
||||||
|
const ALLOWED_PARENT_ORIGINS = [
|
||||||
|
"https://horizons.hostinger.com",
|
||||||
|
"https://horizons.hostinger.dev",
|
||||||
|
"https://horizons-frontend-local.hostinger.dev",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Check to see if the page is in an iframe
|
||||||
|
if (window.self !== window.top) {
|
||||||
|
const STORAGE_KEY = 'horizons-iframe-saved-route';
|
||||||
|
|
||||||
|
const getCurrentRoute = () => location.pathname + location.search + location.hash;
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
try {
|
||||||
|
const currentRoute = getCurrentRoute();
|
||||||
|
sessionStorage.setItem(STORAGE_KEY, currentRoute);
|
||||||
|
window.parent.postMessage({message: 'route-changed', route: currentRoute}, '*');
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const replaceHistoryState = (url) => {
|
||||||
|
try {
|
||||||
|
history.replaceState(null, '', url);
|
||||||
|
window.dispatchEvent(new PopStateEvent('popstate', { state: history.state }));
|
||||||
|
return true;
|
||||||
|
} catch {}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const restore = () => {
|
||||||
|
try {
|
||||||
|
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!saved) return;
|
||||||
|
|
||||||
|
if (!saved.startsWith('/')) {
|
||||||
|
sessionStorage.removeItem(STORAGE_KEY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = getCurrentRoute();
|
||||||
|
if (current !== saved) {
|
||||||
|
if (!replaceHistoryState(saved)) {
|
||||||
|
replaceHistoryState('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(() => setTimeout(() => {
|
||||||
|
try {
|
||||||
|
const text = (document.body?.innerText || '').trim();
|
||||||
|
|
||||||
|
// If the restored route results in too little content, assume it is invalid and navigate home
|
||||||
|
if (text.length < 50) {
|
||||||
|
replaceHistoryState('/');
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}, 1000));
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const originalPushState = history.pushState;
|
||||||
|
history.pushState = function(...args) {
|
||||||
|
originalPushState.apply(this, args);
|
||||||
|
save();
|
||||||
|
};
|
||||||
|
|
||||||
|
const originalReplaceState = history.replaceState;
|
||||||
|
history.replaceState = function(...args) {
|
||||||
|
originalReplaceState.apply(this, args);
|
||||||
|
save();
|
||||||
|
};
|
||||||
|
|
||||||
|
const 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('popstate', save);
|
||||||
|
window.addEventListener('hashchange', save);
|
||||||
|
window.addEventListener("message", function (event) {
|
||||||
|
const parentOrigin = getParentOrigin();
|
||||||
|
|
||||||
|
if (event.data?.type === "redirect-home" && parentOrigin && ALLOWED_PARENT_ORIGINS.includes(parentOrigin)) {
|
||||||
|
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||||
|
|
||||||
|
if(saved && saved !== '/') {
|
||||||
|
replaceHistoryState('/')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: 'script',
|
||||||
|
attrs: { type: 'module' },
|
||||||
|
children: script,
|
||||||
|
injectTo: 'head'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
6
src/postcss.config.js
Normal file
6
src/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
19
src/public/.htaccess
Normal file
19
src/public/.htaccess
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<IfModule mod_rewrite.c>
|
||||||
|
RewriteEngine On
|
||||||
|
RewriteBase /
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
|
RewriteRule ^ index.html [L]
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
<IfModule mod_headers.c>
|
||||||
|
Header set X-Powered-By "Hostinger Horizons"
|
||||||
|
|
||||||
|
# Cache everything on CDN by default
|
||||||
|
Header set Cache-Control "public, s-maxage=604800, max-age=0"
|
||||||
|
|
||||||
|
# Cache in browser all assets
|
||||||
|
<If "%{REQUEST_URI} =~ m#^/assets/.*$#">
|
||||||
|
Header set Cache-Control "public, max-age=604800"
|
||||||
|
</If>
|
||||||
|
</IfModule>
|
||||||
5
src/public/music-icon.svg
Normal file
5
src/public/music-icon.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-music">
|
||||||
|
<path d="M9 18V5l12-2v13"/>
|
||||||
|
<circle cx="6" cy="18" r="3"/>
|
||||||
|
<circle cx="18" cy="16" r="3"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 313 B |
38
src/src/App.jsx
Normal file
38
src/src/App.jsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { Toaster } from '@/components/ui/toaster';
|
||||||
|
import LoginPage from '@/pages/LoginPage';
|
||||||
|
import DashboardPage from '@/pages/DashboardPage';
|
||||||
|
import { AuthProvider, useAuth } from '@/contexts/AuthContext';
|
||||||
|
|
||||||
|
function ProtectedRoute({ children }) {
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
|
|
||||||
|
return isAuthenticated ? children : <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<Router>
|
||||||
|
<div className="min-h-screen bg-slate-900">
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route
|
||||||
|
path="/dashboard"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<DashboardPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
|
</Routes>
|
||||||
|
<Toaster />
|
||||||
|
</div>
|
||||||
|
</Router>
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
17
src/src/components/CallToAction.jsx
Normal file
17
src/src/components/CallToAction.jsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
|
const CallToAction = () => {
|
||||||
|
return (
|
||||||
|
<motion.p
|
||||||
|
className='text-md text-white max-w-lg mx-auto'
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.8 }}
|
||||||
|
>
|
||||||
|
Let's turn your ideas into reality.
|
||||||
|
</motion.p>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CallToAction;
|
||||||
14
src/src/components/HeroImage.jsx
Normal file
14
src/src/components/HeroImage.jsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const HeroImage = () => {
|
||||||
|
return (
|
||||||
|
<div className='flex justify-center items-center'>
|
||||||
|
<img
|
||||||
|
src='https://imagedelivery.net/LqiWLm-3MGbYHtFuUbcBtA/119580eb-abd9-4191-b93a-f01938786700/public'
|
||||||
|
alt='Hostinger Horizons'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HeroImage;
|
||||||
18
src/src/components/WelcomeMessage.jsx
Normal file
18
src/src/components/WelcomeMessage.jsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
|
const WelcomeMessage = () => {
|
||||||
|
return (
|
||||||
|
<motion.p
|
||||||
|
className='text-xl md:text-2xl text-white max-w-2xl mx-auto'
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.5 }}
|
||||||
|
>
|
||||||
|
Hello there! I'm <span className='font-semibold text-purple-300'>Horizons</span>, your AI coding companion.
|
||||||
|
I'm here to help you build amazing web application!
|
||||||
|
</motion.p>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WelcomeMessage;
|
||||||
39
src/src/components/dashboard/MetricCard.jsx
Normal file
39
src/src/components/dashboard/MetricCard.jsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export function MetricCard({ title, value, revenue, icon, onClick, isActive, isLoading, statusColor }) {
|
||||||
|
const baseClasses = "metric-card p-5 rounded-xl shadow-lg transition-all duration-300 ease-in-out cursor-pointer border-2";
|
||||||
|
const activeClasses =
|
||||||
|
statusColor === 'yellow' ? "border-yellow-500 bg-yellow-500/10 hover:bg-yellow-500/20" :
|
||||||
|
statusColor === 'blue' ? "border-blue-500 bg-blue-500/10 hover:bg-blue-500/20" :
|
||||||
|
statusColor === 'green' ? "border-green-500 bg-green-500/10 hover:bg-green-500/20" :
|
||||||
|
"border-blue-500 bg-blue-500/10 hover:bg-blue-500/20"; // Default active for "Total"
|
||||||
|
|
||||||
|
const inactiveClasses = "border-slate-700/50 bg-slate-800/30 hover:bg-slate-700/50";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.1 }}
|
||||||
|
className={cn(baseClasses, isActive ? activeClasses : inactiveClasses)}
|
||||||
|
onClick={onClick}
|
||||||
|
whileHover={{ y: -5 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-slate-400 text-sm">{title}</p>
|
||||||
|
<p className={`text-2xl font-bold ${isActive && statusColor ? `text-${statusColor}-400` : 'text-white'}`}>
|
||||||
|
{isLoading ? '...' : value}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-300 mt-2">
|
||||||
|
{isLoading ? '...' : revenue}
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
src/src/components/dashboard/OrderCard.jsx
Normal file
111
src/src/components/dashboard/OrderCard.jsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Music, CheckCircle, Clock, PlayCircle, Send, RefreshCw } from 'lucide-react';
|
||||||
|
import { OrderDetailItem } from '@/components/dashboard/OrderDetailItem';
|
||||||
|
|
||||||
|
export function OrderCard({ order, index, onStatusChange, isUpdating, isLoadingGlobal, statusPending, statusProcessing, statusCompleted }) {
|
||||||
|
|
||||||
|
const getStatusColor = (status) => {
|
||||||
|
if (status === statusPending) return "status-preparation";
|
||||||
|
if (status === statusProcessing) return "status-created";
|
||||||
|
if (status === statusCompleted) return "status-completed";
|
||||||
|
return "status-preparation";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusIcon = (status) => {
|
||||||
|
if (status === statusPending) return <Clock className="w-4 h-4" />;
|
||||||
|
if (status === statusProcessing) return <Music className="w-4 h-4" />;
|
||||||
|
if (status === statusCompleted) return <CheckCircle className="w-4 h-4" />;
|
||||||
|
return <Clock className="w-4 h-4" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCurrency = (amount) => {
|
||||||
|
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderActionButton = () => {
|
||||||
|
if (order.status === statusPending) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
onClick={() => onStatusChange(order.id, statusProcessing)}
|
||||||
|
disabled={isUpdating || isLoadingGlobal}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 text-white w-full sm:w-auto"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{isUpdating ? <RefreshCw className="w-4 h-4 mr-2 animate-spin" /> : <PlayCircle className="w-4 h-4 mr-2" />}
|
||||||
|
Commencer le traitement
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (order.status === statusProcessing) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
onClick={() => onStatusChange(order.id, statusCompleted)}
|
||||||
|
disabled={isUpdating || isLoadingGlobal}
|
||||||
|
className="bg-green-600 hover:bg-green-700 text-white w-full sm:w-auto"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{isUpdating ? <RefreshCw className="w-4 h-4 mr-2 animate-spin" /> : <Send className="w-4 h-4 mr-2" />}
|
||||||
|
Terminer le traitement
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={order.id}
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: 0.05 * index, duration: 0.3 }}
|
||||||
|
className="order-card p-4 sm:p-6 rounded-lg bg-slate-800 border border-slate-700 shadow-md hover:shadow-slate-600/50 transition-shadow"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col sm:flex-row justify-between items-start mb-4">
|
||||||
|
<div className="flex-grow min-w-0">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center mb-1 sm:mb-0">
|
||||||
|
<span className="text-lg font-semibold text-white block overflow-hidden text-ellipsis whitespace-nowrap mr-0 sm:mr-2" title={`ID: ${order.id}`}>
|
||||||
|
Commande #{order.id.substring(0,8)}...
|
||||||
|
</span>
|
||||||
|
<span className={`status-badge ${getStatusColor(order.status)} flex items-center gap-1 w-fit mt-1 sm:mt-0`}>
|
||||||
|
{getStatusIcon(order.status)}
|
||||||
|
{order.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 sm:mt-0 sm:ml-4 flex-shrink-0 w-full sm:w-auto">
|
||||||
|
{renderActionButton()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-2 text-sm">
|
||||||
|
<OrderDetailItem label="ID Commande" value={order.id} isId={true} />
|
||||||
|
<OrderDetailItem label="Date" value={new Date(order.created_at).toLocaleString('fr-FR')} />
|
||||||
|
<OrderDetailItem label="Client" value={order.customer_email} />
|
||||||
|
<OrderDetailItem label="Prix" value={formatCurrency(Number(order.price))} />
|
||||||
|
|
||||||
|
<OrderDetailItem label="Nom du destinataire" value={order.recipient_name} />
|
||||||
|
<OrderDetailItem label="Chanson pour" value={order.song_for_whom} />
|
||||||
|
<OrderDetailItem label="Occasion" value={order.occasion} />
|
||||||
|
|
||||||
|
<OrderDetailItem label="Langue" value={order.language} />
|
||||||
|
<OrderDetailItem label="Voix" value={order.voice_gender} />
|
||||||
|
<OrderDetailItem label="Style musical" value={order.musical_style} />
|
||||||
|
|
||||||
|
<OrderDetailItem label="Ambiance" value={order.mood} />
|
||||||
|
<OrderDetailItem label="Nom produit" value={order.product_name} />
|
||||||
|
<OrderDetailItem label="ID Session Stripe" value={order.session_id ? order.session_id : 'N/A'} isId={true} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ (order.anecdote1 || order.anecdote2 || order.anecdote3) &&
|
||||||
|
<div className="mt-4 pt-3 border-t border-slate-700/50">
|
||||||
|
<h4 className="font-semibold text-slate-200 mb-1">Anecdotes :</h4>
|
||||||
|
{order.anecdote1 && <p className="text-xs text-slate-400 mb-1">- {order.anecdote1}</p>}
|
||||||
|
{order.anecdote2 && <p className="text-xs text-slate-400 mb-1">- {order.anecdote2}</p>}
|
||||||
|
{order.anecdote3 && <p className="text-xs text-slate-400">- {order.anecdote3}</p>}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
src/src/components/dashboard/OrderDetailItem.jsx
Normal file
16
src/src/components/dashboard/OrderDetailItem.jsx
Normal file
@@ -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 (
|
||||||
|
<div className="py-1">
|
||||||
|
<span className="font-semibold text-slate-300">{label}: </span>
|
||||||
|
{isId ? (
|
||||||
|
<span className="text-slate-400 break-all" title={value}>{value}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-400">{value}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
src/src/components/dashboard/index.js
Normal file
3
src/src/components/dashboard/index.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './MetricCard';
|
||||||
|
export * from './OrderCard';
|
||||||
|
export * from './OrderDetailItem';
|
||||||
47
src/src/components/ui/button.jsx
Normal file
47
src/src/components/ui/button.jsx
Normal file
@@ -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 (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
Button.displayName = 'Button';
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
19
src/src/components/ui/input.jsx
Normal file
19
src/src/components/ui/input.jsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const Input = React.forwardRef(({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
Input.displayName = 'Input';
|
||||||
|
|
||||||
|
export { Input };
|
||||||
19
src/src/components/ui/label.jsx
Normal file
19
src/src/components/ui/label.jsx
Normal file
@@ -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) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(labelVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Label };
|
||||||
135
src/src/components/ui/select.jsx
Normal file
135
src/src/components/ui/select.jsx
Normal file
@@ -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) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
));
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
|
const SelectScrollUpButton = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex cursor-default items-center justify-center py-1',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
));
|
||||||
|
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||||
|
|
||||||
|
const SelectScrollDownButton = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex cursor-default items-center justify-center py-1',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
));
|
||||||
|
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
|
||||||
|
|
||||||
|
const SelectContent = React.forwardRef(({ className, children, position = 'popper', ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-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',
|
||||||
|
position === 'popper' &&
|
||||||
|
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
'p-1',
|
||||||
|
position === 'popper' &&
|
||||||
|
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
));
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const SelectLabel = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||||
|
|
||||||
|
const SelectItem = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
));
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
const SelectSeparator = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectGroup,
|
||||||
|
SelectValue,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectContent,
|
||||||
|
SelectLabel,
|
||||||
|
SelectItem,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
};
|
||||||
101
src/src/components/ui/toast.jsx
Normal file
101
src/src/components/ui/toast.jsx
Normal file
@@ -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) => (
|
||||||
|
<ToastPrimitives.Viewport
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
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 (
|
||||||
|
<ToastPrimitives.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(toastVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
Toast.displayName = ToastPrimitives.Root.displayName;
|
||||||
|
|
||||||
|
const ToastAction = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Action
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-destructive/30 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ToastAction.displayName = ToastPrimitives.Action.displayName;
|
||||||
|
|
||||||
|
const ToastClose = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Close
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
toast-close=""
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</ToastPrimitives.Close>
|
||||||
|
));
|
||||||
|
ToastClose.displayName = ToastPrimitives.Close.displayName;
|
||||||
|
|
||||||
|
const ToastTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm font-semibold', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ToastTitle.displayName = ToastPrimitives.Title.displayName;
|
||||||
|
|
||||||
|
const ToastDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm opacity-90', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ToastDescription.displayName = ToastPrimitives.Description.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Toast,
|
||||||
|
ToastAction,
|
||||||
|
ToastClose,
|
||||||
|
ToastDescription,
|
||||||
|
ToastProvider,
|
||||||
|
ToastTitle,
|
||||||
|
ToastViewport,
|
||||||
|
};
|
||||||
34
src/src/components/ui/toaster.jsx
Normal file
34
src/src/components/ui/toaster.jsx
Normal file
@@ -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 (
|
||||||
|
<ToastProvider>
|
||||||
|
{toasts.map(({ id, title, description, action, ...props }) => {
|
||||||
|
return (
|
||||||
|
<Toast key={id} {...props}>
|
||||||
|
<div className="grid gap-1">
|
||||||
|
{title && <ToastTitle>{title}</ToastTitle>}
|
||||||
|
{description && (
|
||||||
|
<ToastDescription>{description}</ToastDescription>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{action}
|
||||||
|
<ToastClose />
|
||||||
|
</Toast>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<ToastViewport />
|
||||||
|
</ToastProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
src/src/components/ui/use-toast.js
Normal file
103
src/src/components/ui/use-toast.js
Normal file
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
74
src/src/contexts/AuthContext.jsx
Normal file
74
src/src/contexts/AuthContext.jsx
Normal file
@@ -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 (
|
||||||
|
<AuthContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
src/src/index.css
Normal file
95
src/src/index.css
Normal file
@@ -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); }
|
||||||
|
}
|
||||||
6
src/src/lib/supabaseClient.js
Normal file
6
src/src/lib/supabaseClient.js
Normal file
@@ -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);
|
||||||
6
src/src/lib/utils.js
Normal file
6
src/src/lib/utils.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
10
src/src/main.jsx
Normal file
10
src/src/main.jsx
Normal file
@@ -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(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
366
src/src/pages/DashboardPage.jsx
Normal file
366
src/src/pages/DashboardPage.jsx
Normal file
@@ -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 (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-slate-100">
|
||||||
|
<header className="border-b border-slate-700/50 bg-slate-800/30 backdrop-blur-sm sticky top-0 z-50">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex justify-between items-center h-16">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-purple-600 rounded-lg flex items-center justify-center">
|
||||||
|
<Music className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-white">Backoffice Admin</h1>
|
||||||
|
<p className="text-sm text-slate-400">Dites-le en Chanson</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={logout}
|
||||||
|
variant="outline"
|
||||||
|
className="border-slate-600 text-slate-300 hover:bg-slate-700 hover:text-white"
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4 mr-2" />
|
||||||
|
Déconnexion
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||||
|
<MetricCard
|
||||||
|
title="Total Commandes"
|
||||||
|
value={metrics.total}
|
||||||
|
revenue={formatCurrency(metrics.totalRevenue)}
|
||||||
|
icon={<Users className="w-8 h-8 text-blue-400" />}
|
||||||
|
onClick={() => handleFilterClick(null)}
|
||||||
|
isActive={activeFilter === null}
|
||||||
|
isLoading={isLoading && metrics.total === 0}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
title="En Attente"
|
||||||
|
value={metrics.pending}
|
||||||
|
revenue={formatCurrency(metrics.pendingRevenue)}
|
||||||
|
icon={<Clock className="w-8 h-8 text-yellow-400" />}
|
||||||
|
onClick={() => handleFilterClick('pending')}
|
||||||
|
isActive={activeFilter === 'pending'}
|
||||||
|
isLoading={isLoading && metrics.pending === 0}
|
||||||
|
statusColor="yellow"
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
title="En Traitement"
|
||||||
|
value={metrics.processing}
|
||||||
|
revenue={formatCurrency(metrics.processingRevenue)}
|
||||||
|
icon={<Music className="w-8 h-8 text-blue-400" />}
|
||||||
|
onClick={() => handleFilterClick('processing')}
|
||||||
|
isActive={activeFilter === 'processing'}
|
||||||
|
isLoading={isLoading && metrics.processing === 0}
|
||||||
|
statusColor="blue"
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
title="Traitées"
|
||||||
|
value={metrics.completed}
|
||||||
|
revenue={formatCurrency(metrics.completedRevenue)}
|
||||||
|
icon={<CheckCircle className="w-8 h-8 text-green-400" />}
|
||||||
|
onClick={() => handleFilterClick('completed')}
|
||||||
|
isActive={activeFilter === 'completed'}
|
||||||
|
isLoading={isLoading && metrics.completed === 0}
|
||||||
|
statusColor="green"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.5 }}
|
||||||
|
className="bg-slate-800/50 backdrop-blur-md border border-slate-700/60 rounded-xl p-4 sm:p-6 shadow-2xl"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col sm:flex-row items-center justify-between mb-6">
|
||||||
|
<h2 className="text-xl font-bold text-white flex items-center gap-2 mb-4 sm:mb-0">
|
||||||
|
<Package className="w-5 h-5" />
|
||||||
|
{activeFilter ? `Commandes: ${statusMapping[activeFilter]}` : 'Toutes les Commandes'}
|
||||||
|
{activeFilter && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => handleFilterClick(null)} className="ml-2 text-slate-400 hover:text-white">
|
||||||
|
<Filter className="w-3 h-3 mr-1" /> Voir tout
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
<Button
|
||||||
|
onClick={() => { fetchOrdersAndMetrics(); fetchPaginatedOrders();}}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="border-slate-600 text-slate-300 hover:bg-slate-700 hover:text-white"
|
||||||
|
disabled={isLoading || isUpdating !== null}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 mr-2 ${(isLoading || isUpdating !== null) ? 'animate-spin' : ''}`} />
|
||||||
|
Actualiser
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading && orders.length === 0 ? (
|
||||||
|
<div className="flex justify-center items-center h-64">
|
||||||
|
<motion.div
|
||||||
|
animate={{ rotate: 360 }}
|
||||||
|
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
||||||
|
className="w-12 h-12 border-4 border-blue-500 border-t-transparent rounded-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : fetchError ? (
|
||||||
|
<div className="text-center py-10 text-red-400 bg-red-900/20 rounded-lg border border-red-700/50 p-6">
|
||||||
|
<AlertTriangle className="w-12 h-12 mx-auto mb-4" />
|
||||||
|
<p className="text-xl font-semibold">Erreur de chargement des commandes</p>
|
||||||
|
<p className="text-sm">{fetchError}</p>
|
||||||
|
<Button onClick={() => { fetchOrdersAndMetrics(); fetchPaginatedOrders();}} className="mt-4 bg-red-600 hover:bg-red-700 text-white" disabled={isLoading}>
|
||||||
|
{isLoading ? <RefreshCw className="w-4 h-4 mr-2 animate-spin" /> : null}
|
||||||
|
Réessayer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : !isLoading && orders.length === 0 ? (
|
||||||
|
<div className="text-center py-10 text-slate-400">
|
||||||
|
<Package className="w-12 h-12 mx-auto mb-4 text-slate-500" />
|
||||||
|
<p className="text-xl font-semibold">Aucune commande {activeFilter ? `avec le statut "${statusMapping[activeFilter]}"` : 'pour le moment'}</p>
|
||||||
|
<p className="text-sm">Les nouvelles commandes apparaîtront ici.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{orders.map((order, index) => (
|
||||||
|
<OrderCard
|
||||||
|
key={order.id}
|
||||||
|
order={order}
|
||||||
|
index={index}
|
||||||
|
onStatusChange={handleStatusChange}
|
||||||
|
isUpdating={isUpdating === order.id}
|
||||||
|
isLoadingGlobal={isLoading}
|
||||||
|
statusPending={STATUS_PENDING}
|
||||||
|
statusProcessing={STATUS_PROCESSING}
|
||||||
|
statusCompleted={STATUS_COMPLETED}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="mt-8 flex justify-between items-center">
|
||||||
|
<Button
|
||||||
|
onClick={() => handlePageChange(currentPage - 1)}
|
||||||
|
disabled={currentPage === 1 || isLoading}
|
||||||
|
variant="outline"
|
||||||
|
className="border-slate-600 text-slate-300 hover:bg-slate-700 hover:text-white"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4 mr-2" />
|
||||||
|
Précédent
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm text-slate-400">
|
||||||
|
Page {currentPage} sur {totalPages} ({totalOrders} commandes)
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
onClick={() => handlePageChange(currentPage + 1)}
|
||||||
|
disabled={currentPage === totalPages || isLoading}
|
||||||
|
variant="outline"
|
||||||
|
className="border-slate-600 text-slate-300 hover:bg-slate-700 hover:text-white"
|
||||||
|
>
|
||||||
|
Suivant
|
||||||
|
<ChevronRight className="w-4 h-4 ml-2" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DashboardPage;
|
||||||
136
src/src/pages/LoginPage.jsx
Normal file
136
src/src/pages/LoginPage.jsx
Normal file
@@ -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 (
|
||||||
|
<div className="login-container flex items-center justify-center p-4 min-h-screen">
|
||||||
|
<motion.div
|
||||||
|
animate={{ rotate: 360 }}
|
||||||
|
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
||||||
|
className="w-12 h-12 border-4 border-blue-500 border-t-transparent rounded-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAuthenticated) {
|
||||||
|
return <Navigate to="/dashboard" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="login-container flex items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
className="glass-effect rounded-2xl p-8 shadow-2xl"
|
||||||
|
>
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<motion.div
|
||||||
|
className="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full mb-4 floating-animation"
|
||||||
|
whileHover={{ scale: 1.1 }}
|
||||||
|
>
|
||||||
|
<Music className="w-8 h-8 text-white" />
|
||||||
|
</motion.div>
|
||||||
|
<h1 className="text-2xl font-bold text-white mb-2">
|
||||||
|
Backoffice Admin
|
||||||
|
</h1>
|
||||||
|
<p className="text-slate-300 flex items-center justify-center gap-2">
|
||||||
|
<Sparkles className="w-4 h-4" />
|
||||||
|
Dites-le en Chanson
|
||||||
|
<Sparkles className="w-4 h-4" />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password" className="text-slate-200">
|
||||||
|
Mot de passe administrateur
|
||||||
|
</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => 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
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="w-full bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white font-medium py-3 pulse-glow"
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<motion.div
|
||||||
|
animate={{ rotate: 360 }}
|
||||||
|
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
||||||
|
className="w-5 h-5 border-2 border-white border-t-transparent rounded-full"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
"Se connecter"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
Accès sécurisé réservé aux administrateurs
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LoginPage;
|
||||||
76
src/tailwind.config.js
Normal file
76
src/tailwind.config.js
Normal file
@@ -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')],
|
||||||
|
};
|
||||||
181
src/tools/generate-llms.js
Normal file
181
src/tools/generate-llms.js
Normal file
@@ -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: /<Route\s+[^>]*>/g,
|
||||||
|
path: /path=["']([^"']+)["']/,
|
||||||
|
element: /element=\{<(\w+)[^}]*\/?\s*>\}/,
|
||||||
|
helmet: /<Helmet[^>]*?>([\s\S]*?)<\/Helmet>/i,
|
||||||
|
helmetTest: /<Helmet[\s\S]*?<\/Helmet>/i,
|
||||||
|
title: /<title[^>]*?>\s*(.*?)\s*<\/title>/i,
|
||||||
|
description: /<meta\s+name=["']description["']\s+content=["'](.*?)["']/i
|
||||||
|
};
|
||||||
|
|
||||||
|
function cleanContent(content) {
|
||||||
|
return content
|
||||||
|
.replace(CLEAN_CONTENT_REGEX.comments, '')
|
||||||
|
.replace(CLEAN_CONTENT_REGEX.templateLiterals, '""')
|
||||||
|
.replace(CLEAN_CONTENT_REGEX.strings, '""');
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanText(text) {
|
||||||
|
if (!text) return text;
|
||||||
|
|
||||||
|
return text
|
||||||
|
.replace(CLEAN_CONTENT_REGEX.jsxExpressions, '')
|
||||||
|
.replace(CLEAN_CONTENT_REGEX.htmlEntities.quot, '"')
|
||||||
|
.replace(CLEAN_CONTENT_REGEX.htmlEntities.amp, '&')
|
||||||
|
.replace(CLEAN_CONTENT_REGEX.htmlEntities.lt, '<')
|
||||||
|
.replace(CLEAN_CONTENT_REGEX.htmlEntities.gt, '>')
|
||||||
|
.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();
|
||||||
|
}
|
||||||
265
src/vite.config.js
Normal file
265
src/vite.config.js
Normal file
@@ -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'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user