new file: contribute/plugins/utils/ast-utils.js
This commit is contained in:
parent
d7fc469a3d
commit
4448bccc9b
600 changed files with 121282 additions and 114 deletions
1
contribute/.nvmrc
Normal file
1
contribute/.nvmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
20.19.1
|
||||
1
contribute/.version
Normal file
1
contribute/.version
Normal file
|
|
@ -0,0 +1 @@
|
|||
20
|
||||
53
contribute/eslint.config.mjs
Normal file
53
contribute/eslint.config.mjs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import react from 'eslint-plugin-react';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import importPlugin from 'eslint-plugin-import';
|
||||
import globals from 'globals';
|
||||
|
||||
export default [
|
||||
{ ignores: ['node_modules/**', 'dist/**', 'build/**', 'vite.config.js'] },
|
||||
{
|
||||
files: ['**/*.js', '**/*.jsx'],
|
||||
plugins: { react, 'react-hooks': reactHooks, import: importPlugin },
|
||||
languageOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||
globals: { ...globals.browser, React: 'readonly', Intl: 'readonly' },
|
||||
},
|
||||
settings: {
|
||||
react: { version: 'detect' },
|
||||
'import/resolver': {
|
||||
node: { extensions: ['.js', '.jsx'] },
|
||||
alias: { map: [['@', './src']], extensions: ['.js', '.jsx'] },
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
...react.configs.recommended.rules,
|
||||
...reactHooks.configs.recommended.rules,
|
||||
...importPlugin.flatConfigs.recommended.rules,
|
||||
|
||||
// Non-critical rules - disabled since code works fine without them
|
||||
'react/prop-types': 'off',
|
||||
'react/no-unescaped-entities': 'off',
|
||||
'react/display-name': 'off', // Non-critical, component works without displayName
|
||||
'react/jsx-uses-react': 'off', // Not needed in React 17+, non-critical
|
||||
'react/react-in-jsx-scope': 'off', // Not needed in React 17+, non-critical
|
||||
'react/jsx-uses-vars': 'off', // Non-critical, code works fine
|
||||
'react/jsx-no-comment-textnodes': 'off', // Non-critical, comments could be visible if put inside the JSX, most cases are just rendering text like '///'
|
||||
|
||||
'no-unused-vars': 'off', // Non-critical, code works fine with unused vars
|
||||
'import/no-named-as-default': 'off', // Can cause runtime import errors, usually fine to leave as is
|
||||
'import/no-named-as-default-member': 'off', // Can cause runtime import errors
|
||||
|
||||
// Critical rules that prevent runtime errors
|
||||
'no-undef': 'error', // Undefined variables cause runtime errors
|
||||
|
||||
// Override recommended import rules for stricter checking
|
||||
'import/no-self-import': 'error', // Extremely fast rule, breaking results in infinite loop/bundling error
|
||||
|
||||
// Disable expensive rules for performance
|
||||
'import/no-cycle': 'off', // AI rarely makes this error, and the rule is very slow to run
|
||||
},
|
||||
},
|
||||
{ files: ['tools/**/*.js', 'tailwind.config.js'], languageOptions: { globals: globals.node } },
|
||||
];
|
||||
14
contribute/index.html
Normal file
14
contribute/index.html
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="https://horizons-cdn.hostinger.com/1edbdcc3-df77-45f9-bc30-9f11766aa973/1df388aa94d930bbeb7ed03cb7a60888.png" />
|
||||
<meta name="generator" content="Hostinger Horizons" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AeThex</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
10836
contribute/package-lock.json
generated
Normal file
10836
contribute/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
59
contribute/package.json
Normal file
59
contribute/package.json
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
{
|
||||
"name": "web-app",
|
||||
"type": "module",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite --host :: --port 3000",
|
||||
"build": "node tools/generate-llms.js || true && vite build",
|
||||
"preview": "vite preview --host :: --port 3000"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/traverse": "^7.26.4",
|
||||
"@emotion/is-prop-valid": "^1.2.1",
|
||||
"@radix-ui/react-accordion": "^1.1.2",
|
||||
"@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-select": "^2.0.0",
|
||||
"@radix-ui/react-slider": "^1.1.2",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-switch": "^1.0.3",
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"@radix-ui/react-toast": "^1.1.5",
|
||||
"@supabase/supabase-js": "2.30.0",
|
||||
"@tailwindcss/typography": "^0.5.10",
|
||||
"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-dropzone": "^14.2.3",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-qrcode-logo": "^2.9.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/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",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-react-app": "^7.0.1",
|
||||
"postcss": "^8.4.31",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"terser": "^5.39.0",
|
||||
"vite": "^4.4.5"
|
||||
}
|
||||
}
|
||||
430
contribute/plugins/selection-mode/selection-mode-script.js
Normal file
430
contribute/plugins/selection-mode/selection-mode-script.js
Normal file
|
|
@ -0,0 +1,430 @@
|
|||
const ALLOWED_PARENT_ORIGINS = [
|
||||
'https://horizons.hostinger.com',
|
||||
'https://horizons.hostinger.dev',
|
||||
'https://horizons-frontend-local.hostinger.dev',
|
||||
'http://localhost:4000',
|
||||
];
|
||||
|
||||
const IMPORTANT_STYLES = [
|
||||
'display',
|
||||
'position',
|
||||
'flex-direction',
|
||||
'justify-content',
|
||||
'align-items',
|
||||
'width',
|
||||
'height',
|
||||
'padding',
|
||||
'margin',
|
||||
'border',
|
||||
'background-color',
|
||||
'color',
|
||||
'font-size',
|
||||
'font-weight',
|
||||
'font-family',
|
||||
'border-radius',
|
||||
'box-shadow',
|
||||
'gap',
|
||||
'grid-template-columns',
|
||||
];
|
||||
|
||||
const PRIMARY_400_COLOR = '#7B68EE';
|
||||
const TEXT_CONTEXT_MAX_LENGTH = 500;
|
||||
const DATA_SELECTION_MODE_ENABLED_ATTRIBUTE = 'data-selection-mode-enabled';
|
||||
const MESSAGE_TYPE_ENABLE_SELECTION_MODE = 'enableSelectionMode';
|
||||
const MESSAGE_TYPE_DISABLE_SELECTION_MODE = 'disableSelectionMode';
|
||||
|
||||
let selectionModeEnabled = false;
|
||||
let currentHoverElement = null;
|
||||
let overlayDiv = null;
|
||||
let selectedOverlayDiv = null;
|
||||
let selectedElement = null;
|
||||
|
||||
|
||||
function injectStyles() {
|
||||
if (document.getElementById('selection-mode-styles')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.id = 'selection-mode-styles';
|
||||
style.textContent = `
|
||||
#selection-mode-overlay {
|
||||
position: absolute;
|
||||
border: 2px dashed ${PRIMARY_400_COLOR};
|
||||
pointer-events: none;
|
||||
z-index: 999999;
|
||||
}
|
||||
#selection-mode-selected-overlay {
|
||||
position: absolute;
|
||||
border: 3px solid ${PRIMARY_400_COLOR};
|
||||
pointer-events: none;
|
||||
z-index: 999998;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
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 {
|
||||
console.warn('[SELECTION MODE] Invalid referrer URL:', document.referrer);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract file path from React Fiber metadata (simplified - only for filePath)
|
||||
* @param {*} node - DOM node
|
||||
* @returns {string|null} - File path if found, null otherwise
|
||||
*/
|
||||
function getFilePathFromNode(node) {
|
||||
const fiberKey = Object.keys(node).find(k => k.startsWith('__reactFiber'));
|
||||
if (!fiberKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fiber = node[fiberKey];
|
||||
if (!fiber) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Traverse up the fiber tree to find source metadata
|
||||
let currentFiber = fiber;
|
||||
while (currentFiber) {
|
||||
const source = currentFiber._debugSource
|
||||
|| currentFiber.memoizedProps?.__source
|
||||
|| currentFiber.pendingProps?.__source;
|
||||
|
||||
if (source?.fileName) {
|
||||
return source.fileName;
|
||||
}
|
||||
|
||||
currentFiber = currentFiber.return;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a CSS selector path to uniquely identify the element
|
||||
* @param {*} element
|
||||
* @returns {string} CSS selector path
|
||||
*/
|
||||
function getPathToElement(element) {
|
||||
const path = [];
|
||||
let current = element;
|
||||
let depth = 0;
|
||||
const maxDepth = 20; // Prevent infinite loops
|
||||
|
||||
while (current && current.nodeType === Node.ELEMENT_NODE && depth < maxDepth) {
|
||||
let selector = current.nodeName.toLowerCase();
|
||||
|
||||
if (current.id) {
|
||||
selector += `#${current.id}`;
|
||||
path.unshift(selector);
|
||||
break; // ID is unique, stop here
|
||||
}
|
||||
|
||||
if (current.className && typeof current.className === 'string') {
|
||||
const classes = current.className.trim().split(/\s+/).filter(c => c.length > 0);
|
||||
if (classes.length > 0) {
|
||||
selector += `.${classes.join('.')}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (current.parentElement) {
|
||||
const siblings = Array.from(current.parentElement.children);
|
||||
const sameTypeSiblings = siblings.filter(s => s.nodeName === current.nodeName);
|
||||
if (sameTypeSiblings.length > 1) {
|
||||
const index = sameTypeSiblings.indexOf(current) + 1;
|
||||
selector += `:nth-of-type(${index})`;
|
||||
}
|
||||
}
|
||||
|
||||
path.unshift(selector);
|
||||
current = current.parentElement;
|
||||
depth++;
|
||||
}
|
||||
|
||||
return path.join(' > ');
|
||||
}
|
||||
|
||||
function getComputedStyles(element) {
|
||||
const computedStyles = window.getComputedStyle(element);
|
||||
|
||||
return Object.fromEntries(IMPORTANT_STYLES.map((style) => {
|
||||
const styleValue = computedStyles.getPropertyValue(style)?.trim();
|
||||
|
||||
return styleValue && styleValue !== 'none' && styleValue !== 'normal'
|
||||
? [style, styleValue]
|
||||
: null;
|
||||
})
|
||||
.filter(Boolean));
|
||||
}
|
||||
|
||||
function extractDOMContext(element) {
|
||||
if (!element) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const textContent = element.textContent?.trim();
|
||||
|
||||
return {
|
||||
outerHTML: element.outerHTML,
|
||||
selector: getPathToElement(element),
|
||||
attributes: (element.attributes && element.attributes.length > 0)
|
||||
? Object.fromEntries(Array.from(element.attributes).map((attr) => [attr.name, attr.value]))
|
||||
: {},
|
||||
computedStyles: getComputedStyles(element),
|
||||
textContent: (textContent && textContent.length > 0 && textContent.length < TEXT_CONTEXT_MAX_LENGTH)
|
||||
? element.textContent?.trim()
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
function createOverlay() {
|
||||
if (overlayDiv) {
|
||||
return;
|
||||
}
|
||||
|
||||
injectStyles();
|
||||
|
||||
overlayDiv = document.createElement('div');
|
||||
overlayDiv.id = 'selection-mode-overlay';
|
||||
document.body.appendChild(overlayDiv);
|
||||
}
|
||||
|
||||
function createSelectedOverlay() {
|
||||
if (selectedOverlayDiv) {
|
||||
return;
|
||||
}
|
||||
|
||||
injectStyles();
|
||||
|
||||
selectedOverlayDiv = document.createElement('div');
|
||||
selectedOverlayDiv.id = 'selection-mode-selected-overlay';
|
||||
document.body.appendChild(selectedOverlayDiv);
|
||||
}
|
||||
|
||||
function removeOverlay() {
|
||||
if (overlayDiv && overlayDiv.parentNode) {
|
||||
overlayDiv.parentNode.removeChild(overlayDiv);
|
||||
overlayDiv = null;
|
||||
}
|
||||
if (selectedOverlayDiv && selectedOverlayDiv.parentNode) {
|
||||
selectedOverlayDiv.parentNode.removeChild(selectedOverlayDiv);
|
||||
selectedOverlayDiv = null;
|
||||
}
|
||||
}
|
||||
|
||||
function showOverlay(element) {
|
||||
if (!overlayDiv) {
|
||||
createOverlay();
|
||||
}
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
overlayDiv.style.left = `${rect.left + window.scrollX}px`;
|
||||
overlayDiv.style.top = `${rect.top + window.scrollY}px`;
|
||||
overlayDiv.style.width = `${rect.width}px`;
|
||||
overlayDiv.style.height = `${rect.height}px`;
|
||||
overlayDiv.style.display = 'block';
|
||||
}
|
||||
|
||||
function showSelectedOverlay(element) {
|
||||
if (!selectedOverlayDiv) {
|
||||
createSelectedOverlay();
|
||||
}
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
selectedOverlayDiv.style.left = `${rect.left + window.scrollX}px`;
|
||||
selectedOverlayDiv.style.top = `${rect.top + window.scrollY}px`;
|
||||
selectedOverlayDiv.style.width = `${rect.width}px`;
|
||||
selectedOverlayDiv.style.height = `${rect.height}px`;
|
||||
selectedOverlayDiv.style.display = 'block';
|
||||
}
|
||||
|
||||
function hideOverlay() {
|
||||
if (overlayDiv) {
|
||||
overlayDiv.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseMove(event) {
|
||||
if (!selectionModeEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const element = document.elementFromPoint(event.clientX, event.clientY);
|
||||
if (!element) {
|
||||
hideOverlay();
|
||||
currentHoverElement = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (element === overlayDiv || element === selectedOverlayDiv) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only update if we're hovering a different element
|
||||
if (currentHoverElement !== element) {
|
||||
currentHoverElement = element;
|
||||
|
||||
// Show outline on the element
|
||||
showOverlay(element);
|
||||
}
|
||||
}
|
||||
|
||||
function handleTouchStart(event) {
|
||||
if (!selectionModeEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const touch = event.touches[0];
|
||||
if (!touch) {
|
||||
return;
|
||||
}
|
||||
|
||||
const element = document.elementFromPoint(touch.clientX, touch.clientY);
|
||||
if (!element) {
|
||||
currentHoverElement = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (element === overlayDiv || element === selectedOverlayDiv) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentHoverElement = element;
|
||||
|
||||
showOverlay(element);
|
||||
}
|
||||
|
||||
function stripFilePath(filePath) {
|
||||
if (!filePath) {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
const publicHtmlIndex = filePath.indexOf('public_html/');
|
||||
if (publicHtmlIndex !== -1) {
|
||||
return filePath.substring(publicHtmlIndex + 'public_html/'.length);
|
||||
}
|
||||
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function handleClick(event) {
|
||||
if (!selectionModeEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentHoverElement) {
|
||||
const element = document.elementFromPoint(event.clientX, event.clientY);
|
||||
|
||||
if (!element || element === overlayDiv || element === selectedOverlayDiv) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentHoverElement = element;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
|
||||
const domContext = extractDOMContext(currentHoverElement);
|
||||
|
||||
if (!domContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectedElement = currentHoverElement;
|
||||
if (selectedElement) {
|
||||
showSelectedOverlay(selectedElement);
|
||||
}
|
||||
|
||||
// Extract file path from React Fiber (if available)
|
||||
const filePath = getFilePathFromNode(currentHoverElement);
|
||||
const strippedFilePath = filePath ? stripFilePath(filePath) : undefined;
|
||||
|
||||
// Send domContext and filePath to parent window
|
||||
const parentOrigin = getParentOrigin();
|
||||
if (parentOrigin && ALLOWED_PARENT_ORIGINS.includes(parentOrigin)) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: 'elementSelected',
|
||||
payload: {
|
||||
filePath: strippedFilePath,
|
||||
domContext,
|
||||
},
|
||||
},
|
||||
parentOrigin,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
if (!selectionModeEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
hideOverlay();
|
||||
currentHoverElement = null;
|
||||
}
|
||||
|
||||
function enableSelectionMode() {
|
||||
if (selectionModeEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectionModeEnabled = true;
|
||||
document.getElementById('root')?.setAttribute(DATA_SELECTION_MODE_ENABLED_ATTRIBUTE, 'true');
|
||||
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
createOverlay();
|
||||
document.addEventListener('mousemove', handleMouseMove, true);
|
||||
document.addEventListener('touchstart', handleTouchStart, true);
|
||||
document.addEventListener('click', handleClick, true);
|
||||
document.addEventListener('mouseleave', handleMouseLeave, true);
|
||||
}
|
||||
|
||||
function disableSelectionMode() {
|
||||
if (!selectionModeEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectionModeEnabled = false;
|
||||
document.getElementById('root')?.removeAttribute(DATA_SELECTION_MODE_ENABLED_ATTRIBUTE);
|
||||
|
||||
document.body.style.userSelect = '';
|
||||
|
||||
hideOverlay();
|
||||
removeOverlay();
|
||||
currentHoverElement = null;
|
||||
selectedElement = null;
|
||||
|
||||
document.removeEventListener('mousemove', handleMouseMove, true);
|
||||
document.removeEventListener('touchstart', handleTouchStart, true);
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
document.removeEventListener('mouseleave', handleMouseLeave, true);
|
||||
}
|
||||
|
||||
window.addEventListener('message', (event) => {
|
||||
if (event.data?.type === MESSAGE_TYPE_ENABLE_SELECTION_MODE) {
|
||||
enableSelectionMode();
|
||||
}
|
||||
if (event.data?.type === MESSAGE_TYPE_DISABLE_SELECTION_MODE) {
|
||||
disableSelectionMode();
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = resolve(__filename, '..');
|
||||
|
||||
export default function selectionModePlugin() {
|
||||
return {
|
||||
name: 'vite:selection-mode',
|
||||
apply: 'serve',
|
||||
|
||||
transformIndexHtml() {
|
||||
const scriptPath = resolve(__dirname, 'selection-mode-script.js');
|
||||
const scriptContent = readFileSync(scriptPath, 'utf-8');
|
||||
|
||||
return [
|
||||
{
|
||||
tag: 'script',
|
||||
attrs: { type: 'module' },
|
||||
children: scriptContent,
|
||||
injectTo: 'body',
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
}
|
||||
279
contribute/plugins/utils/ast-utils.js
Normal file
279
contribute/plugins/utils/ast-utils.js
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import generate from '@babel/generator';
|
||||
import { parse } from '@babel/parser';
|
||||
import traverseBabel from '@babel/traverse';
|
||||
import {
|
||||
isJSXIdentifier,
|
||||
isJSXMemberExpression,
|
||||
} from '@babel/types';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const VITE_PROJECT_ROOT = path.resolve(__dirname, '../..');
|
||||
|
||||
// Blacklist of components that should not be extracted (utility/non-visual components)
|
||||
const COMPONENT_BLACKLIST = new Set([
|
||||
'Helmet',
|
||||
'HelmetProvider',
|
||||
'Head',
|
||||
'head',
|
||||
'Meta',
|
||||
'meta',
|
||||
'Script',
|
||||
'script',
|
||||
'NoScript',
|
||||
'noscript',
|
||||
'Style',
|
||||
'style',
|
||||
'title',
|
||||
'Title',
|
||||
'link',
|
||||
'Link',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Validates that a file path is safe to access
|
||||
* @param {string} filePath - Relative file path
|
||||
* @returns {{ isValid: boolean, absolutePath?: string, error?: string }} - Object containing validation result
|
||||
*/
|
||||
export function validateFilePath(filePath) {
|
||||
if (!filePath) {
|
||||
return { isValid: false, error: 'Missing filePath' };
|
||||
}
|
||||
|
||||
const absoluteFilePath = path.resolve(VITE_PROJECT_ROOT, filePath);
|
||||
|
||||
if (filePath.includes('..')
|
||||
|| !absoluteFilePath.startsWith(VITE_PROJECT_ROOT)
|
||||
|| absoluteFilePath.includes('node_modules')) {
|
||||
return { isValid: false, error: 'Invalid path' };
|
||||
}
|
||||
|
||||
if (!fs.existsSync(absoluteFilePath)) {
|
||||
return { isValid: false, error: 'File not found' };
|
||||
}
|
||||
|
||||
return { isValid: true, absolutePath: absoluteFilePath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a file into a Babel AST
|
||||
* @param {string} absoluteFilePath - Absolute path to file
|
||||
* @returns {object} Babel AST
|
||||
*/
|
||||
export function parseFileToAST(absoluteFilePath) {
|
||||
const content = fs.readFileSync(absoluteFilePath, 'utf-8');
|
||||
|
||||
return parse(content, {
|
||||
sourceType: 'module',
|
||||
plugins: ['jsx', 'typescript'],
|
||||
errorRecovery: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a JSX opening element at a specific line and column
|
||||
* @param {object} ast - Babel AST
|
||||
* @param {number} line - Line number (1-indexed)
|
||||
* @param {number} column - Column number (0-indexed for get-code-block, 1-indexed for apply-edit)
|
||||
* @returns {object | null} Babel path to the JSX opening element
|
||||
*/
|
||||
export function findJSXElementAtPosition(ast, line, column) {
|
||||
let targetNodePath = null;
|
||||
let closestNodePath = null;
|
||||
let closestDistance = Infinity;
|
||||
const allNodesOnLine = [];
|
||||
|
||||
const visitor = {
|
||||
JSXOpeningElement(path) {
|
||||
const node = path.node;
|
||||
if (node.loc) {
|
||||
// Exact match (with tolerance for off-by-one column differences)
|
||||
if (node.loc.start.line === line
|
||||
&& Math.abs(node.loc.start.column - column) <= 1) {
|
||||
targetNodePath = path;
|
||||
path.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
// Track all nodes on the same line
|
||||
if (node.loc.start.line === line) {
|
||||
allNodesOnLine.push({
|
||||
path,
|
||||
column: node.loc.start.column,
|
||||
distance: Math.abs(node.loc.start.column - column),
|
||||
});
|
||||
}
|
||||
|
||||
// Track closest match on the same line for fallback
|
||||
if (node.loc.start.line === line) {
|
||||
const distance = Math.abs(node.loc.start.column - column);
|
||||
if (distance < closestDistance) {
|
||||
closestDistance = distance;
|
||||
closestNodePath = path;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
// Also check JSXElement nodes that contain the position
|
||||
JSXElement(path) {
|
||||
const node = path.node;
|
||||
if (!node.loc) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this element spans the target line (for multi-line elements)
|
||||
if (node.loc.start.line > line || node.loc.end.line < line) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If we're inside this element's range, consider its opening element
|
||||
if (!path.node.openingElement?.loc) {
|
||||
return;
|
||||
}
|
||||
|
||||
const openingLine = path.node.openingElement.loc.start.line;
|
||||
const openingCol = path.node.openingElement.loc.start.column;
|
||||
|
||||
// Prefer elements that start on the exact line
|
||||
if (openingLine === line) {
|
||||
const distance = Math.abs(openingCol - column);
|
||||
if (distance < closestDistance) {
|
||||
closestDistance = distance;
|
||||
closestNodePath = path.get('openingElement');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle elements that start before the target line
|
||||
if (openingLine < line) {
|
||||
const distance = (line - openingLine) * 100; // Penalize by line distance
|
||||
if (distance < closestDistance) {
|
||||
closestDistance = distance;
|
||||
closestNodePath = path.get('openingElement');
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
traverseBabel.default(ast, visitor);
|
||||
|
||||
// Return exact match if found, otherwise return closest match if within reasonable distance
|
||||
// Use larger threshold (50 chars) for same-line elements, 5 lines for multi-line elements
|
||||
const threshold = closestDistance < 100 ? 50 : 500;
|
||||
return targetNodePath || (closestDistance <= threshold ? closestNodePath : null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a JSX element name is blacklisted
|
||||
* @param {object} jsxOpeningElement - Babel JSX opening element node
|
||||
* @returns {boolean} True if blacklisted
|
||||
*/
|
||||
function isBlacklistedComponent(jsxOpeningElement) {
|
||||
if (!jsxOpeningElement || !jsxOpeningElement.name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle JSXIdentifier (e.g., <Helmet>)
|
||||
if (isJSXIdentifier(jsxOpeningElement.name)) {
|
||||
return COMPONENT_BLACKLIST.has(jsxOpeningElement.name.name);
|
||||
}
|
||||
|
||||
// Handle JSXMemberExpression (e.g., <React.Fragment>)
|
||||
if (isJSXMemberExpression(jsxOpeningElement.name)) {
|
||||
let current = jsxOpeningElement.name;
|
||||
while (isJSXMemberExpression(current)) {
|
||||
current = current.property;
|
||||
}
|
||||
if (isJSXIdentifier(current)) {
|
||||
return COMPONENT_BLACKLIST.has(current.name);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates code from an AST node
|
||||
* @param {object} node - Babel AST node
|
||||
* @param {object} options - Generator options
|
||||
* @returns {string} Generated code
|
||||
*/
|
||||
export function generateCode(node, options = {}) {
|
||||
const generateFunction = generate.default || generate;
|
||||
const output = generateFunction(node, options);
|
||||
return output.code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a full source file from AST with source maps
|
||||
* @param {object} ast - Babel AST
|
||||
* @param {string} sourceFileName - Source file name for source map
|
||||
* @param {string} originalCode - Original source code
|
||||
* @returns {{code: string, map: object}} - Object containing generated code and source map
|
||||
*/
|
||||
export function generateSourceWithMap(ast, sourceFileName, originalCode) {
|
||||
const generateFunction = generate.default || generate;
|
||||
return generateFunction(ast, {
|
||||
sourceMaps: true,
|
||||
sourceFileName,
|
||||
}, originalCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts code blocks from a JSX element at a specific location
|
||||
* @param {string} filePath - Relative file path
|
||||
* @param {number} line - Line number
|
||||
* @param {number} column - Column number
|
||||
* @param {object} [domContext] - Optional DOM context to return on failure
|
||||
* @returns {{success: boolean, filePath?: string, specificLine?: string, error?: string, domContext?: object}} - Object with metadata for LLM
|
||||
*/
|
||||
export function extractCodeBlocks(filePath, line, column, domContext) {
|
||||
try {
|
||||
// Validate file path
|
||||
const validation = validateFilePath(filePath);
|
||||
if (!validation.isValid) {
|
||||
return { success: false, error: validation.error, domContext };
|
||||
}
|
||||
|
||||
// Parse AST
|
||||
const ast = parseFileToAST(validation.absolutePath);
|
||||
|
||||
// Find target node
|
||||
const targetNodePath = findJSXElementAtPosition(ast, line, column);
|
||||
|
||||
if (!targetNodePath) {
|
||||
return { success: false, error: 'Target node not found at specified line/column', domContext };
|
||||
}
|
||||
|
||||
// Check if the target node is a blacklisted component
|
||||
const isBlacklisted = isBlacklistedComponent(targetNodePath.node);
|
||||
|
||||
if (isBlacklisted) {
|
||||
return {
|
||||
success: true,
|
||||
filePath,
|
||||
specificLine: '',
|
||||
};
|
||||
}
|
||||
|
||||
// Get specific line code
|
||||
const specificLine = generateCode(targetNodePath.parentPath?.node || targetNodePath.node);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
filePath,
|
||||
specificLine,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[ast-utils] Error extracting code blocks:', error);
|
||||
return { success: false, error: 'Failed to extract code blocks', domContext };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Project root path
|
||||
*/
|
||||
export { VITE_PROJECT_ROOT };
|
||||
357
contribute/plugins/visual-editor/edit-mode-script.js
Normal file
357
contribute/plugins/visual-editor/edit-mode-script.js
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
// eslint-disable-next-line import/no-unresolved
|
||||
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;
|
||||
}
|
||||
|
||||
// Don't handle if selection mode is active
|
||||
if (document.getElementById("root")?.getAttribute("data-selection-mode-enabled") === "true") {
|
||||
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() {
|
||||
// Don't enable if selection mode is active
|
||||
if (document.getElementById("root")?.getAttribute("data-selection-mode-enabled") === "true") {
|
||||
console.warn("[EDIT MODE] Cannot enable edit mode while selection mode is active");
|
||||
return;
|
||||
}
|
||||
|
||||
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
contribute/plugins/visual-editor/visual-editor-config.js
Normal file
137
contribute/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
contribute/plugins/visual-editor/vite-plugin-edit-mode.js
Normal file
32
contribute/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'
|
||||
}
|
||||
];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,365 @@
|
|||
import path from 'path';
|
||||
import { parse } from '@babel/parser';
|
||||
import traverseBabel from '@babel/traverse';
|
||||
import * as t from '@babel/types';
|
||||
import fs from 'fs';
|
||||
import {
|
||||
validateFilePath,
|
||||
parseFileToAST,
|
||||
findJSXElementAtPosition,
|
||||
generateCode,
|
||||
generateSourceWithMap,
|
||||
VITE_PROJECT_ROOT
|
||||
} from '../utils/ast-utils.js';
|
||||
|
||||
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 output = generateSourceWithMap(babelAst, 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;
|
||||
|
||||
// Validate file path
|
||||
const validation = validateFilePath(filePath);
|
||||
if (!validation.isValid) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
return res.end(JSON.stringify({ error: validation.error }));
|
||||
}
|
||||
absoluteFilePath = validation.absolutePath;
|
||||
|
||||
// Parse AST
|
||||
const originalContent = fs.readFileSync(absoluteFilePath, 'utf-8');
|
||||
const babelAst = parseFileToAST(absoluteFilePath);
|
||||
|
||||
// Find target node (note: apply-edit uses column+1)
|
||||
const targetNodePath = findJSXElementAtPosition(babelAst, line, column + 1);
|
||||
|
||||
if (!targetNodePath) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
return res.end(JSON.stringify({ error: 'Target node not found by line/column', editId }));
|
||||
}
|
||||
|
||||
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
|
||||
beforeCode = generateCode(targetOpeningElement);
|
||||
|
||||
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;
|
||||
afterCode = generateCode(targetOpeningElement);
|
||||
}
|
||||
} else {
|
||||
if (parentElementNode && t.isJSXElement(parentElementNode)) {
|
||||
beforeCode = generateCode(parentElementNode);
|
||||
|
||||
parentElementNode.children = [];
|
||||
if (newFullText && newFullText.trim() !== '') {
|
||||
const newTextNode = t.jsxText(newFullText);
|
||||
parentElementNode.children.push(newTextNode);
|
||||
}
|
||||
modified = true;
|
||||
afterCode = generateCode(parentElementNode);
|
||||
}
|
||||
}
|
||||
|
||||
if (!modified) {
|
||||
res.writeHead(409, { 'Content-Type': 'application/json' });
|
||||
return res.end(JSON.stringify({ error: 'Could not apply changes to AST.' }));
|
||||
}
|
||||
|
||||
const webRelativeFilePath = path.relative(VITE_PROJECT_ROOT, absoluteFilePath).split(path.sep).join('/');
|
||||
const output = generateSourceWithMap(babelAst, webRelativeFilePath, originalContent);
|
||||
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
contribute/plugins/vite-plugin-iframe-route-restoration.js
Normal file
125
contribute/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
contribute/postcss.config.js
Normal file
6
contribute/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
19
contribute/public/.htaccess
Normal file
19
contribute/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>
|
||||
9
contribute/public/aethex-icon.svg
Normal file
9
contribute/public/aethex-icon.svg
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
|
||||
<defs>
|
||||
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#0D94EA;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#0D41EA;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path fill="url(#grad1)" d="M128 0 L158.4 69.6 L233.6 69.6 L172.8 112.8 L203.2 182.4 L128 139.2 L52.8 182.4 L83.2 112.8 L22.4 69.6 L97.6 69.6 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 492 B |
16
contribute/public/llms.txt
Normal file
16
contribute/public/llms.txt
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
## Pages
|
||||
- [- AeThex Careers`}](/jobapplication): No description available
|
||||
- [- AeThex Careers`}](/jobdetail): No description available
|
||||
- [About AeThex | Our Mission, Vision, and Team](/about): Learn about AeThex
|
||||
- [AeThex | Engineering the Next Digital Epoch](/home): AeThex is a research and engineering collective building the foundational layers for the next generation of the internet. Discover our work in decentralized AI, next-gen protocols, and digital sovereignty.
|
||||
- [Contact Us | AeThex](/contact): Get in touch with the AeThex team for inquiries, partnerships, or press via our contact form or official channels.
|
||||
- [Get Involved | AeThex Collective](/getinvolved): Join the AeThex collective. Find volunteer opportunities, learn about our mission, and become a co-owner of the future of the internet.
|
||||
- [My Applications - AeThex Careers](/myapplications): Track the status of your job applications with AeThex.
|
||||
- [My Profile - AeThex](/myprofile): Manage your AeThex contributor profile.
|
||||
- [My Registered Events - AeThex](/myevents): View and manage all the AeThex events you are registered for.
|
||||
- [My Tickets | AeThex Contributor](/mytickets): View and manage your support tickets.
|
||||
- [News & Press | AeThex](/news): The latest news, announcements, and press mentions from AeThex.
|
||||
- [Notifications | AeThex Contributor](/notifications): View all your notifications.
|
||||
- [Privacy Policy | AeThex](/privacypolicy): Read the AeThex privacy policy to understand how we collect, store, and use your personal data to provide our services.
|
||||
- [Technology | AeThex](/technology): Explore the core technologies and research areas that power AeThex
|
||||
- [Terms and Conditions | AeThex](/termsandconditions): Read the AeThex Terms and Conditions to understand the rules and guidelines for using our website and services.
|
||||
109
contribute/src/App.jsx
Normal file
109
contribute/src/App.jsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import React from 'react';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
import { Toaster } from '@/components/ui/toaster';
|
||||
import { useAuth } from '@/contexts/SupabaseAuthContext';
|
||||
import { useSite } from '@/contexts/SiteContext';
|
||||
import LoadingScreen from '@/components/LoadingScreen';
|
||||
import MaintenanceScreen from '@/components/MaintenanceScreen';
|
||||
import AuthModal from '@/components/AuthModal';
|
||||
import { Route, Routes, useLocation, Navigate } from 'react-router-dom';
|
||||
|
||||
import PageLayout from '@/components/PageLayout';
|
||||
import HomePage from '@/pages/HomePage';
|
||||
import AboutPage from '@/pages/AboutPage';
|
||||
import TeamPage from '@/pages/TeamPage';
|
||||
import TechnologyPage from '@/pages/TechnologyPage';
|
||||
import NewsPage from '@/pages/NewsPage';
|
||||
import ContactPage from '@/pages/ContactPage';
|
||||
import PrivacyPolicyPage from '@/pages/PrivacyPolicyPage';
|
||||
import TermsAndConditionsPage from '@/pages/TermsAndConditionsPage';
|
||||
import MyProfilePage from '@/pages/MyProfilePage';
|
||||
import GetInvolvedPage from '@/pages/GetInvolvedPage';
|
||||
import MyTicketsPage from '@/pages/MyTicketsPage';
|
||||
import NotificationsPage from '@/pages/NotificationsPage';
|
||||
|
||||
import ProtectedRoute from '@/components/ProtectedRoute';
|
||||
import AdminLayout from '@/pages/admin/AdminLayout';
|
||||
import AdminDashboardPage from '@/pages/admin/AdminDashboardPage';
|
||||
import AdminContributorsPage from '@/pages/admin/AdminContributorsPage';
|
||||
import AdminProjectsPage from '@/pages/admin/AdminProjectsPage';
|
||||
import AdminTicketsPage from '@/pages/admin/AdminTicketsPage';
|
||||
import AdminSettingsPage from '@/pages/admin/AdminSettingsPage';
|
||||
|
||||
const AppContent = () => {
|
||||
const { showAuthModal, setShowAuthModal, loading: authLoading, profile } = useAuth();
|
||||
const { siteConfig, loading: siteLoading } = useSite();
|
||||
const location = useLocation();
|
||||
|
||||
if (authLoading || siteLoading) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
const isMaintenance = siteConfig?.system_status === 'maintenance';
|
||||
const isAdmin = profile && ['admin', 'site_owner', 'oversee'].includes(profile.role);
|
||||
|
||||
if (isMaintenance && !isAdmin) {
|
||||
return <MaintenanceScreen message={siteConfig.system_status_message} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence mode="wait">
|
||||
<Routes location={location} key={location.pathname}>
|
||||
<Route element={<PageLayout />}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/about" element={<AboutPage />} />
|
||||
<Route path="/get-involved" element={<GetInvolvedPage />} />
|
||||
<Route path="/team" element={<TeamPage />} />
|
||||
<Route path="/technology" element={<TechnologyPage />} />
|
||||
<Route path="/news" element={<NewsPage />} />
|
||||
<Route path="/contact" element={<ContactPage />} />
|
||||
<Route path="/privacy" element={<PrivacyPolicyPage />} />
|
||||
<Route path="/terms" element={<TermsAndConditionsPage />} />
|
||||
<Route path="/profile" element={
|
||||
<ProtectedRoute>
|
||||
<MyProfilePage />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/my-tickets" element={
|
||||
<ProtectedRoute>
|
||||
<MyTicketsPage />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/notifications" element={
|
||||
<ProtectedRoute>
|
||||
<NotificationsPage />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
</Route>
|
||||
|
||||
<Route path="/admin" element={
|
||||
<ProtectedRoute adminOnly>
|
||||
<AdminLayout />
|
||||
</ProtectedRoute>
|
||||
}>
|
||||
<Route index element={<AdminDashboardPage />} />
|
||||
<Route path="contributors" element={<AdminContributorsPage />} />
|
||||
<Route path="projects" element={<AdminProjectsPage />} />
|
||||
<Route path="tickets" element={<AdminTicketsPage />} />
|
||||
<Route path="settings" element={<AdminSettingsPage />} />
|
||||
<Route path="*" element={<Navigate to="/admin" replace />} />
|
||||
</Route>
|
||||
|
||||
</Routes>
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{showAuthModal && <AuthModal onClose={() => setShowAuthModal(false)} />}
|
||||
</AnimatePresence>
|
||||
|
||||
<Toaster />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function App() {
|
||||
return <AppContent />;
|
||||
}
|
||||
|
||||
export default App;
|
||||
18
contribute/src/components/AeThexLogo.jsx
Normal file
18
contribute/src/components/AeThexLogo.jsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import React from 'react';
|
||||
|
||||
const AeThexLogo = ({ className, hideText }) => (
|
||||
<div className={`flex items-center gap-3 ${className}`}>
|
||||
<img
|
||||
src="https://horizons-cdn.hostinger.com/1edbdcc3-df77-45f9-bc30-9f11766aa973/1df388aa94d930bbeb7ed03cb7a60888.png"
|
||||
alt="AeThex Company Logo - Stylized Blue and Silver Symbol"
|
||||
className="h-full w-auto"
|
||||
/>
|
||||
{!hideText && (
|
||||
<span className="font-bold text-2xl tracking-tighter text-white font-mono">
|
||||
AETHEX
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default AeThexLogo;
|
||||
192
contribute/src/components/AuthModal.jsx
Normal file
192
contribute/src/components/AuthModal.jsx
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
import React, { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { X, Loader2, Mail, Lock, Shield, ArrowRight, Github, Chrome } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { useAuth } from '@/contexts/SupabaseAuthContext';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
|
||||
const backdropVariants = {
|
||||
visible: { opacity: 1 },
|
||||
hidden: { opacity: 0 }
|
||||
};
|
||||
|
||||
const modalVariants = {
|
||||
hidden: { opacity: 0, scale: 0.9, y: 50 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
y: 0,
|
||||
transition: { type: 'spring', damping: 25, stiffness: 200 }
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
scale: 0.9,
|
||||
y: 50,
|
||||
transition: { duration: 0.2 }
|
||||
}
|
||||
};
|
||||
|
||||
const AuthModal = ({ onClose }) => {
|
||||
const [view, setView] = useState('sign_in'); // 'sign_in', 'sign_up', 'forgot_password'
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { signIn, signUp, signInWithGitHub, signInWithGoogle, sendPasswordResetEmail } = useAuth();
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleAuthAction = async (action, successMessage) => {
|
||||
setLoading(true);
|
||||
const { error } = await action();
|
||||
setLoading(false);
|
||||
if (!error) {
|
||||
if (successMessage) {
|
||||
toast(successMessage);
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (view === 'sign_in') {
|
||||
await handleAuthAction(() => signIn(email, password));
|
||||
} else if (view === 'sign_up') {
|
||||
await handleAuthAction(() => signUp(email, password), {
|
||||
title: "Account Created",
|
||||
description: "Please check your email to verify your account.",
|
||||
});
|
||||
} else if (view === 'forgot_password') {
|
||||
await handleAuthAction(() => sendPasswordResetEmail(email), {
|
||||
title: "Password Reset Email Sent",
|
||||
description: "Please check your inbox for instructions.",
|
||||
});
|
||||
if(!loading) setView('sign_in');
|
||||
}
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
switch (view) {
|
||||
case 'forgot_password':
|
||||
return (
|
||||
<>
|
||||
<h2 className="text-2xl font-bold text-white mb-2 text-center">Forgot Password?</h2>
|
||||
<p className="text-gray-400 mb-6 text-center font-mono text-sm">Enter your email to receive a password reset link.</p>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||
<Input type="email" id="email-forgot" value={email} onChange={(e) => setEmail(e.target.value)} required className="pl-10" placeholder="Enter your email" />
|
||||
</div>
|
||||
<Button type="submit" className="w-full bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white py-3 text-base flex items-center justify-center gap-2" disabled={loading}>
|
||||
{loading ? <Loader2 className="animate-spin" /> : <>Send Reset Link <ArrowRight className="w-4 h-4" /></>}
|
||||
</Button>
|
||||
</form>
|
||||
<p className="mt-6 text-center text-sm text-gray-400">
|
||||
Remembered your password?{' '}
|
||||
<button onClick={() => setView('sign_in')} className="font-medium text-primary hover:underline">
|
||||
Sign In
|
||||
</button>
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
case 'sign_up':
|
||||
case 'sign_in':
|
||||
default:
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="h-16 w-16 rounded-full bg-primary/10 border border-primary/20 flex items-center justify-center">
|
||||
<Shield className="w-8 h-8 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-center">
|
||||
{view === 'sign_in' ? 'Welcome Back' : 'Become a Contributor'}
|
||||
</h2>
|
||||
<p className="text-gray-400 mb-6 text-center font-mono text-sm">
|
||||
{view === 'sign_in' ? 'Sign in to your AeThex account' : 'Create an account to start contributing'}
|
||||
</p>
|
||||
|
||||
<Button variant="outline" className="w-full mb-2 bg-transparent border-gray-700 hover:bg-gray-800" onClick={() => handleAuthAction(signInWithGitHub)} disabled={loading}>
|
||||
<Github className="mr-2 h-4 w-4"/> Continue with GitHub
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full mb-4 bg-transparent border-gray-700 hover:bg-gray-800" onClick={() => handleAuthAction(signInWithGoogle)} disabled={loading}>
|
||||
<Chrome className="mr-2 h-4 w-4"/> Continue with Google
|
||||
</Button>
|
||||
|
||||
<div className="relative flex py-2 items-center">
|
||||
<div className="flex-grow border-t border-gray-700"></div>
|
||||
<span className="flex-shrink mx-4 text-gray-500 text-xs font-mono">OR CONTINUE WITH EMAIL</span>
|
||||
<div className="flex-grow border-t border-gray-700"></div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4 mt-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email Address</Label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||
<Input type="email" id="email" value={email} onChange={(e) => setEmail(e.target.value)} required className="pl-10" placeholder="Enter your email" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||
<Input type="password" id="password" value={password} onChange={(e) => setPassword(e.target.value)} required className="pl-10" placeholder="Enter your password" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{view === 'sign_in' && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox id="remember" />
|
||||
<Label htmlFor="remember" className="text-sm font-normal text-gray-400">Remember me</Label>
|
||||
</div>
|
||||
<button onClick={() => setView('forgot_password')} type="button" className="text-sm text-primary hover:underline">Forgot password?</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white py-3 text-base flex items-center justify-center gap-2" disabled={loading}>
|
||||
{loading ? <Loader2 className="animate-spin" /> : <>{view === 'sign_in' ? 'Sign In' : 'Create Account'} <ArrowRight className="w-4 h-4" /></>}
|
||||
</Button>
|
||||
</form>
|
||||
<p className="mt-6 text-center text-sm text-gray-400">
|
||||
{view === 'sign_in' ? "Don't have an account?" : "Already have an account?"}{' '}
|
||||
<button onClick={() => setView(view === 'sign_in' ? 'sign_up' : 'sign_in')} className="font-medium text-primary hover:underline">
|
||||
{view === 'sign_in' ? 'Sign Up' : 'Sign In'}
|
||||
</button>
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
variants={backdropVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="hidden"
|
||||
className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4 font-mono"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
variants={modalVariants}
|
||||
className="radial-gradient-background bg-[#0A071E] rounded-2xl border border-primary/20 max-w-md w-full shadow-2xl shadow-primary/10 text-white"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="p-8 relative">
|
||||
<button onClick={onClose} className="absolute top-4 right-4 z-10 text-gray-400 hover:text-white p-1 rounded-full transition-colors">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{renderContent()}
|
||||
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthModal;
|
||||
17
contribute/src/components/CallToAction.jsx
Normal file
17
contribute/src/components/CallToAction.jsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
const CallToAction = () => {
|
||||
return (
|
||||
<motion.h1
|
||||
className='text-xl font-bold text-white leading-8 w-full'
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.5 }}
|
||||
>
|
||||
Let's turn your ideas into reality
|
||||
</motion.h1>
|
||||
);
|
||||
};
|
||||
|
||||
export default CallToAction;
|
||||
103
contribute/src/components/EventCard.jsx
Normal file
103
contribute/src/components/EventCard.jsx
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Calendar, MapPin, Star, ArrowRight } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { formatDate } from '@/lib/utils';
|
||||
|
||||
const cardVariants = {
|
||||
hidden: { opacity: 0, y: 30 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.5, ease: "easeOut" }
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
y: -30,
|
||||
transition: { duration: 0.3, ease: "easeIn" }
|
||||
}
|
||||
};
|
||||
|
||||
const EventCard = ({ event, onSelectEvent, getCategoryColor, getCategoryLabel }) => {
|
||||
const eventUrl = `https://events.aethex.biz/event/${event.id}`;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
variants={cardVariants}
|
||||
whileHover={{ y: -8, scale: 1.03 }}
|
||||
transition={{ type: 'spring', stiffness: 300 }}
|
||||
className="group relative"
|
||||
layout
|
||||
>
|
||||
<div className="bg-gray-900/50 backdrop-blur-sm rounded-2xl border border-gray-800 overflow-hidden group-hover:border-primary/50 transition-all duration-300 h-full flex flex-col shadow-lg group-hover:shadow-primary/20">
|
||||
<div className="relative h-48 overflow-hidden">
|
||||
<div className={`absolute inset-0 bg-gradient-to-br ${getCategoryColor(event.category)} opacity-30 group-hover:opacity-50 transition-opacity duration-300`}></div>
|
||||
<motion.img
|
||||
whileHover={{ scale: 1.1 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="w-full h-full object-cover opacity-60 group-hover:opacity-80 transition-opacity duration-300"
|
||||
alt={`${event.title} event banner`}
|
||||
src="https://images.unsplash.com/photo-1540575467063-178a50c2df87?q=80&w=2070&auto=format&fit=crop"
|
||||
/>
|
||||
<div className="absolute top-4 left-4">
|
||||
<span className="bg-black/60 backdrop-blur-sm text-white px-3 py-1 rounded-full text-xs font-medium border border-white/20">
|
||||
{getCategoryLabel(event.category)}
|
||||
</span>
|
||||
</div>
|
||||
{event.featured && (
|
||||
<motion.div
|
||||
initial={{ scale: 0, rotate: -45 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
transition={{ delay: 0.5, type: 'spring' }}
|
||||
className="absolute top-4 right-4 z-10"
|
||||
>
|
||||
<div className="bg-yellow-500/80 backdrop-blur-sm text-black px-3 py-1 rounded-full text-xs font-semibold flex items-center border border-yellow-300/50">
|
||||
<Star className="w-3 h-3 mr-1" />
|
||||
Featured
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-6 flex flex-col flex-grow">
|
||||
<h2 className="text-lg font-bold text-white mb-2 group-hover:text-primary transition-colors">
|
||||
{event.title}
|
||||
</h2>
|
||||
<p className="text-gray-400 mb-4 text-sm line-clamp-2 flex-grow">
|
||||
{event.description}
|
||||
</p>
|
||||
|
||||
<div className="space-y-2 mb-6 text-sm">
|
||||
<div className="flex items-center text-gray-400">
|
||||
<Calendar className="w-4 h-4 mr-2 text-primary/70" />
|
||||
<span>{formatDate(event.date)}</span>
|
||||
</div>
|
||||
<div className="flex items-center text-gray-400">
|
||||
<MapPin className="w-4 h-4 mr-2 text-primary/70" />
|
||||
<span>{event.location}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-auto">
|
||||
<div className="text-xl font-bold text-white">
|
||||
{event.price === 0 ? 'Free' : `$${event.price}`}
|
||||
</div>
|
||||
<Button asChild className="bg-primary hover:bg-primary/90 text-white px-4 py-2 text-sm">
|
||||
<a href={eventUrl} target="_blank" rel="noopener noreferrer">
|
||||
<span>View Details</span>
|
||||
<motion.div
|
||||
className="inline-block"
|
||||
whileHover={{ x: 4 }}
|
||||
>
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</motion.div>
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventCard;
|
||||
40
contribute/src/components/EventCardSkeleton.jsx
Normal file
40
contribute/src/components/EventCardSkeleton.jsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
const EventCardSkeleton = () => {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="bg-gray-900/50 backdrop-blur-sm rounded-2xl border border-gray-800 overflow-hidden"
|
||||
>
|
||||
<div className="animate-pulse">
|
||||
<div className="bg-gray-800 h-48 w-full"></div>
|
||||
<div className="p-6">
|
||||
<div className="h-5 bg-gray-700 rounded w-3/4 mb-4"></div>
|
||||
<div className="h-3 bg-gray-700 rounded w-full mb-2"></div>
|
||||
<div className="h-3 bg-gray-700 rounded w-5/6 mb-6"></div>
|
||||
|
||||
<div className="space-y-3 mb-6">
|
||||
<div className="flex items-center">
|
||||
<div className="h-4 w-4 bg-gray-700 rounded-full mr-2"></div>
|
||||
<div className="h-3 bg-gray-700 rounded w-1/2"></div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="h-4 w-4 bg-gray-700 rounded-full mr-2"></div>
|
||||
<div className="h-3 bg-gray-700 rounded w-1/3"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-auto">
|
||||
<div className="h-8 bg-gray-700 rounded w-1/4"></div>
|
||||
<div className="h-10 bg-gray-700 rounded w-1/3"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventCardSkeleton;
|
||||
162
contribute/src/components/EventDetailModal.jsx
Normal file
162
contribute/src/components/EventDetailModal.jsx
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import React from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Calendar, Clock, MapPin, Users, X, Loader2, ExternalLink } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { formatDate, formatTime } from '@/lib/utils';
|
||||
|
||||
const backdropVariants = {
|
||||
visible: { opacity: 1 },
|
||||
hidden: { opacity: 0 }
|
||||
};
|
||||
|
||||
const modalVariants = {
|
||||
hidden: { opacity: 0, scale: 0.9, y: 50 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
y: 0,
|
||||
transition: { type: 'spring', damping: 25, stiffness: 200 }
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
scale: 0.9,
|
||||
y: 50,
|
||||
transition: { duration: 0.2 }
|
||||
}
|
||||
};
|
||||
|
||||
const EventDetailModal = ({ event, onClose, onRegister, onUnregister, isRegistered, getCategoryColor, getCategoryLabel, isLoading }) => {
|
||||
const eventUrl = `https://events.aethex.biz/event/${event.id}`;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
variants={backdropVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="hidden"
|
||||
className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
variants={modalVariants}
|
||||
className="bg-gray-900 rounded-2xl border border-gray-800 max-w-4xl w-full max-h-[90vh] overflow-y-auto shadow-2xl shadow-primary/10"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 z-10 bg-black/50 hover:bg-black/70 text-white p-2 rounded-full transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<div className="relative h-64 overflow-hidden rounded-t-2xl">
|
||||
<div className={`absolute inset-0 bg-gradient-to-br ${getCategoryColor(event.category)} opacity-30`}></div>
|
||||
<motion.img
|
||||
initial={{ scale: 1.1, opacity: 0.7 }}
|
||||
animate={{ scale: 1, opacity: 0.6 }}
|
||||
transition={{ duration: 0.8, ease: "easeOut" }}
|
||||
className="w-full h-full object-cover"
|
||||
alt={`${event.title} detailed view`}
|
||||
src="https://images.unsplash.com/photo-1523580494863-6f3031224c94?q=80&w=2070&auto=format&fit=crop"
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ delay: 0.2, duration: 0.5 }}
|
||||
className="absolute bottom-6 left-6"
|
||||
>
|
||||
<span className="bg-black/60 backdrop-blur-sm text-white px-4 py-2 rounded-full font-medium border border-white/20">
|
||||
{getCategoryLabel(event.category)}
|
||||
</span>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
<div className="flex flex-col lg:flex-row gap-8">
|
||||
<div className="flex-1">
|
||||
<h1 className="text-3xl font-bold text-white mb-4">{event.title}</h1>
|
||||
<p className="text-gray-300 mb-6 leading-relaxed">{event.full_description}</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold text-white mb-3">Event Details</h2>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center text-gray-300"><Calendar className="w-5 h-5 mr-3 text-primary" /><span>{formatDate(event.date)}</span></div>
|
||||
<div className="flex items-center text-gray-300"><Clock className="w-5 h-5 mr-3 text-primary" /><span>{formatTime(event.time)}</span></div>
|
||||
<div className="flex items-center text-gray-300"><MapPin className="w-5 h-5 mr-3 text-primary" /><span>{event.location}</span></div>
|
||||
<div className="flex items-center text-gray-300"><Users className="w-5 h-5 mr-3 text-primary" /><span>{event.registered_count}/{event.capacity} registered</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold text-white mb-3">Speakers</h2>
|
||||
<ul className="space-y-2 list-disc list-inside text-gray-300">
|
||||
{event.speakers && event.speakers.map((speaker, index) => <li key={index}>{speaker}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-white mb-4">Agenda</h2>
|
||||
<div className="space-y-3">
|
||||
{event.agenda && event.agenda.map((item, index) => (
|
||||
<div key={index} className="flex items-start gap-4 p-4 bg-gray-900/50 rounded-lg border border-gray-800">
|
||||
<div className="text-primary font-semibold min-w-[4rem]">{item.time}</div>
|
||||
<div className="text-gray-300">{item.title}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:w-80">
|
||||
<div className="bg-gray-900/50 rounded-xl p-6 border border-gray-800 sticky top-6">
|
||||
<div className="text-center mb-6">
|
||||
<div className="text-3xl font-bold text-white mb-2">{event.price === 0 ? 'Free' : `$${event.price}`}</div>
|
||||
<div className="text-gray-400">per person</div>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between text-sm text-gray-400 mb-2">
|
||||
<span>Availability</span>
|
||||
<span>{event.capacity - event.registered_count} spots left</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-700 rounded-full h-2.5">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${(event.registered_count / event.capacity) * 100}%` }}
|
||||
transition={{ duration: 1, ease: 'easeOut' }}
|
||||
className="bg-primary h-2.5 rounded-full"
|
||||
></motion.div>
|
||||
</div>
|
||||
</div>
|
||||
{isRegistered ? (
|
||||
<div className="space-y-3">
|
||||
<div className="bg-green-500/10 border border-green-500/20 rounded-lg p-4 text-center">
|
||||
<div className="text-green-400 font-semibold">✓ Registered</div>
|
||||
<div className="text-green-300 text-sm mt-1">You're all set!</div>
|
||||
</div>
|
||||
<Button onClick={() => onUnregister(event.id)} variant="outline" className="w-full border-red-500/30 text-red-400 hover:bg-red-500/10 hover:text-red-300" disabled={isLoading}>
|
||||
{isLoading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
Cancel Registration
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button asChild className="w-full bg-primary hover:bg-primary/90 text-white py-3 text-base" disabled={event.registered_count >= event.capacity}>
|
||||
<a href={eventUrl} target="_blank" rel="noopener noreferrer">
|
||||
{event.registered_count >= event.capacity ? 'Event Full' : 'Register Now'}
|
||||
<ExternalLink className="ml-2 h-4 w-4"/>
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
<div className="mt-4 text-xs text-gray-500 text-center">Registration and details are managed on our dedicated events platform.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventDetailModal;
|
||||
120
contribute/src/components/EventList.jsx
Normal file
120
contribute/src/components/EventList.jsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import React, { useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import EventCard from '@/components/EventCard';
|
||||
import EventCardSkeleton from '@/components/EventCardSkeleton';
|
||||
import { Search, SlidersHorizontal } from 'lucide-react';
|
||||
import { useEvents } from '@/hooks/useEvents';
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const EventList = ({ loading, filteredEvents, onSelectEvent, getCategoryColor, getCategoryLabel, setSearchTerm, setSelectedCategory }) => {
|
||||
const { categories } = useEvents();
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-12 text-center">
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-white tracking-tight">AeThex Events</h1>
|
||||
<p className="mt-4 text-lg text-gray-300 max-w-2xl mx-auto">
|
||||
Explore our upcoming conferences, workshops, and networking opportunities.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 flex flex-col md:flex-row gap-4 items-center">
|
||||
<div className="relative w-full md:flex-1">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search events by title, topic, or location..."
|
||||
className="w-full bg-gray-900/50 border border-gray-700/50 rounded-lg pl-12 pr-4 py-3 text-white focus:ring-2 focus:ring-primary focus:border-primary transition"
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<button onClick={() => setShowFilters(!showFilters)} className="flex items-center gap-2 bg-gray-900/50 border border-gray-700/50 px-4 py-3 rounded-lg hover:border-primary/50 transition-colors">
|
||||
<SlidersHorizontal className="w-5 h-5 text-gray-400" />
|
||||
<span className="text-white">Filters</span>
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{showFilters && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 10 }}
|
||||
className="absolute right-0 mt-2 w-48 bg-gray-900 border border-gray-800 rounded-lg shadow-lg p-2 z-20"
|
||||
>
|
||||
{categories.map(category => (
|
||||
<button
|
||||
key={category}
|
||||
onClick={() => {
|
||||
setSelectedCategory(category);
|
||||
setShowFilters(false);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 rounded text-sm text-gray-300 hover:bg-gray-800"
|
||||
>
|
||||
{getCategoryLabel(category)}
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<EventCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
|
||||
>
|
||||
<AnimatePresence>
|
||||
{filteredEvents.map((event) => (
|
||||
<EventCard
|
||||
key={event.id}
|
||||
event={event}
|
||||
onSelectEvent={onSelectEvent}
|
||||
getCategoryColor={getCategoryColor}
|
||||
getCategoryLabel={getCategoryLabel}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{filteredEvents.length === 0 && !loading && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-center py-16"
|
||||
>
|
||||
<motion.div
|
||||
className="text-6xl mb-4"
|
||||
animate={{ rotate: [0, 10, -10, 10, 0], scale: [1, 1.1, 1] }}
|
||||
transition={{ duration: 1, repeat: Infinity, repeatDelay: 2 }}
|
||||
>
|
||||
🔍
|
||||
</motion.div>
|
||||
<h3 className="text-2xl font-bold text-white mb-2">No events found</h3>
|
||||
<p className="text-gray-400">Try adjusting your search or filter criteria.</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventList;
|
||||
160
contribute/src/components/Footer.jsx
Normal file
160
contribute/src/components/Footer.jsx
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useAuth } from '@/contexts/SupabaseAuthContext';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, DropdownMenuGroup } from '@/components/ui/dropdown-menu';
|
||||
import { User, LogOut, Shield, Mail, Loader2 } from 'lucide-react';
|
||||
import AeThexLogo from './AeThexLogo';
|
||||
import { Input } from './ui/input';
|
||||
import { supabase } from '@/lib/customSupabaseClient';
|
||||
import { useToast } from './ui/use-toast';
|
||||
|
||||
const NewsletterForm = () => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
const { error } = await supabase.from('newsletter_subscribers').insert({ email });
|
||||
if (error) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Subscription Failed',
|
||||
description: error.code === '23505' ? 'This email is already subscribed.' : error.message,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: 'Subscribed!',
|
||||
description: 'Thank you for subscribing to the AeThex newsletter.',
|
||||
});
|
||||
setEmail('');
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-2">
|
||||
<p className="font-bold text-white tracking-wider">Stay Updated</p>
|
||||
<p className="text-gray-400 text-sm mb-2">Join our newsletter for updates on our progress and new opportunities.</p>
|
||||
<div className="flex w-full max-w-sm items-center space-x-2">
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
disabled={loading}
|
||||
className="bg-slate-800/50 border-slate-700"
|
||||
/>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Subscribe'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
const Footer = ({ onAuthClick }) => {
|
||||
const { user, profile, signOut } = useAuth();
|
||||
const isAdmin = profile && ['admin', 'site_owner', 'oversee'].includes(profile.role);
|
||||
const avatarUrl = profile?.avatar_url || `https://api.dicebear.com/7.x/bottts/svg?seed=${user?.id}`;
|
||||
|
||||
const UserMenu = () => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
|
||||
<img
|
||||
className="h-10 w-10 rounded-full object-cover border-2 border-primary/50 hover:border-primary transition-colors cursor-pointer"
|
||||
alt={profile?.username || 'User Avatar'}
|
||||
src={avatarUrl}
|
||||
/>
|
||||
</motion.div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-64 bg-slate-900/80 border-primary/20 text-white shadow-2xl shadow-primary/10 rounded-xl font-mono backdrop-blur-md mb-2"
|
||||
align="end"
|
||||
forceMount
|
||||
>
|
||||
<DropdownMenuLabel className="font-normal p-3">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-sm font-semibold leading-none">{profile?.full_name || profile?.username}</p>
|
||||
<p className="text-xs leading-none text-gray-400">{profile?.email}</p>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator className="bg-primary/10" />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem asChild className="focus:bg-slate-800 focus:text-primary transition-colors cursor-pointer p-3">
|
||||
<Link to="/profile">
|
||||
<User className="mr-3 h-4 w-4" />
|
||||
<span>My Profile</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem asChild className="focus:bg-slate-800 focus:text-primary transition-colors cursor-pointer p-3">
|
||||
<Link to="/admin">
|
||||
<Shield className="mr-3 h-4 w-4" />
|
||||
<span>Admin Panel</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator className="bg-primary/10" />
|
||||
<DropdownMenuItem onClick={signOut} className="text-red-400 focus:bg-red-900/40 focus:text-red-300 transition-colors cursor-pointer p-3">
|
||||
<LogOut className="mr-3 h-4 w-4" />
|
||||
<span>Log out</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
return (
|
||||
<footer className="bg-slate-950/80 backdrop-blur-lg border-t border-white/10 font-mono">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
<div className="col-span-1">
|
||||
<AeThexLogo className="h-8 mb-4" />
|
||||
<p className="text-gray-400 text-sm">Engineering the foundational layers for the next digital epoch.</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-bold text-white mb-4 tracking-wider">Company</p>
|
||||
<ul className="space-y-2">
|
||||
<li><Link to="/about" className="text-gray-400 hover:text-primary transition-colors">About Us</Link></li>
|
||||
<li><Link to="/get-involved" className="text-gray-400 hover:text-primary transition-colors">Get Involved</Link></li>
|
||||
<li><Link to="/team" className="text-gray-400 hover:text-primary transition-colors">Our Team</Link></li>
|
||||
<li><Link to="/news" className="text-gray-400 hover:text-primary transition-colors">News</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-bold text-white mb-4 tracking-wider">Resources</p>
|
||||
<ul className="space-y-2">
|
||||
<li><Link to="/technology" className="text-gray-400 hover:text-primary transition-colors">Technology</Link></li>
|
||||
<li><Link to="/contact" className="text-gray-400 hover:text-primary transition-colors">Contact</Link></li>
|
||||
<li><Link to="/privacy" className="text-gray-400 hover:text-primary transition-colors">Privacy Policy</Link></li>
|
||||
<li><Link to="/terms" className="text-gray-400 hover:text-primary transition-colors">Terms & Conditions</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<NewsletterForm />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 pt-8 border-t border-white/10 flex flex-col sm:flex-row justify-between items-center text-gray-500 text-sm">
|
||||
<p>© {new Date().getFullYear()} AeThex. All rights reserved.</p>
|
||||
<div className="flex items-center gap-4 mt-4 sm:mt-0">
|
||||
{user ? (
|
||||
<UserMenu />
|
||||
) : (
|
||||
<Button onClick={onAuthClick} variant="outline" className="border-primary/50 text-primary/80 hover:bg-primary hover:text-slate-900 transition-colors duration-300 text-xs py-1 h-auto">
|
||||
Contributor Login
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
133
contribute/src/components/Header.jsx
Normal file
133
contribute/src/components/Header.jsx
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import React from 'react';
|
||||
import { Link, NavLink } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAuth } from '@/contexts/SupabaseAuthContext';
|
||||
import AeThexLogo from '@/components/AeThexLogo';
|
||||
import NotificationBell from '@/components/NotificationBell';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { LayoutDashboard, User, LogOut, Ticket, Bell } from 'lucide-react';
|
||||
|
||||
const navLinks = [
|
||||
{ name: 'Home', path: '/' },
|
||||
{ name: 'About', path: '/about' },
|
||||
{ name: 'Get Involved', path: '/get-involved' },
|
||||
{ name: 'Team', path: '/team' },
|
||||
{ name: 'Technology', path: '/technology' },
|
||||
{ name: 'News', path: '/news' },
|
||||
{ name: 'Contact', path: '/contact' },
|
||||
];
|
||||
|
||||
const Header = () => {
|
||||
const { user, profile, signOut, setShowAuthModal } = useAuth();
|
||||
const isAdmin = profile && ['admin', 'site_owner', 'oversee'].includes(profile.role);
|
||||
const avatarUrl = profile?.avatar_url || `https://api.dicebear.com/7.x/bottts/svg?seed=${user?.id}`;
|
||||
|
||||
return (
|
||||
<motion.header
|
||||
initial={{ y: -100 }}
|
||||
animate={{ y: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 50, delay: 0.2 }}
|
||||
className="sticky top-4 inset-x-0 max-w-6xl mx-auto z-40"
|
||||
>
|
||||
<div className="flex items-center justify-between p-3 bg-black/50 backdrop-blur-lg rounded-2xl border border-white/10 shadow-2xl shadow-black/40">
|
||||
<Link to="/" className="flex items-center gap-2">
|
||||
<AeThexLogo className="h-8 w-auto" />
|
||||
<span className="text-xl font-bold tracking-tighter bg-clip-text text-transparent bg-gradient-to-r from-white to-gray-400 hidden sm:block">
|
||||
AeThex
|
||||
</span>
|
||||
</Link>
|
||||
<nav className="hidden lg:flex items-center gap-2">
|
||||
{navLinks.map((link) => (
|
||||
<NavLink
|
||||
key={link.name}
|
||||
to={link.path}
|
||||
className={({ isActive }) =>
|
||||
`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'text-white bg-primary/10'
|
||||
: 'text-gray-400 hover:text-white hover:bg-white/5'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{link.name}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<div className="flex items-center gap-3">
|
||||
{user ? (
|
||||
<>
|
||||
<NotificationBell />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="relative h-10 w-10 rounded-full p-0 flex-shrink-0 overflow-hidden">
|
||||
<img
|
||||
className="h-full w-full object-cover"
|
||||
src={avatarUrl}
|
||||
alt={profile?.username || 'User avatar'}
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56" align="end" forceMount>
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-sm font-medium leading-none">{profile?.username}</p>
|
||||
<p className="text-xs leading-none text-muted-foreground">
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/admin">
|
||||
<LayoutDashboard className="mr-2 h-4 w-4" />
|
||||
<span>Admin Dashboard</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/profile">
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
<span>My Profile</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/notifications">
|
||||
<Bell className="mr-2 h-4 w-4" />
|
||||
<span>Notifications</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/my-tickets">
|
||||
<Ticket className="mr-2 h-4 w-4" />
|
||||
<span>My Tickets</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={signOut}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<span>Log out</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
) : (
|
||||
<Button onClick={() => setShowAuthModal(true)}>
|
||||
Sign In
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.header>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
31
contribute/src/components/HeroImage.jsx
Normal file
31
contribute/src/components/HeroImage.jsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import React from 'react';
|
||||
|
||||
const HeroImage = () => {
|
||||
return (
|
||||
<div className="relative w-8 h-8 shrink-0" data-name="ic-sparkles">
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="w-full h-full"
|
||||
>
|
||||
<path
|
||||
d="M11.787 9.5356C11.5053 8.82147 10.4947 8.82147 10.213 9.5356L8.742 13.2654C8.65601 13.4834 8.48343 13.656 8.2654 13.742L4.5356 15.213C3.82147 15.4947 3.82147 16.5053 4.5356 16.787L8.2654 18.258C8.48343 18.344 8.65601 18.5166 8.742 18.7346L10.213 22.4644C10.4947 23.1785 11.5053 23.1785 11.787 22.4644L13.258 18.7346C13.344 18.5166 13.5166 18.344 13.7346 18.258L17.4644 16.787C18.1785 16.5053 18.1785 15.4947 17.4644 15.213L13.7346 13.742C13.5166 13.656 13.344 13.4834 13.258 13.2654L11.787 9.5356Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M23.5621 2.38257C23.361 1.87248 22.639 1.87248 22.4379 2.38257L21.3871 5.04671C21.3257 5.20245 21.2024 5.32572 21.0467 5.38714L18.3826 6.43787C17.8725 6.63904 17.8725 7.36096 18.3826 7.56214L21.0467 8.61286C21.2024 8.67428 21.3257 8.79755 21.3871 8.95329L22.4379 11.6174C22.639 12.1275 23.361 12.1275 23.5621 11.6174L24.6129 8.95329C24.6743 8.79755 24.7976 8.67428 24.9533 8.61286L27.6174 7.56214C28.1275 7.36096 28.1275 6.63904 27.6174 6.43787L24.9533 5.38714C24.7976 5.32572 24.6743 5.20245 24.6129 5.04671L23.5621 2.38257Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M23.3373 22.2295C23.2166 21.9235 22.7834 21.9235 22.6627 22.2295L22.0323 23.828C21.9954 23.9215 21.9215 23.9954 21.828 24.0323L20.2295 24.6627C19.9235 24.7834 19.9235 25.2166 20.2295 25.3373L21.828 25.9677C21.9215 26.0046 21.9954 26.0785 22.0323 26.172L22.6627 27.7705C22.7834 28.0765 23.2166 28.0765 23.3373 27.7705L23.9677 26.172C24.0046 26.0785 24.0785 26.0046 24.172 25.9677L25.7705 25.3373C26.0765 25.2166 26.0765 24.7834 25.7705 24.6627L24.172 24.0323C24.0785 23.9954 24.0046 23.9215 23.9677 23.828L23.3373 22.2295Z"
|
||||
fill="white"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroImage;
|
||||
130
contribute/src/components/LoadingScreen.jsx
Normal file
130
contribute/src/components/LoadingScreen.jsx
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import AeThexLogo from '@/components/AeThexLogo';
|
||||
|
||||
const BinaryDigit = () => {
|
||||
const [position, setPosition] = useState({
|
||||
top: `${Math.random() * 100}%`,
|
||||
left: `${Math.random() * 100}%`,
|
||||
});
|
||||
const duration = Math.random() * 5 + 5; // 5 to 10 seconds
|
||||
|
||||
return (
|
||||
<motion.span
|
||||
className="absolute text-primary/20 text-xs font-mono"
|
||||
style={{ ...position }}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: [0, 1, 0] }}
|
||||
transition={{ duration, repeat: Infinity, ease: "linear" }}
|
||||
>
|
||||
{Math.round(Math.random())}
|
||||
</motion.span>
|
||||
);
|
||||
};
|
||||
|
||||
const DataFallBar = ({ i }) => {
|
||||
const height = Math.random() * 24 + 8;
|
||||
const duration = Math.random() * 0.5 + 0.8;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="w-1.5 bg-gradient-to-t from-primary/50 to-purple-600/50"
|
||||
style={{ height: `${height}px`, opacity: 0 }}
|
||||
animate={{
|
||||
y: [0, 48],
|
||||
opacity: [1, 0],
|
||||
}}
|
||||
transition={{
|
||||
duration,
|
||||
repeat: Infinity,
|
||||
ease: 'linear',
|
||||
delay: i * 0.15 + Math.random() * 0.5,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const LoadingScreen = () => {
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [text, setText] = useState("Initializing AeThex OS...");
|
||||
|
||||
useEffect(() => {
|
||||
let currentProgress = 0;
|
||||
const interval = setInterval(() => {
|
||||
currentProgress += 1;
|
||||
|
||||
if (currentProgress > 98) {
|
||||
currentProgress = 98;
|
||||
}
|
||||
setProgress(currentProgress);
|
||||
|
||||
if (currentProgress > 60 && currentProgress < 80) {
|
||||
setText("Calibrating systems...");
|
||||
} else if (currentProgress >= 80) {
|
||||
setText("Booting interface...");
|
||||
}
|
||||
|
||||
}, 40);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-[#06080d] overflow-hidden relative">
|
||||
<AnimatePresence>
|
||||
<>
|
||||
{Array.from({ length: 30 }).map((_, i) => (
|
||||
<BinaryDigit key={i} />
|
||||
))}
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="flex flex-col items-center gap-8 z-10 w-full max-w-sm px-4"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, ease: "easeOut" }}
|
||||
className="relative"
|
||||
>
|
||||
<div className="absolute -inset-2 bg-primary/20 rounded-[28px] blur-xl opacity-75"></div>
|
||||
<div className="relative w-24 h-24 bg-slate-900/50 rounded-3xl flex items-center justify-center border border-primary/20 backdrop-blur-sm p-4">
|
||||
<AeThexLogo className="h-full" hideText />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div className="flex justify-center items-start h-12 w-24 overflow-hidden relative -mt-4">
|
||||
<div className="absolute top-0 flex justify-center items-start gap-1">
|
||||
{Array.from({ length: 9 }).map((_, i) => (
|
||||
<DataFallBar key={i} i={i} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-xs -mt-4">
|
||||
<div className="h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
className="h-full bg-gradient-to-r from-primary to-purple-500 rounded-full"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${progress}%` }}
|
||||
transition={{ duration: 0.1, ease: "linear" }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-center text-sm text-gray-400 font-mono mt-2 tracking-widest">{progress}%</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center font-mono h-16">
|
||||
<p className="text-lg text-primary">{text}</p>
|
||||
<p className="text-sm text-gray-500 mt-1">Please wait while we prepare your experience...</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadingScreen;
|
||||
39
contribute/src/components/MaintenanceScreen.jsx
Normal file
39
contribute/src/components/MaintenanceScreen.jsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import AeThexLogo from '@/components/AeThexLogo';
|
||||
import { HardDrive } from 'lucide-react';
|
||||
|
||||
const MaintenanceScreen = ({ message }) => {
|
||||
const defaultMessage = "We're currently performing scheduled maintenance. We'll be back online shortly. Thank you for your patience!";
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-[#06080d] text-white font-sans p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, ease: "easeOut" }}
|
||||
className="text-center max-w-2xl"
|
||||
>
|
||||
<div className="flex justify-center mb-8">
|
||||
<div className="relative w-24 h-24 bg-slate-900/50 rounded-3xl flex items-center justify-center border border-primary/20 backdrop-blur-sm p-4">
|
||||
<div className="absolute -inset-2 bg-primary/20 rounded-[28px] blur-xl opacity-75"></div>
|
||||
<AeThexLogo className="h-full" hideText />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-primary mb-4 flex items-center justify-center gap-4">
|
||||
<HardDrive className="w-10 h-10" />
|
||||
Under Maintenance
|
||||
</h1>
|
||||
<p className="text-lg text-gray-300 mb-2">
|
||||
Our site is temporarily unavailable.
|
||||
</p>
|
||||
<p className="text-md text-gray-400">
|
||||
{message || defaultMessage}
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MaintenanceScreen;
|
||||
130
contribute/src/components/NotificationBell.jsx
Normal file
130
contribute/src/components/NotificationBell.jsx
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import React from 'react';
|
||||
import { Bell, MessageSquare, Briefcase, UserPlus } from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuFooter
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Button } from './ui/button';
|
||||
import { useNotifications } from '@/contexts/NotificationContext';
|
||||
import { timeAgo } from '@/lib/utils';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
|
||||
const NotificationIcon = ({ type }) => {
|
||||
switch (type) {
|
||||
case 'new_message':
|
||||
return <MessageSquare className="w-5 h-5 text-blue-400" />;
|
||||
case 'new_job_application':
|
||||
return <Briefcase className="w-5 h-5 text-green-400" />;
|
||||
case 'user_joined':
|
||||
return <UserPlus className="w-5 h-5 text-purple-400" />;
|
||||
default:
|
||||
return <Bell className="w-5 h-5 text-gray-400" />;
|
||||
}
|
||||
}
|
||||
|
||||
const getNotificationDetails = (notification) => {
|
||||
const { type, data } = notification;
|
||||
switch(type) {
|
||||
case 'new_message':
|
||||
return {
|
||||
text: <p>New message from <strong>{data.sender_username}</strong>: "{data.message_preview}..."</p>,
|
||||
link: `/messages/${data.conversation_id}`
|
||||
};
|
||||
case 'new_job_application':
|
||||
return {
|
||||
text: <p>New application for <strong>{data.job_title}</strong> from {data.applicant_username}.</p>,
|
||||
link: `/admin/applications/${data.application_id}`
|
||||
};
|
||||
case 'user_joined':
|
||||
return {
|
||||
text: <p><strong>{data.username}</strong> just joined the platform!</p>,
|
||||
link: `/profile/${data.user_id}`
|
||||
};
|
||||
default:
|
||||
return {
|
||||
text: <p>{data.message || 'New notification'}</p>,
|
||||
link: '#'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const NotificationItem = ({ notification }) => {
|
||||
const { markAsRead } = useNotifications();
|
||||
const navigate = useNavigate();
|
||||
const { text, link } = getNotificationDetails(notification);
|
||||
|
||||
const handleClick = (e) => {
|
||||
e.preventDefault();
|
||||
markAsRead(notification.id);
|
||||
navigate(link);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
className={`p-3 flex items-start gap-3 cursor-pointer focus:bg-gray-800 ${!notification.is_read ? 'bg-primary/10' : ''}`}
|
||||
onSelect={handleClick}
|
||||
>
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<NotificationIcon type={notification.type} />
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<div className="text-sm text-gray-300">{text}</div>
|
||||
<p className="text-xs text-gray-500 mt-1">{timeAgo(notification.created_at)}</p>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
const NotificationBell = () => {
|
||||
const { notifications, unreadCount, markAllAsRead } = useNotifications();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="relative h-10 w-10 p-0 rounded-full flex-shrink-0">
|
||||
<Bell className="h-5 w-5 text-gray-400" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute top-1 right-1 flex h-4 w-4">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-4 w-4 bg-red-500 text-white text-xs items-center justify-center">{unreadCount}</span>
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-80 md:w-96 bg-gray-900 border-white/10 text-white shadow-2xl">
|
||||
<div className="flex items-center justify-between p-3">
|
||||
<DropdownMenuLabel className="p-0 text-base font-semibold">Notifications</DropdownMenuLabel>
|
||||
{unreadCount > 0 && (
|
||||
<Button variant="link" className="p-0 h-auto text-sm text-primary" onClick={(e) => { e.stopPropagation(); markAllAsRead(); }}>Mark all as read</Button>
|
||||
)}
|
||||
</div>
|
||||
<DropdownMenuSeparator className="bg-white/10"/>
|
||||
|
||||
<div className="max-h-80 overflow-y-auto custom-scrollbar">
|
||||
{notifications.length > 0 ? (
|
||||
notifications.slice(0, 5).map(notification => (
|
||||
<NotificationItem key={notification.id} notification={notification} />
|
||||
))
|
||||
) : (
|
||||
<div className="text-center text-gray-500 p-8">
|
||||
<p>No new notifications</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DropdownMenuSeparator className="bg-white/10"/>
|
||||
<DropdownMenuFooter className="p-1">
|
||||
<Link to="/notifications" className="w-full">
|
||||
<Button variant="ghost" className="w-full text-primary">View All Notifications</Button>
|
||||
</Link>
|
||||
</DropdownMenuFooter>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationBell;
|
||||
36
contribute/src/components/PageLayout.jsx
Normal file
36
contribute/src/components/PageLayout.jsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import React from 'react';
|
||||
import { Outlet, useLocation } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import Header from '@/components/Header';
|
||||
import { useAuth } from '@/contexts/SupabaseAuthContext';
|
||||
|
||||
const PageLayout = () => {
|
||||
const location = useLocation();
|
||||
const { setShowAuthModal } = useAuth();
|
||||
|
||||
const handleAuthClick = () => {
|
||||
if (setShowAuthModal) {
|
||||
setShowAuthModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 text-white flex flex-col radial-gradient-background">
|
||||
<Header onAuthClick={handleAuthClick} />
|
||||
<main className="flex-1 w-full pt-8 pb-16">
|
||||
<motion.div
|
||||
key={location.key}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.5, ease: 'easeInOut' }}
|
||||
className="container mx-auto px-4"
|
||||
>
|
||||
<Outlet context={{ onAuthClick: handleAuthClick }} />
|
||||
</motion.div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageLayout;
|
||||
144
contribute/src/components/PassportModal.jsx
Normal file
144
contribute/src/components/PassportModal.jsx
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { X, Copy, Check, Loader2 } from 'lucide-react';
|
||||
import { QRCode } from 'react-qrcode-logo';
|
||||
import AeThexLogo from './AeThexLogo';
|
||||
import { useAuth } from '@/contexts/SupabaseAuthContext';
|
||||
import { supabase } from '@/lib/customSupabaseClient';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const backdropVariants = {
|
||||
visible: { opacity: 1 },
|
||||
hidden: { opacity: 0 }
|
||||
};
|
||||
|
||||
const modalVariants = {
|
||||
hidden: { opacity: 0, scale: 0.9, y: 50 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
y: 0,
|
||||
transition: { type: 'spring', damping: 25, stiffness: 200 }
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
scale: 0.9,
|
||||
y: 50,
|
||||
transition: { duration: 0.2 }
|
||||
}
|
||||
};
|
||||
|
||||
const PassportModal = ({ onClose }) => {
|
||||
const { user } = useAuth();
|
||||
const [profile, setProfile] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProfile = async () => {
|
||||
if (user) {
|
||||
setLoading(true);
|
||||
const { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.select('username, avatar_url, aethex_passport_id, created_at')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching profile:', error);
|
||||
toast.error("Failed to load Passport data.");
|
||||
} else {
|
||||
setProfile(data);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchProfile();
|
||||
}, [user]);
|
||||
|
||||
const handleCopy = () => {
|
||||
if (profile?.aethex_passport_id) {
|
||||
navigator.clipboard.writeText(profile.aethex_passport_id);
|
||||
setCopied(true);
|
||||
toast.success("Passport ID Copied!");
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
variants={backdropVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="hidden"
|
||||
className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
variants={modalVariants}
|
||||
className="relative bg-gray-900 rounded-2xl border border-primary/20 w-full max-w-sm shadow-2xl shadow-primary/10 overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 z-20 bg-black/50 hover:bg-black/70 text-white p-2 rounded-full transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<div className="relative p-8 bg-grid-pattern">
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-gray-900"></div>
|
||||
<div className="relative z-10 flex flex-col items-center text-center">
|
||||
<img
|
||||
src={profile?.avatar_url || 'https://i.pravatar.cc/150?u=a042581f4e29026704d'}
|
||||
alt="User Avatar"
|
||||
className="w-24 h-24 rounded-full object-cover border-4 border-primary shadow-lg"
|
||||
/>
|
||||
<h2 className="text-2xl font-bold text-white mt-4">{profile?.username || 'Loading...'}</h2>
|
||||
<p className="text-sm text-gray-400">{user?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center h-48">
|
||||
<Loader2 className="w-8 h-8 text-primary animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center space-y-6">
|
||||
<div className="bg-white p-3 rounded-lg shadow-md">
|
||||
<QRCode
|
||||
value={profile?.aethex_passport_id || 'no-id'}
|
||||
size={150}
|
||||
logoImage="/aethex-icon.svg"
|
||||
logoWidth={40}
|
||||
logoHeight={40}
|
||||
qrStyle="dots"
|
||||
eyeRadius={5}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-center w-full">
|
||||
<p className="text-xs text-gray-500 uppercase font-semibold">Passport ID</p>
|
||||
<div className="flex items-center justify-center mt-1 bg-gray-800 rounded-lg px-3 py-2 border border-gray-700">
|
||||
<p className="text-sm text-primary font-mono truncate">{profile?.aethex_passport_id}</p>
|
||||
<button onClick={handleCopy} className="ml-3 text-gray-400 hover:text-white">
|
||||
{copied ? <Check className="w-4 h-4 text-green-500"/> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-gray-500 uppercase font-semibold">Member Since</p>
|
||||
<p className="text-sm text-white mt-1">{profile ? new Date(profile.created_at).toLocaleDateString() : '...'}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-gray-900/50 px-8 py-4 border-t border-gray-800 flex justify-center items-center">
|
||||
<AeThexLogo />
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PassportModal;
|
||||
31
contribute/src/components/ProtectedRoute.jsx
Normal file
31
contribute/src/components/ProtectedRoute.jsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import React from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '@/contexts/SupabaseAuthContext';
|
||||
import LoadingScreen from '@/components/LoadingScreen';
|
||||
|
||||
const ProtectedRoute = ({ children, adminOnly = false }) => {
|
||||
const { user, profile, loading } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (loading) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
if (adminOnly) {
|
||||
if (!profile) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
if (!['admin', 'site_owner', 'oversee'].includes(profile?.role)) {
|
||||
return <Navigate to="/" state={{ from: location }} replace />;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
export default ProtectedRoute;
|
||||
17
contribute/src/components/WelcomeMessage.jsx
Normal file
17
contribute/src/components/WelcomeMessage.jsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
const WelcomeMessage = () => {
|
||||
return (
|
||||
<motion.p
|
||||
className='text-sm text-white leading-5 w-full'
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.8 }}
|
||||
>
|
||||
Write in the chat what you want to create.
|
||||
</motion.p>
|
||||
);
|
||||
};
|
||||
|
||||
export default WelcomeMessage;
|
||||
43
contribute/src/components/ui/accordion.jsx
Normal file
43
contribute/src/components/ui/accordion.jsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import React from "react"
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
||||
import { ChevronDown } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Accordion = AccordionPrimitive.Root
|
||||
|
||||
const AccordionItem = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<AccordionPrimitive.Item ref={ref} className={cn("border-b border-white/10", className)} {...props} />
|
||||
))
|
||||
AccordionItem.displayName = "AccordionItem"
|
||||
|
||||
const AccordionTrigger = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180 text-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
))
|
||||
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
|
||||
|
||||
const AccordionContent = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Content
|
||||
ref={ref}
|
||||
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pb-4 pt-0", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
))
|
||||
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
36
contribute/src/components/ui/badge.jsx
Normal file
36
contribute/src/components/ui/badge.jsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import React from "react"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
success:
|
||||
"border-transparent bg-green-500 text-white hover:bg-green-500/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}) {
|
||||
return (<div className={cn(badgeVariants({ variant }), className)} {...props} />);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
47
contribute/src/components/ui/button.jsx
Normal file
47
contribute/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-all duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 relative overflow-hidden group',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground shadow-lg shadow-primary/20 hover:bg-primary/90 hover:scale-105 transform',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground hover:bg-destructive/90 hover:scale-105 transform',
|
||||
outline:
|
||||
'border border-input bg-transparent 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 text-base',
|
||||
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 };
|
||||
60
contribute/src/components/ui/card.jsx
Normal file
60
contribute/src/components/ui/card.jsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Card = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-xl border border-white/10 bg-gray-950/20 text-white shadow-2xl shadow-black/40 backdrop-blur-lg transition-all duration-300 hover:border-primary/50 hover:shadow-primary/10',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = 'Card';
|
||||
|
||||
const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex flex-col space-y-1.5 p-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = 'CardHeader';
|
||||
|
||||
const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn('text-xl font-bold tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-white to-gray-400', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = 'CardTitle';
|
||||
|
||||
const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<p ref={ref} className={cn('text-sm text-gray-400', className)} {...props} />
|
||||
));
|
||||
CardDescription.displayName = 'CardDescription';
|
||||
|
||||
const CardContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = 'CardContent';
|
||||
|
||||
const CardFooter = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex items-center p-6 pt-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = 'CardFooter';
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||
22
contribute/src/components/ui/checkbox.jsx
Normal file
22
contribute/src/components/ui/checkbox.jsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { Check } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Checkbox = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
|
||||
export { Checkbox }
|
||||
94
contribute/src/components/ui/dialog.jsx
Normal file
94
contribute/src/components/ui/dialog.jsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-slate-800 bg-gray-900/90 p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
{children}
|
||||
<DialogPrimitive.Close
|
||||
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-slate-950 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-slate-100 data-[state=open]:text-slate-500 dark:ring-offset-slate-950 dark:focus:ring-slate-300 dark:data-[state=open]:bg-slate-800 dark:data-[state=open]:text-slate-400">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<div
|
||||
className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
|
||||
{...props} />
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<div
|
||||
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
|
||||
{...props} />
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||
{...props} />
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-slate-500 dark:text-slate-400", className)}
|
||||
{...props} />
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
172
contribute/src/components/ui/dropdown-menu.jsx
Normal file
172
contribute/src/components/ui/dropdown-menu.jsx
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-slate-100 data-[state=open]:bg-slate-100 dark:focus:bg-slate-800 dark:data-[state=open]:bg-slate-800",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-slate-200 bg-white p-1 text-slate-950 shadow-lg 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 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-slate-200 bg-white p-1 text-slate-950 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 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-slate-100 focus:text-slate-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-slate-800 dark:focus:text-slate-50",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-slate-100 focus:text-slate-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-slate-800 dark:focus:text-slate-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}>
|
||||
<span
|
||||
className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-slate-100 focus:text-slate-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-slate-800 dark:focus:text-slate-50",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<span
|
||||
className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-slate-100 dark:bg-slate-800", className)}
|
||||
{...props} />
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
(<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props} />)
|
||||
);
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
|
||||
const DropdownMenuFooter = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("p-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuFooter.displayName = "DropdownMenuFooter";
|
||||
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuFooter,
|
||||
}
|
||||
19
contribute/src/components/ui/input.jsx
Normal file
19
contribute/src/components/ui/input.jsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Input = React.forwardRef(({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
(<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-white/10 bg-gray-950/40 px-3 py-2 text-sm text-white ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-slate-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus:border-primary transition-all duration-300 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props} />)
|
||||
);
|
||||
})
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
16
contribute/src/components/ui/label.jsx
Normal file
16
contribute/src/components/ui/label.jsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-gray-300"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
120
contribute/src/components/ui/select.jsx
Normal file
120
contribute/src/components/ui/select.jsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
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-slate-800 bg-gray-900/50 px-3 py-2 text-sm ring-offset-background placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
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 border-slate-800 bg-gray-900 text-slate-50 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-slate-800 focus:text-slate-50 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-slate-100/10 dark:bg-slate-800", className)}
|
||||
{...props} />
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
23
contribute/src/components/ui/switch.jsx
Normal file
23
contribute/src/components/ui/switch.jsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import React from 'react';
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Switch = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
));
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||
|
||||
export { Switch };
|
||||
83
contribute/src/components/ui/table.jsx
Normal file
83
contribute/src/components/ui/table.jsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Table = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props} />
|
||||
</div>
|
||||
))
|
||||
Table.displayName = "Table"
|
||||
|
||||
const TableHeader = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = "TableHeader"
|
||||
|
||||
const TableBody = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props} />
|
||||
))
|
||||
TableBody.displayName = "TableBody"
|
||||
|
||||
const TableFooter = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn("bg-slate-900 font-medium text-slate-50 dark:bg-slate-50 dark:text-slate-900", className)}
|
||||
{...props} />
|
||||
))
|
||||
TableFooter.displayName = "TableFooter"
|
||||
|
||||
const TableRow = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-slate-100/50 data-[state=selected]:bg-slate-100 dark:hover:bg-slate-800/50 dark:data-[state=selected]:bg-slate-800",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
TableRow.displayName = "TableRow"
|
||||
|
||||
const TableHead = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-12 px-4 text-left align-middle font-medium text-slate-500 [&:has([role=checkbox])]:pr-0 dark:text-slate-400",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
TableHead.displayName = "TableHead"
|
||||
|
||||
const TableCell = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||
{...props} />
|
||||
))
|
||||
TableCell.displayName = "TableCell"
|
||||
|
||||
const TableCaption = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-slate-500 dark:text-slate-400", className)}
|
||||
{...props} />
|
||||
))
|
||||
TableCaption.displayName = "TableCaption"
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
18
contribute/src/components/ui/textarea.jsx
Normal file
18
contribute/src/components/ui/textarea.jsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Textarea = React.forwardRef(({ className, ...props }, ref) => {
|
||||
return (
|
||||
(<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-white/10 bg-gray-950/40 px-3 py-2 text-sm text-white ring-offset-background placeholder:text-slate-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus:border-primary transition-all duration-300 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props} />)
|
||||
);
|
||||
})
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
104
contribute/src/components/ui/toast.jsx
Normal file
104
contribute/src/components/ui/toast.jsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
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 backdrop-blur-sm',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-gray-900/80 border-gray-700 text-gray-50',
|
||||
destructive:
|
||||
'group destructive border-red-500/50 bg-red-900/80 text-red-50',
|
||||
success:
|
||||
'group success border-green-500/50 bg-green-900/80 text-green-50',
|
||||
},
|
||||
},
|
||||
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,
|
||||
toastVariants,
|
||||
};
|
||||
34
contribute/src/components/ui/toaster.jsx
Normal file
34
contribute/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
contribute/src/components/ui/use-toast.js
Normal file
103
contribute/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,
|
||||
}
|
||||
}
|
||||
105
contribute/src/contexts/NotificationContext.jsx
Normal file
105
contribute/src/contexts/NotificationContext.jsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import { supabase } from '@/lib/customSupabaseClient';
|
||||
import { useAuth } from './SupabaseAuthContext';
|
||||
|
||||
const NotificationContext = createContext();
|
||||
|
||||
export const NotificationProvider = ({ children }) => {
|
||||
const { user } = useAuth();
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchNotifications = useCallback(async () => {
|
||||
if (!user) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
const { data, error } = await supabase
|
||||
.from('notifications')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching notifications:', error);
|
||||
} else {
|
||||
setNotifications(data);
|
||||
setUnreadCount(data.filter(n => !n.is_read).length);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchNotifications();
|
||||
}, [fetchNotifications]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
|
||||
const channel = supabase
|
||||
.channel(`user_notifications:${user.id}`)
|
||||
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'notifications', filter: `user_id=eq.${user.id}` },
|
||||
(payload) => {
|
||||
setNotifications(prev => [payload.new, ...prev]);
|
||||
setUnreadCount(prev => prev + 1);
|
||||
}
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
supabase.removeChannel(channel);
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
const markAsRead = async (notificationId) => {
|
||||
const { data, error } = await supabase
|
||||
.from('notifications')
|
||||
.update({ is_read: true })
|
||||
.eq('id', notificationId)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error('Error marking notification as read:', error);
|
||||
} else {
|
||||
setNotifications(prev => prev.map(n => n.id === notificationId ? data : n));
|
||||
setUnreadCount(prev => (prev > 0 ? prev - 1 : 0));
|
||||
}
|
||||
};
|
||||
|
||||
const markAllAsRead = async () => {
|
||||
if (!user) return;
|
||||
const { error } = await supabase
|
||||
.from('notifications')
|
||||
.update({ is_read: true })
|
||||
.eq('user_id', user.id)
|
||||
.eq('is_read', false);
|
||||
|
||||
if (error) {
|
||||
console.error('Error marking all notifications as read:', error);
|
||||
} else {
|
||||
setNotifications(prev => prev.map(n => ({ ...n, is_read: true })));
|
||||
setUnreadCount(0);
|
||||
}
|
||||
};
|
||||
|
||||
const value = {
|
||||
notifications,
|
||||
unreadCount,
|
||||
loading,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
};
|
||||
|
||||
return <NotificationContext.Provider value={value}>{children}</NotificationContext.Provider>;
|
||||
};
|
||||
|
||||
export const useNotifications = () => {
|
||||
const context = useContext(NotificationContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useNotifications must be used within a NotificationProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
92
contribute/src/contexts/SiteContext.jsx
Normal file
92
contribute/src/contexts/SiteContext.jsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import React, { createContext, useContext, useEffect, useState, useMemo, useCallback } from 'react';
|
||||
import { supabase } from '@/lib/customSupabaseClient';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { useAuth } from '@/contexts/SupabaseAuthContext';
|
||||
|
||||
const SiteContext = createContext(undefined);
|
||||
|
||||
const SITE_NAME = "AeThex Corporate";
|
||||
|
||||
export const SiteProvider = ({ children }) => {
|
||||
const { toast } = useToast();
|
||||
const { user, profile, loading: authLoading } = useAuth();
|
||||
const [siteId, setSiteId] = useState(null);
|
||||
const [siteConfig, setSiteConfig] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const initializeSite = useCallback(async (currentUser, currentUserProfile) => {
|
||||
if (!currentUser || !currentUserProfile) {
|
||||
setSiteId(null);
|
||||
setSiteConfig(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data, error } = await supabase.rpc('get_or_create_site_config', {
|
||||
p_site_name: SITE_NAME
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (data && data.length > 0) {
|
||||
const config = data[0];
|
||||
setSiteConfig(config);
|
||||
setSiteId(config.site_id);
|
||||
} else {
|
||||
// This case should ideally not be reached due to the function's design,
|
||||
// but it's good practice to handle it.
|
||||
toast({
|
||||
variant: "warning",
|
||||
title: "Site Not Found",
|
||||
description: "Could not retrieve or create site configuration.",
|
||||
});
|
||||
setSiteId(null);
|
||||
setSiteConfig(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error initializing site:", error);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Site Initialization Failed",
|
||||
description: error.message || "Could not initialize the site.",
|
||||
});
|
||||
setSiteId(null);
|
||||
setSiteConfig(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [toast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading) {
|
||||
initializeSite(user, profile);
|
||||
}
|
||||
}, [user, profile, authLoading, initializeSite]);
|
||||
|
||||
const refreshSiteConfig = useCallback(async () => {
|
||||
if (user && profile) {
|
||||
await initializeSite(user, profile);
|
||||
}
|
||||
}, [user, profile, initializeSite]);
|
||||
|
||||
const value = useMemo(() => ({
|
||||
siteId,
|
||||
siteConfig,
|
||||
loading: authLoading || loading,
|
||||
refreshSiteConfig,
|
||||
}), [siteId, siteConfig, authLoading, loading, refreshSiteConfig]);
|
||||
|
||||
return <SiteContext.Provider value={value}>{children}</SiteContext.Provider>;
|
||||
};
|
||||
|
||||
export const useSite = () => {
|
||||
const context = useContext(SiteContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useSite must be used within a SiteProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
195
contribute/src/contexts/SupabaseAuthContext.jsx
Normal file
195
contribute/src/contexts/SupabaseAuthContext.jsx
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import React, { createContext, useState, useEffect, useContext, useCallback, useMemo } from 'react';
|
||||
import { supabase } from '@/lib/customSupabaseClient';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
|
||||
const SupabaseAuthContext = createContext(null);
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(SupabaseAuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const AuthProvider = ({ children }) => {
|
||||
const [user, setUser] = useState(null);
|
||||
const [profile, setProfile] = useState(null);
|
||||
const [session, setSession] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAuthModal, setShowAuthModal] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
const fetchProfile = useCallback(async (userId) => {
|
||||
if (!userId) return null;
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.select('*')
|
||||
.eq('id', userId)
|
||||
.single();
|
||||
if (error && error.code !== 'PGRST116') {
|
||||
console.error('Error fetching profile:', error.message);
|
||||
return null;
|
||||
};
|
||||
return data || null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching profile:', error.message);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleAuthStateChange = useCallback(async (event, session) => {
|
||||
setLoading(true);
|
||||
setSession(session);
|
||||
const currentUser = session?.user ?? null;
|
||||
setUser(currentUser);
|
||||
|
||||
if (currentUser) {
|
||||
const userProfile = await fetchProfile(currentUser.id);
|
||||
setProfile(userProfile);
|
||||
} else {
|
||||
setProfile(null);
|
||||
}
|
||||
|
||||
if (event === 'SIGNED_IN') {
|
||||
setShowAuthModal(false);
|
||||
toast({
|
||||
title: "Signed In Successfully",
|
||||
description: `Welcome back, ${session?.user.email}!`,
|
||||
});
|
||||
}
|
||||
if (event === 'SIGNED_OUT') {
|
||||
setProfile(null);
|
||||
setUser(null);
|
||||
setSession(null);
|
||||
// No toast on programmatic sign-out to avoid duplicate messages.
|
||||
}
|
||||
if (event === 'TOKEN_REFRESHED' && session === null) {
|
||||
// This case handles the "Invalid Refresh Token" error implicitly.
|
||||
// Supabase sets session to null after a failed refresh.
|
||||
signOut(true); // Pass true to show a toast
|
||||
}
|
||||
setLoading(false);
|
||||
}, [fetchProfile, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
supabase.auth.getSession().then(({ data: { session }, error }) => {
|
||||
if (error && error.message.includes('Invalid Refresh Token')) {
|
||||
signOut(true);
|
||||
} else {
|
||||
handleAuthStateChange('INITIAL_SESSION', session);
|
||||
}
|
||||
}).finally(() => setLoading(false));
|
||||
|
||||
const { data: authListener } = supabase.auth.onAuthStateChange(handleAuthStateChange);
|
||||
return () => {
|
||||
authListener.subscription.unsubscribe();
|
||||
};
|
||||
}, [handleAuthStateChange]);
|
||||
|
||||
const signIn = async (email, password) => {
|
||||
const { error } = await supabase.auth.signInWithPassword({ email, password });
|
||||
if (error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Sign In Failed",
|
||||
description: error.message,
|
||||
});
|
||||
}
|
||||
return { error };
|
||||
};
|
||||
|
||||
const signUp = async (email, password) => {
|
||||
const { error } = await supabase.auth.signUp({ email, password });
|
||||
if (error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Sign Up Failed",
|
||||
description: error.message,
|
||||
});
|
||||
}
|
||||
return { error };
|
||||
};
|
||||
|
||||
const signOut = async (showSessionExpiredToast = false) => {
|
||||
const { error } = await supabase.auth.signOut();
|
||||
if (error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Sign Out Failed",
|
||||
description: error.message,
|
||||
});
|
||||
} else {
|
||||
if (showSessionExpiredToast) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Session Expired",
|
||||
description: "Your session has expired. Please sign in again.",
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: "Signed Out",
|
||||
description: "You have been successfully signed out.",
|
||||
});
|
||||
}
|
||||
}
|
||||
setProfile(null);
|
||||
setUser(null);
|
||||
setSession(null);
|
||||
return { error };
|
||||
};
|
||||
|
||||
const sendPasswordResetEmail = async (email) => {
|
||||
const { error } = await supabase.auth.resetPasswordForEmail(email, {
|
||||
redirectTo: `${window.location.origin}/password-reset`,
|
||||
});
|
||||
if (error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error Sending Email",
|
||||
description: error.message,
|
||||
});
|
||||
}
|
||||
return { error };
|
||||
};
|
||||
|
||||
const signInWithProvider = async (provider) => {
|
||||
const { error } = await supabase.auth.signInWithOAuth({
|
||||
provider: provider,
|
||||
options: {
|
||||
redirectTo: window.location.origin,
|
||||
},
|
||||
});
|
||||
if (error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: `${provider} Sign In Failed`,
|
||||
description: error.message,
|
||||
});
|
||||
}
|
||||
return { error };
|
||||
};
|
||||
|
||||
const value = useMemo(() => ({
|
||||
user,
|
||||
profile,
|
||||
session,
|
||||
loading,
|
||||
showAuthModal,
|
||||
setShowAuthModal,
|
||||
signIn,
|
||||
signUp,
|
||||
signOut,
|
||||
signInWithGitHub: () => signInWithProvider('github'),
|
||||
signInWithGoogle: () => signInWithProvider('google'),
|
||||
sendPasswordResetEmail,
|
||||
}), [user, profile, session, loading, showAuthModal]);
|
||||
|
||||
return (
|
||||
<SupabaseAuthContext.Provider value={value}>
|
||||
{children}
|
||||
</SupabaseAuthContext.Provider>
|
||||
);
|
||||
};
|
||||
172
contribute/src/hooks/useEvents.js
Normal file
172
contribute/src/hooks/useEvents.js
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { supabase } from '@/lib/customSupabaseClient';
|
||||
import { useAuth } from '@/contexts/SupabaseAuthContext';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
|
||||
const CATEGORIES = {
|
||||
'tech-summit': { label: 'Tech Summit', color: 'bg-blue-500' },
|
||||
'dev-workshop': { label: 'Dev Workshop', color: 'bg-green-500' },
|
||||
'product-launch': { label: 'Product Launch', color: 'bg-purple-500' },
|
||||
'networking-mixer': { label: 'Networking Mixer', color: 'bg-yellow-500' },
|
||||
};
|
||||
|
||||
export const useEvents = () => {
|
||||
const { user } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const [events, setEvents] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [registrationLoading, setRegistrationLoading] = useState(false);
|
||||
const [registeredEvents, setRegisteredEvents] = useState([]);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState('all');
|
||||
|
||||
const fetchEvents = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const { data, error } = await supabase
|
||||
.from('aethex_events')
|
||||
.select('*, aethex_event_registrations(count)');
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching events:', error);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error",
|
||||
description: "Could not fetch events.",
|
||||
});
|
||||
setEvents([]);
|
||||
} else {
|
||||
const formattedEvents = data.map(event => ({
|
||||
...event,
|
||||
registered_count: event.aethex_event_registrations[0]?.count || 0,
|
||||
}));
|
||||
setEvents(formattedEvents);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [toast]);
|
||||
|
||||
const fetchRegisteredEvents = useCallback(async () => {
|
||||
if (!user) {
|
||||
setRegisteredEvents([]);
|
||||
return;
|
||||
}
|
||||
const { data, error } = await supabase
|
||||
.from('aethex_event_registrations')
|
||||
.select('event_id')
|
||||
.eq('user_id', user.id);
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching registered events:', error);
|
||||
} else {
|
||||
setRegisteredEvents(data.map(reg => reg.event_id));
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchEvents();
|
||||
}, [fetchEvents]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRegisteredEvents();
|
||||
}, [user, fetchRegisteredEvents]);
|
||||
|
||||
const handleRegister = useCallback(async (eventId) => {
|
||||
if (!user) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Authentication Required",
|
||||
description: "You must be signed in to register for an event.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setRegistrationLoading(true);
|
||||
const { error } = await supabase
|
||||
.from('aethex_event_registrations')
|
||||
.insert({ event_id: eventId, user_id: user.id });
|
||||
|
||||
if (error) {
|
||||
console.error('Error registering for event:', error);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Registration Failed",
|
||||
description: error.message,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
variant: "success",
|
||||
title: "Registration Successful!",
|
||||
description: "You've successfully registered for the event and earned 100 loyalty points!",
|
||||
});
|
||||
setRegisteredEvents(prev => [...prev, eventId]);
|
||||
setEvents(prevEvents => prevEvents.map(event =>
|
||||
event.id === eventId ? { ...event, registered_count: event.registered_count + 1 } : event
|
||||
));
|
||||
}
|
||||
setRegistrationLoading(false);
|
||||
}, [user, toast]);
|
||||
|
||||
const handleUnregister = useCallback(async (eventId) => {
|
||||
if (!user) return;
|
||||
setRegistrationLoading(true);
|
||||
const { error } = await supabase
|
||||
.from('aethex_event_registrations')
|
||||
.delete()
|
||||
.match({ event_id: eventId, user_id: user.id });
|
||||
|
||||
if (error) {
|
||||
console.error('Error unregistering from event:', error);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Unregistration Failed",
|
||||
description: error.message,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: "Unregistered Successfully",
|
||||
description: "You have been unregistered from the event.",
|
||||
});
|
||||
setRegisteredEvents(prev => prev.filter(id => id !== eventId));
|
||||
setEvents(prevEvents => prevEvents.map(event =>
|
||||
event.id === eventId ? { ...event, registered_count: Math.max(0, event.registered_count - 1) } : event
|
||||
));
|
||||
}
|
||||
setRegistrationLoading(false);
|
||||
}, [user, toast]);
|
||||
|
||||
const filteredEvents = useMemo(() => {
|
||||
return events
|
||||
.filter(event =>
|
||||
event.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
event.description.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
.filter(event =>
|
||||
selectedCategory === 'all' || event.category === selectedCategory
|
||||
);
|
||||
}, [events, searchTerm, selectedCategory]);
|
||||
|
||||
const getCategoryColor = useCallback((category) => {
|
||||
return CATEGORIES[category]?.color || 'bg-gray-500';
|
||||
}, []);
|
||||
|
||||
const getCategoryLabel = useCallback((category) => {
|
||||
return CATEGORIES[category]?.label || 'General';
|
||||
}, []);
|
||||
|
||||
const categories = useMemo(() => ['all', ...Object.keys(CATEGORIES)], []);
|
||||
|
||||
return {
|
||||
events,
|
||||
filteredEvents,
|
||||
loading,
|
||||
registrationLoading,
|
||||
registeredEvents,
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
selectedCategory,
|
||||
setSelectedCategory,
|
||||
handleRegister,
|
||||
handleUnregister,
|
||||
getCategoryColor,
|
||||
getCategoryLabel,
|
||||
categories,
|
||||
};
|
||||
};
|
||||
18
contribute/src/hooks/useForm.js
Normal file
18
contribute/src/hooks/useForm.js
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
|
||||
export const useForm = (initialState = {}) => {
|
||||
const [values, setValues] = useState(initialState);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setValues(initialState);
|
||||
}, [initialState]);
|
||||
|
||||
const handleInputChange = useCallback(({ target }) => {
|
||||
setValues(prev => ({
|
||||
...prev,
|
||||
[target.name]: target.type === 'checkbox' ? target.checked : target.value,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
return { values, setValues, handleInputChange, reset };
|
||||
};
|
||||
120
contribute/src/index.css
Normal file
120
contribute/src/index.css
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Courier+Prime:wght@400;700&display=swap');
|
||||
|
||||
body {
|
||||
font-family: 'Courier Prime', 'Courier New', Courier, monospace;
|
||||
background-color: #03040B;
|
||||
color: hsl(var(--foreground));
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: 228 33% 4%;
|
||||
--foreground: 210 20% 98%;
|
||||
--card: 224 71.4% 4.1%;
|
||||
--card-foreground: 210 20% 98%;
|
||||
--popover: 224 71.4% 4.1%;
|
||||
--popover-foreground: 210 20% 98%;
|
||||
--primary: 210 100% 56%; /* Aethex Blue */
|
||||
--secondary: 260 100% 70%; /* Aethex Purple */
|
||||
--primary-foreground: 210 20% 98%;
|
||||
--secondary-foreground: 210 20% 98%;
|
||||
--muted: 215 27.9% 16.9%;
|
||||
--muted-foreground: 217.9 10.6% 64.9%;
|
||||
--accent: 215 27.9% 16.9%;
|
||||
--accent-foreground: 210 20% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 20% 98%;
|
||||
--border: 215 27.9% 16.9%;
|
||||
--input: 215 27.9% 16.9%;
|
||||
--ring: 210 100% 56%;
|
||||
--radius: 0.75rem;
|
||||
}
|
||||
|
||||
* {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
.font-mono {
|
||||
font-family: 'Courier Prime', 'Courier New', Courier, monospace;
|
||||
}
|
||||
|
||||
.radial-gradient-background {
|
||||
background-color: #0a0e14;
|
||||
background-image: radial-gradient(circle at 1px 1px, hsl(var(--border)) 1px, transparent 0);
|
||||
background-size: 40px 40px;
|
||||
}
|
||||
|
||||
.aurora-background {
|
||||
position: relative;
|
||||
background-color: #03040B;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.aurora-background::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 150%;
|
||||
height: 150%;
|
||||
background-image:
|
||||
radial-gradient(ellipse at 20% 30%, hsl(var(--primary) / 0.1), transparent 60%),
|
||||
radial-gradient(ellipse at 80% 60%, hsl(var(--secondary) / 0.1), transparent 60%);
|
||||
transform: translate(-50%, -50%);
|
||||
animation: aurora-flow 20s linear infinite;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
@keyframes aurora-flow {
|
||||
0% { transform: translate(-50%, -50%) rotate(0deg); }
|
||||
100% { transform: translate(-50%, -50%) rotate(360deg); }
|
||||
}
|
||||
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: hsl(var(--background));
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: hsl(var(--primary));
|
||||
border-radius: 20px;
|
||||
border: 3px solid hsl(var(--background));
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: hsl(204, 80%, 63%);
|
||||
}
|
||||
|
||||
.prose {
|
||||
--tw-prose-body: theme(colors.gray.300);
|
||||
--tw-prose-headings: theme(colors.white);
|
||||
--tw-prose-lead: theme(colors.gray.400);
|
||||
--tw-prose-links: hsl(var(--primary)); /* Use direct HSL from --primary */
|
||||
--tw-prose-bold: theme(colors.white);
|
||||
--tw-prose-counters: theme(colors.gray.400);
|
||||
--tw-prose-bullets: theme(colors.gray.600);
|
||||
--tw-prose-hr: theme(colors.gray.700);
|
||||
--tw-prose-quotes: theme(colors.gray.200);
|
||||
--tw-prose-quote-borders: hsl(var(--primary)); /* Use direct HSL from --primary */
|
||||
--tw-prose-captions: theme(colors.gray.400);
|
||||
--tw-prose-code: hsl(var(--primary)); /* Use direct HSL from --primary */
|
||||
--tw-prose-pre-code: theme(colors.gray.300);
|
||||
--tw-prose-pre-bg: theme(colors.gray.900);
|
||||
--tw-prose-th-borders: theme(colors.gray.600);
|
||||
--tw-prose-td-borders: theme(colors.gray.700);
|
||||
}
|
||||
13
contribute/src/lib/customSupabaseClient.js
Normal file
13
contribute/src/lib/customSupabaseClient.js
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const supabaseUrl = 'https://kmdeisowhtsalsekkzqd.supabase.co';
|
||||
const supabaseAnonKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImttZGVpc293aHRzYWxzZWtrenFkIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTM3Mzc2NTIsImV4cCI6MjA2OTMxMzY1Mn0.2mvk-rDZnHOzdx6Cgcysh51a3cflOlRWO6OA1Z5YWuQ';
|
||||
|
||||
const customSupabaseClient = createClient(supabaseUrl, supabaseAnonKey);
|
||||
|
||||
export default customSupabaseClient;
|
||||
|
||||
export {
|
||||
customSupabaseClient,
|
||||
customSupabaseClient as supabase,
|
||||
};
|
||||
61
contribute/src/lib/utils.js
Normal file
61
contribute/src/lib/utils.js
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export const formatDate = (dateString) => {
|
||||
if (!dateString) return 'N/A';
|
||||
const date = new Date(dateString);
|
||||
const userTimezoneOffset = date.getTimezoneOffset() * 60000;
|
||||
const adjustedDate = new Date(date.getTime() + userTimezoneOffset);
|
||||
|
||||
return adjustedDate.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
export const formatTime = (timeString) => {
|
||||
if (!timeString) return 'N/A';
|
||||
const [hours, minutes] = timeString.split(':');
|
||||
const date = new Date();
|
||||
date.setHours(hours);
|
||||
date.setMinutes(minutes);
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
});
|
||||
};
|
||||
|
||||
export const timeAgo = (dateString) => {
|
||||
if (!dateString) return null;
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const seconds = Math.floor((now - date) / 1000);
|
||||
|
||||
let interval = seconds / 31536000;
|
||||
if (interval > 1) {
|
||||
return Math.floor(interval) + " years ago";
|
||||
}
|
||||
interval = seconds / 2592000;
|
||||
if (interval > 1) {
|
||||
return Math.floor(interval) + " months ago";
|
||||
}
|
||||
interval = seconds / 86400;
|
||||
if (interval > 1) {
|
||||
return Math.floor(interval) + " days ago";
|
||||
}
|
||||
interval = seconds / 3600;
|
||||
if (interval > 1) {
|
||||
return Math.floor(interval) + " hours ago";
|
||||
}
|
||||
interval = seconds / 60;
|
||||
if (interval > 1) {
|
||||
return Math.floor(interval) + " minutes ago";
|
||||
}
|
||||
return Math.floor(seconds) + " seconds ago";
|
||||
}
|
||||
28
contribute/src/main.jsx
Normal file
28
contribute/src/main.jsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from '@/App';
|
||||
import '@/index.css';
|
||||
import { AuthProvider } from '@/contexts/SupabaseAuthContext';
|
||||
import { SiteProvider } from '@/contexts/SiteContext';
|
||||
import { NotificationProvider } from '@/contexts/NotificationContext';
|
||||
|
||||
const AppWrapper = () => {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<NotificationProvider>
|
||||
<SiteProvider>
|
||||
<App />
|
||||
</SiteProvider>
|
||||
</NotificationProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<AppWrapper />
|
||||
</React.StrictMode>
|
||||
);
|
||||
119
contribute/src/pages/AboutPage.jsx
Normal file
119
contribute/src/pages/AboutPage.jsx
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import React from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
const AboutPage = () => {
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.2,
|
||||
delayChildren: 0.3,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { y: 20, opacity: 0 },
|
||||
visible: { y: 0, opacity: 1, transition: { duration: 0.5 } }
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>About AeThex | Our Mission, Vision, and Team</title>
|
||||
<meta name="description" content="Learn about AeThex's mission to build the future of digital infrastructure, our core values, and the team of visionaries driving us forward." />
|
||||
<meta property="og:title" content="About AeThex | Our Mission, Vision, and Team" />
|
||||
<meta property="og:description" content="Learn about AeThex's mission, vision, and the team behind our innovations." />
|
||||
</Helmet>
|
||||
|
||||
<div className="aurora-background -mx-4 -mt-8">
|
||||
<div className="relative z-10 container mx-auto text-white py-16 sm:py-24 px-4">
|
||||
|
||||
<motion.div
|
||||
className="text-center"
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.7 }}
|
||||
>
|
||||
<h1 className="text-5xl md:text-7xl font-bold tracking-tighter mb-4 font-mono">
|
||||
About <span className="text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-purple-500">AeThex</span>
|
||||
</h1>
|
||||
<p className="text-lg md:text-xl text-gray-400 max-w-3xl mx-auto">
|
||||
We are a collective of architects, innovators, and visionaries dedicated to engineering the foundational systems for the next digital epoch.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="py-16 sm:py-24">
|
||||
<motion.div
|
||||
className="grid md:grid-cols-2 gap-12 items-center mb-24"
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, amount: 0.3 }}
|
||||
>
|
||||
<motion.div variants={itemVariants}>
|
||||
<img className="rounded-xl shadow-2xl shadow-primary/10 w-full h-auto" alt="A diverse team of engineers collaborating around a futuristic holographic display" src="https://images.unsplash.com/photo-1531497258014-b5736f376b1b" />
|
||||
</motion.div>
|
||||
<motion.div variants={itemVariants} className="prose prose-lg prose-invert max-w-none">
|
||||
<h2 className="font-mono text-3xl">Our Story</h2>
|
||||
<p>AeThex was born from a simple yet profound observation: the digital world was built on centralized, fragile foundations. We envisioned a new internet—more resilient, intelligent, and equitable. We are not just a company; we are a movement to build that future.</p>
|
||||
<p>Our journey began with a small group of experts in decentralized systems, AI, and cryptography. Today, we are a growing ecosystem of contributors, each an owner, all unified by a single, audacious goal.</p>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, amount: 0.3 }}
|
||||
>
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-center mb-12 font-mono">Our Core Principles</h2>
|
||||
<motion.div variants={itemVariants} className="max-w-4xl mx-auto grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<Card className="bg-gray-900/40 border-white/10">
|
||||
<CardHeader>
|
||||
<CardTitle>Decentralization First</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-400">We prioritize architectural designs that distribute power, eliminate single points of failure, and resist censorship.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-gray-900/40 border-white/10">
|
||||
<CardHeader>
|
||||
<CardTitle>Radical Ownership</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-400">Our contributors are co-owners. We believe that those who build the future should have a stake in it.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-gray-900/40 border-white/10">
|
||||
<CardHeader>
|
||||
<CardTitle>Long-Term Vision</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-400">We are not driven by short-term trends. Our focus is on building robust, scalable, and enduring infrastructure.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-gray-900/40 border-white/10">
|
||||
<CardHeader>
|
||||
<CardTitle>Open Collaboration</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-400">We foster an environment of intense, transparent collaboration, believing that the best ideas emerge from diverse perspectives.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AboutPage;
|
||||
148
contribute/src/pages/ContactPage.jsx
Normal file
148
contribute/src/pages/ContactPage.jsx
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { motion } from 'framer-motion';
|
||||
import { supabase } from '@/lib/customSupabaseClient';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Loader2, Mail, Phone, MapPin } from 'lucide-react';
|
||||
|
||||
const ContactPage = () => {
|
||||
const [formData, setFormData] = useState({ name: '', email: '', subject: '', message: '' });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleChange = (e) => {
|
||||
setFormData({ ...formData, [e.target.id]: e.target.value });
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
const { error } = await supabase.from('contact_submissions').insert([
|
||||
{ name: formData.name, email: formData.email, subject: formData.subject, message: formData.message }
|
||||
]);
|
||||
|
||||
if (error) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Error submitting form',
|
||||
description: error.message,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: 'Message Sent!',
|
||||
description: "Thank you for reaching out. We'll be in touch soon.",
|
||||
});
|
||||
setFormData({ name: '', email: '', subject: '', message: '' });
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const contactInfo = [
|
||||
{ icon: Mail, title: "General Inquiries", value: "contact@aethex.com" },
|
||||
{ icon: Phone, title: "Phone Support", value: "+1 (555) 010-4567" },
|
||||
{ icon: MapPin, title: "Headquarters", value: "Digital Realm / Decentralized" },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>Contact Us | AeThex</title>
|
||||
<meta name="description" content="Get in touch with the AeThex team for inquiries, partnerships, or press via our contact form or official channels." />
|
||||
<meta property="og:title" content="Contact Us | AeThex" />
|
||||
<meta property="og:description" content="Reach out to the AeThex collective." />
|
||||
</Helmet>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="max-w-6xl mx-auto"
|
||||
>
|
||||
<div className="text-center mb-16">
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-5xl md:text-7xl font-bold tracking-tighter mb-4 font-mono"
|
||||
>
|
||||
Contact <span className="text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-purple-500">Us</span>
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="text-lg md:text-xl text-gray-400 max-w-3xl mx-auto"
|
||||
>
|
||||
Have a question, proposal, or just want to connect? We're here to listen.
|
||||
</motion.p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-5 gap-12">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="md:col-span-2 space-y-8"
|
||||
>
|
||||
{contactInfo.map((item, index) => (
|
||||
<div key={index} className="flex gap-4 items-start">
|
||||
<div className="p-3 bg-primary/10 rounded-lg w-max border border-primary/20">
|
||||
<item.icon className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-white text-lg">{item.title}</h3>
|
||||
<p className="text-gray-400">{item.value}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
className="md:col-span-3"
|
||||
>
|
||||
<Card className="bg-gray-900/40 border-white/10">
|
||||
<CardHeader>
|
||||
<CardTitle>Send us a message</CardTitle>
|
||||
<CardDescription>Fill out the form below and we'll get back to you.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Full Name</Label>
|
||||
<Input id="name" type="text" value={formData.name} onChange={handleChange} required disabled={loading} placeholder="John Doe" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email Address</Label>
|
||||
<Input id="email" type="email" value={formData.email} onChange={handleChange} required disabled={loading} placeholder="you@example.com" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subject">Subject</Label>
|
||||
<Input id="subject" type="text" value={formData.subject} onChange={handleChange} disabled={loading} placeholder="Partnership Inquiry" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="message">Your Message</Label>
|
||||
<Textarea id="message" value={formData.message} onChange={handleChange} required disabled={loading} rows={5} placeholder="Tell us how we can help..." />
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
Send Message
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContactPage;
|
||||
210
contribute/src/pages/GetInvolvedPage.jsx
Normal file
210
contribute/src/pages/GetInvolvedPage.jsx
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { motion } from 'framer-motion';
|
||||
import { supabase } from '@/lib/customSupabaseClient';
|
||||
import { useAuth } from '@/contexts/SupabaseAuthContext';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from '@/components/ui/card';
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
|
||||
import { Loader2, ArrowRight, CheckCircle } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: { staggerChildren: 0.15 }
|
||||
}
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { y: 20, opacity: 0 },
|
||||
visible: { y: 0, opacity: 1, transition: { duration: 0.5 } }
|
||||
};
|
||||
|
||||
const GetInvolvedPage = () => {
|
||||
const [opportunities, setOpportunities] = useState([]);
|
||||
const [testimonials, setTestimonials] = useState([]);
|
||||
const [faqs, setFaqs] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [applying, setApplying] = useState(null);
|
||||
const [applied, setApplied] = useState([]);
|
||||
const { user, profile, setShowAuthModal } = useAuth();
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [opps, tests, faqsData, apps] = await Promise.all([
|
||||
supabase.from('volunteer_opportunities').select('*').eq('is_active', true),
|
||||
supabase.from('volunteer_testimonials').select('*'),
|
||||
supabase.from('faqs').select('*').eq('category', 'Volunteering').order('display_order'),
|
||||
user ? supabase.from('volunteer_applications').select('opportunity_id').eq('user_id', user.id) : Promise.resolve({ data: [] })
|
||||
]);
|
||||
|
||||
if (opps.error) throw opps.error;
|
||||
if (tests.error) throw tests.error;
|
||||
if (faqsData.error) throw faqsData.error;
|
||||
if (apps.error) throw apps.error;
|
||||
|
||||
setOpportunities(opps.data);
|
||||
setTestimonials(tests.data);
|
||||
setFaqs(faqsData.data);
|
||||
setApplied(apps.data.map(a => a.opportunity_id));
|
||||
|
||||
} catch (error) {
|
||||
toast({ variant: 'destructive', title: 'Error fetching data', description: error.message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, [toast, user]);
|
||||
|
||||
const handleApply = async (opportunityId) => {
|
||||
if (!user) {
|
||||
setShowAuthModal(true);
|
||||
toast({ title: 'Please sign in', description: 'You need to be logged in to apply for an opportunity.' });
|
||||
return;
|
||||
}
|
||||
|
||||
setApplying(opportunityId);
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('volunteer_applications')
|
||||
.insert({ opportunity_id: opportunityId, user_id: user.id });
|
||||
|
||||
if (error) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Application failed',
|
||||
description: error.message,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
variant: 'success',
|
||||
title: 'Application Submitted!',
|
||||
description: 'Thank you for your interest. We will review your application.',
|
||||
});
|
||||
setApplied(prev => [...prev, opportunityId]);
|
||||
}
|
||||
setApplying(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>Get Involved | AeThex Collective</title>
|
||||
<meta name="description" content="Join the AeThex collective. Find volunteer opportunities, learn about our mission, and become a co-owner of the future of the internet." />
|
||||
<meta property="og:title" content="Get Involved | AeThex Collective" />
|
||||
<meta property="og:description" content="Contribute your skills to build decentralized, intelligent technologies." />
|
||||
</Helmet>
|
||||
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="max-w-6xl mx-auto"
|
||||
>
|
||||
<motion.div variants={itemVariants} className="text-center mb-16">
|
||||
<h1 className="text-5xl md:text-7xl font-bold tracking-tighter mb-4 font-mono">
|
||||
Join the <span className="text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-purple-500">Collective</span>
|
||||
</h1>
|
||||
<p className="text-lg md:text-xl text-gray-400 max-w-3xl mx-auto">
|
||||
We are building a decentralized future, and we believe it should be built by a decentralized community. Your skills, passion, and vision can help shape the next digital epoch.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.section variants={itemVariants} className="mb-24">
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-center mb-12 font-mono">Available Opportunities</h2>
|
||||
{loading ? (
|
||||
<div className="flex justify-center"><Loader2 className="h-8 w-8 animate-spin" /></div>
|
||||
) : (
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{opportunities.map(opp => (
|
||||
<Card key={opp.id} className="flex flex-col">
|
||||
<CardHeader>
|
||||
<CardTitle>{opp.title}</CardTitle>
|
||||
<CardDescription>{opp.time_commitment}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-grow">
|
||||
<p className="text-gray-400 mb-4">{opp.description}</p>
|
||||
<div className="space-y-1">
|
||||
<h4 className="font-bold text-sm text-white">Skills Required:</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{opp.skills_required.map(skill => <Badge key={skill} variant="secondary">{skill}</Badge>)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => handleApply(opp.id)}
|
||||
disabled={applying === opp.id || applied.includes(opp.id)}
|
||||
>
|
||||
{applying === opp.id ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : applied.includes(opp.id) ? (
|
||||
<>
|
||||
<CheckCircle className="mr-2 h-4 w-4" /> Applied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Apply Now <ArrowRight className="ml-2 h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</motion.section>
|
||||
|
||||
<motion.section variants={itemVariants} className="mb-24 bg-gray-900/30 rounded-xl p-8 md:p-12 border border-white/10">
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-center mb-12 font-mono">Voices of the Collective</h2>
|
||||
{loading ? (
|
||||
<div className="flex justify-center"><Loader2 className="h-8 w-8 animate-spin" /></div>
|
||||
) : (
|
||||
<div className="grid md:grid-cols-2 gap-8">
|
||||
{testimonials.map(t => (
|
||||
<blockquote key={t.id} className="p-6 border-l-4 border-primary bg-gray-950/40 rounded-r-lg">
|
||||
<p className="text-gray-300 italic">"{t.testimonial}"</p>
|
||||
<footer className="mt-4 flex items-center gap-3">
|
||||
<img class="h-12 w-12 rounded-full object-cover" alt={`Avatar of ${t.name}`} src="https://images.unsplash.com/photo-1701269395744-32e3c1d11645" />
|
||||
<div>
|
||||
<p className="font-bold text-white">{t.name}</p>
|
||||
<p className="text-sm text-primary">{t.role}</p>
|
||||
</div>
|
||||
</footer>
|
||||
</blockquote>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</motion.section>
|
||||
|
||||
<motion.section variants={itemVariants} className="max-w-3xl mx-auto">
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-center mb-12 font-mono">Frequently Asked Questions</h2>
|
||||
{loading ? (
|
||||
<div className="flex justify-center"><Loader2 className="h-8 w-8 animate-spin" /></div>
|
||||
) : (
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
{faqs.map(faq => (
|
||||
<AccordionItem key={faq.id} value={`item-${faq.id}`}>
|
||||
<AccordionTrigger className="text-lg text-left">{faq.question}</AccordionTrigger>
|
||||
<AccordionContent className="text-base text-gray-400">
|
||||
{faq.answer}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
)}
|
||||
</motion.section>
|
||||
</motion.div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default GetInvolvedPage;
|
||||
154
contribute/src/pages/HomePage.jsx
Normal file
154
contribute/src/pages/HomePage.jsx
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import React from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { ArrowRight, Cpu, Shield, Users, Sparkles } from 'lucide-react';
|
||||
|
||||
const HomePage = () => {
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.2,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { y: 20, opacity: 0 },
|
||||
visible: { y: 0, opacity: 1, transition: { duration: 0.5 } },
|
||||
};
|
||||
|
||||
const services = [
|
||||
{
|
||||
icon: Cpu,
|
||||
title: "Decentralized AI",
|
||||
description: "Pioneering intelligent systems that operate on distributed networks for unparalleled security and autonomy.",
|
||||
link: "/technology#ai"
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
title: "Next-Gen Protocols",
|
||||
description: "Architecting the foundational communication and data protocols for a truly decentralized internet.",
|
||||
link: "/technology#protocols"
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
title: "Digital Sovereignty",
|
||||
description: "Building tools and platforms that empower individuals with true ownership of their data and digital identity.",
|
||||
link: "/technology#sovereignty"
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>AeThex | Engineering the Next Digital Epoch</title>
|
||||
<meta name="description" content="AeThex is a research and engineering collective building the foundational layers for the next generation of the internet. Discover our work in decentralized AI, next-gen protocols, and digital sovereignty." />
|
||||
<meta property="og:title" content="AeThex | Engineering the Next Digital Epoch" />
|
||||
<meta property="og:description" content="Pioneering the future of decentralized systems, AI, and digital ownership." />
|
||||
</Helmet>
|
||||
|
||||
<div className="relative -mx-4 -mt-8 mb-16 overflow-hidden">
|
||||
<div className="aurora-background">
|
||||
<div className="relative z-10 container mx-auto text-white py-24 sm:py-32 px-4 text-center">
|
||||
<motion.h1
|
||||
className="text-5xl md:text-7xl font-bold tracking-tighter mb-4 font-mono"
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.7 }}
|
||||
>
|
||||
Engineering the Next <br className="hidden md:block"/>Digital <span className="text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-purple-500">Epoch</span>
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
className="text-lg md:text-xl text-gray-400 max-w-3xl mx-auto mb-8"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.7, delay: 0.2 }}
|
||||
>
|
||||
AeThex is a research and engineering collective building the foundational layers for the next generation of the internet. We create decentralized, intelligent, and user-centric technologies to foster a more resilient and equitable digital world.
|
||||
</motion.p>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.7, delay: 0.4 }}
|
||||
className="flex justify-center items-center gap-4"
|
||||
>
|
||||
<Button asChild size="lg">
|
||||
<Link to="/get-involved">
|
||||
Get Involved <Sparkles className="ml-2 h-5 w-5" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild size="lg" variant="outline">
|
||||
<Link to="/about">
|
||||
Our Mission <ArrowRight className="ml-2 h-5 w-5" />
|
||||
</Link>
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="py-16 sm:py-24">
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, amount: 0.3 }}
|
||||
>
|
||||
<motion.h2 variants={itemVariants} className="text-3xl md:text-4xl font-bold text-center mb-4 font-mono tracking-tight">Our Focus Areas</motion.h2>
|
||||
<motion.p variants={itemVariants} className="text-lg text-gray-400 text-center max-w-2xl mx-auto mb-16">
|
||||
We are deeply engaged in solving the most complex challenges at the intersection of decentralization and artificial intelligence.
|
||||
</motion.p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{services.map((service, index) => (
|
||||
<motion.div key={index} variants={itemVariants}>
|
||||
<Card className="h-full bg-gray-900/40 border-white/10 hover:border-primary/50 hover:bg-primary/10 transition-all duration-300">
|
||||
<CardHeader>
|
||||
<div className="p-3 bg-primary/10 rounded-lg w-max mb-4 border border-primary/20">
|
||||
<service.icon className="w-8 h-8 text-primary" />
|
||||
</div>
|
||||
<CardTitle>{service.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-400">{service.description}</p>
|
||||
<Link to={service.link} className="text-primary font-semibold mt-4 inline-block hover:underline">
|
||||
Learn More →
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="py-16 sm:py-24">
|
||||
<motion.div
|
||||
className="grid md:grid-cols-2 gap-12 items-center"
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, amount: 0.3 }}
|
||||
>
|
||||
<motion.div variants={itemVariants}>
|
||||
<h2 className="font-mono text-3xl font-bold tracking-tight">For the Builders, by the Builders</h2>
|
||||
<p className="mt-4 text-lg text-gray-400">AeThex operates as an equity-based collective. We are not hiring employees; we are onboarding partners and co-owners who share our long-term vision. Our structure is designed for those who seek not just to participate in the future, but to build and own a piece of it.</p>
|
||||
<Button asChild size="lg" className="mt-6">
|
||||
<Link to="/get-involved">Join the Collective</Link>
|
||||
</Button>
|
||||
</motion.div>
|
||||
<motion.div variants={itemVariants}>
|
||||
<img class="rounded-xl shadow-2xl shadow-primary/10 w-full h-auto" alt="A group of diverse software engineers collaborating intently in a modern, dark-themed office space" src="https://images.unsplash.com/photo-1651009188116-bb5f80eaf6aa" />
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomePage;
|
||||
245
contribute/src/pages/JobApplicationPage.jsx
Normal file
245
contribute/src/pages/JobApplicationPage.jsx
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { supabase } from '@/lib/customSupabaseClient';
|
||||
import { useAuth } from '@/contexts/SupabaseAuthContext';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Loader2, ArrowLeft, Briefcase, MapPin, UploadCloud, FileText, Trash2, ShieldCheck } from 'lucide-react';
|
||||
|
||||
const JobApplicationPage = () => {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [job, setJob] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [coverLetter, setCoverLetter] = useState('');
|
||||
const [resumeFile, setResumeFile] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchJob = async () => {
|
||||
setLoading(true);
|
||||
const { data, error } = await supabase
|
||||
.from('job_openings')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.single();
|
||||
|
||||
if (error || !data) {
|
||||
toast({ variant: 'destructive', title: 'Error', description: 'Could not find the job opening.' });
|
||||
navigate('/');
|
||||
return;
|
||||
}
|
||||
setJob(data);
|
||||
|
||||
const { data: application, error: appError } = await supabase
|
||||
.from('job_applications')
|
||||
.select('id')
|
||||
.eq('user_id', user.id)
|
||||
.eq('job_id', id)
|
||||
.maybeSingle();
|
||||
|
||||
if (application) {
|
||||
toast({ title: 'Already Applied', description: "You have already applied for this position." });
|
||||
navigate(`/job/${id}`);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
if (user) {
|
||||
fetchJob();
|
||||
}
|
||||
}, [id, user, navigate, toast]);
|
||||
|
||||
const onDrop = (acceptedFiles) => {
|
||||
if (acceptedFiles.length > 0) {
|
||||
const file = acceptedFiles[0];
|
||||
if(file.size > 5 * 1024 * 1024) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "File too large",
|
||||
description: "Resume file must be under 5MB.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setResumeFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: { 'application/pdf': ['.pdf'] },
|
||||
maxFiles: 1,
|
||||
});
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!resumeFile) {
|
||||
toast({ variant: 'destructive', title: 'Resume Required', description: 'Please upload your resume to apply.' });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
let resumeUrl = null;
|
||||
|
||||
try {
|
||||
const fileExt = resumeFile.name.split('.').pop();
|
||||
const fileName = `${user.id}-${Date.now()}.${fileExt}`;
|
||||
const filePath = `resumes/${fileName}`;
|
||||
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from('job-applications')
|
||||
.upload(filePath, resumeFile);
|
||||
|
||||
if (uploadError) throw uploadError;
|
||||
|
||||
const { data: urlData } = supabase.storage
|
||||
.from('job-applications')
|
||||
.getPublicUrl(filePath);
|
||||
|
||||
resumeUrl = urlData.publicUrl;
|
||||
|
||||
const { error: insertError } = await supabase
|
||||
.from('job_applications')
|
||||
.insert({
|
||||
job_id: job.id,
|
||||
user_id: user.id,
|
||||
status: 'submitted',
|
||||
cover_letter: coverLetter,
|
||||
resume_url: resumeUrl,
|
||||
submitted_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (insertError) throw insertError;
|
||||
|
||||
toast({
|
||||
variant: "success",
|
||||
title: "Application Submitted!",
|
||||
description: `Your application for ${job.title} has been received.`,
|
||||
});
|
||||
navigate('/my-applications');
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Application Failed",
|
||||
description: error.message,
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-[60vh]">
|
||||
<Loader2 className="w-12 h-12 text-primary animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!job) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{`Apply for ${job.title} - AeThex Careers`}</title>
|
||||
<meta name="description" content={`Submit your application for the ${job.title} role at AeThex.`} />
|
||||
</Helmet>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
<div className="mb-8">
|
||||
<Button asChild variant="ghost" className="mb-4">
|
||||
<Link to={`/job/${id}`} className="flex items-center text-gray-400 hover:text-white">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back to Job Details
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-3xl font-bold">Apply for {job.title}</CardTitle>
|
||||
<CardDescription className="flex justify-center items-center gap-4 pt-2">
|
||||
<span className="flex items-center gap-1.5"><Briefcase className="w-4 h-4 text-primary" /> {job.division} / {job.department}</span>
|
||||
<span className="flex items-center gap-1.5"><MapPin className="w-4 h-4 text-primary" /> {job.location}</span>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-8">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="coverLetter">Cover Letter (Optional)</Label>
|
||||
<Textarea
|
||||
id="coverLetter"
|
||||
placeholder="Tell us why you're a great fit for this equity-track role..."
|
||||
value={coverLetter}
|
||||
onChange={(e) => setCoverLetter(e.target.value)}
|
||||
rows={8}
|
||||
className="bg-gray-900/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="resume">Resume (PDF, max 5MB)</Label>
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`flex justify-center items-center w-full px-6 py-10 border-2 border-dashed rounded-lg cursor-pointer transition-colors ${isDragActive ? 'border-primary bg-primary/10' : 'border-gray-700 hover:border-gray-600'}`}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<div className="text-center">
|
||||
<UploadCloud className="w-12 h-12 mx-auto text-gray-500 mb-3" />
|
||||
<p className="font-semibold text-white">
|
||||
{isDragActive ? "Drop the file here..." : "Drag & drop your resume here, or click to select"}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">PDF format only, up to 5MB</p>
|
||||
</div>
|
||||
</div>
|
||||
{resumeFile && (
|
||||
<div className="mt-4 flex items-center justify-between p-3 bg-gray-800 rounded-md">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="w-6 h-6 text-primary" />
|
||||
<span className="text-sm text-white">{resumeFile.name}</span>
|
||||
</div>
|
||||
<Button type="button" variant="destructive" size="icon" onClick={() => setResumeFile(null)}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="flex items-center space-x-2 text-sm text-green-400">
|
||||
<ShieldCheck className="w-5 h-5"/>
|
||||
<span>By submitting, you agree to our NDA and the terms of this equity-based role.</span>
|
||||
</div>
|
||||
<Button type="submit" size="lg" className="w-full max-w-xs" disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Submitting Application
|
||||
</>
|
||||
) : "Confirm & Submit Application"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default JobApplicationPage;
|
||||
227
contribute/src/pages/JobDetailPage.jsx
Normal file
227
contribute/src/pages/JobDetailPage.jsx
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import { supabase } from '@/lib/customSupabaseClient';
|
||||
import { useAuth } from '@/contexts/SupabaseAuthContext';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Loader2, Briefcase, MapPin, ArrowLeft, CheckCircle, AlertCircle, AlertTriangle, Lock, Flag } from 'lucide-react';
|
||||
import { timeAgo } from '@/lib/utils';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
|
||||
const EquityDisclaimer = () => (
|
||||
<motion.div
|
||||
className="my-8 p-6 bg-yellow-900/20 border-2 border-yellow-500/30 rounded-xl"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<AlertTriangle className="w-10 h-10 text-yellow-400 flex-shrink-0 mt-1" />
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-yellow-300 mb-2">READ BEFORE APPLYING — THIS IS AN EQUITY ROLE</h3>
|
||||
<p className="text-yellow-200/80">
|
||||
This is not a job in the traditional sense. There is no guaranteed pay, salary, or benefits. Instead, this is an <strong>equity-track contributor role</strong> for individuals aligned with AeThex’s long-term mission.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
const NdaDisclaimer = () => (
|
||||
<motion.div
|
||||
className="my-8 p-6 bg-red-900/20 border-2 border-red-500/30 rounded-xl"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.4 }}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<Lock className="w-10 h-10 text-red-400 flex-shrink-0 mt-1" />
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-red-300 mb-2">Strict Confidentiality Required</h3>
|
||||
<p className="text-red-200/80">
|
||||
Due to the sensitive nature of our work, all contributors must sign a comprehensive Non-Disclosure Agreement (NDA). This protects our intellectual property and the future we are building together.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
const FinalNote = () => (
|
||||
<motion.div
|
||||
className="my-8 p-6 bg-purple-900/20 border-2 border-purple-500/30 rounded-xl"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.6 }}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<Flag className="w-10 h-10 text-purple-400 flex-shrink-0 mt-1" />
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-purple-300 mb-2">Final Note on This Role</h3>
|
||||
<p className="text-purple-200/80">
|
||||
By applying, you acknowledge that you are stepping in as a strategic partner, not an employee. Your participation is based on ownership, contribution, and vision alignment. This is not employment. <strong>This is equity. This is ownership. This is legacy.</strong>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
const JobDetailPage = () => {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { user, setShowAuthModal } = useAuth();
|
||||
|
||||
const [job, setJob] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [hasApplied, setHasApplied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
const fetchJob = async () => {
|
||||
setLoading(true);
|
||||
const { data, error } = await supabase
|
||||
.from('job_openings')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.single();
|
||||
|
||||
if (!isMounted) return;
|
||||
|
||||
if (error || !data) {
|
||||
console.error('Error fetching job:', error);
|
||||
navigate('/');
|
||||
} else {
|
||||
setJob(data);
|
||||
}
|
||||
};
|
||||
|
||||
const checkApplicationStatus = async () => {
|
||||
if (!user) return;
|
||||
const { data, error } = await supabase
|
||||
.from('job_applications')
|
||||
.select('id')
|
||||
.eq('user_id', user.id)
|
||||
.eq('job_id', id)
|
||||
.maybeSingle(); // Use maybeSingle to avoid error if no application is found
|
||||
|
||||
if (!isMounted) return;
|
||||
|
||||
if (data) {
|
||||
setHasApplied(true);
|
||||
}
|
||||
};
|
||||
|
||||
fetchJob().then(() => {
|
||||
checkApplicationStatus().then(() => {
|
||||
if (isMounted) setLoading(false);
|
||||
});
|
||||
});
|
||||
|
||||
return () => { isMounted = false; };
|
||||
}, [id, navigate, user]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-[60vh]">
|
||||
<Loader2 className="w-12 h-12 text-primary animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!job) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleApplyClick = () => {
|
||||
if (!user) {
|
||||
setShowAuthModal(true);
|
||||
} else {
|
||||
navigate(`/job/${job.id}/apply`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{`${job.title} - AeThex Careers`}</title>
|
||||
<meta name="description" content={job.description.substring(0, 160)} />
|
||||
</Helmet>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
<div className="mb-8">
|
||||
<Button asChild variant="ghost" className="mb-4">
|
||||
<Link to="/" className="flex items-center text-gray-400 hover:text-white">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back to All Roles
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="lg:grid lg:grid-cols-3 lg:gap-8">
|
||||
<div className="lg:col-span-2">
|
||||
<Card className="mb-8">
|
||||
<CardHeader>
|
||||
<CardDescription className="text-sm text-gray-400">Posted {timeAgo(job.posted_at)}</CardDescription>
|
||||
<CardTitle className="text-3xl font-bold text-white">{job.title}</CardTitle>
|
||||
<div className="flex items-center gap-4 pt-2 text-gray-300">
|
||||
<span className="flex items-center gap-1.5"><Briefcase className="w-4 h-4 text-primary" /> {job.division} / {job.department}</span>
|
||||
<span className="flex items-center gap-1.5"><MapPin className="w-4 h-4 text-primary" /> {job.location}</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="prose prose-invert prose-lg max-w-none text-gray-300 whitespace-pre-wrap">
|
||||
{job.description}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<EquityDisclaimer />
|
||||
<NdaDisclaimer />
|
||||
<FinalNote />
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-1">
|
||||
<Card className="sticky top-24">
|
||||
<CardHeader>
|
||||
<CardTitle>Ready to Contribute?</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{hasApplied ? (
|
||||
<div className="flex items-center gap-3 p-4 rounded-lg bg-green-500/10 border border-green-500/30">
|
||||
<CheckCircle className="w-8 h-8 text-green-400" />
|
||||
<div>
|
||||
<h4 className="font-semibold text-white">Application Submitted</h4>
|
||||
<p className="text-sm text-gray-300">We've received your application.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-gray-400">
|
||||
Submit your application to be considered for this role. We'll review your profile and get back to you soon.
|
||||
</p>
|
||||
<Button onClick={handleApplyClick} className="w-full" size="lg">
|
||||
Apply Now
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{!user && !hasApplied && (
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-amber-500/10 border border-amber-500/30 text-amber-300 text-sm">
|
||||
<AlertCircle className="w-5 h-5 mt-0.5 flex-shrink-0" />
|
||||
<p>
|
||||
You must be <button onClick={() => setShowAuthModal(true)} className="font-bold underline hover:text-amber-200">signed in</button> to apply for this position.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default JobDetailPage;
|
||||
146
contribute/src/pages/MyApplicationsPage.jsx
Normal file
146
contribute/src/pages/MyApplicationsPage.jsx
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { motion } from 'framer-motion';
|
||||
import { supabase } from '@/lib/customSupabaseClient';
|
||||
import { useAuth } from '@/contexts/SupabaseAuthContext';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Loader2, FileText, ArrowLeft, AlertCircle } from 'lucide-react';
|
||||
import { timeAgo } from '@/lib/utils';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
const getStatusVariant = (status) => {
|
||||
switch (status.toLowerCase()) {
|
||||
case 'submitted':
|
||||
return 'default';
|
||||
case 'under review':
|
||||
return 'secondary';
|
||||
case 'interviewing':
|
||||
return 'success';
|
||||
case 'rejected':
|
||||
return 'destructive';
|
||||
case 'hired':
|
||||
return 'success';
|
||||
default:
|
||||
return 'outline';
|
||||
}
|
||||
};
|
||||
|
||||
const MyApplicationsPage = () => {
|
||||
const { user } = useAuth();
|
||||
const [applications, setApplications] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchApplications = async () => {
|
||||
if (!user) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
const { data, error } = await supabase
|
||||
.from('job_applications')
|
||||
.select(`
|
||||
*,
|
||||
job_openings (
|
||||
title,
|
||||
department,
|
||||
location
|
||||
)
|
||||
`)
|
||||
.eq('user_id', user.id)
|
||||
.order('submitted_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching applications:', error);
|
||||
setApplications([]);
|
||||
} else {
|
||||
setApplications(data);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
fetchApplications();
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>My Applications - AeThex Careers</title>
|
||||
<meta name="description" content="Track the status of your job applications with AeThex." />
|
||||
</Helmet>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<div className="mb-8 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white">My Applications</h1>
|
||||
<p className="text-gray-400 mt-1">Track the status of all your applications here.</p>
|
||||
</div>
|
||||
<Button asChild variant="outline">
|
||||
<Link to="/" className="flex items-center">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back to Job Listings
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-900/50 border border-gray-800 rounded-lg overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center p-16">
|
||||
<Loader2 className="w-8 h-8 text-primary animate-spin" />
|
||||
</div>
|
||||
) : applications.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Position</TableHead>
|
||||
<TableHead>Location</TableHead>
|
||||
<TableHead>Submitted</TableHead>
|
||||
<TableHead className="text-right">Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{applications.map((app) => (
|
||||
<TableRow key={app.id}>
|
||||
<TableCell className="font-medium">
|
||||
<Link to={`/job/${app.job_id}`} className="hover:text-primary transition-colors">
|
||||
{app.job_openings.title}
|
||||
</Link>
|
||||
<div className="text-xs text-gray-500">{app.job_openings.department}</div>
|
||||
</TableCell>
|
||||
<TableCell>{app.job_openings.location}</TableCell>
|
||||
<TableCell>{timeAgo(app.submitted_at)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Badge variant={getStatusVariant(app.status)} className="capitalize">
|
||||
{app.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16 px-6">
|
||||
<FileText className="w-16 h-16 mx-auto text-primary mb-4" />
|
||||
<h2 className="text-2xl font-bold text-white mb-2">No Applications Yet</h2>
|
||||
<p className="text-gray-400 mb-6">You haven't applied for any positions.</p>
|
||||
<Button asChild>
|
||||
<Link to="/">Explore Open Roles</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MyApplicationsPage;
|
||||
151
contribute/src/pages/MyEventsPage.jsx
Normal file
151
contribute/src/pages/MyEventsPage.jsx
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { supabase } from '@/lib/customSupabaseClient';
|
||||
import { useAuth } from '@/contexts/SupabaseAuthContext';
|
||||
import EventCard from '@/components/EventCard';
|
||||
import EventCardSkeleton from '@/components/EventCardSkeleton';
|
||||
import EventDetailModal from '@/components/EventDetailModal';
|
||||
import { useEvents } from '@/hooks/useEvents';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Ticket, ArrowLeft } from 'lucide-react';
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const MyEventsPage = () => {
|
||||
const { user } = useAuth();
|
||||
const [myEvents, setMyEvents] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedEvent, setSelectedEvent] = useState(null);
|
||||
|
||||
const {
|
||||
getCategoryColor,
|
||||
getCategoryLabel,
|
||||
handleUnregister,
|
||||
registrationLoading,
|
||||
registeredEvents,
|
||||
handleRegister
|
||||
} = useEvents();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMyEvents = async () => {
|
||||
if (!user) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
const { data, error } = await supabase
|
||||
.from('aethex_event_registrations')
|
||||
.select(`
|
||||
aethex_events (
|
||||
*,
|
||||
aethex_event_registrations (count)
|
||||
)
|
||||
`)
|
||||
.eq('user_id', user.id);
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching my events:', error);
|
||||
setMyEvents([]);
|
||||
} else {
|
||||
const formattedEvents = data.map(item => ({
|
||||
...item.aethex_events,
|
||||
registered: item.aethex_events.aethex_event_registrations[0]?.count || 0,
|
||||
}));
|
||||
setMyEvents(formattedEvents);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
fetchMyEvents();
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>My Registered Events - AeThex</title>
|
||||
<meta name="description" content="View and manage all the AeThex events you are registered for." />
|
||||
</Helmet>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white">My Registered Events</h1>
|
||||
<p className="text-gray-400 mt-1">Here are all the upcoming events you've signed up for.</p>
|
||||
</div>
|
||||
<Button asChild variant="outline">
|
||||
<Link to="/" className="flex items-center">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back to All Events
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{[...Array(3)].map((_, i) => <EventCardSkeleton key={i} />)}
|
||||
</div>
|
||||
) : myEvents.length > 0 ? (
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
|
||||
>
|
||||
<AnimatePresence>
|
||||
{myEvents.map((event) => (
|
||||
<EventCard
|
||||
key={event.id}
|
||||
event={event}
|
||||
onSelectEvent={setSelectedEvent}
|
||||
getCategoryColor={getCategoryColor}
|
||||
getCategoryLabel={getCategoryLabel}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
) : (
|
||||
<div className="text-center py-16 bg-gray-900/30 rounded-2xl border border-gray-800">
|
||||
<Ticket className="w-16 h-16 mx-auto text-primary mb-4" />
|
||||
<h2 className="text-2xl font-bold text-white mb-2">No Registered Events</h2>
|
||||
<p className="text-gray-400 mb-6">You haven't registered for any events yet.</p>
|
||||
<Button asChild>
|
||||
<Link to="/">Explore Events</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
<AnimatePresence>
|
||||
{selectedEvent && (
|
||||
<EventDetailModal
|
||||
event={selectedEvent}
|
||||
onClose={() => setSelectedEvent(null)}
|
||||
onRegister={() => handleRegister(selectedEvent.id)}
|
||||
onUnregister={handleUnregister}
|
||||
isRegistered={registeredEvents.includes(selectedEvent.id)}
|
||||
getCategoryColor={getCategoryColor}
|
||||
getCategoryLabel={getCategoryLabel}
|
||||
isLoading={registrationLoading}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MyEventsPage;
|
||||
121
contribute/src/pages/MyProfilePage.jsx
Normal file
121
contribute/src/pages/MyProfilePage.jsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import React from 'react';
|
||||
import { useAuth } from '@/contexts/SupabaseAuthContext';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { toast } from '@/components/ui/use-toast';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
const MyProfilePage = () => {
|
||||
const { user, profile, loading } = useAuth();
|
||||
|
||||
const handleNotImplemented = () => {
|
||||
toast({
|
||||
title: "🚧 Feature in Development",
|
||||
description: "This feature isn't implemented yet—but we're working on it! 🚀",
|
||||
variant: "destructive"
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-[60vh]">
|
||||
<Loader2 className="w-12 h-12 text-primary animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!profile) {
|
||||
return (
|
||||
<div className="text-center">
|
||||
<p>Could not load profile.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const avatarUrl = profile?.avatar_url || `https://api.dicebear.com/7.x/bottts/svg?seed=${user?.id}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>My Profile - AeThex</title>
|
||||
<meta name="description" content="Manage your AeThex contributor profile." />
|
||||
</Helmet>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<div className="flex flex-col md:flex-row items-start gap-8">
|
||||
<motion.div
|
||||
className="w-full md:w-1/3 lg:w-1/4"
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.2, duration: 0.5 }}
|
||||
>
|
||||
<Card className="bg-slate-900/50 border-primary/20 backdrop-blur-sm overflow-hidden">
|
||||
<CardHeader className="p-0">
|
||||
<div className="relative h-40 bg-gradient-to-br from-primary/20 to-secondary/20">
|
||||
<img
|
||||
alt="Abstract banner for user profile"
|
||||
className="w-full h-full object-cover"
|
||||
src="https://images.unsplash.com/photo-1686140386811-099f53c0dd54" />
|
||||
</div>
|
||||
<div className="relative p-6 flex flex-col items-center -mt-16">
|
||||
<div className="relative">
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={profile.username}
|
||||
className="w-28 h-28 rounded-full border-4 border-slate-900 object-cover"
|
||||
/>
|
||||
<span className="absolute bottom-1 right-1 block h-5 w-5 rounded-full border-2 border-slate-900 bg-green-500" />
|
||||
</div>
|
||||
<CardTitle className="mt-4 text-2xl font-bold">{profile.full_name || profile.username}</CardTitle>
|
||||
<p className="text-gray-400">@{profile.username}</p>
|
||||
<p className="mt-2 text-center text-sm text-gray-300">{profile.bio}</p>
|
||||
<Button onClick={handleNotImplemented} variant="outline" className="mt-4 w-full">Edit Profile</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="w-full md:w-2/3 lg:w-3/4"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.4, duration: 0.5 }}
|
||||
>
|
||||
<Card className="bg-slate-900/50 border-primary/20 backdrop-blur-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Profile Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-400">Email</label>
|
||||
<p className="text-lg">{profile.email}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-400">Role</label>
|
||||
<p className="text-lg capitalize">{profile.role}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-400">Loyalty Points</label>
|
||||
<p className="text-lg">{profile.loyalty_points}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-400">Joined</label>
|
||||
<p className="text-lg">{new Date(profile.created_at).toLocaleDateString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MyProfilePage;
|
||||
223
contribute/src/pages/MyTicketsPage.jsx
Normal file
223
contribute/src/pages/MyTicketsPage.jsx
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { motion } from 'framer-motion';
|
||||
import { supabase } from '@/lib/customSupabaseClient';
|
||||
import { useAuth } from '@/contexts/SupabaseAuthContext';
|
||||
import { useSite } from '@/contexts/SiteContext';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Loader2, PlusCircle, Ticket } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { timeAgo } from '@/lib/utils';
|
||||
|
||||
const CreateTicketModal = ({ onTicketCreated }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [priority, setPriority] = useState('Medium');
|
||||
const { user } = useAuth();
|
||||
const { siteId } = useSite();
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!title || !description) {
|
||||
toast({ variant: 'destructive', title: 'Missing fields', description: 'Please fill out all required fields.' });
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
|
||||
const { error } = await supabase.from('tickets').insert({
|
||||
title,
|
||||
description,
|
||||
priority,
|
||||
status: 'Open',
|
||||
type: 'General',
|
||||
created_by: user.id,
|
||||
site_id: siteId,
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
if (error) {
|
||||
toast({ variant: 'destructive', title: 'Error creating ticket', description: error.message });
|
||||
} else {
|
||||
toast({ variant: 'success', title: 'Ticket Created!', description: 'Your support ticket has been submitted.' });
|
||||
setTitle('');
|
||||
setDescription('');
|
||||
setPriority('Medium');
|
||||
onTicketCreated();
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<PlusCircle className="mr-2 h-4 w-4" />
|
||||
Create New Ticket
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create a Support Ticket</DialogTitle>
|
||||
<DialogDescription>
|
||||
Describe the issue you're facing. Our team will get back to you as soon as possible.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="title" className="text-right">Title</Label>
|
||||
<Input id="title" value={title} onChange={(e) => setTitle(e.target.value)} className="col-span-3" required />
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="description" className="text-right">Description</Label>
|
||||
<Textarea id="description" value={description} onChange={(e) => setDescription(e.target.value)} className="col-span-3" required />
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="priority" className="text-right">Priority</Label>
|
||||
<Select value={priority} onValueChange={setPriority}>
|
||||
<SelectTrigger className="col-span-3">
|
||||
<SelectValue placeholder="Select priority" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Low">Low</SelectItem>
|
||||
<SelectItem value="Medium">Medium</SelectItem>
|
||||
<SelectItem value="High">High</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Submit Ticket
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const MyTicketsPage = () => {
|
||||
const [tickets, setTickets] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { user } = useAuth();
|
||||
const { toast } = useToast();
|
||||
|
||||
const fetchTickets = useCallback(async () => {
|
||||
if (!user) return;
|
||||
setLoading(true);
|
||||
const { data, error } = await supabase
|
||||
.from('tickets')
|
||||
.select('*')
|
||||
.eq('created_by', user.id)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
toast({ variant: 'destructive', title: 'Error fetching tickets', description: error.message });
|
||||
} else {
|
||||
setTickets(data);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [user, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTickets();
|
||||
}, [fetchTickets]);
|
||||
|
||||
const getStatusVariant = (status) => {
|
||||
switch (status) {
|
||||
case 'Open': return 'success';
|
||||
case 'In Progress': return 'secondary';
|
||||
case 'Closed': return 'outline';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityVariant = (priority) => {
|
||||
switch (priority) {
|
||||
case 'High': return 'destructive';
|
||||
case 'Medium': return 'warning';
|
||||
case 'Low': return 'success';
|
||||
default: return 'outline';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>My Tickets | AeThex Contributor</title>
|
||||
<meta name="description" content="View and manage your support tickets." />
|
||||
</Helmet>
|
||||
<div className="max-w-6xl mx-auto py-8 px-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white">My Support Tickets</h1>
|
||||
<p className="text-gray-400 mt-1">Track the status of your support requests.</p>
|
||||
</div>
|
||||
<CreateTicketModal onTicketCreated={fetchTickets} />
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Your Tickets</CardTitle>
|
||||
<CardDescription>A list of all tickets you have submitted.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center p-16">
|
||||
<Loader2 className="w-8 h-8 text-primary animate-spin" />
|
||||
</div>
|
||||
) : tickets.length === 0 ? (
|
||||
<div className="text-center py-16">
|
||||
<Ticket className="mx-auto h-12 w-12 text-gray-500" />
|
||||
<h3 className="mt-2 text-lg font-medium text-white">No tickets found</h3>
|
||||
<p className="mt-1 text-sm text-gray-400">Get started by creating a new ticket.</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Priority</TableHead>
|
||||
<TableHead className="text-right">Created</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tickets.map((ticket) => (
|
||||
<TableRow key={ticket.id}>
|
||||
<TableCell className="font-medium">{ticket.title}</TableCell>
|
||||
<TableCell><Badge variant={getStatusVariant(ticket.status)}>{ticket.status}</Badge></TableCell>
|
||||
<TableCell><Badge variant={getPriorityVariant(ticket.priority)}>{ticket.priority}</Badge></TableCell>
|
||||
<TableCell className="text-right">{timeAgo(ticket.created_at)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MyTicketsPage;
|
||||
34
contribute/src/pages/NewsPage.jsx
Normal file
34
contribute/src/pages/NewsPage.jsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import React from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
const NewsPage = () => {
|
||||
// Placeholder content. In a real app, this would come from the blog_posts table.
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>News & Press | AeThex</title>
|
||||
<meta name="description" content="The latest news, announcements, and press mentions from AeThex." />
|
||||
</Helmet>
|
||||
<div className="text-center mb-16">
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-5xl md:text-7xl font-bold tracking-tighter mb-4 font-mono"
|
||||
>
|
||||
News & <span className="text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-purple-500">Updates</span>
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="text-lg md:text-xl text-gray-400 max-w-3xl mx-auto"
|
||||
>
|
||||
This section is under construction. Check back soon for the latest from AeThex.
|
||||
</motion.p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewsPage;
|
||||
138
contribute/src/pages/NotificationsPage.jsx
Normal file
138
contribute/src/pages/NotificationsPage.jsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useNotifications } from '@/contexts/NotificationContext';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Loader2, Bell, MessageSquare, Briefcase, UserPlus } from 'lucide-react';
|
||||
import { timeAgo } from '@/lib/utils';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const NotificationIcon = ({ type }) => {
|
||||
switch (type) {
|
||||
case 'new_message':
|
||||
return <MessageSquare className="w-6 h-6 text-blue-400" />;
|
||||
case 'new_job_application':
|
||||
return <Briefcase className="w-6 h-6 text-green-400" />;
|
||||
case 'user_joined':
|
||||
return <UserPlus className="w-6 h-6 text-purple-400" />;
|
||||
default:
|
||||
return <Bell className="w-6 h-6 text-gray-400" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getNotificationDetails = (notification) => {
|
||||
const { type, data } = notification;
|
||||
switch(type) {
|
||||
case 'new_message':
|
||||
return {
|
||||
text: <>New message from <strong>{data.sender_username}</strong>: "{data.message_preview}..."</>,
|
||||
link: `/messages/${data.conversation_id}`
|
||||
};
|
||||
case 'new_job_application':
|
||||
return {
|
||||
text: <>New application for <strong>{data.job_title}</strong> from {data.applicant_username}.</>,
|
||||
link: `/admin/applications/${data.application_id}`
|
||||
};
|
||||
case 'user_joined':
|
||||
return {
|
||||
text: <><strong>{data.username}</strong> just joined the platform!</>,
|
||||
link: `/profile/${data.user_id}`
|
||||
};
|
||||
default:
|
||||
return {
|
||||
text: <>{data.message || 'New notification'}</>,
|
||||
link: '#'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const NotificationsPage = () => {
|
||||
const { notifications, loading, unreadCount, markAllAsRead, markAsRead } = useNotifications();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleNotificationClick = (notification) => {
|
||||
const { link } = getNotificationDetails(notification);
|
||||
if (!notification.is_read) {
|
||||
markAsRead(notification.id);
|
||||
}
|
||||
if (link && link !== '#') {
|
||||
navigate(link);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>Notifications | AeThex Contributor</title>
|
||||
<meta name="description" content="View all your notifications." />
|
||||
</Helmet>
|
||||
<div className="max-w-4xl mx-auto py-8 px-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white">All Notifications</h1>
|
||||
<p className="text-gray-400 mt-1">Here's what you've missed.</p>
|
||||
</div>
|
||||
{unreadCount > 0 && (
|
||||
<Button onClick={markAllAsRead}>Mark All as Read</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Your Alerts</CardTitle>
|
||||
<CardDescription>A complete history of your notifications.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center p-16">
|
||||
<Loader2 className="w-8 h-8 text-primary animate-spin" />
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="text-center py-16">
|
||||
<Bell className="mx-auto h-12 w-12 text-gray-500" />
|
||||
<h3 className="mt-2 text-lg font-medium text-white">All caught up!</h3>
|
||||
<p className="mt-1 text-sm text-gray-400">You have no new notifications.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{notifications.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
onClick={() => handleNotificationClick(notification)}
|
||||
className={`flex items-start gap-4 p-4 rounded-lg transition-colors cursor-pointer border ${
|
||||
notification.is_read
|
||||
? 'bg-gray-900/30 border-gray-800 hover:bg-gray-800/50'
|
||||
: 'bg-primary/10 border-primary/20 hover:bg-primary/20'
|
||||
}`}
|
||||
>
|
||||
<div className="flex-shrink-0 pt-1">
|
||||
<NotificationIcon type={notification.type} />
|
||||
</div>
|
||||
<div className="flex-grow">
|
||||
<p className="text-sm text-gray-200">{getNotificationDetails(notification).text}</p>
|
||||
<p className="text-xs text-gray-400 mt-1">{timeAgo(notification.created_at)}</p>
|
||||
</div>
|
||||
{!notification.is_read && (
|
||||
<div className="flex-shrink-0 self-center">
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-primary animate-pulse"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationsPage;
|
||||
78
contribute/src/pages/PrivacyPolicyPage.jsx
Normal file
78
contribute/src/pages/PrivacyPolicyPage.jsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import React from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
const PrivacyPolicyPage = () => {
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>Privacy Policy | AeThex</title>
|
||||
<meta name="description" content="Read the AeThex privacy policy to understand how we collect, store, and use your personal data to provide our services." />
|
||||
<meta property="og:title" content="Privacy Policy | AeThex" />
|
||||
<meta property="og:description" content="Learn how AeThex handles user data with a commitment to privacy and transparency." />
|
||||
</Helmet>
|
||||
<motion.div
|
||||
className="prose prose-invert prose-lg max-w-4xl mx-auto py-16"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ staggerChildren: 0.2 }}
|
||||
>
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
Privacy Policy
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
className="text-gray-400"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
Last updated: {new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}
|
||||
</motion.p>
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
AeThex ("us", "we", or "our") operates this website (the "Service"). This page informs you of our policies regarding the collection, use, and disclosure of personal data when you use our Service and the choices you have associated with that data. Our commitment is to user sovereignty and data minimization.
|
||||
</motion.p>
|
||||
|
||||
<motion.h2 initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.3 }}>Information Collection and Use</motion.h2>
|
||||
<motion.p initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.4 }}>
|
||||
We collect only the data that is essential for the functionality of our services and for securing our network. This includes:
|
||||
</motion.p>
|
||||
<motion.ul initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.5 }}>
|
||||
<li><strong>Account Information:</strong> When you create an account, we may ask for information such as your username and email address to create and manage your contributor profile.</li>
|
||||
<li><strong>Contact Form Submissions:</strong> When you contact us via our contact form, we collect your name, email, and message content to respond to your inquiries.</li>
|
||||
<li><strong>Usage Data:</strong> We may collect anonymous data on how the Service is accessed and used to improve our offerings. This data is aggregated and cannot be used to identify you personally.</li>
|
||||
</motion.ul>
|
||||
|
||||
<motion.h2 initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.6 }}>Data Use</motion.h2>
|
||||
<motion.p initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.7 }}>
|
||||
AeThex uses the collected data for various purposes:
|
||||
</motion.p>
|
||||
<motion.ul initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.8 }}>
|
||||
<li>To provide and maintain our Service</li>
|
||||
<li>To notify you about changes to our Service</li>
|
||||
<li>To allow you to participate in interactive features of our Service when you choose to do so</li>
|
||||
<li>To provide customer support</li>
|
||||
<li>To monitor the usage of our Service to detect, prevent and address technical issues</li>
|
||||
</motion.ul>
|
||||
|
||||
<motion.h2 initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.9 }}>Data Security</motion.h2>
|
||||
<motion.p initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 1.0 }}>
|
||||
The security of your data is important to us. We strive to use commercially acceptable means to protect your Personal Data, but remember that no method of transmission over the Internet or method of electronic storage is 100% secure. We do not sell, trade, or otherwise transfer your personally identifiable information to outside parties.
|
||||
</motion.p>
|
||||
|
||||
<motion.h2 initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 1.1 }}>Changes to This Privacy Policy</motion.h2>
|
||||
<motion.p initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 1.2 }}>
|
||||
We may update our Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page. You are advised to review this Privacy Policy periodically for any changes.
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PrivacyPolicyPage;
|
||||
112
contribute/src/pages/TeamPage.jsx
Normal file
112
contribute/src/pages/TeamPage.jsx
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { motion } from 'framer-motion';
|
||||
import { supabase } from '@/lib/customSupabaseClient';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Loader2, Linkedin, Twitter, Github } from 'lucide-react';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
|
||||
const TeamMemberCard = ({ member }) => {
|
||||
const socialLinks = member.social_links || {};
|
||||
return (
|
||||
<Card className="text-center bg-gray-900/40 border-white/10 overflow-hidden transform hover:-translate-y-2 transition-transform duration-300">
|
||||
<CardContent className="p-6">
|
||||
<img
|
||||
className="w-32 h-32 rounded-full mx-auto mb-4 border-4 border-primary/30 object-cover"
|
||||
alt={`Portrait of ${member.name}, ${member.role}`}
|
||||
src="https://images.unsplash.com/photo-1575383596664-30f4489f9786" />
|
||||
<h3 className="text-xl font-bold text-white">{member.name}</h3>
|
||||
<p className="text-primary font-semibold">{member.role}</p>
|
||||
<p className="text-gray-400 mt-2 text-sm">{member.bio}</p>
|
||||
<div className="flex justify-center gap-4 mt-4">
|
||||
{socialLinks.linkedin && <a href={socialLinks.linkedin} target="_blank" rel="noopener noreferrer" className="text-gray-500 hover:text-primary"><Linkedin /></a>}
|
||||
{socialLinks.twitter && <a href={socialLinks.twitter} target="_blank" rel="noopener noreferrer" className="text-gray-500 hover:text-primary"><Twitter /></a>}
|
||||
{socialLinks.github && <a href={socialLinks.github} target="_blank" rel="noopener noreferrer" className="text-gray-500 hover:text-primary"><Github /></a>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const TeamPage = () => {
|
||||
const [team, setTeam] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTeam = async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('team_members')
|
||||
.select('*')
|
||||
.order('order_index', { ascending: true });
|
||||
|
||||
if (error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to load team",
|
||||
description: error.message,
|
||||
});
|
||||
} else {
|
||||
setTeam(data);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
fetchTeam();
|
||||
}, [toast]);
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: { opacity: 1, transition: { staggerChildren: 0.1 } },
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { y: 20, opacity: 0 },
|
||||
visible: { y: 0, opacity: 1 },
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>Our Team | AeThex</title>
|
||||
<meta name="description" content="Meet the collective of visionaries, engineers, and architects building the future at AeThex." />
|
||||
<meta property="og:title" content="Our Team | AeThex" />
|
||||
<meta property="og:description" content="Meet the collective of visionaries, engineers, and architects building the future at AeThex." />
|
||||
</Helmet>
|
||||
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={containerVariants}
|
||||
>
|
||||
<div className="text-center mb-16">
|
||||
<motion.h1 variants={itemVariants} className="text-5xl md:text-7xl font-bold tracking-tighter mb-4 font-mono">
|
||||
Our <span className="text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-purple-500">Collective</span>
|
||||
</motion.h1>
|
||||
<motion.p variants={itemVariants} className="text-lg md:text-xl text-gray-400 max-w-3xl mx-auto">
|
||||
We are a distributed team of researchers, engineers, and strategists united by a shared vision for a decentralized future.
|
||||
</motion.p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center py-20">
|
||||
<Loader2 className="w-12 h-12 text-primary animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
|
||||
>
|
||||
{team.map((member) => (
|
||||
<motion.div key={member.id} variants={itemVariants}>
|
||||
<TeamMemberCard member={member} />
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TeamPage;
|
||||
34
contribute/src/pages/TechnologyPage.jsx
Normal file
34
contribute/src/pages/TechnologyPage.jsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import React from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
const TechnologyPage = () => {
|
||||
// Placeholder content. In a real app, this would come from a CMS or the database.
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>Technology | AeThex</title>
|
||||
<meta name="description" content="Explore the core technologies and research areas that power AeThex's vision for a decentralized future." />
|
||||
</Helmet>
|
||||
<div className="text-center mb-16">
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-5xl md:text-7xl font-bold tracking-tighter mb-4 font-mono"
|
||||
>
|
||||
Our <span className="text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-purple-500">Technology</span> Stack
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="text-lg md:text-xl text-gray-400 max-w-3xl mx-auto"
|
||||
>
|
||||
A brief overview of the technological pillars we are building upon. This page is currently a placeholder.
|
||||
</motion.p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TechnologyPage;
|
||||
73
contribute/src/pages/TermsAndConditionsPage.jsx
Normal file
73
contribute/src/pages/TermsAndConditionsPage.jsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import React from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
const TermsAndConditionsPage = () => {
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>Terms and Conditions | AeThex</title>
|
||||
<meta name="description" content="Read the AeThex Terms and Conditions to understand the rules and guidelines for using our website and services." />
|
||||
<meta property="og:title" content="Terms and Conditions | AeThex" />
|
||||
<meta property="og:description" content="Review the terms of service for the AeThex platform." />
|
||||
</Helmet>
|
||||
<motion.div
|
||||
className="prose prose-invert prose-lg max-w-4xl mx-auto py-16"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ staggerChildren: 0.2 }}
|
||||
>
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
Terms and Conditions
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
className="text-gray-400"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
Last updated: {new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}
|
||||
</motion.p>
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
Welcome to AeThex. These terms and conditions outline the rules and regulations for the use of our website. By accessing this website, we assume you accept these terms and conditions. Do not continue to use AeThex if you do not agree to all of the terms and conditions stated on this page.
|
||||
</motion.p>
|
||||
|
||||
<motion.h2 initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.3 }}>1. Intellectual Property Rights</motion.h2>
|
||||
<motion.p initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.4 }}>
|
||||
Other than the content you own, under these Terms, AeThex and/or its licensors own all the intellectual property rights and materials contained in this Website. You are granted a limited license only for purposes of viewing the material contained on this Website. Contributor-owned content remains the property of the contributor.
|
||||
</motion.p>
|
||||
|
||||
<motion.h2 initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.5 }}>2. Restrictions</motion.h2>
|
||||
<motion.p initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.6 }}>
|
||||
You are specifically restricted from all of the following:
|
||||
</motion.p>
|
||||
<motion.ul initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.7 }}>
|
||||
<li>Publishing any Website material in any other media without citation.</li>
|
||||
<li>Selling, sublicensing and/or otherwise commercializing any Website material.</li>
|
||||
<li>Using this Website in any way that is or may be damaging to this Website.</li>
|
||||
<li>Using this Website contrary to applicable laws and regulations, or in any way may cause harm to the Website, or to any person or business entity.</li>
|
||||
<li>Engaging in any data mining, data harvesting, data extracting or any other similar activity in relation to this Website.</li>
|
||||
</motion.ul>
|
||||
|
||||
<motion.h2 initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.8 }}>3. Limitation of Liability</motion.h2>
|
||||
<motion.p initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.9 }}>
|
||||
In no event shall AeThex, nor any of its officers, directors and contributors, be held liable for anything arising out of or in any way connected with your use of this Website whether such liability is under contract. AeThex, including its officers, directors and contributors shall not be held liable for any indirect, consequential or special liability arising out of or in any way related to your use of this Website.
|
||||
</motion.p>
|
||||
|
||||
<motion.h2 initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 1.0 }}>4. Governing Law & Jurisdiction</motion.h2>
|
||||
<motion.p initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 1.1 }}>
|
||||
These Terms will be governed by and interpreted in accordance with the laws of the jurisdiction in which the company is registered, and you submit to the non-exclusive jurisdiction of the state and federal courts located in such jurisdiction for the resolution of any disputes.
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TermsAndConditionsPage;
|
||||
7
contribute/src/pages/admin/AdminAnnouncementsPage.jsx
Normal file
7
contribute/src/pages/admin/AdminAnnouncementsPage.jsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
const AdminAnnouncementsPage = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
export default AdminAnnouncementsPage;
|
||||
7
contribute/src/pages/admin/AdminApplicationsPage.jsx
Normal file
7
contribute/src/pages/admin/AdminApplicationsPage.jsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
const AdminApplicationsPage = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
export default AdminApplicationsPage;
|
||||
7
contribute/src/pages/admin/AdminBlogPage.jsx
Normal file
7
contribute/src/pages/admin/AdminBlogPage.jsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
const AdminBlogPage = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
export default AdminBlogPage;
|
||||
7
contribute/src/pages/admin/AdminCandidatesPage.jsx
Normal file
7
contribute/src/pages/admin/AdminCandidatesPage.jsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
const AdminCandidatesPage = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
export default AdminCandidatesPage;
|
||||
96
contribute/src/pages/admin/AdminContributorsPage.jsx
Normal file
96
contribute/src/pages/admin/AdminContributorsPage.jsx
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { supabase } from '@/lib/customSupabaseClient';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { useSite } from '@/contexts/SiteContext';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
const AdminContributorsPage = () => {
|
||||
const [contributors, setContributors] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { toast } = useToast();
|
||||
const { siteId } = useSite();
|
||||
|
||||
const fetchContributors = useCallback(async () => {
|
||||
if (!siteId) return;
|
||||
setLoading(true);
|
||||
|
||||
const { data, error } = await supabase.rpc('get_all_users_for_site', { p_site_id: siteId });
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching contributors:', error);
|
||||
toast({ variant: 'destructive', title: 'Error', description: 'Could not fetch contributors.' });
|
||||
} else {
|
||||
setContributors(data);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [toast, siteId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchContributors();
|
||||
}, [fetchContributors]);
|
||||
|
||||
const getRoleVariant = (role) => {
|
||||
switch (role) {
|
||||
case 'site_owner': return 'default';
|
||||
case 'admin': return 'secondary';
|
||||
case 'oversee': return 'destructive';
|
||||
default: return 'outline';
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white">Contributors</h1>
|
||||
<p className="text-gray-400 mt-1">Manage all users contributing to this site.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>All Contributors</CardTitle>
|
||||
<CardDescription>A total of {contributors.length} contributors.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center p-16">
|
||||
<Loader2 className="w-8 h-8 text-primary animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead className="text-right">Role</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{contributors.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-3">
|
||||
<img src={user.avatar_url} alt={user.username} className="w-8 h-8 rounded-full" />
|
||||
{user.username}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{user.email}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Badge variant={getRoleVariant(user.role)} className="capitalize">{user.role.replace('_', ' ')}</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminContributorsPage;
|
||||
68
contribute/src/pages/admin/AdminDashboardPage.jsx
Normal file
68
contribute/src/pages/admin/AdminDashboardPage.jsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Users, FolderGit2, Ticket, Loader2 } from 'lucide-react';
|
||||
import { useAuth } from '@/contexts/SupabaseAuthContext';
|
||||
import { supabase } from '@/lib/customSupabaseClient';
|
||||
import { useSite } from '@/contexts/SiteContext';
|
||||
|
||||
const StatCard = ({ title, value, icon: Icon, loading }) => {
|
||||
return (
|
||||
<Card className="bg-gray-900/40 border-white/10">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-400">{title}</CardTitle>
|
||||
<Icon className="h-5 w-5 text-gray-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||
) : (
|
||||
<div className="text-2xl font-bold text-white">{value}</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const AdminDashboardPage = () => {
|
||||
const { profile } = useAuth();
|
||||
const { siteId } = useSite();
|
||||
const [stats, setStats] = useState({ totalUsers: 0, activeProjects: 0, openTickets: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStats = async () => {
|
||||
if (!siteId) return;
|
||||
setLoading(true);
|
||||
|
||||
const { data, error } = await supabase.rpc('get_employee_management_stats', { p_site_id: siteId });
|
||||
|
||||
if (error) {
|
||||
console.error("Error fetching dashboard stats:", error);
|
||||
} else {
|
||||
setStats(data);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
fetchStats();
|
||||
}, [siteId]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-white">Welcome, {profile?.username || 'Admin'}!</h1>
|
||||
<p className="text-gray-400">Here's an overview of your contributor site.</p>
|
||||
</div>
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<StatCard title="Total Contributors" value={stats.totalUsers} icon={Users} loading={loading} />
|
||||
<StatCard title="Active Projects" value={stats.activeProjects} icon={FolderGit2} loading={loading} />
|
||||
<StatCard title="Open Tickets" value={stats.openTickets} icon={Ticket} loading={loading} />
|
||||
</div>
|
||||
<div className="mt-8 text-center text-gray-500">
|
||||
<p>More detailed analytics will be available here soon.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminDashboardPage;
|
||||
7
contribute/src/pages/admin/AdminDocumentsPage.jsx
Normal file
7
contribute/src/pages/admin/AdminDocumentsPage.jsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
const AdminDocumentsPage = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
export default AdminDocumentsPage;
|
||||
7
contribute/src/pages/admin/AdminEventsPage.jsx
Normal file
7
contribute/src/pages/admin/AdminEventsPage.jsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
const AdminEventsPage = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
export default AdminEventsPage;
|
||||
7
contribute/src/pages/admin/AdminJobsPage.jsx
Normal file
7
contribute/src/pages/admin/AdminJobsPage.jsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
const AdminJobsPage = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
export default AdminJobsPage;
|
||||
104
contribute/src/pages/admin/AdminLayout.jsx
Normal file
104
contribute/src/pages/admin/AdminLayout.jsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import React, { useState } from 'react';
|
||||
import { NavLink, Outlet, Link } from 'react-router-dom';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { LayoutDashboard, Settings, Menu, X, Users, FolderGit2, Ticket } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import AeThexLogo from '@/components/AeThexLogo';
|
||||
import { useAuth } from '@/contexts/SupabaseAuthContext';
|
||||
import NotificationBell from '@/components/NotificationBell';
|
||||
|
||||
const navItems = [
|
||||
{ to: '/admin', icon: LayoutDashboard, label: 'Dashboard' },
|
||||
{ to: '/admin/contributors', icon: Users, label: 'Contributors' },
|
||||
{ to: '/admin/projects', icon: FolderGit2, label: 'Projects' },
|
||||
{ to: '/admin/tickets', icon: Ticket, label: 'Tickets' },
|
||||
{ to: '/admin/settings', icon: Settings, label: 'Settings' },
|
||||
];
|
||||
|
||||
const Sidebar = ({ isMobile, closeSidebar }) => (
|
||||
<aside className={`flex flex-col bg-slate-950/80 backdrop-blur-lg border-r border-white/10 ${isMobile ? 'w-64' : 'lg:w-64'}`}>
|
||||
<div className="p-4 flex items-center justify-between border-b border-white/10">
|
||||
<Link to="/" className="flex items-center gap-2">
|
||||
<AeThexLogo className="h-8" />
|
||||
<span className="font-bold text-lg">Contributor Admin</span>
|
||||
</Link>
|
||||
{isMobile && (
|
||||
<Button variant="ghost" size="icon" onClick={closeSidebar}>
|
||||
<X className="h-6 w-6" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<nav className="flex-1 px-2 py-4 space-y-1">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.label}
|
||||
to={item.to}
|
||||
end={item.to === '/admin'}
|
||||
onClick={isMobile ? closeSidebar : undefined}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center px-4 py-2 text-sm font-medium rounded-md transition-colors duration-200 ${
|
||||
isActive
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'text-gray-300 hover:bg-slate-800/50 hover:text-white'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<item.icon className="mr-3 h-5 w-5" />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
|
||||
const AdminLayout = () => {
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||
const { profile } = useAuth();
|
||||
const avatarUrl = profile?.avatar_url || `https://api.dicebear.com/7.x/bottts/svg?seed=${profile?.id}`;
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-slate-900 text-white font-mono">
|
||||
<div className="hidden lg:flex lg:flex-shrink-0">
|
||||
<Sidebar />
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{isSidebarOpen && (
|
||||
<motion.div
|
||||
initial={{ x: '-100%' }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: '-100%' }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
||||
className="fixed inset-y-0 left-0 z-50 lg:hidden"
|
||||
>
|
||||
<Sidebar isMobile closeSidebar={() => setIsSidebarOpen(false)} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="flex flex-col w-0 flex-1 overflow-hidden">
|
||||
<header className="sticky top-0 z-30 lg:hidden flex items-center justify-between p-2 bg-slate-950/80 backdrop-blur-lg border-b border-white/10">
|
||||
<Button variant="ghost" size="icon" onClick={() => setIsSidebarOpen(true)}>
|
||||
<Menu className="h-6 w-6" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-4">
|
||||
<NotificationBell />
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt="User Avatar"
|
||||
className="h-8 w-8 rounded-full"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 relative overflow-y-auto focus:outline-none bg-grid-pattern">
|
||||
<div className="py-8 px-4 sm:px-6 lg:px-8">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminLayout;
|
||||
7
contribute/src/pages/admin/AdminPagesPage.jsx
Normal file
7
contribute/src/pages/admin/AdminPagesPage.jsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
const AdminPagesPage = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
export default AdminPagesPage;
|
||||
103
contribute/src/pages/admin/AdminProjectsPage.jsx
Normal file
103
contribute/src/pages/admin/AdminProjectsPage.jsx
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { supabase } from '@/lib/customSupabaseClient';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { useSite } from '@/contexts/SiteContext';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { timeAgo } from '@/lib/utils';
|
||||
|
||||
const AdminProjectsPage = () => {
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { toast } = useToast();
|
||||
const { siteId } = useSite();
|
||||
|
||||
const fetchProjects = useCallback(async () => {
|
||||
if (!siteId) return;
|
||||
setLoading(true);
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('projects')
|
||||
.select('*, owner:profiles(username, avatar_url)')
|
||||
.eq('site_id', siteId)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching projects:', error);
|
||||
toast({ variant: 'destructive', title: 'Error', description: 'Could not fetch projects.' });
|
||||
} else {
|
||||
setProjects(data);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [toast, siteId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProjects();
|
||||
}, [fetchProjects]);
|
||||
|
||||
const getStatusVariant = (status) => {
|
||||
switch (status) {
|
||||
case 'In Progress': return 'success';
|
||||
case 'Completed': return 'default';
|
||||
case 'On Hold': return 'secondary';
|
||||
default: return 'outline';
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white">Projects</h1>
|
||||
<p className="text-gray-400 mt-1">Oversee all contributor projects.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>All Projects</CardTitle>
|
||||
<CardDescription>A total of {projects.length} projects.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center p-16">
|
||||
<Loader2 className="w-8 h-8 text-primary animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead>Owner</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="text-right">Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{projects.map((project) => (
|
||||
<TableRow key={project.id}>
|
||||
<TableCell className="font-medium">{project.title}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<img src={project.owner.avatar_url} alt={project.owner.username} className="w-8 h-8 rounded-full" />
|
||||
{project.owner.username}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{timeAgo(project.created_at)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Badge variant={getStatusVariant(project.status)}>{project.status}</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminProjectsPage;
|
||||
7
contribute/src/pages/admin/AdminProspectsPage.jsx
Normal file
7
contribute/src/pages/admin/AdminProspectsPage.jsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
const AdminProspectsPage = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
export default AdminProspectsPage;
|
||||
151
contribute/src/pages/admin/AdminSettingsPage.jsx
Normal file
151
contribute/src/pages/admin/AdminSettingsPage.jsx
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { supabase } from '@/lib/customSupabaseClient';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { useSite } from '@/contexts/SiteContext';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardHeader, CardTitle, CardContent, CardDescription, CardFooter } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
|
||||
const AdminSettingsPage = () => {
|
||||
const [editableSettings, setEditableSettings] = useState(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const { toast } = useToast();
|
||||
const { siteId, siteConfig, loading: siteLoading, refreshSiteConfig } = useSite();
|
||||
|
||||
useEffect(() => {
|
||||
if (siteConfig) {
|
||||
setEditableSettings(siteConfig);
|
||||
}
|
||||
}, [siteConfig]);
|
||||
|
||||
const handleSave = useCallback(async (settingsToSave) => {
|
||||
if (!siteId) {
|
||||
toast({ variant: 'destructive', title: 'Error', description: 'Site ID is missing. Cannot save settings.' });
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('site_config')
|
||||
.update(settingsToSave)
|
||||
.eq('site_id', siteId)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
toast({ title: 'Settings saved successfully' });
|
||||
await refreshSiteConfig();
|
||||
} catch (error) {
|
||||
toast({ variant: 'destructive', title: 'Error saving settings', description: error.message });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [siteId, refreshSiteConfig, toast]);
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
const { id, value } = e.target;
|
||||
setEditableSettings(prev => ({ ...prev, [id]: value }));
|
||||
};
|
||||
|
||||
const handleSwitchChange = (checked) => {
|
||||
const newStatus = checked ? 'maintenance' : 'online';
|
||||
const newSettings = { ...editableSettings, system_status: newStatus };
|
||||
setEditableSettings(newSettings);
|
||||
handleSave({ system_status: newStatus });
|
||||
};
|
||||
|
||||
const handleGeneralSave = () => {
|
||||
if (!editableSettings) return;
|
||||
const settingsToSave = {
|
||||
site_name: editableSettings.site_name,
|
||||
site_description: editableSettings.site_description
|
||||
};
|
||||
handleSave(settingsToSave);
|
||||
};
|
||||
|
||||
const handleMessageSave = () => {
|
||||
if (!editableSettings) return;
|
||||
const settingsToSave = {
|
||||
system_status_message: editableSettings.system_status_message
|
||||
};
|
||||
handleSave(settingsToSave);
|
||||
};
|
||||
|
||||
if (siteLoading || !editableSettings) {
|
||||
return <div className="flex justify-center items-center h-full"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div>;
|
||||
}
|
||||
|
||||
const isMaintenanceMode = editableSettings.system_status === 'maintenance';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white mb-8">Site Settings</h1>
|
||||
<div className="grid gap-8">
|
||||
<Card className="bg-gray-900/50 border-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle>General Settings</CardTitle>
|
||||
<CardDescription>Manage general site information and settings.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="site_id">Site ID</Label>
|
||||
<Input id="site_id" value={siteId || ''} readOnly disabled className="font-mono text-gray-400" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="site_name">Site Name</Label>
|
||||
<Input id="site_name" value={editableSettings.site_name || ''} onChange={handleInputChange} disabled={saving} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="site_description">Site Description</Label>
|
||||
<Textarea id="site_description" value={editableSettings.site_description || ''} onChange={handleInputChange} disabled={saving} />
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="border-t border-white/10 px-6 py-4">
|
||||
<Button onClick={handleGeneralSave} disabled={saving}>
|
||||
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Save General Settings
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gray-900/50 border-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle>Maintenance Mode</CardTitle>
|
||||
<CardDescription>Put the site in maintenance mode to perform updates.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="maintenance-mode"
|
||||
checked={isMaintenanceMode}
|
||||
onCheckedChange={handleSwitchChange}
|
||||
disabled={saving}
|
||||
/>
|
||||
<Label htmlFor="maintenance-mode">Enable Maintenance Mode</Label>
|
||||
</div>
|
||||
{isMaintenanceMode && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="system_status_message">Maintenance Message</Label>
|
||||
<Textarea id="system_status_message" placeholder="e.g., We'll be back shortly!" value={editableSettings.system_status_message || ''} onChange={handleInputChange} disabled={saving} />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="border-t border-white/10 px-6 py-4">
|
||||
<Button onClick={handleMessageSave} disabled={saving || !isMaintenanceMode}>
|
||||
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Save Maintenance Message
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminSettingsPage;
|
||||
7
contribute/src/pages/admin/AdminTeamPage.jsx
Normal file
7
contribute/src/pages/admin/AdminTeamPage.jsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
const AdminTeamPage = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
export default AdminTeamPage;
|
||||
120
contribute/src/pages/admin/AdminTicketsPage.jsx
Normal file
120
contribute/src/pages/admin/AdminTicketsPage.jsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { supabase } from '@/lib/customSupabaseClient';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { useSite } from '@/contexts/SiteContext';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { timeAgo } from '@/lib/utils';
|
||||
|
||||
const AdminTicketsPage = () => {
|
||||
const [tickets, setTickets] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { toast } = useToast();
|
||||
const { siteId } = useSite();
|
||||
|
||||
const fetchTickets = useCallback(async () => {
|
||||
if (!siteId) return;
|
||||
setLoading(true);
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('tickets')
|
||||
.select(`
|
||||
*,
|
||||
created_by:profiles!tickets_created_by_fkey(username, avatar_url),
|
||||
assigned_to:profiles!tickets_assigned_to_fkey(username, avatar_url)
|
||||
`)
|
||||
.eq('site_id', siteId)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching tickets:', error);
|
||||
toast({ variant: 'destructive', title: 'Error', description: 'Could not fetch tickets.' });
|
||||
} else {
|
||||
setTickets(data);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [toast, siteId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTickets();
|
||||
}, [fetchTickets]);
|
||||
|
||||
const getStatusVariant = (status) => {
|
||||
switch (status) {
|
||||
case 'Open': return 'success';
|
||||
case 'In Progress': return 'secondary';
|
||||
case 'Closed': return 'outline';
|
||||
default: return 'default';
|
||||
}
|
||||
}
|
||||
|
||||
const getPriorityVariant = (priority) => {
|
||||
switch (priority) {
|
||||
case 'High': return 'destructive';
|
||||
case 'Medium': return 'warning';
|
||||
case 'Low': return 'success';
|
||||
default: return 'outline';
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white">Tickets</h1>
|
||||
<p className="text-gray-400 mt-1">Track and resolve contributor support tickets.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>All Tickets</CardTitle>
|
||||
<CardDescription>A total of {tickets.length} tickets.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center p-16">
|
||||
<Loader2 className="w-8 h-8 text-primary animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead>Submitter</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Priority</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tickets.map((ticket) => (
|
||||
<TableRow key={ticket.id}>
|
||||
<TableCell className="font-medium">{ticket.title}</TableCell>
|
||||
<TableCell>
|
||||
{ticket.created_by ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<img src={ticket.created_by.avatar_url} alt={ticket.created_by.username} className="w-8 h-8 rounded-full" />
|
||||
{ticket.created_by.username}
|
||||
</div>
|
||||
) : 'N/A'}
|
||||
</TableCell>
|
||||
<TableCell>{timeAgo(ticket.created_at)}</TableCell>
|
||||
<TableCell><Badge variant={getStatusVariant(ticket.status)}>{ticket.status}</Badge></TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Badge variant={getPriorityVariant(ticket.priority)}>{ticket.priority}</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminTicketsPage;
|
||||
118
contribute/src/pages/admin/AdminTimeOffPage.jsx
Normal file
118
contribute/src/pages/admin/AdminTimeOffPage.jsx
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { supabase } from '@/lib/customSupabaseClient';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Loader2, Check, X, MoreHorizontal } from 'lucide-react';
|
||||
import { formatDate } from '@/lib/utils';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
||||
|
||||
const AdminTimeOffPage = () => {
|
||||
const [requests, setRequests] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { toast } = useToast();
|
||||
|
||||
const fetchRequests = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const { data, error } = await supabase
|
||||
.from('time_off_requests')
|
||||
.select('*, profiles(username, avatar_url)')
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
toast({ variant: 'destructive', title: 'Error fetching time off requests', description: error.message });
|
||||
} else {
|
||||
setRequests(data);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [toast]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRequests();
|
||||
}, [fetchRequests]);
|
||||
|
||||
const handleUpdateStatus = async (id, status) => {
|
||||
const { error } = await supabase
|
||||
.from('time_off_requests')
|
||||
.update({ status })
|
||||
.eq('id', id);
|
||||
|
||||
if (error) {
|
||||
toast({ variant: 'destructive', title: 'Error updating status', description: error.message });
|
||||
} else {
|
||||
toast({ variant: 'success', title: 'Status Updated' });
|
||||
fetchRequests();
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusVariant = (status) => {
|
||||
switch (status.toLowerCase()) {
|
||||
case 'approved': return 'success';
|
||||
case 'rejected': return 'destructive';
|
||||
case 'pending': return 'warning';
|
||||
default: return 'outline';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white">Time Off Requests</h1>
|
||||
<p className="text-gray-400 mt-1">Review and manage employee time off requests.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-white/10 rounded-lg overflow-hidden bg-gray-950/20 backdrop-blur-lg">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-b-white/10 hover:bg-transparent">
|
||||
<TableHead className="text-white/80">Employee</TableHead>
|
||||
<TableHead className="text-white/80">Dates</TableHead>
|
||||
<TableHead className="text-white/80">Reason</TableHead>
|
||||
<TableHead className="text-center text-white/80">Status</TableHead>
|
||||
<TableHead className="text-right text-white/80">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow><TableCell colSpan="5" className="text-center py-16"><Loader2 className="mx-auto h-8 w-8 animate-spin text-primary" /></TableCell></TableRow>
|
||||
) : requests.length === 0 ? (
|
||||
<TableRow><TableCell colSpan="5" className="text-center py-16 text-gray-500">No time off requests found.</TableCell></TableRow>
|
||||
) : (
|
||||
requests.map((req) => (
|
||||
<TableRow key={req.id} className="border-b-white/10 last:border-b-0 hover:bg-white/5">
|
||||
<TableCell className="font-medium text-white">
|
||||
<div className="flex items-center gap-3">
|
||||
<img src={req.profiles.avatar_url} alt={req.profiles.username} className="w-8 h-8 rounded-full" />
|
||||
{req.profiles.username}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-300">{formatDate(req.start_date)} - {formatDate(req.end_date)}</TableCell>
|
||||
<TableCell className="text-gray-300 max-w-xs truncate">{req.reason}</TableCell>
|
||||
<TableCell className="text-center"><Badge variant={getStatusVariant(req.status)} className="capitalize">{req.status}</Badge></TableCell>
|
||||
<TableCell className="text-right">
|
||||
{req.status === 'pending' && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0"><MoreHorizontal className="h-4 w-4" /></Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="bg-gray-900 border-white/10 text-white">
|
||||
<DropdownMenuItem onClick={() => handleUpdateStatus(req.id, 'approved')} className="text-green-400 focus:text-green-400 cursor-pointer"><Check className="mr-2 h-4 w-4" /> Approve</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleUpdateStatus(req.id, 'rejected')} className="text-red-400 focus:text-red-400 cursor-pointer"><X className="mr-2 h-4 w-4" /> Reject</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminTimeOffPage;
|
||||
7
contribute/src/pages/admin/AdminUsersPage.jsx
Normal file
7
contribute/src/pages/admin/AdminUsersPage.jsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
const AdminUsersPage = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
export default AdminUsersPage;
|
||||
7
contribute/src/pages/admin/AdminWaitlistPage.jsx
Normal file
7
contribute/src/pages/admin/AdminWaitlistPage.jsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
const AdminWaitlistPage = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
export default AdminWaitlistPage;
|
||||
7
contribute/src/pages/admin/EventFormModal.jsx
Normal file
7
contribute/src/pages/admin/EventFormModal.jsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
const EventFormModal = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
export default EventFormModal;
|
||||
7
contribute/src/pages/admin/JobFormModal.jsx
Normal file
7
contribute/src/pages/admin/JobFormModal.jsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
const JobFormModal = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
export default JobFormModal;
|
||||
87
contribute/tailwind.config.js
Normal file
87
contribute/tailwind.config.js
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
const { fontFamily } = require('tailwindcss/defaultTheme')
|
||||
|
||||
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" },
|
||||
},
|
||||
"aurora-flow": {
|
||||
"0%": { transform: "translate(-50%, -50%) rotate(0deg)" },
|
||||
"100%": { transform: "translate(-50%, -50%) rotate(360deg)" },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
"aurora-flow": "aurora-flow 20s linear infinite",
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', ...fontFamily.sans],
|
||||
mono: ['Courier Prime', 'Courier New', ...fontFamily.mono],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate"), require('@tailwindcss/typography')],
|
||||
};
|
||||
184
contribute/tools/generate-llms.js
Normal file
184
contribute/tools/generate-llms.js
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
#!/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))
|
||||
.filter(itemPath => fs.statSync(itemPath).isFile());
|
||||
}
|
||||
|
||||
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, []))
|
||||
pages = pages.filter(Boolean);
|
||||
} 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();
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue