Initial commit

This commit is contained in:
balvarez
2026-01-01 12:47:02 +01:00
commit 9863b8ecb5
40 changed files with 3027 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.idea
src/dist/
src/node_modules/
src/package-lock.json

1
Jenkinsfile vendored Normal file
View File

@@ -0,0 +1 @@
nodePipeline(name: 'dites-le-en-chanson-backoffice')

2
src/.env Normal file
View 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
View File

@@ -0,0 +1 @@
20.19.1

1
src/.version Normal file
View File

@@ -0,0 +1 @@
11

15
src/index.html Normal file
View 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
View 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"
}
}

View 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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/{/g, '&#123;')
.replace(/}/g, '&#125;')
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();
}
});

View 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;
}
`;

View 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'
}
];
}
};
}

View 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.' }));
}
});
});
}
};
}

View 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
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

19
src/public/.htaccess Normal file
View 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>

View 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
View 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;

View 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;

View 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;

View 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;

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,3 @@
export * from './MetricCard';
export * from './OrderCard';
export * from './OrderDetailItem';

View 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 };

View 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 };

View 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 };

View 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,
};

View 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,
};

View 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>
);
}

View 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,
}
}

View 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
View 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); }
}

View 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
View 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
View 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>
);

View 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
View 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
View 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
View 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: /&quot;/g,
amp: /&amp;/g,
lt: /&lt;/g,
gt: /&gt;/g,
apos: /&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
View 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'
]
}
}
});