Compare commits
65 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fa19dbe504 | |||
| 29d40c6f10 | |||
| 9133928401 | |||
| 5e40b3105c | |||
| 354e2844d1 | |||
| 597d7ffb6f | |||
| 7072a21ed8 | |||
| 754b023435 | |||
| ac57b932c2 | |||
| e607310689 | |||
| 1f8906bc3d | |||
| d6ddf12c04 | |||
| 994263dc45 | |||
| 23d46e7c40 | |||
| d8e0bdc814 | |||
| f45a2525e6 | |||
| 5852fc6616 | |||
| aaf9afdc9f | |||
| 98a6df5081 | |||
| 2c3379026b | |||
| 84be8e5828 | |||
| 1a5de83d50 | |||
| 476b09499a | |||
| 974462e983 | |||
| 749f04c2c4 | |||
| 7de4e85a38 | |||
| 4266adc853 | |||
| f7be617e1d | |||
| 4dd2ba9d9d | |||
| f8209c3712 | |||
| d776b80fe0 | |||
| dd89d8388e | |||
| f5ec13701b | |||
| bbdbf0176e | |||
| 2fc90639f7 | |||
| 5cf3d8fe70 | |||
| e3d802eeef | |||
| ca59231480 | |||
| 1d0ece6940 | |||
| 5fcc7fdbf1 | |||
| 3b12557a89 | |||
| 666e4e67ad | |||
| 11f4759e78 | |||
| de4a05a6e6 | |||
| b682f3378c | |||
| 56891182d5 | |||
| d25d34b8cd | |||
| 016cb6671a | |||
| 9313c01637 | |||
| 96b1446292 | |||
| 14b1459767 | |||
| e2cc586584 | |||
| c71025d717 | |||
| e8aeecec43 | |||
| 4e63ae529e | |||
| a8c01b9cb2 | |||
| b6579f511d | |||
| db46b366ab | |||
| efa732f347 | |||
| a09a68ed39 | |||
| 81f7660768 | |||
| a53a91caeb | |||
| 841a1713d2 | |||
| 737495d186 | |||
| 6ed9b94d3a |
+20
@@ -15,3 +15,23 @@ devscripts
|
||||
backend/data/stackpulse.db
|
||||
backend/data/stackpulse.db-shm
|
||||
backend/data/stackpulse.db-wal
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
!.vscode/extensions.json
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -22,6 +22,9 @@ RUN npm run build
|
||||
# ===============================
|
||||
FROM node:20-alpine AS runtime
|
||||
|
||||
#Install SSH-Client
|
||||
RUN apk add --no-cache openssh-client
|
||||
|
||||
# Arbeitsverzeichnis
|
||||
WORKDIR /app/backend
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
# 📦 StackPulse 
|
||||
|
||||
**StackPulse** ist eine kleine Web-App, die über die Portainer-API deine Docker-Stacks verwaltet und aktualisiert.
|
||||
Aktuell funktioniert StackPulse nur mit der Business-Edition von Portainer. Die Communitiy-Edition wird in einem späteren Release implementiert!
|
||||
Sie besteht aus einem **Backend (Node.js/Express)** und einem **Frontend (React/Tailwind)**.
|
||||
|
||||
Ziel:
|
||||
@@ -26,7 +27,7 @@ Ziel:
|
||||
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
<details>
|
||||
<summary>✅ v0.2 – Release</summary>
|
||||
|
||||
### Backend
|
||||
@@ -46,37 +47,52 @@ Ziel:
|
||||
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
<summary>🟡 v0.3 – In Entwicklung</summary>
|
||||
<details>
|
||||
<summary>✅ v0.3 – Release</summary>
|
||||
|
||||
### Backend
|
||||
- [ ] Automatische Datenbereinigung: Duplikate bei Stack-IDs erkennen & entfernen
|
||||
- [ ] Erweiterung der Logs
|
||||
- [x] Automatische Datenbereinigung: Duplikate bei Stack-IDs erkennen & entfernen
|
||||
- [x] Erweiterung der Logs
|
||||
- [x] API für Filter & Suche (Stacks nach Name/Status abrufen)
|
||||
- [ ] API für Sortierung der Stacks
|
||||
|
||||
### Frontend
|
||||
- [x] Filter: Stacks nach Name oder Status durchsuchen
|
||||
- [ ] Sortierung: Stacks im Frontend sortieren
|
||||
- [ ] Benachrichtigungen im UI: erfolgreicher/fehlgeschlagener Redeploy (Toast + Notification-Center)
|
||||
- [ ] Visualisierung der Datenbereinigung (Konflikt/Auto-Fix Meldungen)
|
||||
- [x] Benachrichtigungen im UI: erfolgreicher/fehlgeschlagener Redeploy (Toast)
|
||||
- [x] Visualisierung der Datenbereinigung (Konflikt/Auto-Fix Meldungen)
|
||||
|
||||
### Features
|
||||
- [ ] Frontend-Filter und Sortierungen für schnellere Navigation bei vielen Stacks
|
||||
- [ ] Echtzeit-Feedback im UI (Notifications)
|
||||
- [ ] Datenkonsistenz sichern: keine doppelten Stack-IDs mehr
|
||||
- [x] Frontend-Filter für schnellere Navigation bei vielen Stacks
|
||||
- [x] Echtzeit-Feedback im UI (Notifications)
|
||||
- [x] Datenkonsistenz sichern: keine doppelten Stack-IDs mehr
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🔮 Geplante Features (v0.4+)</summary>
|
||||
<details open>
|
||||
<summary>✅ v0.4 – Release</summary>
|
||||
|
||||
### Backend
|
||||
- keine Änderungen
|
||||
|
||||
### Frontend
|
||||
- [x] GUI komplett überarbeitet
|
||||
- [x] Umstellung auf Material Tailwind
|
||||
- [x] Dashboard von Creative Tim als neues Frontend
|
||||
|
||||
### Features
|
||||
- [x] Neue, übersichtliche Oberfläche im Material Design
|
||||
- [x] leicht erweiterbar durch vorgefertigte Komponenten
|
||||
- [x] kleine Bugfixes
|
||||
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
<summary>🔮 Geplante Features (v0.5+)</summary>
|
||||
|
||||
- Notifications (z. B. via Webhooks oder Mail)
|
||||
- Authentifizierung & Benutzerverwaltung
|
||||
- Monitoring (Status, CPU/RAM)
|
||||
- Verbesserte UI/UX
|
||||
- Export/Import von Scheduler-Jobs (z. B. JSON)
|
||||
- Health-Checks nach Redeploy (automatisierte Tests, Statusprüfung)
|
||||
- Integration Community Edition
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ CREATE TABLE IF NOT EXISTS redeploy_logs (
|
||||
);
|
||||
`;
|
||||
|
||||
|
||||
|
||||
db.exec(createRedeployLogsTable);
|
||||
|
||||
try {
|
||||
@@ -28,4 +30,17 @@ try {
|
||||
|
||||
console.log('✅ redeploy_logs table ready');
|
||||
|
||||
const createSettingsTable = `
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`;
|
||||
|
||||
db.exec(createSettingsTable);
|
||||
|
||||
console.log('✅ settings table ready');
|
||||
|
||||
|
||||
db.close();
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { db } from './index.js';
|
||||
|
||||
const getSettingStmt = db.prepare('SELECT key, value, updated_at FROM settings WHERE key = ?');
|
||||
const setSettingStmt = db.prepare(`
|
||||
INSERT INTO settings (key, value, updated_at)
|
||||
VALUES (@key, @value, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value = excluded.value,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`);
|
||||
const deleteSettingStmt = db.prepare('DELETE FROM settings WHERE key = ?');
|
||||
|
||||
export function getSetting(key) {
|
||||
try {
|
||||
return getSettingStmt.get(key) || null;
|
||||
} catch (err) {
|
||||
console.error(`❌ [Settings] Fehler beim Lesen des Settings "${key}":`, err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setSetting(key, value) {
|
||||
try {
|
||||
setSettingStmt.run({ key, value });
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`❌ [Settings] Fehler beim Speichern des Settings "${key}":`, err.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteSetting(key) {
|
||||
try {
|
||||
deleteSettingStmt.run(key);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`❌ [Settings] Fehler beim Löschen des Settings "${key}":`, err.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getJsonSetting(key, defaultValue = null) {
|
||||
const row = getSetting(key);
|
||||
if (!row || row.value === null || row.value === undefined) return defaultValue;
|
||||
try {
|
||||
return JSON.parse(row.value);
|
||||
} catch (err) {
|
||||
console.warn(`⚠️ [Settings] Konnte JSON für Setting "${key}" nicht parsen:`, err.message);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
export function setJsonSetting(key, value) {
|
||||
try {
|
||||
const payload = value === undefined ? null : JSON.stringify(value);
|
||||
return setSetting(key, payload);
|
||||
} catch (err) {
|
||||
console.error(`❌ [Settings] Fehler beim Serialisieren von Setting "${key}":`, err.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+1506
-266
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,63 @@
|
||||
import { getJsonSetting, setJsonSetting } from '../db/settings.js';
|
||||
|
||||
const MAINTENANCE_KEY = 'maintenance_mode';
|
||||
|
||||
const DEFAULT_STATE = {
|
||||
active: false,
|
||||
message: null,
|
||||
activatedAt: null,
|
||||
updatedAt: null,
|
||||
extra: null
|
||||
};
|
||||
|
||||
let maintenanceState = loadState();
|
||||
|
||||
function loadState() {
|
||||
const saved = getJsonSetting(MAINTENANCE_KEY, null);
|
||||
if (!saved) {
|
||||
const initial = { ...DEFAULT_STATE, updatedAt: new Date().toISOString() };
|
||||
setJsonSetting(MAINTENANCE_KEY, initial);
|
||||
return initial;
|
||||
}
|
||||
return {
|
||||
...DEFAULT_STATE,
|
||||
...saved
|
||||
};
|
||||
}
|
||||
|
||||
function persistState(state) {
|
||||
maintenanceState = {
|
||||
...DEFAULT_STATE,
|
||||
...state,
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
setJsonSetting(MAINTENANCE_KEY, maintenanceState);
|
||||
return maintenanceState;
|
||||
}
|
||||
|
||||
export function getMaintenanceState() {
|
||||
return { ...maintenanceState };
|
||||
}
|
||||
|
||||
export function isMaintenanceModeActive() {
|
||||
return Boolean(maintenanceState?.active);
|
||||
}
|
||||
|
||||
export function activateMaintenanceMode({ message = null, extra = null } = {}) {
|
||||
const now = new Date().toISOString();
|
||||
return persistState({
|
||||
active: true,
|
||||
message,
|
||||
extra,
|
||||
activatedAt: now
|
||||
});
|
||||
}
|
||||
|
||||
export function deactivateMaintenanceMode({ message = null } = {}) {
|
||||
return persistState({
|
||||
active: false,
|
||||
message,
|
||||
extra: null,
|
||||
activatedAt: null
|
||||
});
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 156 KiB |
@@ -1,12 +1,39 @@
|
||||
<!--
|
||||
=========================================================
|
||||
* Material Tailwind Dashboard React - v2.1.0
|
||||
=========================================================
|
||||
* Product Page: https://www.creative-tim.com/product/material-tailwind-dashboard-react
|
||||
* Copyright 2023 Creative Tim (https://www.creative-tim.com)
|
||||
* Licensed under MIT (https://github.com/creativetimofficial/material-tailwind-dashboard-react/blob/main/LICENSE.md)
|
||||
* Coded by Creative Tim
|
||||
=========================================================
|
||||
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
-->
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/img/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>StackPulse</title>
|
||||
<title>Material Tailwind Dashboard React | By Creative Tim</title>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700;900&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.2.0/css/all.min.css"
|
||||
integrity="sha512-xh6O/CkQoPOWDdYTDqeRdPCVd1SpvCA9XXcUnZS2FmJNp1coAFzvtCN9BmamE+4aHK8yyUHUSCcJHgXloTyT2A=="
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
/>
|
||||
<script defer data-site="YOUR_DOMAIN_HERE" src="https://api.nepcha.com/js/nepcha-analytics.js"></script>
|
||||
|
||||
<script type="module" crossorigin src="/assets/index-a1f09468.js"></script>
|
||||
<link rel="stylesheet" href="/assets/index-0842cd3a.css">
|
||||
<!-- Nepcha Analytics (nepcha.com) -->
|
||||
<!-- Nepcha is a easy-to-use web analytics. No cookies and fully compliant with GDPR, CCPA and PECR. -->
|
||||
<script defer data-site="YOUR_DOMAIN_HERE" src="https://api.nepcha.com/js/nepcha-analytics.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-12a5cbeb.js"></script>
|
||||
<link rel="stylesheet" href="/assets/index-c94d5694.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+30
-3
@@ -1,10 +1,37 @@
|
||||
<!--
|
||||
=========================================================
|
||||
* Material Tailwind Dashboard React - v2.1.0
|
||||
=========================================================
|
||||
* Product Page: https://www.creative-tim.com/product/material-tailwind-dashboard-react
|
||||
* Copyright 2023 Creative Tim (https://www.creative-tim.com)
|
||||
* Licensed under MIT (https://github.com/creativetimofficial/material-tailwind-dashboard-react/blob/main/LICENSE.md)
|
||||
* Coded by Creative Tim
|
||||
=========================================================
|
||||
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
-->
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/img/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>StackPulse</title>
|
||||
<link rel="stylesheet" href="/src/index.css" />
|
||||
<title>Material Tailwind Dashboard React | By Creative Tim</title>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700;900&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.2.0/css/all.min.css"
|
||||
integrity="sha512-xh6O/CkQoPOWDdYTDqeRdPCVd1SpvCA9XXcUnZS2FmJNp1coAFzvtCN9BmamE+4aHK8yyUHUSCcJHgXloTyT2A=="
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
/>
|
||||
<script defer data-site="YOUR_DOMAIN_HERE" src="https://api.nepcha.com/js/nepcha-analytics.js"></script>
|
||||
|
||||
<!-- Nepcha Analytics (nepcha.com) -->
|
||||
<!-- Nepcha is a easy-to-use web analytics. No cookies and fully compliant with GDPR, CCPA and PECR. -->
|
||||
<script defer data-site="YOUR_DOMAIN_HERE" src="https://api.nepcha.com/js/nepcha-analytics.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"],
|
||||
},
|
||||
},
|
||||
}
|
||||
+25
-14
@@ -1,23 +1,34 @@
|
||||
{
|
||||
"name": "stackpulse-frontend",
|
||||
"version": "0.1.0",
|
||||
"name": "material-tailwind-dashboard-react",
|
||||
"private": true,
|
||||
"version": "2.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.5",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"socket.io-client": "^4.8.1"
|
||||
},
|
||||
"axios": "1.7.5",
|
||||
"socket.io-client": "4.8.1",
|
||||
"@heroicons/react": "2.0.18",
|
||||
"@material-tailwind/react": "2.1.4",
|
||||
"apexcharts": "3.44.0",
|
||||
"prop-types": "15.8.1",
|
||||
"react": "18.2.0",
|
||||
"react-apexcharts": "1.4.1",
|
||||
"react-dom": "18.2.0",
|
||||
"react-router-dom": "6.17.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"vite": "^4.4.9"
|
||||
"@types/react": "18.2.31",
|
||||
"@types/react-dom": "18.2.14",
|
||||
"@vitejs/plugin-react": "4.1.0",
|
||||
"autoprefixer": "10.4.16",
|
||||
"postcss": "8.4.31",
|
||||
"prettier": "3.0.3",
|
||||
"prettier-plugin-tailwindcss": "0.5.6",
|
||||
"tailwindcss": "3.3.4",
|
||||
"vite": "4.5.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
module.exports = {
|
||||
tailwindConfig: "./tailwind.config.cjs",
|
||||
plugins: [require("prettier-plugin-tailwindcss")],
|
||||
};
|
||||
+10
-39
@@ -1,43 +1,14 @@
|
||||
import React from "react";
|
||||
import { NavLink, Route, Routes } from "react-router-dom";
|
||||
import Stacks from "./Stacks.jsx";
|
||||
import Logs from "./Logs.jsx";
|
||||
import logo from "./assets/images/stackpulse.png";
|
||||
import { Routes, Route, Navigate } from "react-router-dom";
|
||||
import { Dashboard, Auth } from "@/layouts";
|
||||
|
||||
const navLinkBase =
|
||||
"px-4 py-2 rounded-md font-medium transition-colors duration-150";
|
||||
|
||||
const getNavClass = ({ isActive }) =>
|
||||
`${navLinkBase} ${isActive ? "bg-purple-600 text-white" : "text-gray-300 hover:bg-gray-700"}`;
|
||||
|
||||
export default function App() {
|
||||
function App() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-white">
|
||||
<header className="bg-gray-800 shadow-md">
|
||||
<div className="max-w-6xl mx-auto px-6 py-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<span className="text-xs text-gray-500">v0.2.1</span>
|
||||
<img src={logo} alt="StackPulse" className="h-10 w-auto" />
|
||||
</div>
|
||||
<nav className="flex gap-2 items-end">
|
||||
<NavLink to="/" end className={getNavClass}>
|
||||
Stacks
|
||||
</NavLink>
|
||||
<NavLink to="/logs" className={getNavClass}>
|
||||
Logs
|
||||
</NavLink>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="max-w-6xl mx-auto px-6 py-6">
|
||||
<Routes>
|
||||
<Route path="/" element={<Stacks />} />
|
||||
<Route path="/logs" element={<Logs />} />
|
||||
<Route path="*" element={<Stacks />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
<Routes>
|
||||
<Route path="/dashboard/*" element={<Dashboard />} />
|
||||
<Route path="/auth/*" element={<Auth />} />
|
||||
<Route path="*" element={<Navigate to="/dashboard/stacks" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -1,374 +0,0 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import axios from "axios";
|
||||
import { io } from "socket.io-client";
|
||||
|
||||
const REDEPLOY_STATE = {
|
||||
IDLE: "idle",
|
||||
QUEUED: "queued",
|
||||
RUNNING: "running"
|
||||
};
|
||||
|
||||
export default function Stacks() {
|
||||
const [stacks, setStacks] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [selectedStackIds, setSelectedStackIds] = useState([]);
|
||||
|
||||
const mergeStackState = (previousStacks, incomingStacks) => {
|
||||
const prevMap = new Map(previousStacks.map((stack) => [stack.Id, stack]));
|
||||
const sortedIncoming = [...incomingStacks].sort((a, b) => a.Name.localeCompare(b.Name));
|
||||
|
||||
return sortedIncoming.map((stack) => {
|
||||
const previous = prevMap.get(stack.Id);
|
||||
const redeployState = stack.redeployState
|
||||
?? previous?.redeployState
|
||||
?? (previous?.redeploying ? REDEPLOY_STATE.RUNNING : REDEPLOY_STATE.IDLE);
|
||||
|
||||
return {
|
||||
...stack,
|
||||
redeployState,
|
||||
redeploying: redeployState === REDEPLOY_STATE.RUNNING,
|
||||
redeployQueued: redeployState === REDEPLOY_STATE.QUEUED,
|
||||
redeployDisabled: stack.redeployDisabled ?? previous?.redeployDisabled ?? false
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const socket = io("/", {
|
||||
path: "/socket.io",
|
||||
transports: ["websocket"]
|
||||
});
|
||||
console.log("🔌 Socket connected");
|
||||
|
||||
socket.on("redeployStatus", async ({ stackId, status }) => {
|
||||
const nextState = status || REDEPLOY_STATE.IDLE;
|
||||
console.log(`🔄 Stack ${stackId} Redeploy Status: ${nextState}`);
|
||||
|
||||
setStacks(prev =>
|
||||
prev.map(stack => {
|
||||
if (stack.Id !== stackId) return stack;
|
||||
const updated = {
|
||||
...stack,
|
||||
redeployState: nextState,
|
||||
redeploying: nextState === REDEPLOY_STATE.RUNNING,
|
||||
redeployQueued: nextState === REDEPLOY_STATE.QUEUED
|
||||
};
|
||||
if (nextState === REDEPLOY_STATE.IDLE) {
|
||||
updated.updateStatus = "✅";
|
||||
}
|
||||
return updated;
|
||||
})
|
||||
);
|
||||
|
||||
if (nextState === REDEPLOY_STATE.IDLE) {
|
||||
try {
|
||||
const res = await axios.get("/api/stacks");
|
||||
setStacks(prev => mergeStackState(prev, res.data));
|
||||
} catch (err) {
|
||||
console.error("Fehler beim Aktualisieren nach Redeploy:", err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => socket.disconnect();
|
||||
}, []);
|
||||
|
||||
const fetchStacks = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await axios.get("/api/stacks");
|
||||
setStacks(prev => mergeStackState(prev, res.data));
|
||||
} catch (err) {
|
||||
console.error("❌ Fehler beim Abrufen der Stacks:", err);
|
||||
setError("Fehler beim Laden der Stacks");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchStacks();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedStackIds(prev => {
|
||||
const filtered = prev.filter(id => {
|
||||
const match = stacks.find(stack => stack.Id === id);
|
||||
return match
|
||||
&& match.updateStatus !== "✅"
|
||||
&& !match.redeployDisabled
|
||||
&& (match.redeployState ?? REDEPLOY_STATE.IDLE) === REDEPLOY_STATE.IDLE;
|
||||
});
|
||||
return filtered.length === prev.length ? prev : filtered;
|
||||
});
|
||||
}, [stacks]);
|
||||
|
||||
const toggleStackSelection = (stackId, disabled) => {
|
||||
if (disabled) return;
|
||||
setSelectedStackIds(prev =>
|
||||
prev.includes(stackId)
|
||||
? prev.filter(id => id !== stackId)
|
||||
: [...prev, stackId]
|
||||
);
|
||||
};
|
||||
|
||||
const handleRedeploy = async (stackId) => {
|
||||
const targetStack = stacks.find((stack) => stack.Id === stackId);
|
||||
if (!targetStack || targetStack.redeployDisabled || targetStack.redeployState !== REDEPLOY_STATE.IDLE) return;
|
||||
|
||||
setSelectedStackIds((prev) => prev.filter((id) => id !== stackId));
|
||||
setStacks((prev) =>
|
||||
prev.map((stack) =>
|
||||
stack.Id === stackId
|
||||
? {
|
||||
...stack,
|
||||
redeployState: REDEPLOY_STATE.RUNNING,
|
||||
redeploying: true,
|
||||
redeployQueued: false
|
||||
}
|
||||
: stack
|
||||
)
|
||||
);
|
||||
|
||||
try {
|
||||
await axios.put(`/api/stacks/${stackId}/redeploy`);
|
||||
// Statusupdates kommen über Socket.IO
|
||||
} catch (err) {
|
||||
console.error("❌ Fehler beim Redeploy:", err);
|
||||
setStacks((prev) =>
|
||||
prev.map((stack) =>
|
||||
stack.Id === stackId
|
||||
? {
|
||||
...stack,
|
||||
redeployState: REDEPLOY_STATE.IDLE,
|
||||
redeploying: false,
|
||||
redeployQueued: false
|
||||
}
|
||||
: stack
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRedeployAll = async () => {
|
||||
const outdatedStacks = stacks.filter((stack) =>
|
||||
stack.updateStatus !== "✅"
|
||||
&& !stack.redeployDisabled
|
||||
&& (stack.redeployState ?? REDEPLOY_STATE.IDLE) === REDEPLOY_STATE.IDLE
|
||||
);
|
||||
if (!outdatedStacks.length) return;
|
||||
|
||||
const outdatedIds = new Set(outdatedStacks.map((stack) => stack.Id));
|
||||
|
||||
setStacks(prev =>
|
||||
prev.map(stack =>
|
||||
outdatedIds.has(stack.Id)
|
||||
? {
|
||||
...stack,
|
||||
redeployState: REDEPLOY_STATE.QUEUED,
|
||||
redeploying: false,
|
||||
redeployQueued: true
|
||||
}
|
||||
: stack
|
||||
)
|
||||
);
|
||||
|
||||
try {
|
||||
await axios.put("/api/stacks/redeploy-all");
|
||||
setSelectedStackIds((prev) => prev.filter((id) => !outdatedIds.has(id)));
|
||||
// Statusupdates kommen über Socket.IO
|
||||
} catch (err) {
|
||||
console.error("❌ Fehler beim Redeploy ALL:", err);
|
||||
setStacks(prev =>
|
||||
prev.map(stack =>
|
||||
outdatedIds.has(stack.Id)
|
||||
? {
|
||||
...stack,
|
||||
redeployState: REDEPLOY_STATE.IDLE,
|
||||
redeploying: false,
|
||||
redeployQueued: false
|
||||
}
|
||||
: stack
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRedeploySelection = async () => {
|
||||
if (!selectedStackIds.length) return;
|
||||
|
||||
const eligibleIds = selectedStackIds.filter((id) => {
|
||||
const stack = stacks.find((entry) => entry.Id === id);
|
||||
return stack
|
||||
&& stack.updateStatus !== "✅"
|
||||
&& !stack.redeployDisabled
|
||||
&& (stack.redeployState ?? REDEPLOY_STATE.IDLE) === REDEPLOY_STATE.IDLE;
|
||||
});
|
||||
|
||||
if (!eligibleIds.length) {
|
||||
setSelectedStackIds([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const eligibleSet = new Set(eligibleIds);
|
||||
|
||||
setStacks(prev =>
|
||||
prev.map(stack =>
|
||||
eligibleSet.has(stack.Id)
|
||||
? {
|
||||
...stack,
|
||||
redeployState: REDEPLOY_STATE.QUEUED,
|
||||
redeploying: false,
|
||||
redeployQueued: true
|
||||
}
|
||||
: stack
|
||||
)
|
||||
);
|
||||
|
||||
try {
|
||||
await axios.put("/api/stacks/redeploy-selection", { stackIds: eligibleIds });
|
||||
setSelectedStackIds((prev) => prev.filter((id) => !eligibleSet.has(id)));
|
||||
// Statusupdates kommen über Socket.IO
|
||||
} catch (err) {
|
||||
console.error("❌ Fehler beim Redeploy Auswahl:", err);
|
||||
setStacks(prev =>
|
||||
prev.map(stack =>
|
||||
eligibleSet.has(stack.Id)
|
||||
? {
|
||||
...stack,
|
||||
redeployState: REDEPLOY_STATE.IDLE,
|
||||
redeploying: false,
|
||||
redeployQueued: false
|
||||
}
|
||||
: stack
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const hasSelection = selectedStackIds.length > 0;
|
||||
const hasRedeployableStacks = stacks.some((stack) =>
|
||||
stack.updateStatus !== "✅"
|
||||
&& !stack.redeployDisabled
|
||||
&& (stack.redeployState ?? REDEPLOY_STATE.IDLE) === REDEPLOY_STATE.IDLE
|
||||
);
|
||||
const bulkButtonLabel = hasSelection
|
||||
? `Redeploy Auswahl (${selectedStackIds.length})`
|
||||
: "Redeploy All";
|
||||
|
||||
const bulkActionDisabled = hasSelection
|
||||
? selectedStackIds.length === 0 || selectedStackIds.every(id => {
|
||||
const targetStack = stacks.find(stack => stack.Id === id);
|
||||
return !targetStack
|
||||
|| targetStack.updateStatus === "✅"
|
||||
|| targetStack.redeployDisabled
|
||||
|| (targetStack.redeployState ?? REDEPLOY_STATE.IDLE) !== REDEPLOY_STATE.IDLE;
|
||||
})
|
||||
: !hasRedeployableStacks;
|
||||
|
||||
const handleBulkRedeploy = () => {
|
||||
if (hasSelection) {
|
||||
handleRedeploySelection();
|
||||
} else {
|
||||
handleRedeployAll();
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <p className="text-gray-400">Lade Stacks...</p>;
|
||||
if (error) return <p className="text-red-400">{error}</p>;
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex justify-end mb-4">
|
||||
<button
|
||||
onClick={handleBulkRedeploy}
|
||||
disabled={bulkActionDisabled}
|
||||
className={`px-5 py-2 rounded-lg font-medium transition ${bulkActionDisabled ? 'bg-purple-900 cursor-not-allowed text-gray-400' : 'bg-purple-500 hover:bg-purple-600'}`}
|
||||
>
|
||||
{bulkButtonLabel}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{stacks.map(stack => {
|
||||
const redeployState = stack.redeployState ?? REDEPLOY_STATE.IDLE;
|
||||
const isRunning = redeployState === REDEPLOY_STATE.RUNNING;
|
||||
const isQueued = redeployState === REDEPLOY_STATE.QUEUED;
|
||||
const isBusy = isRunning || isQueued;
|
||||
const isSelected = selectedStackIds.includes(stack.Id);
|
||||
const isCurrent = stack.updateStatus === "✅";
|
||||
const isSelfStack = Boolean(stack.redeployDisabled);
|
||||
const isSelectable = !isBusy && !isCurrent && !isSelfStack;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={stack.Id}
|
||||
className={`flex justify-between items-center p-5 rounded-xl shadow-lg transition border
|
||||
${isSelected ? 'border-purple-500 ring-1 ring-purple-500/40' : 'border-transparent'}
|
||||
${isBusy ? 'bg-gray-700 cursor-wait' : 'bg-gray-800 hover:bg-gray-700'}
|
||||
${!isSelectable && !isBusy ? 'opacity-75' : ''}`}
|
||||
>
|
||||
<div className="flex items-center space-x-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => toggleStackSelection(stack.Id, !isSelectable)}
|
||||
className={`h-5 w-5 text-purple-500 focus:ring-purple-400 border-gray-600 bg-gray-900 rounded ${!isSelectable ? 'opacity-40 cursor-not-allowed' : ''}`}
|
||||
disabled={!isSelectable}
|
||||
/>
|
||||
<div className={`w-12 h-12 flex items-center justify-center rounded-full
|
||||
${stack.updateStatus === "✅" ? 'bg-green-500' :
|
||||
stack.updateStatus === "⚠️" ? 'bg-yellow-500' :
|
||||
'bg-red-500'}`}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-white">{stack.Name}</p>
|
||||
<p className="text-sm text-gray-400">ID: {stack.Id}</p>
|
||||
{stack.duplicateName && (
|
||||
<p className="text-xs text-amber-300">⚠️ Doppelter Name erkannt</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-1 text-sm">
|
||||
{isRunning ? (
|
||||
<>
|
||||
<span className="text-xs uppercase tracking-wide text-orange-300">Redeploy</span>
|
||||
<span className="text-orange-200">läuft…</span>
|
||||
</>
|
||||
) : isQueued ? (
|
||||
<>
|
||||
<span className="text-xs uppercase tracking-wide text-orange-300">Redeploy</span>
|
||||
<span className="text-orange-200">in Warteliste…</span>
|
||||
</>
|
||||
) : isSelfStack ? (
|
||||
<>
|
||||
<span className="text-xs uppercase tracking-wide text-gray-400">System</span>
|
||||
<span className="text-gray-300">Redeploy deaktiviert</span>
|
||||
</>
|
||||
) : isCurrent ? (
|
||||
<>
|
||||
<span className="text-xs uppercase tracking-wide text-gray-400">Status</span>
|
||||
<span className="text-green-300">Aktuell</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleRedeploy(stack.Id)}
|
||||
disabled={isBusy}
|
||||
className="px-5 py-2 rounded-lg font-medium transition bg-blue-500 hover:bg-blue-600"
|
||||
>
|
||||
Redeploy
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{stacks.length === 0 && <p className="text-gray-400">Keine Stacks gefunden.</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 156 KiB |
@@ -0,0 +1,166 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import axios from "axios";
|
||||
|
||||
const MaintenanceContext = createContext(null);
|
||||
|
||||
const INITIAL_STATE = {
|
||||
maintenance: null,
|
||||
update: null,
|
||||
script: null,
|
||||
ssh: null,
|
||||
loading: true,
|
||||
error: "",
|
||||
lastUpdated: null
|
||||
};
|
||||
|
||||
export default function MaintenanceProvider({ children }) {
|
||||
const [state, setState] = useState(INITIAL_STATE);
|
||||
const pollingRef = useRef(null);
|
||||
const wasRunningRef = useRef(false);
|
||||
|
||||
const applyState = useCallback((partial) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
...partial
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const fetchConfig = useCallback(async () => {
|
||||
applyState({ loading: true, error: "" });
|
||||
try {
|
||||
const response = await axios.get("/api/maintenance/config");
|
||||
const data = response.data ?? {};
|
||||
applyState({
|
||||
maintenance: data.maintenance ?? null,
|
||||
update: data.update ?? null,
|
||||
script: data.script ?? null,
|
||||
ssh: data.ssh ?? null,
|
||||
loading: false,
|
||||
error: "",
|
||||
lastUpdated: new Date()
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err.response?.data?.error || err.message || "Fehler beim Laden der Wartungsdaten";
|
||||
applyState({ loading: false, error: message });
|
||||
}
|
||||
}, [applyState]);
|
||||
|
||||
const refreshUpdateStatus = useCallback(async () => {
|
||||
try {
|
||||
const response = await axios.get("/api/maintenance/update-status");
|
||||
const data = response.data ?? {};
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
maintenance: data.maintenance ?? prev.maintenance,
|
||||
update: data.update ?? prev.update,
|
||||
error: ""
|
||||
}));
|
||||
} catch (err) {
|
||||
const message = err.response?.data?.error || err.message || "Fehler beim Aktualisieren des Wartungsstatus";
|
||||
setState((prev) => ({ ...prev, error: message }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
}, [fetchConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.update?.running) {
|
||||
refreshUpdateStatus();
|
||||
const intervalId = setInterval(() => {
|
||||
refreshUpdateStatus();
|
||||
}, 5000);
|
||||
pollingRef.current = intervalId;
|
||||
return () => clearInterval(intervalId);
|
||||
}
|
||||
if (pollingRef.current) {
|
||||
clearInterval(pollingRef.current);
|
||||
pollingRef.current = null;
|
||||
}
|
||||
return undefined;
|
||||
}, [state.update?.running, refreshUpdateStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentlyRunning = Boolean(state.update?.running);
|
||||
if (!currentlyRunning && wasRunningRef.current) {
|
||||
fetchConfig();
|
||||
}
|
||||
wasRunningRef.current = currentlyRunning;
|
||||
}, [state.update?.running, fetchConfig]);
|
||||
|
||||
const triggerUpdate = useCallback(async (payload = {}) => {
|
||||
await axios.post("/api/maintenance/portainer-update", payload);
|
||||
await refreshUpdateStatus();
|
||||
}, [refreshUpdateStatus]);
|
||||
|
||||
const saveScript = useCallback(async (script) => {
|
||||
await axios.put("/api/maintenance/update-script", { script });
|
||||
await fetchConfig();
|
||||
}, [fetchConfig]);
|
||||
|
||||
const resetScript = useCallback(async () => {
|
||||
await axios.delete("/api/maintenance/update-script");
|
||||
await fetchConfig();
|
||||
}, [fetchConfig]);
|
||||
|
||||
const saveSshConfig = useCallback(async (config) => {
|
||||
await axios.put("/api/maintenance/ssh-config", config);
|
||||
await fetchConfig();
|
||||
}, [fetchConfig]);
|
||||
|
||||
const deleteSshConfig = useCallback(async () => {
|
||||
await axios.delete("/api/maintenance/ssh-config");
|
||||
await fetchConfig();
|
||||
}, [fetchConfig]);
|
||||
|
||||
const setMaintenanceMode = useCallback(async ({ active, message } = {}) => {
|
||||
const payload = { active: Boolean(active) };
|
||||
if (message !== undefined) {
|
||||
payload.message = message;
|
||||
}
|
||||
|
||||
const response = await axios.post("/api/maintenance/mode", payload);
|
||||
const nextMaintenance = response.data?.maintenance ?? null;
|
||||
applyState({ maintenance: nextMaintenance, error: "" });
|
||||
return nextMaintenance;
|
||||
}, [applyState]);
|
||||
|
||||
const testSshConnection = useCallback(async (config) => {
|
||||
const response = await axios.post("/api/maintenance/test-ssh", config ?? {});
|
||||
return response.data;
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({
|
||||
maintenance: state.maintenance,
|
||||
update: state.update,
|
||||
script: state.script,
|
||||
ssh: state.ssh,
|
||||
loading: state.loading,
|
||||
error: state.error,
|
||||
lastUpdated: state.lastUpdated,
|
||||
refreshConfig: fetchConfig,
|
||||
refreshUpdateStatus,
|
||||
setMaintenanceMode,
|
||||
triggerUpdate,
|
||||
saveScript,
|
||||
resetScript,
|
||||
saveSshConfig,
|
||||
deleteSshConfig,
|
||||
testSshConnection
|
||||
}), [state, fetchConfig, refreshUpdateStatus, setMaintenanceMode, triggerUpdate, saveScript, resetScript, saveSshConfig, deleteSshConfig, testSshConnection]);
|
||||
|
||||
return (
|
||||
<MaintenanceContext.Provider value={value}>
|
||||
{children}
|
||||
</MaintenanceContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useMaintenance() {
|
||||
const context = useContext(MaintenanceContext);
|
||||
if (!context) {
|
||||
throw new Error('useMaintenance must be used within a MaintenanceProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
|
||||
|
||||
const DEFAULT_PER_PAGE_OPTIONS = [
|
||||
{ value: "10", label: "10" },
|
||||
{ value: "25", label: "25" },
|
||||
{ value: "50", label: "50" },
|
||||
{ value: "100", label: "100" },
|
||||
{ value: "all", label: "Alle" }
|
||||
];
|
||||
|
||||
const DEFAULT_PER_PAGE = "50";
|
||||
|
||||
const PageContext = createContext(null);
|
||||
|
||||
const normalizeNumber = (value, fallback) => {
|
||||
const numeric = Number(value);
|
||||
if (Number.isFinite(numeric) && numeric > 0) {
|
||||
return numeric;
|
||||
}
|
||||
const fallbackNumeric = Number(fallback);
|
||||
if (Number.isFinite(fallbackNumeric) && fallbackNumeric > 0) {
|
||||
return fallbackNumeric;
|
||||
}
|
||||
return 1;
|
||||
};
|
||||
|
||||
export function PageProvider({
|
||||
children,
|
||||
initialPage = 1,
|
||||
initialPerPage = DEFAULT_PER_PAGE,
|
||||
perPageOptions = DEFAULT_PER_PAGE_OPTIONS
|
||||
}) {
|
||||
const [page, setPage] = useState(initialPage);
|
||||
const [perPage, setPerPage] = useState(initialPerPage);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
const [visibleItems, setVisibleItems] = useState(0);
|
||||
|
||||
const initialPageRef = useRef(initialPage);
|
||||
const initialPerPageRef = useRef(initialPerPage);
|
||||
|
||||
const validPerPageValues = useMemo(() => new Set(
|
||||
perPageOptions.map((option) => String(option.value))
|
||||
), [perPageOptions]);
|
||||
|
||||
const perPageIsAll = perPage === "all";
|
||||
|
||||
const resolvePerPageNumber = useCallback(() => {
|
||||
if (perPageIsAll) {
|
||||
return null;
|
||||
}
|
||||
return normalizeNumber(perPage, initialPerPage);
|
||||
}, [perPage, perPageIsAll, initialPerPage]);
|
||||
|
||||
const updateTotals = useCallback((total, visible) => {
|
||||
setTotalItems(Math.max(0, Number(total) || 0));
|
||||
setVisibleItems(Math.max(0, Number(visible) || 0));
|
||||
}, []);
|
||||
|
||||
const resetPagination = useCallback(() => {
|
||||
setPage(initialPageRef.current ?? 1);
|
||||
setPerPage(initialPerPageRef.current ?? DEFAULT_PER_PAGE);
|
||||
setTotalItems(0);
|
||||
setVisibleItems(0);
|
||||
}, []);
|
||||
|
||||
const setPerPageValue = useCallback((value, { resetPage = true } = {}) => {
|
||||
if (value === undefined || value === null) return;
|
||||
const nextValue = String(value);
|
||||
if (!validPerPageValues.has(nextValue)) {
|
||||
return;
|
||||
}
|
||||
setPerPage(nextValue);
|
||||
if (resetPage) {
|
||||
setPage(1);
|
||||
}
|
||||
}, [validPerPageValues]);
|
||||
|
||||
const handlePerPageChange = useCallback((eventOrValue) => {
|
||||
const nextValue = typeof eventOrValue === "string"
|
||||
? eventOrValue
|
||||
: eventOrValue?.target?.value;
|
||||
setPerPageValue(nextValue, { resetPage: true });
|
||||
}, [setPerPageValue]);
|
||||
|
||||
const handlePageChange = useCallback((nextPage) => {
|
||||
if (perPageIsAll) return;
|
||||
|
||||
const numericPage = Number(nextPage);
|
||||
if (!Number.isFinite(numericPage) || numericPage < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const perPageNumber = resolvePerPageNumber();
|
||||
if (!perPageNumber) {
|
||||
return;
|
||||
}
|
||||
|
||||
const maxPages = Math.max(1, Math.ceil((totalItems || 0) / perPageNumber));
|
||||
if (numericPage > maxPages) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPage(numericPage);
|
||||
}, [perPageIsAll, resolvePerPageNumber, totalItems]);
|
||||
|
||||
const totalPages = useMemo(() => {
|
||||
if (perPageIsAll) {
|
||||
return 1;
|
||||
}
|
||||
const perPageNumber = resolvePerPageNumber();
|
||||
if (!perPageNumber) {
|
||||
return 1;
|
||||
}
|
||||
return Math.max(1, Math.ceil((totalItems || 0) / perPageNumber));
|
||||
}, [perPageIsAll, resolvePerPageNumber, totalItems]);
|
||||
|
||||
const rangeStart = useMemo(() => {
|
||||
if (!totalItems) {
|
||||
return 0;
|
||||
}
|
||||
if (perPageIsAll) {
|
||||
return 1;
|
||||
}
|
||||
const perPageNumber = resolvePerPageNumber();
|
||||
return (page - 1) * perPageNumber + 1;
|
||||
}, [totalItems, perPageIsAll, resolvePerPageNumber, page]);
|
||||
|
||||
const rangeEnd = useMemo(() => {
|
||||
if (!totalItems) {
|
||||
return 0;
|
||||
}
|
||||
if (perPageIsAll) {
|
||||
return totalItems;
|
||||
}
|
||||
const perPageNumber = resolvePerPageNumber();
|
||||
const estimatedEnd = (page - 1) * perPageNumber + (visibleItems || perPageNumber);
|
||||
return Math.min(totalItems, estimatedEnd);
|
||||
}, [totalItems, perPageIsAll, resolvePerPageNumber, page, visibleItems]);
|
||||
|
||||
const summaryLabel = useMemo(() => {
|
||||
if (!totalItems) {
|
||||
return "Keine Einträge";
|
||||
}
|
||||
if (perPageIsAll) {
|
||||
return `Zeige alle ${totalItems.toLocaleString()} Einträge`;
|
||||
}
|
||||
return `Zeige ${rangeStart.toLocaleString()} – ${rangeEnd.toLocaleString()} von ${totalItems.toLocaleString()} Einträgen`;
|
||||
}, [totalItems, perPageIsAll, rangeStart, rangeEnd]);
|
||||
|
||||
const value = useMemo(() => ({
|
||||
page,
|
||||
perPage,
|
||||
perPageOptions,
|
||||
perPageIsAll,
|
||||
totalItems,
|
||||
visibleItems,
|
||||
totalPages,
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
summaryLabel,
|
||||
setPage,
|
||||
setPerPage: setPerPageValue,
|
||||
setTotals: updateTotals,
|
||||
setTotalItems,
|
||||
setVisibleItems,
|
||||
handlePageChange,
|
||||
handlePerPageChange,
|
||||
validPerPageValues,
|
||||
resetPagination
|
||||
}), [
|
||||
page,
|
||||
perPage,
|
||||
perPageOptions,
|
||||
perPageIsAll,
|
||||
totalItems,
|
||||
visibleItems,
|
||||
totalPages,
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
summaryLabel,
|
||||
updateTotals,
|
||||
setPerPageValue,
|
||||
handlePageChange,
|
||||
handlePerPageChange,
|
||||
validPerPageValues,
|
||||
resetPagination
|
||||
]);
|
||||
|
||||
return (
|
||||
<PageContext.Provider value={value}>
|
||||
{children}
|
||||
</PageContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePage() {
|
||||
const context = useContext(PageContext);
|
||||
if (!context) {
|
||||
throw new Error("usePage must be used within a PageProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function PaginationControls({ disabled = false }) {
|
||||
const {
|
||||
summaryLabel,
|
||||
perPageIsAll,
|
||||
page,
|
||||
totalPages,
|
||||
handlePageChange
|
||||
} = usePage();
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<span>{summaryLabel}</span>
|
||||
{!perPageIsAll && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handlePageChange(page - 1)}
|
||||
disabled={disabled || page <= 1}
|
||||
className="rounded-md border border-slate-300 p-2.5 text-center text-sm transition-all shadow-sm hover:shadow-lg text-slate-600 hover:text-white hover:bg-slate-800 hover:border-slate-800 focus:text-white focus:bg-slate-800 focus:border-slate-800 active:border-slate-800 active:text-white active:bg-slate-800 disabled:pointer-events-none disabled:opacity-50 disabled:shadow-none"
|
||||
type="button"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" className="h-4 w-4">
|
||||
<path d="M11.03 3.97a.75.75 0 0 1 0 1.06l-6.22 6.22H21a.75.75 0 0 1 0 1.5H4.81l6.22 6.22a.75.75 0 1 1-1.06 1.06l-7.5-7.5a.75.75 0 0 1 0-1.06l7.5-7.5a.75.75 0 0 1 1.06 0Z" />
|
||||
</svg>
|
||||
</button>
|
||||
<p className="text-slate-600">
|
||||
Seite <strong className="text-slate-800">{page}</strong> /
|
||||
<strong className="text-slate-800">{totalPages}</strong>
|
||||
</p>
|
||||
<button
|
||||
onClick={() => handlePageChange(page + 1)}
|
||||
disabled={disabled || page >= totalPages}
|
||||
className="rounded-md border border-slate-300 p-2.5 text-center text-sm transition-all shadow-sm hover:shadow-lg text-slate-600 hover:text-white hover:bg-slate-800 hover:border-slate-800 focus:text-white focus:bg-slate-800 focus:border-slate-800 active:border-slate-800 active:text-white active:bg-slate-800 disabled:pointer-events-none disabled:opacity-50 disabled:shadow-none"
|
||||
type="button"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" className="h-4 w-4">
|
||||
<path d="M12.97 3.97a.75.75 0 0 1 1.06 0l7.5 7.5a.75.75 0 0 1 0 1.06l-7.5 7.5a.75.75 0 1 1-1.06-1.06l6.22-6.22H3a.75.75 0 0 1 0-1.5h16.19l-6.22-6.22a.75.75 0 0 1 0-1.06Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const PAGE_DEFAULTS = {
|
||||
perPage: DEFAULT_PER_PAGE,
|
||||
options: DEFAULT_PER_PAGE_OPTIONS
|
||||
};
|
||||
|
||||
export default PageProvider;
|
||||
@@ -0,0 +1,121 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
const ToastContext = createContext(null);
|
||||
|
||||
const VARIANT_STYLES = {
|
||||
success: {
|
||||
container: "border-mossGreen-500/80 bg-mossGreen-900/90 text-white",
|
||||
accent: "bg-mossGreen-400"
|
||||
},
|
||||
warning: {
|
||||
container: "border-warmAmberGlow-500/80 bg-warmAmberGlow-900/90 text-white",
|
||||
accent: "bg-warmAmberGlow-400"
|
||||
},
|
||||
error: {
|
||||
container: "border-sunsetCoral-500/80 bg-sunsetCoral-900/90 text-white",
|
||||
accent: "bg-sunsetCoral-400"
|
||||
},
|
||||
info: {
|
||||
container: "border-arcticBlue-500 bg-arcticBlue-900/90 text-white",
|
||||
accent: "bg-arcticBlue-400"
|
||||
}
|
||||
};
|
||||
|
||||
const DEFAULT_DURATION = 6000;
|
||||
|
||||
export default function ToastProvider({ children }) {
|
||||
const [toasts, setToasts] = useState([]);
|
||||
const timeoutsRef = useRef(new Map());
|
||||
|
||||
const dismissToast = useCallback((id) => {
|
||||
setToasts((prev) => prev.filter((toast) => toast.id !== id));
|
||||
|
||||
const handle = timeoutsRef.current.get(id);
|
||||
if (handle && typeof window !== "undefined") {
|
||||
window.clearTimeout(handle);
|
||||
}
|
||||
timeoutsRef.current.delete(id);
|
||||
}, []);
|
||||
|
||||
const showToast = useCallback(({ id, title, description, variant = "info", duration = DEFAULT_DURATION } = {}) => {
|
||||
const toastId = id ?? `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
const normalizedVariant = VARIANT_STYLES[variant] ? variant : "info";
|
||||
|
||||
setToasts((prev) => {
|
||||
const withoutExisting = prev.filter((toast) => toast.id !== toastId);
|
||||
return [
|
||||
...withoutExisting,
|
||||
{
|
||||
id: toastId,
|
||||
title,
|
||||
description,
|
||||
variant: normalizedVariant
|
||||
}
|
||||
];
|
||||
});
|
||||
|
||||
if (duration !== null && duration !== Infinity && typeof window !== "undefined") {
|
||||
const timeoutHandle = window.setTimeout(() => {
|
||||
dismissToast(toastId);
|
||||
}, duration);
|
||||
|
||||
const existingHandle = timeoutsRef.current.get(toastId);
|
||||
if (existingHandle) {
|
||||
window.clearTimeout(existingHandle);
|
||||
}
|
||||
timeoutsRef.current.set(toastId, timeoutHandle);
|
||||
}
|
||||
|
||||
return toastId;
|
||||
}, [dismissToast]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (typeof window === "undefined") return;
|
||||
timeoutsRef.current.forEach((handle) => window.clearTimeout(handle));
|
||||
timeoutsRef.current.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const contextValue = useMemo(() => ({ showToast, dismissToast }), [showToast, dismissToast]);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={contextValue}>
|
||||
{children}
|
||||
<div className="pointer-events-none fixed right-4 top-4 z-50 flex w-80 max-w-full flex-col gap-3">
|
||||
{toasts.map((toast) => {
|
||||
const styles = VARIANT_STYLES[toast.variant] ?? VARIANT_STYLES.info;
|
||||
return (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`pointer-events-auto overflow-hidden rounded-lg border px-4 py-3 shadow-xl backdrop-blur ${styles.container}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={`mt-1 h-2 flex-shrink-0 w-2 rounded-full ${styles.accent}`}></span>
|
||||
<div className="flex-1">
|
||||
{toast.title && <p className="text-sm font-semibold">{toast.title}</p>}
|
||||
{toast.description && <p className="mt-1 text-sm leading-snug text-white/80">{toast.description}</p>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dismissToast(toast.id)}
|
||||
className="ml-2 rounded-md p-1 text-xs uppercase tracking-wide text-white/60 transition hover:text-white/90"
|
||||
>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within a ToastProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
export const MaterialTailwind = React.createContext(null);
|
||||
MaterialTailwind.displayName = "MaterialTailwindContext";
|
||||
|
||||
export function reducer(state, action) {
|
||||
switch (action.type) {
|
||||
case "OPEN_SIDENAV": {
|
||||
return { ...state, openSidenav: action.value };
|
||||
}
|
||||
case "SIDENAV_TYPE": {
|
||||
return { ...state, sidenavType: action.value };
|
||||
}
|
||||
case "SIDENAV_COLOR": {
|
||||
return { ...state, sidenavColor: action.value };
|
||||
}
|
||||
case "TRANSPARENT_NAVBAR": {
|
||||
return { ...state, transparentNavbar: action.value };
|
||||
}
|
||||
case "FIXED_NAVBAR": {
|
||||
return { ...state, fixedNavbar: action.value };
|
||||
}
|
||||
case "OPEN_CONFIGURATOR": {
|
||||
return { ...state, openConfigurator: action.value };
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Unhandled action type: ${action.type}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function MaterialTailwindControllerProvider({ children }) {
|
||||
const initialState = {
|
||||
openSidenav: false,
|
||||
sidenavColor: "gray",
|
||||
sidenavType: "white",
|
||||
transparentNavbar: false,
|
||||
fixedNavbar: true,
|
||||
openConfigurator: false,
|
||||
};
|
||||
|
||||
const [controller, dispatch] = React.useReducer(reducer, initialState);
|
||||
const value = React.useMemo(
|
||||
() => [controller, dispatch],
|
||||
[controller, dispatch]
|
||||
);
|
||||
|
||||
return (
|
||||
<MaterialTailwind.Provider value={value}>
|
||||
{children}
|
||||
</MaterialTailwind.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useMaterialTailwindController() {
|
||||
const context = React.useContext(MaterialTailwind);
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useMaterialTailwindController should be used inside the MaterialTailwindControllerProvider."
|
||||
);
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
MaterialTailwindControllerProvider.displayName = "/src/context/index.jsx";
|
||||
|
||||
MaterialTailwindControllerProvider.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
export const setOpenSidenav = (dispatch, value) =>
|
||||
dispatch({ type: "OPEN_SIDENAV", value });
|
||||
export const setSidenavType = (dispatch, value) =>
|
||||
dispatch({ type: "SIDENAV_TYPE", value });
|
||||
export const setSidenavColor = (dispatch, value) =>
|
||||
dispatch({ type: "SIDENAV_COLOR", value });
|
||||
export const setTransparentNavbar = (dispatch, value) =>
|
||||
dispatch({ type: "TRANSPARENT_NAVBAR", value });
|
||||
export const setFixedNavbar = (dispatch, value) =>
|
||||
dispatch({ type: "FIXED_NAVBAR", value });
|
||||
export const setOpenConfigurator = (dispatch, value) =>
|
||||
dispatch({ type: "OPEN_CONFIGURATOR", value });
|
||||
@@ -0,0 +1,61 @@
|
||||
export const chartsConfig = {
|
||||
chart: {
|
||||
toolbar: {
|
||||
show: false,
|
||||
},
|
||||
},
|
||||
title: {
|
||||
show: "",
|
||||
},
|
||||
dataLabels: {
|
||||
enabled: false,
|
||||
},
|
||||
xaxis: {
|
||||
axisTicks: {
|
||||
show: false,
|
||||
},
|
||||
axisBorder: {
|
||||
show: false,
|
||||
},
|
||||
labels: {
|
||||
style: {
|
||||
colors: "#37474f",
|
||||
fontSize: "13px",
|
||||
fontFamily: "inherit",
|
||||
fontWeight: 300,
|
||||
},
|
||||
},
|
||||
},
|
||||
yaxis: {
|
||||
labels: {
|
||||
style: {
|
||||
colors: "#37474f",
|
||||
fontSize: "13px",
|
||||
fontFamily: "inherit",
|
||||
fontWeight: 300,
|
||||
},
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
show: true,
|
||||
borderColor: "#dddddd",
|
||||
strokeDashArray: 5,
|
||||
xaxis: {
|
||||
lines: {
|
||||
show: true,
|
||||
},
|
||||
},
|
||||
padding: {
|
||||
top: 5,
|
||||
right: 20,
|
||||
},
|
||||
},
|
||||
fill: {
|
||||
opacity: 0.8,
|
||||
},
|
||||
tooltip: {
|
||||
theme: "dark",
|
||||
},
|
||||
};
|
||||
|
||||
export default chartsConfig;
|
||||
@@ -0,0 +1 @@
|
||||
export * from "@/configs/charts-config";
|
||||
@@ -0,0 +1,52 @@
|
||||
export const authorsTableData = [
|
||||
{
|
||||
img: "/img/team-2.jpeg",
|
||||
name: "John Michael",
|
||||
email: "john@creative-tim.com",
|
||||
job: ["Manager", "Organization"],
|
||||
online: true,
|
||||
date: "23/04/18",
|
||||
},
|
||||
{
|
||||
img: "/img/team-1.jpeg",
|
||||
name: "Alexa Liras",
|
||||
email: "alexa@creative-tim.com",
|
||||
job: ["Programator", "Developer"],
|
||||
online: false,
|
||||
date: "11/01/19",
|
||||
},
|
||||
{
|
||||
img: "/img/team-4.jpeg",
|
||||
name: "Laurent Perrier",
|
||||
email: "laurent@creative-tim.com",
|
||||
job: ["Executive", "Projects"],
|
||||
online: true,
|
||||
date: "19/09/17",
|
||||
},
|
||||
{
|
||||
img: "/img/team-3.jpeg",
|
||||
name: "Michael Levi",
|
||||
email: "michael@creative-tim.com",
|
||||
job: ["Programator", "Developer"],
|
||||
online: true,
|
||||
date: "24/12/08",
|
||||
},
|
||||
{
|
||||
img: "/img/bruce-mars.jpeg",
|
||||
name: "Bruce Mars",
|
||||
email: "bruce@creative-tim.com",
|
||||
job: ["Manager", "Executive"],
|
||||
online: false,
|
||||
date: "04/10/21",
|
||||
},
|
||||
{
|
||||
img: "/img/team-2.jpeg",
|
||||
name: "Alexander",
|
||||
email: "alexander@creative-tim.com",
|
||||
job: ["Programator", "Developer"],
|
||||
online: false,
|
||||
date: "14/09/20",
|
||||
},
|
||||
];
|
||||
|
||||
export default authorsTableData;
|
||||
@@ -0,0 +1,29 @@
|
||||
export const conversationsData = [
|
||||
{
|
||||
img: "/img/team-1.jpeg",
|
||||
name: "Sophie B.",
|
||||
message: "Hi! I need more information...",
|
||||
},
|
||||
{
|
||||
img: "/img/team-2.jpeg",
|
||||
name: "Alexander",
|
||||
message: "Awesome work, can you...",
|
||||
},
|
||||
{
|
||||
img: "/img/team-3.jpeg",
|
||||
name: "Ivanna",
|
||||
message: "About files I can...",
|
||||
},
|
||||
{
|
||||
img: "/img/team-4.jpeg",
|
||||
name: "Peterson",
|
||||
message: "Have a great afternoon...",
|
||||
},
|
||||
{
|
||||
img: "/img/bruce-mars.jpeg",
|
||||
name: "Bruce Mars",
|
||||
message: "Hi! I need more information...",
|
||||
},
|
||||
];
|
||||
|
||||
export default conversationsData;
|
||||
@@ -0,0 +1,8 @@
|
||||
export * from "@/data/statistics-cards-data";
|
||||
export * from "@/data/statistics-charts-data";
|
||||
export * from "@/data/projects-table-data";
|
||||
export * from "@/data/orders-overview-data";
|
||||
export * from "@/data/platform-settings-data";
|
||||
export * from "@/data/conversations-data";
|
||||
export * from "@/data/projects-data";
|
||||
export * from "@/data/authors-table-data";
|
||||
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
BellIcon,
|
||||
PlusCircleIcon,
|
||||
ShoppingCartIcon,
|
||||
CreditCardIcon,
|
||||
LockOpenIcon,
|
||||
BanknotesIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
|
||||
export const ordersOverviewData = [
|
||||
{
|
||||
icon: BellIcon,
|
||||
color: "text-blue-gray-300",
|
||||
title: "$2400, Design changes",
|
||||
description: "22 DEC 7:20 PM",
|
||||
},
|
||||
{
|
||||
icon: PlusCircleIcon,
|
||||
color: "text-blue-gray-300",
|
||||
title: "New order #1832412",
|
||||
description: "21 DEC 11 PM",
|
||||
},
|
||||
{
|
||||
icon: ShoppingCartIcon,
|
||||
color: "text-blue-gray-300",
|
||||
title: "Server payments for April",
|
||||
description: "21 DEC 9:34 PM",
|
||||
},
|
||||
{
|
||||
icon: CreditCardIcon,
|
||||
color: "text-blue-gray-300",
|
||||
title: "New card added for order #4395133",
|
||||
description: "20 DEC 2:20 AM",
|
||||
},
|
||||
{
|
||||
icon: LockOpenIcon,
|
||||
color: "text-blue-gray-300",
|
||||
title: "Unlock packages for development",
|
||||
description: "18 DEC 4:54 AM",
|
||||
},
|
||||
{
|
||||
icon: BanknotesIcon,
|
||||
color: "text-blue-gray-300",
|
||||
title: "New order #9583120",
|
||||
description: "17 DEC",
|
||||
},
|
||||
];
|
||||
|
||||
export default ordersOverviewData;
|
||||
@@ -0,0 +1,38 @@
|
||||
export const platformSettingsData = [
|
||||
{
|
||||
title: "account",
|
||||
options: [
|
||||
{
|
||||
checked: true,
|
||||
label: "Email me when someone follows me",
|
||||
},
|
||||
{
|
||||
checked: false,
|
||||
label: "Email me when someone answers on my post",
|
||||
},
|
||||
{
|
||||
checked: true,
|
||||
label: "Email me when someone mentions me",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "application",
|
||||
options: [
|
||||
{
|
||||
checked: false,
|
||||
label: "New launches and projects",
|
||||
},
|
||||
{
|
||||
checked: true,
|
||||
label: "Monthly product updates",
|
||||
},
|
||||
{
|
||||
checked: false,
|
||||
label: "Subscribe to newsletter",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default platformSettingsData;
|
||||
@@ -0,0 +1,60 @@
|
||||
export const projectsData = [
|
||||
{
|
||||
img: "/img/home-decor-1.jpeg",
|
||||
title: "Modern",
|
||||
tag: "Project #1",
|
||||
description:
|
||||
"As Uber works through a huge amount of internal management turmoil.",
|
||||
route: "/dashboard/profile",
|
||||
members: [
|
||||
{ img: "/img/team-1.jpeg", name: "Romina Hadid" },
|
||||
{ img: "/img/team-2.jpeg", name: "Ryan Tompson" },
|
||||
{ img: "/img/team-3.jpeg", name: "Jessica Doe" },
|
||||
{ img: "/img/team-4.jpeg", name: "Alexander Smith" },
|
||||
],
|
||||
},
|
||||
{
|
||||
img: "/img/home-decor-2.jpeg",
|
||||
title: "Scandinavian",
|
||||
tag: "Project #2",
|
||||
description:
|
||||
"Music is something that every person has his or her own specific opinion about.",
|
||||
route: "/dashboard/profile",
|
||||
members: [
|
||||
{ img: "/img/team-4.jpeg", name: "Alexander Smith" },
|
||||
{ img: "/img/team-3.jpeg", name: "Jessica Doe" },
|
||||
{ img: "/img/team-2.jpeg", name: "Ryan Tompson" },
|
||||
{ img: "/img/team-1.jpeg", name: "Romina Hadid" },
|
||||
],
|
||||
},
|
||||
{
|
||||
img: "/img/home-decor-3.jpeg",
|
||||
title: "Minimalist",
|
||||
tag: "Project #3",
|
||||
description:
|
||||
"Different people have different taste, and various types of music.",
|
||||
route: "/dashboard/profile",
|
||||
members: [
|
||||
{ img: "/img/team-1.jpeg", name: "Romina Hadid" },
|
||||
{ img: "/img/team-2.jpeg", name: "Ryan Tompson" },
|
||||
{ img: "/img/team-3.jpeg", name: "Jessica Doe" },
|
||||
{ img: "/img/team-4.jpeg", name: "Alexander Smith" },
|
||||
],
|
||||
},
|
||||
{
|
||||
img: "/img/home-decor-4.jpeg",
|
||||
title: "Gothic",
|
||||
tag: "Project #4",
|
||||
description:
|
||||
"Why would anyone pick blue over pink? Pink is obviously a better color.",
|
||||
route: "/dashboard/profile",
|
||||
members: [
|
||||
{ img: "/img/team-4.jpeg", name: "Alexander Smith" },
|
||||
{ img: "/img/team-3.jpeg", name: "Jessica Doe" },
|
||||
{ img: "/img/team-2.jpeg", name: "Ryan Tompson" },
|
||||
{ img: "/img/team-1.jpeg", name: "Romina Hadid" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default projectsData;
|
||||
@@ -0,0 +1,65 @@
|
||||
export const projectsTableData = [
|
||||
{
|
||||
img: "/img/logo-xd.svg",
|
||||
name: "Material XD Version",
|
||||
members: [
|
||||
{ img: "/img/team-1.jpeg", name: "Romina Hadid" },
|
||||
{ img: "/img/team-2.jpeg", name: "Ryan Tompson" },
|
||||
{ img: "/img/team-3.jpeg", name: "Jessica Doe" },
|
||||
{ img: "/img/team-4.jpeg", name: "Alexander Smith" },
|
||||
],
|
||||
budget: "$14,000",
|
||||
completion: 60,
|
||||
},
|
||||
{
|
||||
img: "/img/logo-atlassian.svg",
|
||||
name: "Add Progress Track",
|
||||
members: [
|
||||
{ img: "/img/team-2.jpeg", name: "Ryan Tompson" },
|
||||
{ img: "/img/team-4.jpeg", name: "Alexander Smith" },
|
||||
],
|
||||
budget: "$3,000",
|
||||
completion: 10,
|
||||
},
|
||||
{
|
||||
img: "/img/logo-slack.svg",
|
||||
name: "Fix Platform Errors",
|
||||
members: [
|
||||
{ img: "/img/team-3.jpeg", name: "Jessica Doe" },
|
||||
{ img: "/img/team-1.jpeg", name: "Romina Hadid" },
|
||||
],
|
||||
budget: "Not set",
|
||||
completion: 100,
|
||||
},
|
||||
{
|
||||
img: "/img/logo-spotify.svg",
|
||||
name: "Launch our Mobile App",
|
||||
members: [
|
||||
{ img: "/img/team-4.jpeg", name: "Alexander Smith" },
|
||||
{ img: "/img/team-3.jpeg", name: "Jessica Doe" },
|
||||
{ img: "/img/team-2.jpeg", name: "Ryan Tompson" },
|
||||
{ img: "/img/team-1.jpeg", name: "Romina Hadid" },
|
||||
],
|
||||
budget: "$20,500",
|
||||
completion: 100,
|
||||
},
|
||||
{
|
||||
img: "/img/logo-jira.svg",
|
||||
name: "Add the New Pricing Page",
|
||||
members: [{ img: "/img/team-4.jpeg", name: "Alexander Smith" }],
|
||||
budget: "$500",
|
||||
completion: 25,
|
||||
},
|
||||
{
|
||||
img: "/img/logo-invision.svg",
|
||||
name: "Redesign New Online Shop",
|
||||
members: [
|
||||
{ img: "/img/team-1.jpeg", name: "Romina Hadid" },
|
||||
{ img: "/img/team-4.jpeg", name: "Alexander Smith" },
|
||||
],
|
||||
budget: "$2,000",
|
||||
completion: 40,
|
||||
},
|
||||
];
|
||||
|
||||
export default projectsTableData;
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
BanknotesIcon,
|
||||
UserPlusIcon,
|
||||
UsersIcon,
|
||||
ChartBarIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
|
||||
export const statisticsCardsData = [
|
||||
{
|
||||
color: "gray",
|
||||
icon: BanknotesIcon,
|
||||
title: "Today's Money",
|
||||
value: "$53k",
|
||||
footer: {
|
||||
color: "text-green-500",
|
||||
value: "+55%",
|
||||
label: "than last week",
|
||||
},
|
||||
},
|
||||
{
|
||||
color: "gray",
|
||||
icon: UsersIcon,
|
||||
title: "Today's Users",
|
||||
value: "2,300",
|
||||
footer: {
|
||||
color: "text-green-500",
|
||||
value: "+3%",
|
||||
label: "than last month",
|
||||
},
|
||||
},
|
||||
{
|
||||
color: "gray",
|
||||
icon: UserPlusIcon,
|
||||
title: "New Clients",
|
||||
value: "3,462",
|
||||
footer: {
|
||||
color: "text-red-500",
|
||||
value: "-2%",
|
||||
label: "than yesterday",
|
||||
},
|
||||
},
|
||||
{
|
||||
color: "gray",
|
||||
icon: ChartBarIcon,
|
||||
title: "Sales",
|
||||
value: "$103,430",
|
||||
footer: {
|
||||
color: "text-green-500",
|
||||
value: "+5%",
|
||||
label: "than yesterday",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export default statisticsCardsData;
|
||||
@@ -0,0 +1,131 @@
|
||||
import { chartsConfig } from "@/configs";
|
||||
|
||||
const websiteViewsChart = {
|
||||
type: "bar",
|
||||
height: 220,
|
||||
series: [
|
||||
{
|
||||
name: "Views",
|
||||
data: [50, 20, 10, 22, 50, 10, 40],
|
||||
},
|
||||
],
|
||||
options: {
|
||||
...chartsConfig,
|
||||
colors: "#388e3c",
|
||||
plotOptions: {
|
||||
bar: {
|
||||
columnWidth: "16%",
|
||||
borderRadius: 5,
|
||||
},
|
||||
},
|
||||
xaxis: {
|
||||
...chartsConfig.xaxis,
|
||||
categories: ["M", "T", "W", "T", "F", "S", "S"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const dailySalesChart = {
|
||||
type: "line",
|
||||
height: 220,
|
||||
series: [
|
||||
{
|
||||
name: "Sales",
|
||||
data: [50, 40, 300, 320, 500, 350, 200, 230, 500],
|
||||
},
|
||||
],
|
||||
options: {
|
||||
...chartsConfig,
|
||||
colors: ["#0288d1"],
|
||||
stroke: {
|
||||
lineCap: "round",
|
||||
},
|
||||
markers: {
|
||||
size: 5,
|
||||
},
|
||||
xaxis: {
|
||||
...chartsConfig.xaxis,
|
||||
categories: [
|
||||
"Apr",
|
||||
"May",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Oct",
|
||||
"Nov",
|
||||
"Dec",
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const completedTaskChart = {
|
||||
type: "line",
|
||||
height: 220,
|
||||
series: [
|
||||
{
|
||||
name: "Sales",
|
||||
data: [50, 40, 300, 320, 500, 350, 200, 230, 500],
|
||||
},
|
||||
],
|
||||
options: {
|
||||
...chartsConfig,
|
||||
colors: ["#388e3c"],
|
||||
stroke: {
|
||||
lineCap: "round",
|
||||
},
|
||||
markers: {
|
||||
size: 5,
|
||||
},
|
||||
xaxis: {
|
||||
...chartsConfig.xaxis,
|
||||
categories: [
|
||||
"Apr",
|
||||
"May",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Oct",
|
||||
"Nov",
|
||||
"Dec",
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const completedTasksChart = {
|
||||
...completedTaskChart,
|
||||
series: [
|
||||
{
|
||||
name: "Tasks",
|
||||
data: [50, 40, 300, 220, 500, 250, 400, 230, 500],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const statisticsChartsData = [
|
||||
{
|
||||
color: "white",
|
||||
title: "Website View",
|
||||
description: "Last Campaign Performance",
|
||||
footer: "campaign sent 2 days ago",
|
||||
chart: websiteViewsChart,
|
||||
},
|
||||
{
|
||||
color: "white",
|
||||
title: "Daily Sales",
|
||||
description: "15% increase in today sales",
|
||||
footer: "updated 4 min ago",
|
||||
chart: dailySalesChart,
|
||||
},
|
||||
{
|
||||
color: "white",
|
||||
title: "Completed Tasks",
|
||||
description: "Last Campaign Performance",
|
||||
footer: "just updated",
|
||||
chart: completedTasksChart,
|
||||
},
|
||||
];
|
||||
|
||||
export default statisticsChartsData;
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import {
|
||||
ChartPieIcon,
|
||||
UserIcon,
|
||||
UserPlusIcon,
|
||||
ArrowRightOnRectangleIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import { Navbar, Footer } from "@/widgets/layout";
|
||||
import routes from "@/routes";
|
||||
|
||||
export function Auth() {
|
||||
const navbarRoutes = [
|
||||
{
|
||||
name: "dashboard",
|
||||
path: "/dashboard/home",
|
||||
icon: ChartPieIcon,
|
||||
},
|
||||
{
|
||||
name: "profile",
|
||||
path: "/dashboard/home",
|
||||
icon: UserIcon,
|
||||
},
|
||||
{
|
||||
name: "sign up",
|
||||
path: "/auth/sign-up",
|
||||
icon: UserPlusIcon,
|
||||
},
|
||||
{
|
||||
name: "sign in",
|
||||
path: "/auth/sign-in",
|
||||
icon: ArrowRightOnRectangleIcon,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen w-full">
|
||||
<Routes>
|
||||
{routes.map(
|
||||
({ layout, pages }) =>
|
||||
layout === "auth" &&
|
||||
pages.map(({ path, element }) => (
|
||||
<Route exact path={path} element={element} />
|
||||
))
|
||||
)}
|
||||
</Routes>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Auth.displayName = "/src/layout/Auth.jsx";
|
||||
|
||||
export default Auth;
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import { Cog6ToothIcon } from "@heroicons/react/24/solid";
|
||||
import { IconButton } from "@material-tailwind/react";
|
||||
import {
|
||||
Sidenav,
|
||||
DashboardNavbar,
|
||||
Configurator,
|
||||
Footer,
|
||||
} from "@/widgets/layout";
|
||||
import routes from "@/routes";
|
||||
import { useMaterialTailwindController, setOpenConfigurator } from "@/components";
|
||||
|
||||
export function Dashboard() {
|
||||
const [controller, dispatch] = useMaterialTailwindController();
|
||||
const { sidenavType } = controller;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-blue-gray-50/50">
|
||||
<Sidenav
|
||||
routes={routes}
|
||||
brandImg={
|
||||
sidenavType === "dark" ? "/img/logo-ct.png" : "/img/logo-ct-dark.png"
|
||||
}
|
||||
/>
|
||||
<div className="p-4 xl:ml-80">
|
||||
<DashboardNavbar />
|
||||
<Configurator />
|
||||
<IconButton
|
||||
size="lg"
|
||||
color="white"
|
||||
className="fixed bottom-8 right-8 z-40 rounded-full shadow-blue-gray-900/10 xl:hidden"
|
||||
ripple={false}
|
||||
onClick={() => setOpenConfigurator(dispatch, true)}
|
||||
>
|
||||
<Cog6ToothIcon className="h-5 w-5" />
|
||||
</IconButton>
|
||||
<Routes>
|
||||
{routes.map(
|
||||
({ layout, pages }) =>
|
||||
layout === "dashboard" &&
|
||||
pages.map(({ path, element }) => (
|
||||
<Route exact path={path} element={element} />
|
||||
))
|
||||
)}
|
||||
</Routes>
|
||||
<div className="text-blue-gray-600">
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Dashboard.displayName = "/src/layout/dashboard.jsx";
|
||||
|
||||
export default Dashboard;
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "@/layouts/dashboard";
|
||||
export * from "@/layouts/auth";
|
||||
+29
-3
@@ -1,13 +1,39 @@
|
||||
/**
|
||||
=========================================================
|
||||
* Material Tailwind Dashboard React - v2.1.0
|
||||
=========================================================
|
||||
* Product Page: https://www.creative-tim.com/product/material-tailwind-dashboard-react
|
||||
* Copyright 2023 Creative Tim (https://www.creative-tim.com)
|
||||
* Licensed under MIT (https://github.com/creativetimofficial/material-tailwind-dashboard-react/blob/main/LICENSE.md)
|
||||
* Coded by Creative Tim
|
||||
=========================================================
|
||||
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
*/
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App.jsx";
|
||||
import './index.css'; // Tailwind CSS
|
||||
import { ThemeProvider } from "@material-tailwind/react";
|
||||
import { MaterialTailwindControllerProvider } from "@/components";
|
||||
import "./tailwind.css";
|
||||
import ToastProvider from "@/components/ToastProvider.jsx";
|
||||
import MaintenanceProvider from "@/components/MaintenanceProvider.jsx";
|
||||
import PageProvider from "@/components/PageProvider.jsx";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
<MaintenanceProvider>
|
||||
<ToastProvider>
|
||||
<PageProvider>
|
||||
<ThemeProvider>
|
||||
<MaterialTailwindControllerProvider>
|
||||
<App />
|
||||
</MaterialTailwindControllerProvider>
|
||||
</ThemeProvider>
|
||||
</PageProvider>
|
||||
</ToastProvider>
|
||||
</MaintenanceProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "@/pages/auth/sign-in";
|
||||
export * from "@/pages/auth/sign-up";
|
||||
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
Card,
|
||||
Input,
|
||||
Checkbox,
|
||||
Button,
|
||||
Typography,
|
||||
} from "@material-tailwind/react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
|
||||
export function SignIn() {
|
||||
return (
|
||||
<section className="m-8 flex gap-4">
|
||||
<div className="w-full lg:w-3/5 mt-24">
|
||||
<div className="text-center">
|
||||
<Typography variant="h2" className="font-bold mb-4">Sign In</Typography>
|
||||
<Typography variant="paragraph" color="blue-gray" className="text-lg font-normal">Enter your email and password to Sign In.</Typography>
|
||||
</div>
|
||||
<form className="mt-8 mb-2 mx-auto w-80 max-w-screen-lg lg:w-1/2">
|
||||
<div className="mb-1 flex flex-col gap-6">
|
||||
<Typography variant="small" color="blue-gray" className="-mb-3 font-medium">
|
||||
Your email
|
||||
</Typography>
|
||||
<Input
|
||||
size="lg"
|
||||
placeholder="name@mail.com"
|
||||
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
|
||||
labelProps={{
|
||||
className: "before:content-none after:content-none",
|
||||
}}
|
||||
/>
|
||||
<Typography variant="small" color="blue-gray" className="-mb-3 font-medium">
|
||||
Password
|
||||
</Typography>
|
||||
<Input
|
||||
type="password"
|
||||
size="lg"
|
||||
placeholder="********"
|
||||
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
|
||||
labelProps={{
|
||||
className: "before:content-none after:content-none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Checkbox
|
||||
label={
|
||||
<Typography
|
||||
variant="small"
|
||||
color="gray"
|
||||
className="flex items-center justify-start font-medium"
|
||||
>
|
||||
I agree the
|
||||
<a
|
||||
href="#"
|
||||
className="font-normal text-black transition-colors hover:text-gray-900 underline"
|
||||
>
|
||||
Terms and Conditions
|
||||
</a>
|
||||
</Typography>
|
||||
}
|
||||
containerProps={{ className: "-ml-2.5" }}
|
||||
/>
|
||||
<Button className="mt-6" fullWidth>
|
||||
Sign In
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 mt-6">
|
||||
<Checkbox
|
||||
label={
|
||||
<Typography
|
||||
variant="small"
|
||||
color="gray"
|
||||
className="flex items-center justify-start font-medium"
|
||||
>
|
||||
Subscribe me to newsletter
|
||||
</Typography>
|
||||
}
|
||||
containerProps={{ className: "-ml-2.5" }}
|
||||
/>
|
||||
<Typography variant="small" className="font-medium text-gray-900">
|
||||
<a href="#">
|
||||
Forgot Password
|
||||
</a>
|
||||
</Typography>
|
||||
</div>
|
||||
<div className="space-y-4 mt-8">
|
||||
<Button size="lg" color="white" className="flex items-center gap-2 justify-center shadow-md" fullWidth>
|
||||
<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clipPath="url(#clip0_1156_824)">
|
||||
<path d="M16.3442 8.18429C16.3442 7.64047 16.3001 7.09371 16.206 6.55872H8.66016V9.63937H12.9813C12.802 10.6329 12.2258 11.5119 11.3822 12.0704V14.0693H13.9602C15.4741 12.6759 16.3442 10.6182 16.3442 8.18429Z" fill="#4285F4" />
|
||||
<path d="M8.65974 16.0006C10.8174 16.0006 12.637 15.2922 13.9627 14.0693L11.3847 12.0704C10.6675 12.5584 9.7415 12.8347 8.66268 12.8347C6.5756 12.8347 4.80598 11.4266 4.17104 9.53357H1.51074V11.5942C2.86882 14.2956 5.63494 16.0006 8.65974 16.0006Z" fill="#34A853" />
|
||||
<path d="M4.16852 9.53356C3.83341 8.53999 3.83341 7.46411 4.16852 6.47054V4.40991H1.51116C0.376489 6.67043 0.376489 9.33367 1.51116 11.5942L4.16852 9.53356Z" fill="#FBBC04" />
|
||||
<path d="M8.65974 3.16644C9.80029 3.1488 10.9026 3.57798 11.7286 4.36578L14.0127 2.08174C12.5664 0.72367 10.6469 -0.0229773 8.65974 0.000539111C5.63494 0.000539111 2.86882 1.70548 1.51074 4.40987L4.1681 6.4705C4.8001 4.57449 6.57266 3.16644 8.65974 3.16644Z" fill="#EA4335" />
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1156_824">
|
||||
<rect width="16" height="16" fill="white" transform="translate(0.5)" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
<span>Sign in With Google</span>
|
||||
</Button>
|
||||
<Button size="lg" color="white" className="flex items-center gap-2 justify-center shadow-md" fullWidth>
|
||||
<img src="/img/twitter-logo.svg" height={24} width={24} alt="" />
|
||||
<span>Sign in With Twitter</span>
|
||||
</Button>
|
||||
</div>
|
||||
<Typography variant="paragraph" className="text-center text-blue-gray-500 font-medium mt-4">
|
||||
Not registered?
|
||||
<Link to="/auth/sign-up" className="text-gray-900 ml-1">Create account</Link>
|
||||
</Typography>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
<div className="w-2/5 h-full hidden lg:block">
|
||||
<img
|
||||
src="/img/pattern.png"
|
||||
className="h-full w-full object-cover rounded-3xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default SignIn;
|
||||
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
Card,
|
||||
Input,
|
||||
Checkbox,
|
||||
Button,
|
||||
Typography,
|
||||
} from "@material-tailwind/react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
|
||||
export function SignUp() {
|
||||
return (
|
||||
<section className="m-8 flex">
|
||||
<div className="w-2/5 h-full hidden lg:block">
|
||||
<img
|
||||
src="/img/pattern.png"
|
||||
className="h-full w-full object-cover rounded-3xl"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full lg:w-3/5 flex flex-col items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Typography variant="h2" className="font-bold mb-4">Join Us Today</Typography>
|
||||
<Typography variant="paragraph" color="blue-gray" className="text-lg font-normal">Enter your email and password to register.</Typography>
|
||||
</div>
|
||||
<form className="mt-8 mb-2 mx-auto w-80 max-w-screen-lg lg:w-1/2">
|
||||
<div className="mb-1 flex flex-col gap-6">
|
||||
<Typography variant="small" color="blue-gray" className="-mb-3 font-medium">
|
||||
Your email
|
||||
</Typography>
|
||||
<Input
|
||||
size="lg"
|
||||
placeholder="name@mail.com"
|
||||
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
|
||||
labelProps={{
|
||||
className: "before:content-none after:content-none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Checkbox
|
||||
label={
|
||||
<Typography
|
||||
variant="small"
|
||||
color="gray"
|
||||
className="flex items-center justify-start font-medium"
|
||||
>
|
||||
I agree the
|
||||
<a
|
||||
href="#"
|
||||
className="font-normal text-black transition-colors hover:text-gray-900 underline"
|
||||
>
|
||||
Terms and Conditions
|
||||
</a>
|
||||
</Typography>
|
||||
}
|
||||
containerProps={{ className: "-ml-2.5" }}
|
||||
/>
|
||||
<Button className="mt-6" fullWidth>
|
||||
Register Now
|
||||
</Button>
|
||||
|
||||
<div className="space-y-4 mt-8">
|
||||
<Button size="lg" color="white" className="flex items-center gap-2 justify-center shadow-md" fullWidth>
|
||||
<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clipPath="url(#clip0_1156_824)">
|
||||
<path d="M16.3442 8.18429C16.3442 7.64047 16.3001 7.09371 16.206 6.55872H8.66016V9.63937H12.9813C12.802 10.6329 12.2258 11.5119 11.3822 12.0704V14.0693H13.9602C15.4741 12.6759 16.3442 10.6182 16.3442 8.18429Z" fill="#4285F4" />
|
||||
<path d="M8.65974 16.0006C10.8174 16.0006 12.637 15.2922 13.9627 14.0693L11.3847 12.0704C10.6675 12.5584 9.7415 12.8347 8.66268 12.8347C6.5756 12.8347 4.80598 11.4266 4.17104 9.53357H1.51074V11.5942C2.86882 14.2956 5.63494 16.0006 8.65974 16.0006Z" fill="#34A853" />
|
||||
<path d="M4.16852 9.53356C3.83341 8.53999 3.83341 7.46411 4.16852 6.47054V4.40991H1.51116C0.376489 6.67043 0.376489 9.33367 1.51116 11.5942L4.16852 9.53356Z" fill="#FBBC04" />
|
||||
<path d="M8.65974 3.16644C9.80029 3.1488 10.9026 3.57798 11.7286 4.36578L14.0127 2.08174C12.5664 0.72367 10.6469 -0.0229773 8.65974 0.000539111C5.63494 0.000539111 2.86882 1.70548 1.51074 4.40987L4.1681 6.4705C4.8001 4.57449 6.57266 3.16644 8.65974 3.16644Z" fill="#EA4335" />
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1156_824">
|
||||
<rect width="16" height="16" fill="white" transform="translate(0.5)" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
<span>Sign in With Google</span>
|
||||
</Button>
|
||||
<Button size="lg" color="white" className="flex items-center gap-2 justify-center shadow-md" fullWidth>
|
||||
<img src="/img/twitter-logo.svg" height={24} width={24} alt="" />
|
||||
<span>Sign in With Twitter</span>
|
||||
</Button>
|
||||
</div>
|
||||
<Typography variant="paragraph" className="text-center text-blue-gray-500 font-medium mt-4">
|
||||
Already have an account?
|
||||
<Link to="/auth/sign-in" className="text-gray-900 ml-1">Sign in</Link>
|
||||
</Typography>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default SignUp;
|
||||
@@ -0,0 +1,258 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Typography,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuHandler,
|
||||
MenuList,
|
||||
MenuItem,
|
||||
Avatar,
|
||||
Tooltip,
|
||||
Progress,
|
||||
} from "@material-tailwind/react";
|
||||
import {
|
||||
EllipsisVerticalIcon,
|
||||
ArrowUpIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { StatisticsCard } from "@/widgets/cards";
|
||||
import { StatisticsChart } from "@/widgets/charts";
|
||||
import {
|
||||
statisticsCardsData,
|
||||
statisticsChartsData,
|
||||
projectsTableData,
|
||||
ordersOverviewData,
|
||||
} from "@/data";
|
||||
import { CheckCircleIcon, ClockIcon } from "@heroicons/react/24/solid";
|
||||
|
||||
export function Home() {
|
||||
return (
|
||||
<div className="mt-12">
|
||||
<div className="mb-12 grid gap-y-10 gap-x-6 md:grid-cols-2 xl:grid-cols-4">
|
||||
{statisticsCardsData.map(({ icon, title, footer, ...rest }) => (
|
||||
<StatisticsCard
|
||||
key={title}
|
||||
{...rest}
|
||||
title={title}
|
||||
icon={React.createElement(icon, {
|
||||
className: "w-6 h-6 text-white",
|
||||
})}
|
||||
footer={
|
||||
<Typography className="font-normal text-blue-gray-600">
|
||||
<strong className={footer.color}>{footer.value}</strong>
|
||||
{footer.label}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="mb-6 grid grid-cols-1 gap-y-12 gap-x-6 md:grid-cols-2 xl:grid-cols-3">
|
||||
{statisticsChartsData.map((props) => (
|
||||
<StatisticsChart
|
||||
key={props.title}
|
||||
{...props}
|
||||
footer={
|
||||
<Typography
|
||||
variant="small"
|
||||
className="flex items-center font-normal text-blue-gray-600"
|
||||
>
|
||||
<ClockIcon strokeWidth={2} className="h-4 w-4 text-blue-gray-400" />
|
||||
{props.footer}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="mb-4 grid grid-cols-1 gap-6 xl:grid-cols-3">
|
||||
<Card className="overflow-hidden xl:col-span-2 border border-blue-gray-100 shadow-sm">
|
||||
<CardHeader
|
||||
floated={false}
|
||||
shadow={false}
|
||||
color="transparent"
|
||||
className="m-0 flex items-center justify-between p-6"
|
||||
>
|
||||
<div>
|
||||
<Typography variant="h6" color="blue-gray" className="mb-1">
|
||||
Projects
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="small"
|
||||
className="flex items-center gap-1 font-normal text-blue-gray-600"
|
||||
>
|
||||
<CheckCircleIcon strokeWidth={3} className="h-4 w-4 text-blue-gray-200" />
|
||||
<strong>30 done</strong> this month
|
||||
</Typography>
|
||||
</div>
|
||||
<Menu placement="left-start">
|
||||
<MenuHandler>
|
||||
<IconButton size="sm" variant="text" color="blue-gray">
|
||||
<EllipsisVerticalIcon
|
||||
strokeWidth={3}
|
||||
fill="currenColor"
|
||||
className="h-6 w-6"
|
||||
/>
|
||||
</IconButton>
|
||||
</MenuHandler>
|
||||
<MenuList>
|
||||
<MenuItem>Action</MenuItem>
|
||||
<MenuItem>Another Action</MenuItem>
|
||||
<MenuItem>Something else here</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</CardHeader>
|
||||
<CardBody className="overflow-x-scroll px-0 pt-0 pb-2">
|
||||
<table className="w-full min-w-[640px] table-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
{["companies", "members", "budget", "completion"].map(
|
||||
(el) => (
|
||||
<th
|
||||
key={el}
|
||||
className="border-b border-blue-gray-50 py-3 px-6 text-left"
|
||||
>
|
||||
<Typography
|
||||
variant="small"
|
||||
className="text-[11px] font-medium uppercase text-blue-gray-400"
|
||||
>
|
||||
{el}
|
||||
</Typography>
|
||||
</th>
|
||||
)
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{projectsTableData.map(
|
||||
({ img, name, members, budget, completion }, key) => {
|
||||
const className = `py-3 px-5 ${
|
||||
key === projectsTableData.length - 1
|
||||
? ""
|
||||
: "border-b border-blue-gray-50"
|
||||
}`;
|
||||
|
||||
return (
|
||||
<tr key={name}>
|
||||
<td className={className}>
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar src={img} alt={name} size="sm" />
|
||||
<Typography
|
||||
variant="small"
|
||||
color="blue-gray"
|
||||
className="font-bold"
|
||||
>
|
||||
{name}
|
||||
</Typography>
|
||||
</div>
|
||||
</td>
|
||||
<td className={className}>
|
||||
{members.map(({ img, name }, key) => (
|
||||
<Tooltip key={name} content={name}>
|
||||
<Avatar
|
||||
src={img}
|
||||
alt={name}
|
||||
size="xs"
|
||||
variant="circular"
|
||||
className={`cursor-pointer border-2 border-white ${
|
||||
key === 0 ? "" : "-ml-2.5"
|
||||
}`}
|
||||
/>
|
||||
</Tooltip>
|
||||
))}
|
||||
</td>
|
||||
<td className={className}>
|
||||
<Typography
|
||||
variant="small"
|
||||
className="text-xs font-medium text-blue-gray-600"
|
||||
>
|
||||
{budget}
|
||||
</Typography>
|
||||
</td>
|
||||
<td className={className}>
|
||||
<div className="w-10/12">
|
||||
<Typography
|
||||
variant="small"
|
||||
className="mb-1 block text-xs font-medium text-blue-gray-600"
|
||||
>
|
||||
{completion}%
|
||||
</Typography>
|
||||
<Progress
|
||||
value={completion}
|
||||
variant="gradient"
|
||||
color={completion === 100 ? "green" : "blue"}
|
||||
className="h-1"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Card className="border border-blue-gray-100 shadow-sm">
|
||||
<CardHeader
|
||||
floated={false}
|
||||
shadow={false}
|
||||
color="transparent"
|
||||
className="m-0 p-6"
|
||||
>
|
||||
<Typography variant="h6" color="blue-gray" className="mb-2">
|
||||
Orders Overview
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="small"
|
||||
className="flex items-center gap-1 font-normal text-blue-gray-600"
|
||||
>
|
||||
<ArrowUpIcon
|
||||
strokeWidth={3}
|
||||
className="h-3.5 w-3.5 text-green-500"
|
||||
/>
|
||||
<strong>24%</strong> this month
|
||||
</Typography>
|
||||
</CardHeader>
|
||||
<CardBody className="pt-0">
|
||||
{ordersOverviewData.map(
|
||||
({ icon, color, title, description }, key) => (
|
||||
<div key={title} className="flex items-start gap-4 py-3">
|
||||
<div
|
||||
className={`relative p-1 after:absolute after:-bottom-6 after:left-2/4 after:w-0.5 after:-translate-x-2/4 after:bg-blue-gray-50 after:content-[''] ${
|
||||
key === ordersOverviewData.length - 1
|
||||
? "after:h-0"
|
||||
: "after:h-4/6"
|
||||
}`}
|
||||
>
|
||||
{React.createElement(icon, {
|
||||
className: `!w-5 !h-5 ${color}`,
|
||||
})}
|
||||
</div>
|
||||
<div>
|
||||
<Typography
|
||||
variant="small"
|
||||
color="blue-gray"
|
||||
className="block font-medium"
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography
|
||||
as="span"
|
||||
variant="small"
|
||||
className="text-xs font-medium text-blue-gray-500"
|
||||
>
|
||||
{description}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Home;
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "@/pages/dashboard/stacks";
|
||||
export * from "@/pages/dashboard/maintenance";
|
||||
export * from "@/pages/dashboard/logs";
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,88 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Typography,
|
||||
Alert,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
} from "@material-tailwind/react";
|
||||
import { InformationCircleIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
export function Notifications() {
|
||||
const [showAlerts, setShowAlerts] = React.useState({
|
||||
blue: true,
|
||||
green: true,
|
||||
orange: true,
|
||||
red: true,
|
||||
});
|
||||
const [showAlertsWithIcon, setShowAlertsWithIcon] = React.useState({
|
||||
blue: true,
|
||||
green: true,
|
||||
orange: true,
|
||||
red: true,
|
||||
});
|
||||
const alerts = ["gray", "green", "orange", "red"];
|
||||
|
||||
return (
|
||||
<div className="mx-auto my-20 flex max-w-screen-lg flex-col gap-8">
|
||||
<Card>
|
||||
<CardHeader
|
||||
color="transparent"
|
||||
floated={false}
|
||||
shadow={false}
|
||||
className="m-0 p-4"
|
||||
>
|
||||
<Typography variant="h5" color="blue-gray">
|
||||
Alerts
|
||||
</Typography>
|
||||
</CardHeader>
|
||||
<CardBody className="flex flex-col gap-4 p-4">
|
||||
{alerts.map((color) => (
|
||||
<Alert
|
||||
key={color}
|
||||
open={showAlerts[color]}
|
||||
color={color}
|
||||
onClose={() => setShowAlerts((current) => ({ ...current, [color]: false }))}
|
||||
>
|
||||
A simple {color} alert with an <a href="#">example link</a>. Give
|
||||
it a click if you like.
|
||||
</Alert>
|
||||
))}
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader
|
||||
color="transparent"
|
||||
floated={false}
|
||||
shadow={false}
|
||||
className="m-0 p-4"
|
||||
>
|
||||
<Typography variant="h5" color="blue-gray">
|
||||
Alerts with Icon
|
||||
</Typography>
|
||||
</CardHeader>
|
||||
<CardBody className="flex flex-col gap-4 p-4">
|
||||
{alerts.map((color) => (
|
||||
<Alert
|
||||
key={color}
|
||||
open={showAlertsWithIcon[color]}
|
||||
color={color}
|
||||
icon={
|
||||
<InformationCircleIcon strokeWidth={2} className="h-6 w-6" />
|
||||
}
|
||||
onClose={() => setShowAlertsWithIcon((current) => ({
|
||||
...current,
|
||||
[color]: false,
|
||||
}))}
|
||||
>
|
||||
A simple {color} alert with an <a href="#">example link</a>. Give
|
||||
it a click if you like.
|
||||
</Alert>
|
||||
))}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Notifications;
|
||||
@@ -0,0 +1,221 @@
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
Avatar,
|
||||
Typography,
|
||||
Tabs,
|
||||
TabsHeader,
|
||||
Tab,
|
||||
Switch,
|
||||
Tooltip,
|
||||
Button,
|
||||
} from "@material-tailwind/react";
|
||||
import {
|
||||
HomeIcon,
|
||||
ChatBubbleLeftEllipsisIcon,
|
||||
Cog6ToothIcon,
|
||||
PencilIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import { Link } from "react-router-dom";
|
||||
import { ProfileInfoCard, MessageCard } from "@/widgets/cards";
|
||||
import { platformSettingsData, conversationsData, projectsData } from "@/data";
|
||||
|
||||
export function Profile() {
|
||||
return (
|
||||
<>
|
||||
<div className="relative mt-8 h-72 w-full overflow-hidden rounded-xl bg-[url('/img/background-image.png')] bg-cover bg-center">
|
||||
<div className="absolute inset-0 h-full w-full bg-gray-900/75" />
|
||||
</div>
|
||||
<Card className="mx-3 -mt-16 mb-6 lg:mx-4 border border-blue-gray-100">
|
||||
<CardBody className="p-4">
|
||||
<div className="mb-10 flex items-center justify-between flex-wrap gap-6">
|
||||
<div className="flex items-center gap-6">
|
||||
<Avatar
|
||||
src="/img/bruce-mars.jpeg"
|
||||
alt="bruce-mars"
|
||||
size="xl"
|
||||
variant="rounded"
|
||||
className="rounded-lg shadow-lg shadow-blue-gray-500/40"
|
||||
/>
|
||||
<div>
|
||||
<Typography variant="h5" color="blue-gray" className="mb-1">
|
||||
Richard Davis
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="small"
|
||||
className="font-normal text-blue-gray-600"
|
||||
>
|
||||
CEO / Co-Founder
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-96">
|
||||
<Tabs value="app">
|
||||
<TabsHeader>
|
||||
<Tab value="app">
|
||||
<HomeIcon className="-mt-1 mr-2 inline-block h-5 w-5" />
|
||||
App
|
||||
</Tab>
|
||||
<Tab value="message">
|
||||
<ChatBubbleLeftEllipsisIcon className="-mt-0.5 mr-2 inline-block h-5 w-5" />
|
||||
Message
|
||||
</Tab>
|
||||
<Tab value="settings">
|
||||
<Cog6ToothIcon className="-mt-1 mr-2 inline-block h-5 w-5" />
|
||||
Settings
|
||||
</Tab>
|
||||
</TabsHeader>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
<div className="gird-cols-1 mb-12 grid gap-12 px-4 lg:grid-cols-2 xl:grid-cols-3">
|
||||
<div>
|
||||
<Typography variant="h6" color="blue-gray" className="mb-3">
|
||||
Platform Settings
|
||||
</Typography>
|
||||
<div className="flex flex-col gap-12">
|
||||
{platformSettingsData.map(({ title, options }) => (
|
||||
<div key={title}>
|
||||
<Typography className="mb-4 block text-xs font-semibold uppercase text-blue-gray-500">
|
||||
{title}
|
||||
</Typography>
|
||||
<div className="flex flex-col gap-6">
|
||||
{options.map(({ checked, label }) => (
|
||||
<Switch
|
||||
key={label}
|
||||
id={label}
|
||||
label={label}
|
||||
defaultChecked={checked}
|
||||
labelProps={{
|
||||
className: "text-sm font-normal text-blue-gray-500",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ProfileInfoCard
|
||||
title="Profile Information"
|
||||
description="Hi, I'm Alec Thompson, Decisions: If you can't decide, the answer is no. If two equally difficult paths, choose the one more painful in the short term (pain avoidance is creating an illusion of equality)."
|
||||
details={{
|
||||
"first name": "Alec M. Thompson",
|
||||
mobile: "(44) 123 1234 123",
|
||||
email: "alecthompson@mail.com",
|
||||
location: "USA",
|
||||
social: (
|
||||
<div className="flex items-center gap-4">
|
||||
<i className="fa-brands fa-facebook text-blue-700" />
|
||||
<i className="fa-brands fa-twitter text-blue-400" />
|
||||
<i className="fa-brands fa-instagram text-purple-500" />
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
action={
|
||||
<Tooltip content="Edit Profile">
|
||||
<PencilIcon className="h-4 w-4 cursor-pointer text-blue-gray-500" />
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
<div>
|
||||
<Typography variant="h6" color="blue-gray" className="mb-3">
|
||||
Platform Settings
|
||||
</Typography>
|
||||
<ul className="flex flex-col gap-6">
|
||||
{conversationsData.map((props) => (
|
||||
<MessageCard
|
||||
key={props.name}
|
||||
{...props}
|
||||
action={
|
||||
<Button variant="text" size="sm">
|
||||
reply
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-4 pb-4">
|
||||
<Typography variant="h6" color="blue-gray" className="mb-2">
|
||||
Projects
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="small"
|
||||
className="font-normal text-blue-gray-500"
|
||||
>
|
||||
Architects design houses
|
||||
</Typography>
|
||||
<div className="mt-6 grid grid-cols-1 gap-12 md:grid-cols-2 xl:grid-cols-4">
|
||||
{projectsData.map(
|
||||
({ img, title, description, tag, route, members }) => (
|
||||
<Card key={title} color="transparent" shadow={false}>
|
||||
<CardHeader
|
||||
floated={false}
|
||||
color="gray"
|
||||
className="mx-0 mt-0 mb-4 h-64 xl:h-40"
|
||||
>
|
||||
<img
|
||||
src={img}
|
||||
alt={title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardBody className="py-0 px-1">
|
||||
<Typography
|
||||
variant="small"
|
||||
className="font-normal text-blue-gray-500"
|
||||
>
|
||||
{tag}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h5"
|
||||
color="blue-gray"
|
||||
className="mt-1 mb-2"
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="small"
|
||||
className="font-normal text-blue-gray-500"
|
||||
>
|
||||
{description}
|
||||
</Typography>
|
||||
</CardBody>
|
||||
<CardFooter className="mt-6 flex items-center justify-between py-0 px-1">
|
||||
<Link to={route}>
|
||||
<Button variant="outlined" size="sm">
|
||||
view project
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
{members.map(({ img, name }, key) => (
|
||||
<Tooltip key={name} content={name}>
|
||||
<Avatar
|
||||
src={img}
|
||||
alt={name}
|
||||
size="xs"
|
||||
variant="circular"
|
||||
className={`cursor-pointer border-2 border-white ${
|
||||
key === 0 ? "" : "-ml-2.5"
|
||||
}`}
|
||||
/>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Profile;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
Square3Stack3DIcon,
|
||||
WrenchScrewdriverIcon,
|
||||
ListBulletIcon
|
||||
} from "@heroicons/react/24/solid";
|
||||
import { Stacks, Maintenance, Logs } from "@/pages/dashboard";
|
||||
import { SignIn, SignUp } from "@/pages/auth";
|
||||
|
||||
const icon = {
|
||||
className: "w-5 h-5 text-inherit",
|
||||
};
|
||||
|
||||
export const routes = [
|
||||
{
|
||||
layout: "dashboard",
|
||||
pages: [
|
||||
{
|
||||
icon: <Square3Stack3DIcon {...icon} />,
|
||||
name: "stacks",
|
||||
path: "/stacks",
|
||||
element: <Stacks />,
|
||||
},
|
||||
{
|
||||
icon: <WrenchScrewdriverIcon {...icon} />,
|
||||
name: "wartung",
|
||||
path: "/maintenance",
|
||||
element: <Maintenance />,
|
||||
},
|
||||
{
|
||||
icon: <ListBulletIcon {...icon} />,
|
||||
name: "logs",
|
||||
path: "/logs",
|
||||
element: <Logs />,
|
||||
},
|
||||
],
|
||||
},
|
||||
// {
|
||||
// title: "auth pages",
|
||||
// layout: "auth",
|
||||
// pages: [
|
||||
// {
|
||||
// icon: <ServerStackIcon {...icon} />,
|
||||
// name: "sign in",
|
||||
// path: "/sign-in",
|
||||
// element: <SignIn />,
|
||||
// },
|
||||
// {
|
||||
// icon: <RectangleStackIcon {...icon} />,
|
||||
// name: "sign up",
|
||||
// path: "/sign-up",
|
||||
// element: <SignUp />,
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
];
|
||||
|
||||
export default routes;
|
||||
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "@/widgets/cards/statistics-card";
|
||||
export * from "@/widgets/cards/profile-info-card";
|
||||
export * from "@/widgets/cards/message-card";
|
||||
@@ -0,0 +1,45 @@
|
||||
import PropTypes from "prop-types";
|
||||
import { Avatar, Typography } from "@material-tailwind/react";
|
||||
|
||||
export function MessageCard({ img, name, message, action }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar
|
||||
src={img}
|
||||
alt={name}
|
||||
variant="rounded"
|
||||
className="shadow-lg shadow-blue-gray-500/25"
|
||||
/>
|
||||
<div>
|
||||
<Typography
|
||||
variant="small"
|
||||
color="blue-gray"
|
||||
className="mb-1 font-semibold"
|
||||
>
|
||||
{name}
|
||||
</Typography>
|
||||
<Typography className="text-xs font-normal text-blue-gray-400">
|
||||
{message}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
MessageCard.defaultProps = {
|
||||
action: null,
|
||||
};
|
||||
|
||||
MessageCard.propTypes = {
|
||||
img: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
message: PropTypes.node.isRequired,
|
||||
action: PropTypes.node,
|
||||
};
|
||||
|
||||
MessageCard.displayName = "/src/widgets/cards/message-card.jsx";
|
||||
|
||||
export default MessageCard;
|
||||
@@ -0,0 +1,79 @@
|
||||
import PropTypes from "prop-types";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
Typography,
|
||||
} from "@material-tailwind/react";
|
||||
|
||||
export function ProfileInfoCard({ title, description, details, action }) {
|
||||
return (
|
||||
<Card color="transparent" shadow={false}>
|
||||
<CardHeader
|
||||
color="transparent"
|
||||
shadow={false}
|
||||
floated={false}
|
||||
className="mx-0 mt-0 mb-4 flex items-center justify-between gap-4"
|
||||
>
|
||||
<Typography variant="h6" color="blue-gray">
|
||||
{title}
|
||||
</Typography>
|
||||
{action}
|
||||
</CardHeader>
|
||||
<CardBody className="p-0">
|
||||
{description && (
|
||||
<Typography
|
||||
variant="small"
|
||||
className="font-normal text-blue-gray-500"
|
||||
>
|
||||
{description}
|
||||
</Typography>
|
||||
)}
|
||||
{description && details ? (
|
||||
<hr className="my-8 border-blue-gray-50" />
|
||||
) : null}
|
||||
{details && (
|
||||
<ul className="flex flex-col gap-4 p-0">
|
||||
{Object.keys(details).map((el, key) => (
|
||||
<li key={key} className="flex items-center gap-4">
|
||||
<Typography
|
||||
variant="small"
|
||||
color="blue-gray"
|
||||
className="font-semibold capitalize"
|
||||
>
|
||||
{el}:
|
||||
</Typography>
|
||||
{typeof details[el] === "string" ? (
|
||||
<Typography
|
||||
variant="small"
|
||||
className="font-normal text-blue-gray-500"
|
||||
>
|
||||
{details[el]}
|
||||
</Typography>
|
||||
) : (
|
||||
details[el]
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
ProfileInfoCard.defaultProps = {
|
||||
action: null,
|
||||
description: null,
|
||||
details: {},
|
||||
};
|
||||
|
||||
ProfileInfoCard.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
description: PropTypes.node,
|
||||
details: PropTypes.object,
|
||||
};
|
||||
|
||||
ProfileInfoCard.displayName = "/src/widgets/cards/profile-info-card.jsx";
|
||||
|
||||
export default ProfileInfoCard;
|
||||
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
CardFooter,
|
||||
Typography,
|
||||
} from "@material-tailwind/react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
export function StatisticsCard({ color, icon, title, value, footer }) {
|
||||
return (
|
||||
<Card className="border border-blue-gray-100 shadow-sm">
|
||||
<CardHeader
|
||||
variant="gradient"
|
||||
color={color}
|
||||
floated={false}
|
||||
shadow={false}
|
||||
className="absolute grid h-12 w-12 place-items-center"
|
||||
>
|
||||
{icon}
|
||||
</CardHeader>
|
||||
<CardBody className="p-4 text-right">
|
||||
<Typography variant="small" className="font-normal text-blue-gray-600">
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography variant="h4" color="blue-gray">
|
||||
{value}
|
||||
</Typography>
|
||||
</CardBody>
|
||||
{footer && (
|
||||
<CardFooter className="border-t border-blue-gray-50 p-4">
|
||||
{footer}
|
||||
</CardFooter>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
StatisticsCard.defaultProps = {
|
||||
color: "blue",
|
||||
footer: null,
|
||||
};
|
||||
|
||||
StatisticsCard.propTypes = {
|
||||
color: PropTypes.oneOf([
|
||||
"white",
|
||||
"blue-gray",
|
||||
"gray",
|
||||
"brown",
|
||||
"deep-orange",
|
||||
"orange",
|
||||
"amber",
|
||||
"yellow",
|
||||
"lime",
|
||||
"light-green",
|
||||
"green",
|
||||
"teal",
|
||||
"cyan",
|
||||
"light-blue",
|
||||
"blue",
|
||||
"indigo",
|
||||
"deep-purple",
|
||||
"purple",
|
||||
"pink",
|
||||
"red",
|
||||
]),
|
||||
icon: PropTypes.node.isRequired,
|
||||
title: PropTypes.node.isRequired,
|
||||
value: PropTypes.node.isRequired,
|
||||
footer: PropTypes.node,
|
||||
};
|
||||
|
||||
StatisticsCard.displayName = "/src/widgets/cards/statistics-card.jsx";
|
||||
|
||||
export default StatisticsCard;
|
||||
@@ -0,0 +1 @@
|
||||
export * from "@/widgets/charts/statistics-chart";
|
||||
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
CardFooter,
|
||||
Typography,
|
||||
} from "@material-tailwind/react";
|
||||
import PropTypes from "prop-types";
|
||||
import Chart from "react-apexcharts";
|
||||
|
||||
export function StatisticsChart({ color, chart, title, description, footer }) {
|
||||
return (
|
||||
<Card className="border border-blue-gray-100 shadow-sm">
|
||||
<CardHeader variant="gradient" color={color} floated={false} shadow={false}>
|
||||
<Chart {...chart} />
|
||||
</CardHeader>
|
||||
<CardBody className="px-6 pt-0">
|
||||
<Typography variant="h6" color="blue-gray">
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography variant="small" className="font-normal text-blue-gray-600">
|
||||
{description}
|
||||
</Typography>
|
||||
</CardBody>
|
||||
{footer && (
|
||||
<CardFooter className="border-t border-blue-gray-50 px-6 py-5">
|
||||
{footer}
|
||||
</CardFooter>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
StatisticsChart.defaultProps = {
|
||||
color: "blue",
|
||||
footer: null,
|
||||
};
|
||||
|
||||
StatisticsChart.propTypes = {
|
||||
color: PropTypes.oneOf([
|
||||
"white",
|
||||
"blue-gray",
|
||||
"gray",
|
||||
"brown",
|
||||
"deep-orange",
|
||||
"orange",
|
||||
"amber",
|
||||
"yellow",
|
||||
"lime",
|
||||
"light-green",
|
||||
"green",
|
||||
"teal",
|
||||
"cyan",
|
||||
"light-blue",
|
||||
"blue",
|
||||
"indigo",
|
||||
"deep-purple",
|
||||
"purple",
|
||||
"pink",
|
||||
"red",
|
||||
]),
|
||||
chart: PropTypes.object.isRequired,
|
||||
title: PropTypes.node.isRequired,
|
||||
description: PropTypes.node.isRequired,
|
||||
footer: PropTypes.node,
|
||||
};
|
||||
|
||||
StatisticsChart.displayName = "/src/widgets/charts/statistics-chart.jsx";
|
||||
|
||||
export default StatisticsChart;
|
||||
@@ -0,0 +1,127 @@
|
||||
import React from "react";
|
||||
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import {
|
||||
Button,
|
||||
IconButton,
|
||||
Switch,
|
||||
Typography,
|
||||
Chip,
|
||||
} from "@material-tailwind/react";
|
||||
import {
|
||||
useMaterialTailwindController,
|
||||
setOpenConfigurator,
|
||||
setSidenavColor,
|
||||
setSidenavType,
|
||||
setFixedNavbar,
|
||||
} from "@/components";
|
||||
|
||||
function formatNumber(number, decPlaces) {
|
||||
decPlaces = Math.pow(10, decPlaces);
|
||||
|
||||
const abbrev = ["K", "M", "B", "T"];
|
||||
|
||||
for (let i = abbrev.length - 1; i >= 0; i--) {
|
||||
var size = Math.pow(10, (i + 1) * 3);
|
||||
|
||||
if (size <= number) {
|
||||
number = Math.round((number * decPlaces) / size) / decPlaces;
|
||||
|
||||
if (number == 1000 && i < abbrev.length - 1) {
|
||||
number = 1;
|
||||
i++;
|
||||
}
|
||||
|
||||
number += abbrev[i];
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return number;
|
||||
}
|
||||
|
||||
export function Configurator() {
|
||||
const [controller, dispatch] = useMaterialTailwindController();
|
||||
const { openConfigurator, sidenavColor, sidenavType, fixedNavbar } =
|
||||
controller;
|
||||
const [stars, setStars] = React.useState(0);
|
||||
|
||||
const sidenavColors = {
|
||||
dark: "from-black to-black border-gray-200",
|
||||
green: "from-green-400 to-green-600",
|
||||
orange: "from-orange-400 to-orange-600",
|
||||
red: "from-red-400 to-red-600",
|
||||
pink: "from-pink-400 to-pink-600",
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
const stars = fetch(
|
||||
"https://api.github.com/repos/creativetimofficial/material-tailwind-dashboard-react"
|
||||
)
|
||||
.then((response) => response.json())
|
||||
.then((data) => setStars(formatNumber(data.stargazers_count, 1)));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`fixed top-0 right-0 z-50 h-screen w-96 bg-white px-2.5 shadow-lg transition-transform duration-300 ${
|
||||
openConfigurator ? "translate-x-0" : "translate-x-96"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between px-6 pt-8 pb-6">
|
||||
<div>
|
||||
<Typography variant="h5" color="blue-gray">
|
||||
Dashboard Configurator
|
||||
</Typography>
|
||||
<Typography className="font-normal text-blue-gray-600">
|
||||
See our dashboard options.
|
||||
</Typography>
|
||||
</div>
|
||||
<IconButton
|
||||
variant="text"
|
||||
color="blue-gray"
|
||||
onClick={() => setOpenConfigurator(dispatch, false)}
|
||||
>
|
||||
<XMarkIcon strokeWidth={2.5} className="h-5 w-5" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="py-4 px-6">
|
||||
<div className="mb-12">
|
||||
<Typography variant="h6" color="blue-gray">
|
||||
Sidenav Colors
|
||||
</Typography>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
{Object.keys(sidenavColors).map((color) => (
|
||||
<span
|
||||
key={color}
|
||||
className={`h-6 w-6 cursor-pointer rounded-full border bg-gradient-to-br transition-transform hover:scale-105 ${
|
||||
sidenavColors[color]
|
||||
} ${
|
||||
sidenavColor === color ? "border-black" : "border-transparent"
|
||||
}`}
|
||||
onClick={() => setSidenavColor(dispatch, color)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-12">
|
||||
<hr />
|
||||
<div className="flex items-center justify-between py-5">
|
||||
<Typography variant="h6" color="blue-gray">
|
||||
Navbar Fixed
|
||||
</Typography>
|
||||
<Switch defaultChecked
|
||||
id="navbar-fixed"
|
||||
value={fixedNavbar}
|
||||
onChange={() => setFixedNavbar(dispatch, !fixedNavbar)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
Configurator.displayName = "/src/widgets/layout/configurator.jsx";
|
||||
|
||||
export default Configurator;
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useLocation, Link } from "react-router-dom";
|
||||
import {
|
||||
Navbar,
|
||||
Typography,
|
||||
Button,
|
||||
IconButton,
|
||||
Breadcrumbs,
|
||||
Menu,
|
||||
MenuHandler,
|
||||
MenuList,
|
||||
MenuItem,
|
||||
Avatar,
|
||||
} from "@material-tailwind/react";
|
||||
import {
|
||||
UserCircleIcon,
|
||||
Cog6ToothIcon,
|
||||
BellIcon,
|
||||
ClockIcon,
|
||||
CreditCardIcon,
|
||||
Bars3Icon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import {
|
||||
useMaterialTailwindController,
|
||||
setOpenConfigurator,
|
||||
setOpenSidenav,
|
||||
} from "@/components";
|
||||
|
||||
export function DashboardNavbar() {
|
||||
const [controller, dispatch] = useMaterialTailwindController();
|
||||
const { fixedNavbar, openSidenav } = controller;
|
||||
const { pathname } = useLocation();
|
||||
const [layout, page] = pathname.split("/").filter((el) => el !== "");
|
||||
|
||||
return (
|
||||
<Navbar
|
||||
color={fixedNavbar ? "white" : "transparent"}
|
||||
className={`rounded-xl transition-all ${
|
||||
fixedNavbar
|
||||
? "sticky top-4 z-40 py-3 shadow-md shadow-blue-gray-500/5"
|
||||
: "px-0 py-1"
|
||||
}`}
|
||||
fullWidth
|
||||
blurred={fixedNavbar}
|
||||
>
|
||||
<div className="flex justify-between gap-6 flex-row items-center">
|
||||
<div className="capitalize">
|
||||
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
|
||||
<IconButton
|
||||
variant="text"
|
||||
color="blue-gray"
|
||||
className="grid xl:hidden"
|
||||
onClick={() => setOpenSidenav(dispatch, !openSidenav)}
|
||||
>
|
||||
<Bars3Icon strokeWidth={3} className="h-6 w-6 text-blue-gray-500" />
|
||||
</IconButton>
|
||||
{/* <Link to="/auth/sign-in">
|
||||
<Button
|
||||
variant="text"
|
||||
color="blue-gray"
|
||||
className="hidden items-center gap-1 px-4 xl:flex normal-case"
|
||||
>
|
||||
<UserCircleIcon className="h-5 w-5 text-blue-gray-500" />
|
||||
Sign In
|
||||
</Button>
|
||||
<IconButton
|
||||
variant="text"
|
||||
color="blue-gray"
|
||||
className="grid xl:hidden"
|
||||
>
|
||||
<UserCircleIcon className="h-5 w-5 text-blue-gray-500" />
|
||||
</IconButton>
|
||||
</Link>
|
||||
<Menu>
|
||||
<MenuHandler>
|
||||
<IconButton variant="text" color="blue-gray">
|
||||
<BellIcon className="h-5 w-5 text-blue-gray-500" />
|
||||
</IconButton>
|
||||
</MenuHandler>
|
||||
<MenuList className="w-max border-0">
|
||||
<MenuItem className="flex items-center gap-3">
|
||||
<Avatar
|
||||
src="https://demos.creative-tim.com/material-dashboard/assets/img/team-2.jpg"
|
||||
alt="item-1"
|
||||
size="sm"
|
||||
variant="circular"
|
||||
/>
|
||||
<div>
|
||||
<Typography
|
||||
variant="small"
|
||||
color="blue-gray"
|
||||
className="mb-1 font-normal"
|
||||
>
|
||||
<strong>New message</strong> from Laur
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="small"
|
||||
color="blue-gray"
|
||||
className="flex items-center gap-1 text-xs font-normal opacity-60"
|
||||
>
|
||||
<ClockIcon className="h-3.5 w-3.5" /> 13 minutes ago
|
||||
</Typography>
|
||||
</div>
|
||||
</MenuItem>
|
||||
<MenuItem className="flex items-center gap-4">
|
||||
<Avatar
|
||||
src="https://demos.creative-tim.com/material-dashboard/assets/img/small-logos/logo-spotify.svg"
|
||||
alt="item-1"
|
||||
size="sm"
|
||||
variant="circular"
|
||||
/>
|
||||
<div>
|
||||
<Typography
|
||||
variant="small"
|
||||
color="blue-gray"
|
||||
className="mb-1 font-normal"
|
||||
>
|
||||
<strong>New album</strong> by Travis Scott
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="small"
|
||||
color="blue-gray"
|
||||
className="flex items-center gap-1 text-xs font-normal opacity-60"
|
||||
>
|
||||
<ClockIcon className="h-3.5 w-3.5" /> 1 day ago
|
||||
</Typography>
|
||||
</div>
|
||||
</MenuItem>
|
||||
<MenuItem className="flex items-center gap-4">
|
||||
<div className="grid h-9 w-9 place-items-center rounded-full bg-gradient-to-tr from-blue-gray-800 to-blue-gray-900">
|
||||
<CreditCardIcon className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<Typography
|
||||
variant="small"
|
||||
color="blue-gray"
|
||||
className="mb-1 font-normal"
|
||||
>
|
||||
Payment successfully completed
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="small"
|
||||
color="blue-gray"
|
||||
className="flex items-center gap-1 text-xs font-normal opacity-60"
|
||||
>
|
||||
<ClockIcon className="h-3.5 w-3.5" /> 2 days ago
|
||||
</Typography>
|
||||
</div>
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Menu> */}
|
||||
<IconButton
|
||||
variant="text"
|
||||
color="blue-gray"
|
||||
onClick={() => setOpenConfigurator(dispatch, true)}
|
||||
>
|
||||
<Cog6ToothIcon className="h-5 w-5 text-blue-gray-500 " />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</Navbar>
|
||||
);
|
||||
}
|
||||
|
||||
DashboardNavbar.displayName = "/src/widgets/layout/dashboard-navbar.jsx";
|
||||
|
||||
export default DashboardNavbar;
|
||||
@@ -0,0 +1,20 @@
|
||||
import PropTypes from "prop-types";
|
||||
import { Typography } from "@material-tailwind/react";
|
||||
import { HeartIcon } from "@heroicons/react/24/solid";
|
||||
|
||||
export function Footer() {
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<footer>
|
||||
<div className="flex w-full flex-wrap items-center justify-center md:justify-end">
|
||||
<Typography variant="small" className="mt-1 text-xs text-stormGrey-500 block antialiased font-sans">
|
||||
StackPulse: © {year} by Michael Böhmländer
|
||||
</Typography>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
Footer.displayName = "/src/widgets/layout/footer.jsx";
|
||||
export default Footer;
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "@/widgets/layout/sidenav";
|
||||
export * from "@/widgets/layout/dashboard-navbar";
|
||||
export * from "@/widgets/layout/configurator";
|
||||
export * from "@/widgets/layout/footer";
|
||||
export * from "@/widgets/layout/navbar";
|
||||
@@ -0,0 +1,107 @@
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
Navbar as MTNavbar,
|
||||
Collapse,
|
||||
Typography,
|
||||
Button,
|
||||
IconButton,
|
||||
} from "@material-tailwind/react";
|
||||
import { Bars3Icon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
export function Navbar({ brandName, routes, action }) {
|
||||
const [openNav, setOpenNav] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
window.addEventListener(
|
||||
"resize",
|
||||
() => window.innerWidth >= 960 && setOpenNav(false)
|
||||
);
|
||||
}, []);
|
||||
|
||||
const navList = (
|
||||
<ul className="mb-4 mt-2 flex flex-col gap-2 lg:mb-0 lg:mt-0 lg:flex-row lg:items-center lg:gap-6">
|
||||
{routes.map(({ name, path, icon }) => (
|
||||
<Typography
|
||||
key={name}
|
||||
as="li"
|
||||
variant="small"
|
||||
color="blue-gray"
|
||||
className="capitalize"
|
||||
>
|
||||
<Link to={path} className="flex items-center gap-1 p-1 font-normal">
|
||||
{icon &&
|
||||
React.createElement(icon, {
|
||||
className: "w-[18px] h-[18px] opacity-50 mr-1",
|
||||
})}
|
||||
{name}
|
||||
</Link>
|
||||
</Typography>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
|
||||
return (
|
||||
<MTNavbar className="p-3">
|
||||
<div className="container mx-auto flex items-center justify-between text-blue-gray-900">
|
||||
<Link to="/">
|
||||
<Typography
|
||||
variant="small"
|
||||
className="mr-4 ml-2 cursor-pointer py-1.5 font-bold"
|
||||
>
|
||||
{brandName}
|
||||
</Typography>
|
||||
</Link>
|
||||
<div className="hidden lg:block">{navList}</div>
|
||||
{React.cloneElement(action, {
|
||||
className: "hidden lg:inline-block",
|
||||
})}
|
||||
<IconButton
|
||||
variant="text"
|
||||
size="sm"
|
||||
className="hml-auto text-inherit hover:bg-transparent focus:bg-transparent active:bg-transparent lg:hidden"
|
||||
onClick={() => setOpenNav(!openNav)}
|
||||
>
|
||||
{openNav ? (
|
||||
<XMarkIcon strokeWidth={2} className="h-6 w-6" />
|
||||
) : (
|
||||
<Bars3Icon strokeWidth={2} className="h-6 w-6" />
|
||||
)}
|
||||
</IconButton>
|
||||
</div>
|
||||
<Collapse open={openNav}>
|
||||
<div className="container mx-auto">
|
||||
{navList}
|
||||
{React.cloneElement(action, {
|
||||
className: "w-full block lg:hidden",
|
||||
})}
|
||||
</div>
|
||||
</Collapse>
|
||||
</MTNavbar>
|
||||
);
|
||||
}
|
||||
|
||||
Navbar.defaultProps = {
|
||||
brandName: "Material Tailwind React",
|
||||
action: (
|
||||
<a
|
||||
href="https://www.creative-tim.com/product/material-tailwind-dashboard-react"
|
||||
target="_blank"
|
||||
>
|
||||
<Button variant="gradient" size="sm" fullWidth>
|
||||
free download
|
||||
</Button>
|
||||
</a>
|
||||
),
|
||||
};
|
||||
|
||||
Navbar.propTypes = {
|
||||
brandName: PropTypes.string,
|
||||
routes: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
action: PropTypes.node,
|
||||
};
|
||||
|
||||
Navbar.displayName = "/src/widgets/layout/navbar.jsx";
|
||||
|
||||
export default Navbar;
|
||||
@@ -0,0 +1,102 @@
|
||||
import PropTypes from "prop-types";
|
||||
import { Link, NavLink } from "react-router-dom";
|
||||
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
IconButton,
|
||||
Typography,
|
||||
} from "@material-tailwind/react";
|
||||
import { useMaterialTailwindController, setOpenSidenav } from "@/components";
|
||||
import logo from "@/assets/images/stackpulse.png";
|
||||
|
||||
export function Sidenav({ brandImg, brandName, routes }) {
|
||||
const [controller, dispatch] = useMaterialTailwindController();
|
||||
const { sidenavColor, sidenavType, openSidenav } = controller;
|
||||
const sidenavTypes = {
|
||||
dark: "bg-gradient-to-br from-gray-800 to-gray-900",
|
||||
white: "bg-white shadow-sm",
|
||||
transparent: "bg-transparent",
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`${sidenavTypes[sidenavType]} ${openSidenav ? "translate-x-0" : "-translate-x-80"
|
||||
} fixed inset-0 z-50 my-4 ml-4 h-[calc(100vh-32px)] w-72 rounded-xl transition-transform duration-300 xl:translate-x-0 border border-blue-gray-100`}
|
||||
>
|
||||
<div className="relative py-6 px-8 text-right">
|
||||
<img src={logo} alt="StackPulse" className="w-auto" /><span className="mt-1 text-xs text-stormGrey-500 block antialiased font-sans">v0.4</span>
|
||||
|
||||
<IconButton
|
||||
variant="text"
|
||||
size="sm"
|
||||
ripple={false}
|
||||
className="absolute right-0 top-0 grid rounded-br-none rounded-tl-none"
|
||||
onClick={() => setOpenSidenav(dispatch, false)}
|
||||
>
|
||||
<XMarkIcon strokeWidth={2.5} className="h-5 w-5 text-black" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="m-4">
|
||||
{routes.map(({ layout, title, pages }, key) => (
|
||||
<ul key={key} className="mb-4 flex flex-col gap-1">
|
||||
{title && (
|
||||
<li className="mx-3.5 mt-4 mb-2">
|
||||
<Typography
|
||||
variant="small"
|
||||
color={sidenavType === "dark" ? "white" : "blue-gray"}
|
||||
className="font-black uppercase opacity-75"
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
</li>
|
||||
)}
|
||||
{pages.map(({ icon, name, path }) => (
|
||||
<li key={name}>
|
||||
<NavLink to={`/${layout}${path}`}>
|
||||
{({ isActive }) => (
|
||||
<Button
|
||||
variant={isActive ? "gradient" : "text"}
|
||||
color={
|
||||
isActive
|
||||
? sidenavColor
|
||||
: sidenavType === "dark"
|
||||
? "white"
|
||||
: "blue-gray"
|
||||
}
|
||||
className="flex items-center gap-4 px-4 capitalize"
|
||||
fullWidth
|
||||
>
|
||||
{icon}
|
||||
<Typography
|
||||
color="inherit"
|
||||
className="font-medium capitalize"
|
||||
>
|
||||
{name}
|
||||
</Typography>
|
||||
</Button>
|
||||
)}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
Sidenav.defaultProps = {
|
||||
brandImg: "/img/logo-ct.png",
|
||||
brandName: "Material Tailwind React",
|
||||
};
|
||||
|
||||
Sidenav.propTypes = {
|
||||
brandImg: PropTypes.string,
|
||||
brandName: PropTypes.string,
|
||||
routes: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
};
|
||||
|
||||
Sidenav.displayName = "/src/widgets/layout/sidnave.jsx";
|
||||
|
||||
export default Sidenav;
|
||||
@@ -0,0 +1,38 @@
|
||||
const withMT = require("@material-tailwind/react/utils/withMT");
|
||||
const colors = require("tailwindcss/colors");
|
||||
|
||||
module.exports = withMT({
|
||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
sky: colors.sky,
|
||||
green: colors.green,
|
||||
slate: colors.slate,
|
||||
orange: colors.orange, // falls du z. B. text-orange-300 brauchst
|
||||
auroraTeal: {50:'#f0fafb',100:'#e0f5f7',200:'#c3ecef',300:'#9de0e4',400:'#6ad3d8',500:'#38c6cc',600:'#2aa9b0',700:'#1f8d94',800:'#166e76',900:'#0c4f58'},
|
||||
sunsetCoral: {50:'#fff6f5',100:'#ffece9',200:'#ffd7d3',300:'#ffb8b5',400:'#ff9189',500:'#ff6a5d',600:'#e65048',700:'#b93d38',800:'#8a2c28',900:'#5c1c1a'},
|
||||
lavenderSmoke: {50:'#faf8fc',100:'#f5f1f9',200:'#ece2f3',300:'#dac4e6',400:'#b88acf',500:'#9670b8',600:'#7a5894',700:'#5d4373',800:'#412f52',900:'#291e38'},
|
||||
mossGreen: {50:'#f5faf2',100:'#ebf5e5',200:'#d9ebca',300:'#b8d99e',400:'#8cc56f',500:'#60b240',600:'#4d8f33',700:'#3b6b27',800:'#2a491d',900:'#1c3012'},
|
||||
deepOceanBlue: {50:'#f4faff',100:'#e9f5ff',200:'#cfe6ff',300:'#add1ff',400:'#7ab9ff',500:'#479fff',600:'#3c86e6',700:'#305eaf',800:'#23437f',900:'#162a50'},
|
||||
warmAmberGlow: {50:'#fff9f2',100:'#fff3e5',200:'#ffe6cc',300:'#ffcc99',400:'#ffad4d',500:'#ff8f00',600:'#e67600',700:'#b35a00',800:'#804000',900:'#4d2600'},
|
||||
peachMist: {50:'#fff8f7',100:'#fff0ed',200:'#ffe1db',300:'#ffc2b7',400:'#ff9a87',500:'#ff7257',600:'#e65f4b',700:'#b34938',800:'#803622',900:'#4d230f'},
|
||||
slatePurple: {50:'#f7f5fa',100:'#efe9f5',200:'#ded1ea',300:'#bdb3d5',400:'#9584b5',500:'#6d5795',600:'#5c4a7c',700:'#473861',800:'#322648',900:'#1f1530'},
|
||||
mintTea: {50:'#f2fcfa',100:'#e5f9f6',200:'#ccefee',300:'#99dfdc',400:'#66cfc9',500:'#33bfb6',600:'#2aa395',700:'#228174',800:'#195953',900:'#103330'},
|
||||
copperRust: {50:'#faf7f6',100:'#f5efee',200:'#ebe0dc',300:'#d6c1b8',400:'#b28a86',500:'#8d4d55',600:'#7a444b',700:'#5f3338',800:'#442426',900:'#2d1718'},
|
||||
// Neue 10
|
||||
arcticBlue: {50:'#f2f9fd',100:'#e6f3fb',200:'#cce6f7',300:'#99ceef',400:'#66b6e7',500:'#339ede',600:'#1f86c3',700:'#16699b',800:'#0f4b6f',900:'#082d43'},
|
||||
roseQuartz: {50:'#fdf7f9',100:'#faeff3',200:'#f5dfe6',300:'#ecbfd0',400:'#e08dad',500:'#d45b89',600:'#b94771',700:'#8e3556',800:'#63233b',900:'#391324'},
|
||||
citrusPunch: {50:'#fffdf6',100:'#fffbe9',200:'#fff5cc',300:'#ffeb99',400:'#ffe066',500:'#ffd633',600:'#e6bd00',700:'#b39300',800:'#806900',900:'#4d3f00'},
|
||||
midnightPlum: {50:'#faf7fb',100:'#f4ecf6',200:'#e4cfee',300:'#cba0de',400:'#a869cc',500:'#8431b9',600:'#6d299b',700:'#521f74',800:'#38154d',900:'#1f0b28'},
|
||||
sandstone: {50:'#fbf9f7',100:'#f6f2ee',200:'#ece3da',300:'#d8c6b3',400:'#c0a27f',500:'#a87e4b',600:'#906a3e',700:'#6b4f2f',800:'#47341f',900:'#241a10'},
|
||||
emeraldMist: {50:'#f3faf7',100:'#e7f6ef',200:'#ccecdc',300:'#9ddabf',400:'#6ec8a3',500:'#3fb586',600:'#359771',700:'#2a765a',800:'#1f5442',900:'#13332a'},
|
||||
velvetRed: {50:'#fdf7f7',100:'#faeeee',200:'#f4d6d6',300:'#e7a8a8',400:'#d56666',500:'#c32424',600:'#a61e1e',700:'#801717',800:'#590f0f',900:'#330808'},
|
||||
stormGrey: {50:'#f7f8fa',100:'#eef0f3',200:'#d9dce2',300:'#b6bbc8',400:'#8b91a4',500:'#606780',600:'#4f556b',700:'#3e4456',800:'#2c313f',900:'#1b1e29'},
|
||||
neonLime: {50:'#f7ffe6',100:'#eeffc9',200:'#dbff96',300:'#c2ff52',400:'#a8ff0e',500:'#8de600',600:'#74bf00',700:'#5b9900',800:'#426f00',900:'#294600'},
|
||||
skyMauve: {50:'#faf8fc',100:'#f4f0f9',200:'#e5dbf2',300:'#c9b2e6',400:'#a27dd4',500:'#7b48c2',600:'#633aa0',700:'#4b2b7c',800:'#341d58',900:'#1e1035'},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
});
|
||||
@@ -1,8 +1,11 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: [{ find: "@", replacement: "/src" }],
|
||||
},
|
||||
server: {
|
||||
host: true, // Hört auf allen Netzwerk-Interfaces, kein localhost notwendig
|
||||
port: 5173, // optional, Standardport für Vite
|
||||
|
||||
+1
File diff suppressed because one or more lines are too long
+87
File diff suppressed because one or more lines are too long
Vendored
+15
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>StackPulse</title>
|
||||
|
||||
<script type="module" crossorigin src="/assets/index-e96128a6.js"></script>
|
||||
<link rel="stylesheet" href="/assets/index-736c8c6c.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>StackPulse</title>
|
||||
<link rel="stylesheet" href="/src/index.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
+598
-88
@@ -8,18 +8,27 @@
|
||||
"name": "stackpulse-frontend",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"axios": "^1.7.5",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"socket.io-client": "^4.8.1"
|
||||
"@heroicons/react": "2.0.18",
|
||||
"@material-tailwind/react": "2.1.4",
|
||||
"apexcharts": "3.44.0",
|
||||
"axios": "1.7.5",
|
||||
"prop-types": "15.8.1",
|
||||
"react": "18.2.0",
|
||||
"react-apexcharts": "1.4.1",
|
||||
"react-dom": "18.2.0",
|
||||
"react-router-dom": "6.17.0",
|
||||
"socket.io-client": "4.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"vite": "^4.4.9"
|
||||
"@types/react": "18.2.31",
|
||||
"@types/react-dom": "18.2.14",
|
||||
"@vitejs/plugin-react": "4.1.0",
|
||||
"autoprefixer": "10.4.16",
|
||||
"postcss": "8.4.31",
|
||||
"prettier": "3.0.3",
|
||||
"prettier-plugin-tailwindcss": "0.5.6",
|
||||
"tailwindcss": "3.3.4",
|
||||
"vite": "4.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@alloc/quick-lru": {
|
||||
@@ -310,6 +319,21 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emotion/is-prop-valid": {
|
||||
"version": "0.8.8",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz",
|
||||
"integrity": "sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emotion/memoize": "0.7.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@emotion/memoize": {
|
||||
"version": "0.7.4",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz",
|
||||
"integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.18.20",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz",
|
||||
@@ -662,6 +686,62 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
|
||||
"integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/dom": {
|
||||
"version": "1.7.4",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
|
||||
"integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.7.3",
|
||||
"@floating-ui/utils": "^0.2.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/react": {
|
||||
"version": "0.19.0",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.19.0.tgz",
|
||||
"integrity": "sha512-fgYvN4ksCi5OvmPXkyOT8o5a8PSKHMzPHt+9mR6KYWdF16IAjWRLZPAAziI2sznaWT23drRFrYw64wdvYqqaQw==",
|
||||
"dependencies": {
|
||||
"@floating-ui/react-dom": "^1.2.2",
|
||||
"aria-hidden": "^1.1.3",
|
||||
"tabbable": "^6.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/react-dom": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-1.3.0.tgz",
|
||||
"integrity": "sha512-htwHm67Ji5E/pROEAr7f8IKFShuiCKHwUC/UY4vC3I5jiSvGFAYnSYiZO5MlGmads+QqvUkR9ANHEguGrDv72g==",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.2.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/utils": {
|
||||
"version": "0.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
|
||||
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="
|
||||
},
|
||||
"node_modules/@heroicons/react": {
|
||||
"version": "2.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.0.18.tgz",
|
||||
"integrity": "sha512-7TyMjRrZZMBPa+/5Y8lN0iyvUU/01PeMGX2+RE7cQWpEUIcb4QotzUObFkJDejj/HUH4qjP/eQ0gzzKs2f+6Yw==",
|
||||
"peerDependencies": {
|
||||
"react": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||
@@ -714,6 +794,84 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@material-tailwind/react": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@material-tailwind/react/-/react-2.1.4.tgz",
|
||||
"integrity": "sha512-dRHnC+Ka0IJ8KSfToBmlJRy9gAPv6F1oVZb+ABOPcIR8LxBhAXXhMAjtQf9ilG0nq2VK9AISsYFtyWBsr8Y88A==",
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "0.19.0",
|
||||
"classnames": "2.3.2",
|
||||
"deepmerge": "4.2.2",
|
||||
"framer-motion": "6.5.1",
|
||||
"material-ripple-effects": "2.0.1",
|
||||
"prop-types": "15.8.1",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"tailwind-merge": "1.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16 || ^17 || ^18",
|
||||
"react-dom": "^16 || ^17 || ^18"
|
||||
}
|
||||
},
|
||||
"node_modules/@motionone/animation": {
|
||||
"version": "10.18.0",
|
||||
"resolved": "https://registry.npmjs.org/@motionone/animation/-/animation-10.18.0.tgz",
|
||||
"integrity": "sha512-9z2p5GFGCm0gBsZbi8rVMOAJCtw1WqBTIPw3ozk06gDvZInBPIsQcHgYogEJ4yuHJ+akuW8g1SEIOpTOvYs8hw==",
|
||||
"dependencies": {
|
||||
"@motionone/easing": "^10.18.0",
|
||||
"@motionone/types": "^10.17.1",
|
||||
"@motionone/utils": "^10.18.0",
|
||||
"tslib": "^2.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@motionone/dom": {
|
||||
"version": "10.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@motionone/dom/-/dom-10.12.0.tgz",
|
||||
"integrity": "sha512-UdPTtLMAktHiqV0atOczNYyDd/d8Cf5fFsd1tua03PqTwwCe/6lwhLSQ8a7TbnQ5SN0gm44N1slBfj+ORIhrqw==",
|
||||
"dependencies": {
|
||||
"@motionone/animation": "^10.12.0",
|
||||
"@motionone/generators": "^10.12.0",
|
||||
"@motionone/types": "^10.12.0",
|
||||
"@motionone/utils": "^10.12.0",
|
||||
"hey-listen": "^1.0.8",
|
||||
"tslib": "^2.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@motionone/easing": {
|
||||
"version": "10.18.0",
|
||||
"resolved": "https://registry.npmjs.org/@motionone/easing/-/easing-10.18.0.tgz",
|
||||
"integrity": "sha512-VcjByo7XpdLS4o9T8t99JtgxkdMcNWD3yHU/n6CLEz3bkmKDRZyYQ/wmSf6daum8ZXqfUAgFeCZSpJZIMxaCzg==",
|
||||
"dependencies": {
|
||||
"@motionone/utils": "^10.18.0",
|
||||
"tslib": "^2.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@motionone/generators": {
|
||||
"version": "10.18.0",
|
||||
"resolved": "https://registry.npmjs.org/@motionone/generators/-/generators-10.18.0.tgz",
|
||||
"integrity": "sha512-+qfkC2DtkDj4tHPu+AFKVfR/C30O1vYdvsGYaR13W/1cczPrrcjdvYCj0VLFuRMN+lP1xvpNZHCRNM4fBzn1jg==",
|
||||
"dependencies": {
|
||||
"@motionone/types": "^10.17.1",
|
||||
"@motionone/utils": "^10.18.0",
|
||||
"tslib": "^2.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@motionone/types": {
|
||||
"version": "10.17.1",
|
||||
"resolved": "https://registry.npmjs.org/@motionone/types/-/types-10.17.1.tgz",
|
||||
"integrity": "sha512-KaC4kgiODDz8hswCrS0btrVrzyU2CSQKO7Ps90ibBVSQmjkrt2teqta6/sOG59v7+dPnKMAg13jyqtMKV2yJ7A=="
|
||||
},
|
||||
"node_modules/@motionone/utils": {
|
||||
"version": "10.18.0",
|
||||
"resolved": "https://registry.npmjs.org/@motionone/utils/-/utils-10.18.0.tgz",
|
||||
"integrity": "sha512-3XVF7sgyTSI2KWvTf6uLlBJ5iAgRgmvp3bpuOiQJvInd4nZ19ET8lX5unn30SlmRH7hXbBbH+Gxd0m0klJ3Xtw==",
|
||||
"dependencies": {
|
||||
"@motionone/types": "^10.17.1",
|
||||
"hey-listen": "^1.0.8",
|
||||
"tslib": "^2.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
@@ -760,19 +918,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz",
|
||||
"integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==",
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.10.0.tgz",
|
||||
"integrity": "sha512-Lm+fYpMfZoEucJ7cMxgt4dYt8jLfbpwRCzAjm9UgSLOkmlqo9gupxt6YX3DY0Fk155NT9l17d/ydi+964uS9Lw==",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
"integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@socket.io/component-emitter": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||
@@ -819,26 +971,62 @@
|
||||
"@babel/types": "^7.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||
"integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.2.31",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.31.tgz",
|
||||
"integrity": "sha512-c2UnPv548q+5DFh03y8lEDeMfDwBn9G3dRwfkrxQMo/dOtRHUUO57k6pHvBIfH/VF4Nh+98mZ5aaSe+2echD5g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.28.0",
|
||||
"@babel/plugin-transform-react-jsx-self": "^7.27.1",
|
||||
"@babel/plugin-transform-react-jsx-source": "^7.27.1",
|
||||
"@rolldown/pluginutils": "1.0.0-beta.27",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"react-refresh": "^0.17.0"
|
||||
"@types/prop-types": "*",
|
||||
"@types/scheduler": "*",
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "18.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.14.tgz",
|
||||
"integrity": "sha512-V835xgdSVmyQmI1KLV2BEIUgqEuinxp9O4G6g3FqO/SqLac049E53aysv0oEFD2kHfejeKU+ZqL2bcFWj9gLAQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/scheduler": {
|
||||
"version": "0.26.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.26.0.tgz",
|
||||
"integrity": "sha512-WFHp9YUJQ6CKshqoC37iOlHnQSmxNc795UhB26CyBBttrN9svdIrUjl/NjnNmfcwtncN0h/0PPAFWv9ovP8mLA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.1.0.tgz",
|
||||
"integrity": "sha512-rM0SqazU9iqPUraQ2JlIvReeaxOoRj6n+PzB1C0cBzIbd8qP336nC39/R9yPi3wVcah7E7j/kdU1uCUqMEU4OQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.22.20",
|
||||
"@babel/plugin-transform-react-jsx-self": "^7.22.5",
|
||||
"@babel/plugin-transform-react-jsx-source": "^7.22.5",
|
||||
"@types/babel__core": "^7.20.2",
|
||||
"react-refresh": "^0.14.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.18.0 || >=16.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
"vite": "^4.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@yr/monotone-cubic-spline": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@yr/monotone-cubic-spline/-/monotone-cubic-spline-1.0.3.tgz",
|
||||
"integrity": "sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA=="
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
|
||||
@@ -882,21 +1070,46 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/apexcharts": {
|
||||
"version": "3.44.0",
|
||||
"resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-3.44.0.tgz",
|
||||
"integrity": "sha512-u7Xzrbcxc2yWznN78Jh5NMCYVAsWDfBjRl5ea++rVzFAqjU2hLz4RgKIFwYOBDRQtW1e/Qz8azJTqIJ1+Vu9Qg==",
|
||||
"dependencies": {
|
||||
"@yr/monotone-cubic-spline": "^1.0.3",
|
||||
"svg.draggable.js": "^2.2.2",
|
||||
"svg.easing.js": "^2.0.0",
|
||||
"svg.filter.js": "^2.0.2",
|
||||
"svg.pathmorphing.js": "^0.1.3",
|
||||
"svg.resize.js": "^1.4.3",
|
||||
"svg.select.js": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/arg": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
||||
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/aria-hidden": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
|
||||
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
|
||||
},
|
||||
"node_modules/autoprefixer": {
|
||||
"version": "10.4.21",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
|
||||
"integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==",
|
||||
"version": "10.4.16",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.16.tgz",
|
||||
"integrity": "sha512-7vd3UC6xKp0HLfua5IjZlcXvGAGy7cBAXTg2lyQ/8WpNhd6SiZ8Be+xm3FyBSYJx5GKcpRCzBh7RH4/0dnY+uQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -913,11 +1126,11 @@
|
||||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"browserslist": "^4.24.4",
|
||||
"caniuse-lite": "^1.0.30001702",
|
||||
"fraction.js": "^4.3.7",
|
||||
"browserslist": "^4.21.10",
|
||||
"caniuse-lite": "^1.0.30001538",
|
||||
"fraction.js": "^4.3.6",
|
||||
"normalize-range": "^0.1.2",
|
||||
"picocolors": "^1.1.1",
|
||||
"picocolors": "^1.0.0",
|
||||
"postcss-value-parser": "^4.2.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -931,12 +1144,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.11.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz",
|
||||
"integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==",
|
||||
"version": "1.7.5",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.5.tgz",
|
||||
"integrity": "sha512-fZu86yCo+svH3uqJ/yTdQ0QHpQu5oL+/QE+QPSv6BZSkDAoky9vytxp7u5qk83OJFS3kEBcesWni9WTZAv3tSw==",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.4",
|
||||
"form-data": "^4.0.0",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
@@ -1088,6 +1301,11 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/classnames": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz",
|
||||
"integrity": "sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw=="
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -1158,6 +1376,12 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||
@@ -1175,6 +1399,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/deepmerge": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz",
|
||||
"integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
@@ -1461,6 +1693,34 @@
|
||||
"url": "https://github.com/sponsors/rawify"
|
||||
}
|
||||
},
|
||||
"node_modules/framer-motion": {
|
||||
"version": "6.5.1",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-6.5.1.tgz",
|
||||
"integrity": "sha512-o1BGqqposwi7cgDrtg0dNONhkmPsUFDaLcKXigzuTFC5x58mE8iyTazxSudFzmT6MEyJKfjjU8ItoMe3W+3fiw==",
|
||||
"dependencies": {
|
||||
"@motionone/dom": "10.12.0",
|
||||
"framesync": "6.0.1",
|
||||
"hey-listen": "^1.0.8",
|
||||
"popmotion": "11.0.3",
|
||||
"style-value-types": "5.0.0",
|
||||
"tslib": "^2.1.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@emotion/is-prop-valid": "^0.8.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8 || ^17.0.0 || ^18.0.0",
|
||||
"react-dom": ">=16.8 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/framesync": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/framesync/-/framesync-6.0.1.tgz",
|
||||
"integrity": "sha512-fUY88kXvGiIItgNC7wcTOl0SNRCVXMKSWW2Yzfmn7EKNc+MpCzcz9DhdHcdjbrtN3c6R4H5dTY2jiCpPdysEjA==",
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -1606,6 +1866,11 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/hey-listen": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/hey-listen/-/hey-listen-1.0.8.tgz",
|
||||
"integrity": "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q=="
|
||||
},
|
||||
"node_modules/is-binary-path": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
@@ -1769,6 +2034,11 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/material-ripple-effects": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/material-ripple-effects/-/material-ripple-effects-2.0.1.tgz",
|
||||
"integrity": "sha512-hHlUkZAuXbP94lu02VgrPidbZ3hBtgXBtjlwR8APNqOIgDZMV8MCIcsclL8FmGJQHvnORyvoQgC965vPsiyXLQ=="
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -1904,7 +2174,6 @@
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -1997,10 +2266,21 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/popmotion": {
|
||||
"version": "11.0.3",
|
||||
"resolved": "https://registry.npmjs.org/popmotion/-/popmotion-11.0.3.tgz",
|
||||
"integrity": "sha512-Y55FLdj3UxkR7Vl3s7Qr4e9m0onSnP8W7d/xQLsoJM40vs6UKHFdygs6SWryasTZYqugMjm3BepCF4CWXDiHgA==",
|
||||
"dependencies": {
|
||||
"framesync": "6.0.1",
|
||||
"hey-listen": "^1.0.8",
|
||||
"style-value-types": "5.0.0",
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||
"version": "8.4.31",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||
"integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -2017,9 +2297,9 @@
|
||||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
"nanoid": "^3.3.6",
|
||||
"picocolors": "^1.0.0",
|
||||
"source-map-js": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
@@ -2140,6 +2420,103 @@
|
||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.0.3.tgz",
|
||||
"integrity": "sha512-L/4pUDMxcNa8R/EthV08Zt42WBO4h1rarVtK0K+QJG0X187OLo7l699jWw0GKuwzkPQ//jMFA/8Xm6Fh3J/DAg==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/prettier-plugin-tailwindcss": {
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.5.6.tgz",
|
||||
"integrity": "sha512-2Xgb+GQlkPAUCFi3sV+NOYcSI5XgduvDBL2Zt/hwJudeKXkyvRS65c38SB0yb9UB40+1rL83I6m0RtlOQ8eHdg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@ianvs/prettier-plugin-sort-imports": "*",
|
||||
"@prettier/plugin-pug": "*",
|
||||
"@shopify/prettier-plugin-liquid": "*",
|
||||
"@shufo/prettier-plugin-blade": "*",
|
||||
"@trivago/prettier-plugin-sort-imports": "*",
|
||||
"prettier": "^3.0",
|
||||
"prettier-plugin-astro": "*",
|
||||
"prettier-plugin-css-order": "*",
|
||||
"prettier-plugin-import-sort": "*",
|
||||
"prettier-plugin-jsdoc": "*",
|
||||
"prettier-plugin-organize-attributes": "*",
|
||||
"prettier-plugin-organize-imports": "*",
|
||||
"prettier-plugin-style-order": "*",
|
||||
"prettier-plugin-svelte": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@ianvs/prettier-plugin-sort-imports": {
|
||||
"optional": true
|
||||
},
|
||||
"@prettier/plugin-pug": {
|
||||
"optional": true
|
||||
},
|
||||
"@shopify/prettier-plugin-liquid": {
|
||||
"optional": true
|
||||
},
|
||||
"@shufo/prettier-plugin-blade": {
|
||||
"optional": true
|
||||
},
|
||||
"@trivago/prettier-plugin-sort-imports": {
|
||||
"optional": true
|
||||
},
|
||||
"prettier-plugin-astro": {
|
||||
"optional": true
|
||||
},
|
||||
"prettier-plugin-css-order": {
|
||||
"optional": true
|
||||
},
|
||||
"prettier-plugin-import-sort": {
|
||||
"optional": true
|
||||
},
|
||||
"prettier-plugin-jsdoc": {
|
||||
"optional": true
|
||||
},
|
||||
"prettier-plugin-marko": {
|
||||
"optional": true
|
||||
},
|
||||
"prettier-plugin-organize-attributes": {
|
||||
"optional": true
|
||||
},
|
||||
"prettier-plugin-organize-imports": {
|
||||
"optional": true
|
||||
},
|
||||
"prettier-plugin-style-order": {
|
||||
"optional": true
|
||||
},
|
||||
"prettier-plugin-svelte": {
|
||||
"optional": true
|
||||
},
|
||||
"prettier-plugin-twig-melody": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
"object-assign": "^4.1.1",
|
||||
"react-is": "^16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
@@ -2166,9 +2543,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"version": "18.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz",
|
||||
"integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
@@ -2176,33 +2553,50 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"node_modules/react-apexcharts": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/react-apexcharts/-/react-apexcharts-1.4.1.tgz",
|
||||
"integrity": "sha512-G14nVaD64Bnbgy8tYxkjuXEUp/7h30Q0U33xc3AwtGFijJB9nHqOt1a6eG0WBn055RgRg+NwqbKGtqPxy15d0Q==",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.3.1"
|
||||
"apexcharts": "^3.41.0",
|
||||
"react": ">=0.13"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "18.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
|
||||
"integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
"integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
|
||||
"version": "0.14.2",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
|
||||
"integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "6.30.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz",
|
||||
"integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==",
|
||||
"version": "6.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.17.0.tgz",
|
||||
"integrity": "sha512-YJR3OTJzi3zhqeJYADHANCGPUu9J+6fT5GLv82UWRGSxu6oJYCKVmxUcaBQuGm9udpWmPsvpme/CdHumqgsoaA==",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.0"
|
||||
"@remix-run/router": "1.10.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
@@ -2212,12 +2606,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "6.30.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz",
|
||||
"integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==",
|
||||
"version": "6.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.17.0.tgz",
|
||||
"integrity": "sha512-qWHkkbXQX+6li0COUUPKAUkxjNNqPJuiBd27dVwQGDNsuFBdMbrS6UZ0CLYc4CsbdLYTckn4oB4tGDuPZpPhaQ==",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.0",
|
||||
"react-router": "6.30.1"
|
||||
"@remix-run/router": "1.10.0",
|
||||
"react-router": "6.17.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
@@ -2530,6 +2924,15 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/style-value-types": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/style-value-types/-/style-value-types-5.0.0.tgz",
|
||||
"integrity": "sha512-08yq36Ikn4kx4YU6RD7jWEv27v4V+PUsOGa4n/as8Et3CuODMJQ00ENeAVXAeydX4Z2j1XHZF1K2sX4mGl18fA==",
|
||||
"dependencies": {
|
||||
"hey-listen": "^1.0.8",
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sucrase": {
|
||||
"version": "3.35.0",
|
||||
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz",
|
||||
@@ -2564,34 +2967,127 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/svg.draggable.js": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/svg.draggable.js/-/svg.draggable.js-2.2.2.tgz",
|
||||
"integrity": "sha512-JzNHBc2fLQMzYCZ90KZHN2ohXL0BQJGQimK1kGk6AvSeibuKcIdDX9Kr0dT9+UJ5O8nYA0RB839Lhvk4CY4MZw==",
|
||||
"dependencies": {
|
||||
"svg.js": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/svg.easing.js": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/svg.easing.js/-/svg.easing.js-2.0.0.tgz",
|
||||
"integrity": "sha512-//ctPdJMGy22YoYGV+3HEfHbm6/69LJUTAqI2/5qBvaNHZ9uUFVC82B0Pl299HzgH13rKrBgi4+XyXXyVWWthA==",
|
||||
"dependencies": {
|
||||
"svg.js": ">=2.3.x"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/svg.filter.js": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/svg.filter.js/-/svg.filter.js-2.0.2.tgz",
|
||||
"integrity": "sha512-xkGBwU+dKBzqg5PtilaTb0EYPqPfJ9Q6saVldX+5vCRy31P6TlRCP3U9NxH3HEufkKkpNgdTLBJnmhDHeTqAkw==",
|
||||
"dependencies": {
|
||||
"svg.js": "^2.2.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/svg.js": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/svg.js/-/svg.js-2.7.1.tgz",
|
||||
"integrity": "sha512-ycbxpizEQktk3FYvn/8BH+6/EuWXg7ZpQREJvgacqn46gIddG24tNNe4Son6omdXCnSOaApnpZw6MPCBA1dODA=="
|
||||
},
|
||||
"node_modules/svg.pathmorphing.js": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/svg.pathmorphing.js/-/svg.pathmorphing.js-0.1.3.tgz",
|
||||
"integrity": "sha512-49HWI9X4XQR/JG1qXkSDV8xViuTLIWm/B/7YuQELV5KMOPtXjiwH4XPJvr/ghEDibmLQ9Oc22dpWpG0vUDDNww==",
|
||||
"dependencies": {
|
||||
"svg.js": "^2.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/svg.resize.js": {
|
||||
"version": "1.4.3",
|
||||
"resolved": "https://registry.npmjs.org/svg.resize.js/-/svg.resize.js-1.4.3.tgz",
|
||||
"integrity": "sha512-9k5sXJuPKp+mVzXNvxz7U0uC9oVMQrrf7cFsETznzUDDm0x8+77dtZkWdMfRlmbkEEYvUn9btKuZ3n41oNA+uw==",
|
||||
"dependencies": {
|
||||
"svg.js": "^2.6.5",
|
||||
"svg.select.js": "^2.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/svg.resize.js/node_modules/svg.select.js": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/svg.select.js/-/svg.select.js-2.1.2.tgz",
|
||||
"integrity": "sha512-tH6ABEyJsAOVAhwcCjF8mw4crjXSI1aa7j2VQR8ZuJ37H2MBUbyeqYr5nEO7sSN3cy9AR9DUwNg0t/962HlDbQ==",
|
||||
"dependencies": {
|
||||
"svg.js": "^2.2.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/svg.select.js": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/svg.select.js/-/svg.select.js-3.0.1.tgz",
|
||||
"integrity": "sha512-h5IS/hKkuVCbKSieR9uQCj9w+zLHoPh+ce19bBYyqF53g6mnPB8sAtIbe1s9dh2S2fCmYX2xel1Ln3PJBbK4kw==",
|
||||
"dependencies": {
|
||||
"svg.js": "^2.6.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tabbable": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
|
||||
"integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew=="
|
||||
},
|
||||
"node_modules/tailwind-merge": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-1.8.1.tgz",
|
||||
"integrity": "sha512-+fflfPxvHFr81hTJpQ3MIwtqgvefHZFUHFiIHpVIRXvG/nX9+gu2P7JNlFu2bfDMJ+uHhi/pUgzaYacMoXv+Ww=="
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "3.4.17",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
|
||||
"integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==",
|
||||
"version": "3.3.4",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.4.tgz",
|
||||
"integrity": "sha512-JXZNOkggUAc9T5E7nCrimoXHcSf9h3NWFe5sh36CGD/3M5TRLuQeFnQoDsit2uVTqgoOZHLx5rTykLUu16vsMQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"arg": "^5.0.2",
|
||||
"chokidar": "^3.6.0",
|
||||
"chokidar": "^3.5.3",
|
||||
"didyoumean": "^1.2.2",
|
||||
"dlv": "^1.1.3",
|
||||
"fast-glob": "^3.3.2",
|
||||
"fast-glob": "^3.3.0",
|
||||
"glob-parent": "^6.0.2",
|
||||
"is-glob": "^4.0.3",
|
||||
"jiti": "^1.21.6",
|
||||
"lilconfig": "^3.1.3",
|
||||
"micromatch": "^4.0.8",
|
||||
"jiti": "^1.19.1",
|
||||
"lilconfig": "^2.1.0",
|
||||
"micromatch": "^4.0.5",
|
||||
"normalize-path": "^3.0.0",
|
||||
"object-hash": "^3.0.0",
|
||||
"picocolors": "^1.1.1",
|
||||
"postcss": "^8.4.47",
|
||||
"picocolors": "^1.0.0",
|
||||
"postcss": "^8.4.23",
|
||||
"postcss-import": "^15.1.0",
|
||||
"postcss-js": "^4.0.1",
|
||||
"postcss-load-config": "^4.0.2",
|
||||
"postcss-nested": "^6.2.0",
|
||||
"postcss-selector-parser": "^6.1.2",
|
||||
"resolve": "^1.22.8",
|
||||
"sucrase": "^3.35.0"
|
||||
"postcss-load-config": "^4.0.1",
|
||||
"postcss-nested": "^6.0.1",
|
||||
"postcss-selector-parser": "^6.0.11",
|
||||
"resolve": "^1.22.2",
|
||||
"sucrase": "^3.32.0"
|
||||
},
|
||||
"bin": {
|
||||
"tailwind": "lib/cli.js",
|
||||
@@ -2601,6 +3097,15 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss/node_modules/lilconfig": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz",
|
||||
"integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/thenify": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
|
||||
@@ -2640,6 +3145,11 @@
|
||||
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
|
||||
@@ -2677,9 +3187,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "4.5.14",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-4.5.14.tgz",
|
||||
"integrity": "sha512-+v57oAaoYNnO3hIu5Z/tJRZjq5aHM2zDve9YZ8HngVHbhk66RStobhb1sqPMIPEleV6cNKYK4eGrAbE9Ulbl2g==",
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-4.5.0.tgz",
|
||||
"integrity": "sha512-ulr8rNLA6rkyFAlVWw2q5YJ91v098AFQ2R0PRFwPzREXOUJQPtFUG0t+/ZikhaOCDqFoDhN6/v8Sq0o4araFAw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.18.10",
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "stackpulse-frontend",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "1.7.5",
|
||||
"socket.io-client": "4.8.1",
|
||||
"@heroicons/react": "2.0.18",
|
||||
"@material-tailwind/react": "2.1.4",
|
||||
"apexcharts": "3.44.0",
|
||||
"prop-types": "15.8.1",
|
||||
"react": "18.2.0",
|
||||
"react-apexcharts": "1.4.1",
|
||||
"react-dom": "18.2.0",
|
||||
"react-router-dom": "6.17.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "18.2.31",
|
||||
"@types/react-dom": "18.2.14",
|
||||
"@vitejs/plugin-react": "4.1.0",
|
||||
"autoprefixer": "10.4.16",
|
||||
"postcss": "8.4.31",
|
||||
"prettier": "3.0.3",
|
||||
"prettier-plugin-tailwindcss": "0.5.6",
|
||||
"tailwindcss": "3.3.4",
|
||||
"vite": "4.5.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import React from "react";
|
||||
import { NavLink, Route, Routes } from "react-router-dom";
|
||||
import Stacks from "./Stacks.jsx";
|
||||
import Logs from "./Logs.jsx";
|
||||
import Maintenance from "./Maintenance.jsx";
|
||||
import logo from "./assets/images/stackpulse.png";
|
||||
import { useMaintenance } from "./context/MaintenanceContext.jsx";
|
||||
|
||||
const navLinkBase =
|
||||
"px-4 py-2 rounded-md font-medium transition-colors duration-150";
|
||||
|
||||
const getNavClass = ({ isActive }) =>
|
||||
`${navLinkBase} ${isActive ? "bg-purple-600 text-white" : "text-gray-300 hover:bg-gray-700"}`;
|
||||
|
||||
export default function App() {
|
||||
const { maintenance, update } = useMaintenance();
|
||||
const maintenanceActive = Boolean(maintenance?.active);
|
||||
const maintenanceLabel = maintenance?.message || (update?.running ? "Portainer-Update läuft" : "Wartungsmodus aktiv");
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-white">
|
||||
<header className="bg-gray-800 shadow-md">
|
||||
<div className="max-w-6xl mx-auto px-6 py-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<span className="text-xs text-gray-500">v0.3</span>
|
||||
{maintenanceActive && (
|
||||
<span className="flex items-center gap-1 text-xs text-amber-300">
|
||||
<span className="h-2 w-2 animate-pulse rounded-full bg-amber-400" />
|
||||
{maintenanceLabel}
|
||||
</span>
|
||||
)}
|
||||
<img src={logo} alt="StackPulse" className="h-10 w-auto" />
|
||||
</div>
|
||||
<nav className="flex gap-2 items-end">
|
||||
<NavLink to="/" end className={getNavClass}>
|
||||
Stacks
|
||||
</NavLink>
|
||||
<NavLink to="/maintenance" className={getNavClass}>
|
||||
<span className="flex items-center gap-2">
|
||||
Wartung
|
||||
{maintenanceActive && (
|
||||
<span className="h-2 w-2 animate-pulse rounded-full bg-amber-400" />
|
||||
)}
|
||||
</span>
|
||||
</NavLink>
|
||||
|
||||
<NavLink to="/logs" className={getNavClass}>
|
||||
Logs
|
||||
</NavLink>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="max-w-6xl mx-auto px-6 py-6">
|
||||
<Routes>
|
||||
<Route path="/" element={<Stacks />} />
|
||||
<Route path="/maintenance" element={<Maintenance />} />
|
||||
<Route path="/logs" element={<Logs />} />
|
||||
<Route path="*" element={<Stacks />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -547,22 +547,10 @@ export default function Logs() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="mx-auto max-w-6xl space-y-4 p-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 className="text-xl font-semibold text-gray-100">Redeploy-Logs</h2>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<span>Einträge pro Seite</span>
|
||||
<select
|
||||
value={perPage}
|
||||
onChange={handlePerPageChange}
|
||||
className="rounded-md border border-gray-700 bg-gray-900/70 px-3 py-1.5 text-gray-200 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
{PER_PAGE_OPTIONS.map(({ value, label }) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleExport('txt')}
|
||||
disabled={actionLoading || loading}
|
||||
@@ -819,17 +807,30 @@ export default function Logs() {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-3">
|
||||
<button
|
||||
<Button
|
||||
onClick={handleResetFilters}
|
||||
className="rounded-md border border-gray-600 px-4 py-2 text-gray-200 transition hover:bg-gray-700"
|
||||
>
|
||||
Zurücksetzen
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 text-sm text-gray-300">
|
||||
<span>Einträge pro Seite</span>
|
||||
<select
|
||||
value={perPage}
|
||||
onChange={handlePerPageChange}
|
||||
className="rounded-md border border-gray-700 bg-gray-900 px-2 py-1 text-sm text-gray-100 focus:border-purple-500 focus:outline-none focus:ring-1 focus:ring-purple-500"
|
||||
>
|
||||
{PER_PAGE_OPTIONS.map(({ value, label }) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto bg-gray-800/60 rounded-xl border border-gray-700">
|
||||
<table className="min-w-full divide-y divide-gray-700">
|
||||
<thead className="bg-gray-800">
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 74 KiB |
@@ -0,0 +1,121 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
const ToastContext = createContext(null);
|
||||
|
||||
const VARIANT_STYLES = {
|
||||
success: {
|
||||
container: "border-green-500/80 bg-green-950/90 text-green-100",
|
||||
accent: "bg-green-400"
|
||||
},
|
||||
warning: {
|
||||
container: "border-yellow-500/80 bg-yellow-950/90 text-yellow-100",
|
||||
accent: "bg-yellow-400"
|
||||
},
|
||||
error: {
|
||||
container: "border-red-500/80 bg-red-950/90 text-red-100",
|
||||
accent: "bg-red-400"
|
||||
},
|
||||
info: {
|
||||
container: "border-sky-500/60 bg-sky-950/90 text-sky-100",
|
||||
accent: "bg-sky-400"
|
||||
}
|
||||
};
|
||||
|
||||
const DEFAULT_DURATION = 6000;
|
||||
|
||||
export default function ToastProvider({ children }) {
|
||||
const [toasts, setToasts] = useState([]);
|
||||
const timeoutsRef = useRef(new Map());
|
||||
|
||||
const dismissToast = useCallback((id) => {
|
||||
setToasts((prev) => prev.filter((toast) => toast.id !== id));
|
||||
|
||||
const handle = timeoutsRef.current.get(id);
|
||||
if (handle && typeof window !== "undefined") {
|
||||
window.clearTimeout(handle);
|
||||
}
|
||||
timeoutsRef.current.delete(id);
|
||||
}, []);
|
||||
|
||||
const showToast = useCallback(({ id, title, description, variant = "info", duration = DEFAULT_DURATION } = {}) => {
|
||||
const toastId = id ?? `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
const normalizedVariant = VARIANT_STYLES[variant] ? variant : "info";
|
||||
|
||||
setToasts((prev) => {
|
||||
const withoutExisting = prev.filter((toast) => toast.id !== toastId);
|
||||
return [
|
||||
...withoutExisting,
|
||||
{
|
||||
id: toastId,
|
||||
title,
|
||||
description,
|
||||
variant: normalizedVariant
|
||||
}
|
||||
];
|
||||
});
|
||||
|
||||
if (duration !== null && duration !== Infinity && typeof window !== "undefined") {
|
||||
const timeoutHandle = window.setTimeout(() => {
|
||||
dismissToast(toastId);
|
||||
}, duration);
|
||||
|
||||
const existingHandle = timeoutsRef.current.get(toastId);
|
||||
if (existingHandle) {
|
||||
window.clearTimeout(existingHandle);
|
||||
}
|
||||
timeoutsRef.current.set(toastId, timeoutHandle);
|
||||
}
|
||||
|
||||
return toastId;
|
||||
}, [dismissToast]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (typeof window === "undefined") return;
|
||||
timeoutsRef.current.forEach((handle) => window.clearTimeout(handle));
|
||||
timeoutsRef.current.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const contextValue = useMemo(() => ({ showToast, dismissToast }), [showToast, dismissToast]);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={contextValue}>
|
||||
{children}
|
||||
<div className="pointer-events-none fixed right-4 top-4 z-50 flex w-80 max-w-full flex-col gap-3">
|
||||
{toasts.map((toast) => {
|
||||
const styles = VARIANT_STYLES[toast.variant] ?? VARIANT_STYLES.info;
|
||||
return (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`pointer-events-auto overflow-hidden rounded-lg border px-4 py-3 shadow-xl backdrop-blur ${styles.container}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={`mt-1 h-2 flex-shrink-0 w-2 rounded-full ${styles.accent}`}></span>
|
||||
<div className="flex-1">
|
||||
{toast.title && <p className="text-sm font-semibold">{toast.title}</p>}
|
||||
{toast.description && <p className="mt-1 text-sm leading-snug text-white/80">{toast.description}</p>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dismissToast(toast.id)}
|
||||
className="ml-2 rounded-md p-1 text-xs uppercase tracking-wide text-white/60 transition hover:text-white/90"
|
||||
>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within a ToastProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import axios from "axios";
|
||||
|
||||
const MaintenanceContext = createContext(null);
|
||||
|
||||
const INITIAL_STATE = {
|
||||
maintenance: null,
|
||||
update: null,
|
||||
script: null,
|
||||
ssh: null,
|
||||
loading: true,
|
||||
error: "",
|
||||
lastUpdated: null
|
||||
};
|
||||
|
||||
export default function MaintenanceProvider({ children }) {
|
||||
const [state, setState] = useState(INITIAL_STATE);
|
||||
const pollingRef = useRef(null);
|
||||
const wasRunningRef = useRef(false);
|
||||
|
||||
const applyState = useCallback((partial) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
...partial
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const fetchConfig = useCallback(async () => {
|
||||
applyState({ loading: true, error: "" });
|
||||
try {
|
||||
const response = await axios.get("/api/maintenance/config");
|
||||
const data = response.data ?? {};
|
||||
applyState({
|
||||
maintenance: data.maintenance ?? null,
|
||||
update: data.update ?? null,
|
||||
script: data.script ?? null,
|
||||
ssh: data.ssh ?? null,
|
||||
loading: false,
|
||||
error: "",
|
||||
lastUpdated: new Date()
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err.response?.data?.error || err.message || "Fehler beim Laden der Wartungsdaten";
|
||||
applyState({ loading: false, error: message });
|
||||
}
|
||||
}, [applyState]);
|
||||
|
||||
const refreshUpdateStatus = useCallback(async () => {
|
||||
try {
|
||||
const response = await axios.get("/api/maintenance/update-status");
|
||||
const data = response.data ?? {};
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
maintenance: data.maintenance ?? prev.maintenance,
|
||||
update: data.update ?? prev.update,
|
||||
error: ""
|
||||
}));
|
||||
} catch (err) {
|
||||
const message = err.response?.data?.error || err.message || "Fehler beim Aktualisieren des Wartungsstatus";
|
||||
setState((prev) => ({ ...prev, error: message }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
}, [fetchConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.update?.running) {
|
||||
refreshUpdateStatus();
|
||||
const intervalId = setInterval(() => {
|
||||
refreshUpdateStatus();
|
||||
}, 5000);
|
||||
pollingRef.current = intervalId;
|
||||
return () => clearInterval(intervalId);
|
||||
}
|
||||
if (pollingRef.current) {
|
||||
clearInterval(pollingRef.current);
|
||||
pollingRef.current = null;
|
||||
}
|
||||
return undefined;
|
||||
}, [state.update?.running, refreshUpdateStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentlyRunning = Boolean(state.update?.running);
|
||||
if (!currentlyRunning && wasRunningRef.current) {
|
||||
fetchConfig();
|
||||
}
|
||||
wasRunningRef.current = currentlyRunning;
|
||||
}, [state.update?.running, fetchConfig]);
|
||||
|
||||
const triggerUpdate = useCallback(async (payload = {}) => {
|
||||
await axios.post("/api/maintenance/portainer-update", payload);
|
||||
await refreshUpdateStatus();
|
||||
}, [refreshUpdateStatus]);
|
||||
|
||||
const saveScript = useCallback(async (script) => {
|
||||
await axios.put("/api/maintenance/update-script", { script });
|
||||
await fetchConfig();
|
||||
}, [fetchConfig]);
|
||||
|
||||
const resetScript = useCallback(async () => {
|
||||
await axios.delete("/api/maintenance/update-script");
|
||||
await fetchConfig();
|
||||
}, [fetchConfig]);
|
||||
|
||||
const saveSshConfig = useCallback(async (config) => {
|
||||
await axios.put("/api/maintenance/ssh-config", config);
|
||||
await fetchConfig();
|
||||
}, [fetchConfig]);
|
||||
|
||||
const deleteSshConfig = useCallback(async () => {
|
||||
await axios.delete("/api/maintenance/ssh-config");
|
||||
await fetchConfig();
|
||||
}, [fetchConfig]);
|
||||
|
||||
const testSshConnection = useCallback(async (config) => {
|
||||
const response = await axios.post("/api/maintenance/test-ssh", config ?? {});
|
||||
return response.data;
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({
|
||||
maintenance: state.maintenance,
|
||||
update: state.update,
|
||||
script: state.script,
|
||||
ssh: state.ssh,
|
||||
loading: state.loading,
|
||||
error: state.error,
|
||||
lastUpdated: state.lastUpdated,
|
||||
refreshConfig: fetchConfig,
|
||||
refreshUpdateStatus,
|
||||
triggerUpdate,
|
||||
saveScript,
|
||||
resetScript,
|
||||
saveSshConfig,
|
||||
deleteSshConfig,
|
||||
testSshConnection
|
||||
}), [state, fetchConfig, refreshUpdateStatus, triggerUpdate, saveScript, resetScript, saveSshConfig, deleteSshConfig, testSshConnection]);
|
||||
|
||||
return (
|
||||
<MaintenanceContext.Provider value={value}>
|
||||
{children}
|
||||
</MaintenanceContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useMaintenance() {
|
||||
const context = useContext(MaintenanceContext);
|
||||
if (!context) {
|
||||
throw new Error('useMaintenance must be used within a MaintenanceProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App.jsx";
|
||||
import ToastProvider from "./components/ToastProvider.jsx";
|
||||
import MaintenanceProvider from "./context/MaintenanceContext.jsx";
|
||||
import './index.css'; // Tailwind CSS
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<MaintenanceProvider>
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
</MaintenanceProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: true, // Hört auf allen Netzwerk-Interfaces, kein localhost notwendig
|
||||
port: 5173, // optional, Standardport für Vite
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:4001', // Backend lokal auf Port 4001
|
||||
changeOrigin: true
|
||||
},
|
||||
'/socket.io': {
|
||||
target: 'http://127.0.0.1:4001', // WebSocket-Verbindungen über Proxy weiterleiten
|
||||
ws: true,
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user