SONUM v7 - Évolution v6 (éditeurs/blocs CRUD, tableau de bord stats) + vue liste alternance couleurs

This commit is contained in:
Manus Agent
2026-04-20 11:51:04 -04:00
commit 3bccb0a743
143 changed files with 30933 additions and 0 deletions

113
.gitignore vendored Normal file
View File

@@ -0,0 +1,113 @@
# Dependencies
**/node_modules
.pnpm-store/
# Build outputs
dist/
build/
*.dist
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# IDE and editor files
.vscode/
.idea/
*.swp
*.swo
*~
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
*.bak
# Coverage directory used by tools like istanbul
coverage/
*.lcov
# nyc test coverage
.nyc_output
# Dependency directories
jspm_packages/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
# Gatsby files
.cache/
# Storybook build outputs
.out
.storybook-out
# Temporary folders
tmp/
temp/
# Database
*.db
*.sqlite
*.sqlite3
# Webdev artifacts (checkpoint zips, migrations, etc.)
.webdev/
# Manus version file (auto-generated, not part of source)
client/public/__manus__/version.json

0
.gitkeep Normal file
View File

35
.prettierignore Normal file
View File

@@ -0,0 +1,35 @@
# Dependencies
node_modules/
.pnpm-store/
# Build outputs
dist/
build/
*.dist
# Generated files
*.tsbuildinfo
coverage/
# Package files
package-lock.json
pnpm-lock.yaml
# Database
*.db
*.sqlite
*.sqlite3
# Logs
*.log
# Environment files
.env*
# IDE files
.vscode/
.idea/
# OS files
.DS_Store
Thumbs.db

15
.prettierrc Normal file
View File

@@ -0,0 +1,15 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": false,
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"bracketSpacing": true,
"bracketSameLine": false,
"arrowParens": "avoid",
"endOfLine": "lf",
"quoteProps": "as-needed",
"jsxSingleQuote": false,
"proseWrap": "preserve"
}

26
client/index.html Normal file
View File

@@ -0,0 +1,26 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1" />
<title>SONUM - Cartographie des Solutions Numériques</title>
<!-- THIS IS THE START OF A COMMENT BLOCK, BLOCK TO BE DELETED: Google Fonts here, example:
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
THIS IS THE END OF A COMMENT BLOCK, BLOCK TO BE DELETED -->
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<script
defer
src="%VITE_ANALYTICS_ENDPOINT%/umami"
data-website-id="%VITE_ANALYTICS_WEBSITE_ID%"></script>
</body>
</html>

0
client/public/.gitkeep Normal file
View File

View File

@@ -0,0 +1,821 @@
/**
* Manus Debug Collector (agent-friendly)
*
* Captures:
* 1) Console logs
* 2) Network requests (fetch + XHR)
* 3) User interactions (semantic uiEvents: click/type/submit/nav/scroll/etc.)
*
* Data is periodically sent to /__manus__/logs
* Note: uiEvents are mirrored to sessionEvents for sessionReplay.log
*/
(function () {
"use strict";
// Prevent double initialization
if (window.__MANUS_DEBUG_COLLECTOR__) return;
// ==========================================================================
// Configuration
// ==========================================================================
const CONFIG = {
reportEndpoint: "/__manus__/logs",
bufferSize: {
console: 500,
network: 200,
// semantic, agent-friendly UI events
ui: 500,
},
reportInterval: 2000,
sensitiveFields: [
"password",
"token",
"secret",
"key",
"authorization",
"cookie",
"session",
],
maxBodyLength: 10240,
// UI event logging privacy policy:
// - inputs matching sensitiveFields or type=password are masked by default
// - non-sensitive inputs log up to 200 chars
uiInputMaxLen: 200,
uiTextMaxLen: 80,
// Scroll throttling: minimum ms between scroll events
scrollThrottleMs: 500,
};
// ==========================================================================
// Storage
// ==========================================================================
const store = {
consoleLogs: [],
networkRequests: [],
uiEvents: [],
lastReportTime: Date.now(),
lastScrollTime: 0,
};
// ==========================================================================
// Utility Functions
// ==========================================================================
function sanitizeValue(value, depth) {
if (depth === void 0) depth = 0;
if (depth > 5) return "[Max Depth]";
if (value === null) return null;
if (value === undefined) return undefined;
if (typeof value === "string") {
return value.length > 1000 ? value.slice(0, 1000) + "...[truncated]" : value;
}
if (typeof value !== "object") return value;
if (Array.isArray(value)) {
return value.slice(0, 100).map(function (v) {
return sanitizeValue(v, depth + 1);
});
}
var sanitized = {};
for (var k in value) {
if (Object.prototype.hasOwnProperty.call(value, k)) {
var isSensitive = CONFIG.sensitiveFields.some(function (f) {
return k.toLowerCase().indexOf(f) !== -1;
});
if (isSensitive) {
sanitized[k] = "[REDACTED]";
} else {
sanitized[k] = sanitizeValue(value[k], depth + 1);
}
}
}
return sanitized;
}
function formatArg(arg) {
try {
if (arg instanceof Error) {
return { type: "Error", message: arg.message, stack: arg.stack };
}
if (typeof arg === "object") return sanitizeValue(arg);
return String(arg);
} catch (e) {
return "[Unserializable]";
}
}
function formatArgs(args) {
var result = [];
for (var i = 0; i < args.length; i++) result.push(formatArg(args[i]));
return result;
}
function pruneBuffer(buffer, maxSize) {
if (buffer.length > maxSize) buffer.splice(0, buffer.length - maxSize);
}
function tryParseJson(str) {
if (typeof str !== "string") return str;
try {
return JSON.parse(str);
} catch (e) {
return str;
}
}
// ==========================================================================
// Semantic UI Event Logging (agent-friendly)
// ==========================================================================
function shouldIgnoreTarget(target) {
try {
if (!target || !(target instanceof Element)) return false;
return !!target.closest(".manus-no-record");
} catch (e) {
return false;
}
}
function compactText(s, maxLen) {
try {
var t = (s || "").trim().replace(/\s+/g, " ");
if (!t) return "";
return t.length > maxLen ? t.slice(0, maxLen) + "…" : t;
} catch (e) {
return "";
}
}
function elText(el) {
try {
var t = el.innerText || el.textContent || "";
return compactText(t, CONFIG.uiTextMaxLen);
} catch (e) {
return "";
}
}
function describeElement(el) {
if (!el || !(el instanceof Element)) return null;
var getAttr = function (name) {
return el.getAttribute(name);
};
var tag = el.tagName ? el.tagName.toLowerCase() : null;
var id = el.id || null;
var name = getAttr("name") || null;
var role = getAttr("role") || null;
var ariaLabel = getAttr("aria-label") || null;
var dataLoc = getAttr("data-loc") || null;
var testId =
getAttr("data-testid") ||
getAttr("data-test-id") ||
getAttr("data-test") ||
null;
var type = tag === "input" ? (getAttr("type") || "text") : null;
var href = tag === "a" ? getAttr("href") || null : null;
// a small, stable hint for agents (avoid building full CSS paths)
var selectorHint = null;
if (testId) selectorHint = '[data-testid="' + testId + '"]';
else if (dataLoc) selectorHint = '[data-loc="' + dataLoc + '"]';
else if (id) selectorHint = "#" + id;
else selectorHint = tag || "unknown";
return {
tag: tag,
id: id,
name: name,
type: type,
role: role,
ariaLabel: ariaLabel,
testId: testId,
dataLoc: dataLoc,
href: href,
text: elText(el),
selectorHint: selectorHint,
};
}
function isSensitiveField(el) {
if (!el || !(el instanceof Element)) return false;
var tag = el.tagName ? el.tagName.toLowerCase() : "";
if (tag !== "input" && tag !== "textarea") return false;
var type = (el.getAttribute("type") || "").toLowerCase();
if (type === "password") return true;
var name = (el.getAttribute("name") || "").toLowerCase();
var id = (el.id || "").toLowerCase();
return CONFIG.sensitiveFields.some(function (f) {
return name.indexOf(f) !== -1 || id.indexOf(f) !== -1;
});
}
function getInputValueSafe(el) {
if (!el || !(el instanceof Element)) return null;
var tag = el.tagName ? el.tagName.toLowerCase() : "";
if (tag !== "input" && tag !== "textarea" && tag !== "select") return null;
var v = "";
try {
v = el.value != null ? String(el.value) : "";
} catch (e) {
v = "";
}
if (isSensitiveField(el)) return { masked: true, length: v.length };
if (v.length > CONFIG.uiInputMaxLen) v = v.slice(0, CONFIG.uiInputMaxLen) + "…";
return v;
}
function logUiEvent(kind, payload) {
var entry = {
timestamp: Date.now(),
kind: kind,
url: location.href,
viewport: { width: window.innerWidth, height: window.innerHeight },
payload: sanitizeValue(payload),
};
store.uiEvents.push(entry);
pruneBuffer(store.uiEvents, CONFIG.bufferSize.ui);
}
function installUiEventListeners() {
// Clicks
document.addEventListener(
"click",
function (e) {
var t = e.target;
if (shouldIgnoreTarget(t)) return;
logUiEvent("click", {
target: describeElement(t),
x: e.clientX,
y: e.clientY,
});
},
true
);
// Typing "commit" events
document.addEventListener(
"change",
function (e) {
var t = e.target;
if (shouldIgnoreTarget(t)) return;
logUiEvent("change", {
target: describeElement(t),
value: getInputValueSafe(t),
});
},
true
);
document.addEventListener(
"focusin",
function (e) {
var t = e.target;
if (shouldIgnoreTarget(t)) return;
logUiEvent("focusin", { target: describeElement(t) });
},
true
);
document.addEventListener(
"focusout",
function (e) {
var t = e.target;
if (shouldIgnoreTarget(t)) return;
logUiEvent("focusout", {
target: describeElement(t),
value: getInputValueSafe(t),
});
},
true
);
// Enter/Escape are useful for form flows & modals
document.addEventListener(
"keydown",
function (e) {
if (e.key !== "Enter" && e.key !== "Escape") return;
var t = e.target;
if (shouldIgnoreTarget(t)) return;
logUiEvent("keydown", { key: e.key, target: describeElement(t) });
},
true
);
// Form submissions
document.addEventListener(
"submit",
function (e) {
var t = e.target;
if (shouldIgnoreTarget(t)) return;
logUiEvent("submit", { target: describeElement(t) });
},
true
);
// Throttled scroll events
window.addEventListener(
"scroll",
function () {
var now = Date.now();
if (now - store.lastScrollTime < CONFIG.scrollThrottleMs) return;
store.lastScrollTime = now;
logUiEvent("scroll", {
scrollX: window.scrollX,
scrollY: window.scrollY,
documentHeight: document.documentElement.scrollHeight,
viewportHeight: window.innerHeight,
});
},
{ passive: true }
);
// Navigation tracking for SPAs
function nav(reason) {
logUiEvent("navigate", { reason: reason });
}
var origPush = history.pushState;
history.pushState = function () {
origPush.apply(this, arguments);
nav("pushState");
};
var origReplace = history.replaceState;
history.replaceState = function () {
origReplace.apply(this, arguments);
nav("replaceState");
};
window.addEventListener("popstate", function () {
nav("popstate");
});
window.addEventListener("hashchange", function () {
nav("hashchange");
});
}
// ==========================================================================
// Console Interception
// ==========================================================================
var originalConsole = {
log: console.log.bind(console),
debug: console.debug.bind(console),
info: console.info.bind(console),
warn: console.warn.bind(console),
error: console.error.bind(console),
};
["log", "debug", "info", "warn", "error"].forEach(function (method) {
console[method] = function () {
var args = Array.prototype.slice.call(arguments);
var entry = {
timestamp: Date.now(),
level: method.toUpperCase(),
args: formatArgs(args),
stack: method === "error" ? new Error().stack : null,
};
store.consoleLogs.push(entry);
pruneBuffer(store.consoleLogs, CONFIG.bufferSize.console);
originalConsole[method].apply(console, args);
};
});
window.addEventListener("error", function (event) {
store.consoleLogs.push({
timestamp: Date.now(),
level: "ERROR",
args: [
{
type: "UncaughtError",
message: event.message,
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
stack: event.error ? event.error.stack : null,
},
],
stack: event.error ? event.error.stack : null,
});
pruneBuffer(store.consoleLogs, CONFIG.bufferSize.console);
// Mark an error moment in UI event stream for agents
logUiEvent("error", {
message: event.message,
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
});
});
window.addEventListener("unhandledrejection", function (event) {
var reason = event.reason;
store.consoleLogs.push({
timestamp: Date.now(),
level: "ERROR",
args: [
{
type: "UnhandledRejection",
reason: reason && reason.message ? reason.message : String(reason),
stack: reason && reason.stack ? reason.stack : null,
},
],
stack: reason && reason.stack ? reason.stack : null,
});
pruneBuffer(store.consoleLogs, CONFIG.bufferSize.console);
logUiEvent("unhandledrejection", {
reason: reason && reason.message ? reason.message : String(reason),
});
});
// ==========================================================================
// Fetch Interception
// ==========================================================================
var originalFetch = window.fetch.bind(window);
window.fetch = function (input, init) {
init = init || {};
var startTime = Date.now();
// Handle string, Request object, or URL object
var url = typeof input === "string"
? input
: (input && (input.url || input.href || String(input))) || "";
var method = init.method || (input && input.method) || "GET";
// Don't intercept internal requests
if (url.indexOf("/__manus__/") === 0) {
return originalFetch(input, init);
}
// Safely parse headers (avoid breaking if headers format is invalid)
var requestHeaders = {};
try {
if (init.headers) {
requestHeaders = Object.fromEntries(new Headers(init.headers).entries());
}
} catch (e) {
requestHeaders = { _parseError: true };
}
var entry = {
timestamp: startTime,
type: "fetch",
method: method.toUpperCase(),
url: url,
request: {
headers: requestHeaders,
body: init.body ? sanitizeValue(tryParseJson(init.body)) : null,
},
response: null,
duration: null,
error: null,
};
return originalFetch(input, init)
.then(function (response) {
entry.duration = Date.now() - startTime;
var contentType = (response.headers.get("content-type") || "").toLowerCase();
var contentLength = response.headers.get("content-length");
entry.response = {
status: response.status,
statusText: response.statusText,
headers: Object.fromEntries(response.headers.entries()),
body: null,
};
// Semantic network hint for agents on failures (sync, no need to wait for body)
if (response.status >= 400) {
logUiEvent("network_error", {
kind: "fetch",
method: entry.method,
url: entry.url,
status: response.status,
statusText: response.statusText,
});
}
// Skip body capture for streaming responses (SSE, etc.) to avoid memory leaks
var isStreaming = contentType.indexOf("text/event-stream") !== -1 ||
contentType.indexOf("application/stream") !== -1 ||
contentType.indexOf("application/x-ndjson") !== -1;
if (isStreaming) {
entry.response.body = "[Streaming response - not captured]";
store.networkRequests.push(entry);
pruneBuffer(store.networkRequests, CONFIG.bufferSize.network);
return response;
}
// Skip body capture for large responses to avoid memory issues
if (contentLength && parseInt(contentLength, 10) > CONFIG.maxBodyLength) {
entry.response.body = "[Response too large: " + contentLength + " bytes]";
store.networkRequests.push(entry);
pruneBuffer(store.networkRequests, CONFIG.bufferSize.network);
return response;
}
// Skip body capture for binary content types
var isBinary = contentType.indexOf("image/") !== -1 ||
contentType.indexOf("video/") !== -1 ||
contentType.indexOf("audio/") !== -1 ||
contentType.indexOf("application/octet-stream") !== -1 ||
contentType.indexOf("application/pdf") !== -1 ||
contentType.indexOf("application/zip") !== -1;
if (isBinary) {
entry.response.body = "[Binary content: " + contentType + "]";
store.networkRequests.push(entry);
pruneBuffer(store.networkRequests, CONFIG.bufferSize.network);
return response;
}
// For text responses, clone and read body in background
var clonedResponse = response.clone();
// Async: read body in background, don't block the response
clonedResponse
.text()
.then(function (text) {
if (text.length <= CONFIG.maxBodyLength) {
entry.response.body = sanitizeValue(tryParseJson(text));
} else {
entry.response.body = text.slice(0, CONFIG.maxBodyLength) + "...[truncated]";
}
})
.catch(function () {
entry.response.body = "[Unable to read body]";
})
.finally(function () {
store.networkRequests.push(entry);
pruneBuffer(store.networkRequests, CONFIG.bufferSize.network);
});
// Return response immediately, don't wait for body reading
return response;
})
.catch(function (error) {
entry.duration = Date.now() - startTime;
entry.error = { message: error.message, stack: error.stack };
store.networkRequests.push(entry);
pruneBuffer(store.networkRequests, CONFIG.bufferSize.network);
logUiEvent("network_error", {
kind: "fetch",
method: entry.method,
url: entry.url,
message: error.message,
});
throw error;
});
};
// ==========================================================================
// XHR Interception
// ==========================================================================
var originalXHROpen = XMLHttpRequest.prototype.open;
var originalXHRSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url) {
this._manusData = {
method: (method || "GET").toUpperCase(),
url: url,
startTime: null,
};
return originalXHROpen.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function (body) {
var xhr = this;
if (
xhr._manusData &&
xhr._manusData.url &&
xhr._manusData.url.indexOf("/__manus__/") !== 0
) {
xhr._manusData.startTime = Date.now();
xhr._manusData.requestBody = body ? sanitizeValue(tryParseJson(body)) : null;
xhr.addEventListener("load", function () {
var contentType = (xhr.getResponseHeader("content-type") || "").toLowerCase();
var responseBody = null;
// Skip body capture for streaming responses
var isStreaming = contentType.indexOf("text/event-stream") !== -1 ||
contentType.indexOf("application/stream") !== -1 ||
contentType.indexOf("application/x-ndjson") !== -1;
// Skip body capture for binary content types
var isBinary = contentType.indexOf("image/") !== -1 ||
contentType.indexOf("video/") !== -1 ||
contentType.indexOf("audio/") !== -1 ||
contentType.indexOf("application/octet-stream") !== -1 ||
contentType.indexOf("application/pdf") !== -1 ||
contentType.indexOf("application/zip") !== -1;
if (isStreaming) {
responseBody = "[Streaming response - not captured]";
} else if (isBinary) {
responseBody = "[Binary content: " + contentType + "]";
} else {
// Safe to read responseText for text responses
try {
var text = xhr.responseText || "";
if (text.length > CONFIG.maxBodyLength) {
responseBody = text.slice(0, CONFIG.maxBodyLength) + "...[truncated]";
} else {
responseBody = sanitizeValue(tryParseJson(text));
}
} catch (e) {
// responseText may throw for non-text responses
responseBody = "[Unable to read response: " + e.message + "]";
}
}
var entry = {
timestamp: xhr._manusData.startTime,
type: "xhr",
method: xhr._manusData.method,
url: xhr._manusData.url,
request: { body: xhr._manusData.requestBody },
response: {
status: xhr.status,
statusText: xhr.statusText,
body: responseBody,
},
duration: Date.now() - xhr._manusData.startTime,
error: null,
};
store.networkRequests.push(entry);
pruneBuffer(store.networkRequests, CONFIG.bufferSize.network);
if (entry.response && entry.response.status >= 400) {
logUiEvent("network_error", {
kind: "xhr",
method: entry.method,
url: entry.url,
status: entry.response.status,
statusText: entry.response.statusText,
});
}
});
xhr.addEventListener("error", function () {
var entry = {
timestamp: xhr._manusData.startTime,
type: "xhr",
method: xhr._manusData.method,
url: xhr._manusData.url,
request: { body: xhr._manusData.requestBody },
response: null,
duration: Date.now() - xhr._manusData.startTime,
error: { message: "Network error" },
};
store.networkRequests.push(entry);
pruneBuffer(store.networkRequests, CONFIG.bufferSize.network);
logUiEvent("network_error", {
kind: "xhr",
method: entry.method,
url: entry.url,
message: "Network error",
});
});
}
return originalXHRSend.apply(this, arguments);
};
// ==========================================================================
// Data Reporting
// ==========================================================================
function reportLogs() {
var consoleLogs = store.consoleLogs.splice(0);
var networkRequests = store.networkRequests.splice(0);
var uiEvents = store.uiEvents.splice(0);
// Skip if no new data
if (
consoleLogs.length === 0 &&
networkRequests.length === 0 &&
uiEvents.length === 0
) {
return Promise.resolve();
}
var payload = {
timestamp: Date.now(),
consoleLogs: consoleLogs,
networkRequests: networkRequests,
// Mirror uiEvents to sessionEvents for sessionReplay.log
sessionEvents: uiEvents,
// agent-friendly semantic events
uiEvents: uiEvents,
};
return originalFetch(CONFIG.reportEndpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}).catch(function () {
// Put data back on failure (but respect limits)
store.consoleLogs = consoleLogs.concat(store.consoleLogs);
store.networkRequests = networkRequests.concat(store.networkRequests);
store.uiEvents = uiEvents.concat(store.uiEvents);
pruneBuffer(store.consoleLogs, CONFIG.bufferSize.console);
pruneBuffer(store.networkRequests, CONFIG.bufferSize.network);
pruneBuffer(store.uiEvents, CONFIG.bufferSize.ui);
});
}
// Periodic reporting
setInterval(reportLogs, CONFIG.reportInterval);
// Report on page unload
window.addEventListener("beforeunload", function () {
var consoleLogs = store.consoleLogs;
var networkRequests = store.networkRequests;
var uiEvents = store.uiEvents;
if (
consoleLogs.length === 0 &&
networkRequests.length === 0 &&
uiEvents.length === 0
) {
return;
}
var payload = {
timestamp: Date.now(),
consoleLogs: consoleLogs,
networkRequests: networkRequests,
// Mirror uiEvents to sessionEvents for sessionReplay.log
sessionEvents: uiEvents,
uiEvents: uiEvents,
};
if (navigator.sendBeacon) {
var payloadStr = JSON.stringify(payload);
// sendBeacon has ~64KB limit, truncate if too large
var MAX_BEACON_SIZE = 60000; // Leave some margin
if (payloadStr.length > MAX_BEACON_SIZE) {
// Prioritize: keep recent events, drop older logs
var truncatedPayload = {
timestamp: Date.now(),
consoleLogs: consoleLogs.slice(-50),
networkRequests: networkRequests.slice(-20),
sessionEvents: uiEvents.slice(-100),
uiEvents: uiEvents.slice(-100),
_truncated: true,
};
payloadStr = JSON.stringify(truncatedPayload);
}
navigator.sendBeacon(CONFIG.reportEndpoint, payloadStr);
}
});
// ==========================================================================
// Initialization
// ==========================================================================
// Install semantic UI listeners ASAP
try {
installUiEventListeners();
} catch (e) {
console.warn("[Manus] Failed to install UI listeners:", e);
}
// Mark as initialized
window.__MANUS_DEBUG_COLLECTOR__ = {
version: "2.0-no-rrweb",
store: store,
forceReport: reportLogs,
};
console.debug("[Manus] Debug collector initialized (no rrweb, UI events only)");
})();

55
client/src/App.tsx Normal file
View File

@@ -0,0 +1,55 @@
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import NotFound from "@/pages/NotFound";
import { Route, Switch } from "wouter";
import ErrorBoundary from "./components/ErrorBoundary";
import { ThemeProvider } from "./contexts/ThemeContext";
import Home from "./pages/Home";
import MesEtablissements from "./pages/MesEtablissements";
import MesDemandes from "./pages/MesDemandes";
import FicheEtablissement from "./pages/FicheEtablissement";
import Admin from "./pages/Admin";
import Login from "./pages/Login";
import LoginLocal from "./pages/LoginLocal";
import MesSolutions from "./pages/MesSolutions";
import SolutionsLogicielles from "./pages/SolutionsLogicielles";
import Statistiques from "./pages/Statistiques";
function Router() {
return (
<Switch>
{/* Pages publiques de connexion */}
<Route path="/login" component={Login} />
<Route path="/login/local" component={LoginLocal} />
{/* Pages applicatives (nécessitent une connexion) */}
<Route path="/" component={Home} />
<Route path="/mes-etablissements" component={MesEtablissements} />
<Route path="/mes-demandes" component={MesDemandes} />
<Route path="/etablissement/:id" component={FicheEtablissement} />
<Route path="/admin" component={Admin} />
<Route path="/mes-solutions" component={MesSolutions} />
<Route path="/solutions" component={SolutionsLogicielles} />
<Route path="/statistiques" component={Statistiques} />
{/* Fallback */}
<Route path="/404" component={NotFound} />
<Route component={NotFound} />
</Switch>
);
}
function App() {
return (
<ErrorBoundary>
<ThemeProvider defaultTheme="light">
<TooltipProvider>
<Toaster richColors position="top-right" />
<Router />
</TooltipProvider>
</ThemeProvider>
</ErrorBoundary>
);
}
export default App;

View File

@@ -0,0 +1,86 @@
import { getLoginUrl } from "@/const";
import { trpc } from "@/lib/trpc";
import { TRPCClientError } from "@trpc/client";
import { useCallback, useEffect, useMemo } from "react";
type UseAuthOptions = {
redirectOnUnauthenticated?: boolean;
redirectPath?: string;
};
export function useAuth(options?: UseAuthOptions) {
// Par défaut, rediriger vers la page de choix de connexion (/login)
// et non directement vers l'OAuth Manus, pour laisser le choix à l'utilisateur.
const { redirectOnUnauthenticated = false, redirectPath = "/login" } =
options ?? {};
const utils = trpc.useUtils();
const meQuery = trpc.auth.me.useQuery(undefined, {
retry: false,
refetchOnWindowFocus: false,
});
const logoutMutation = trpc.auth.logout.useMutation({
onSuccess: () => {
utils.auth.me.setData(undefined, null);
},
});
const logout = useCallback(async () => {
try {
await logoutMutation.mutateAsync();
} catch (error: unknown) {
if (
error instanceof TRPCClientError &&
error.data?.code === "UNAUTHORIZED"
) {
return;
}
throw error;
} finally {
utils.auth.me.setData(undefined, null);
await utils.auth.me.invalidate();
}
}, [logoutMutation, utils]);
const state = useMemo(() => {
localStorage.setItem(
"manus-runtime-user-info",
JSON.stringify(meQuery.data)
);
return {
user: meQuery.data ?? null,
loading: meQuery.isLoading || logoutMutation.isPending,
error: meQuery.error ?? logoutMutation.error ?? null,
isAuthenticated: Boolean(meQuery.data),
};
}, [
meQuery.data,
meQuery.error,
meQuery.isLoading,
logoutMutation.error,
logoutMutation.isPending,
]);
useEffect(() => {
if (!redirectOnUnauthenticated) return;
if (meQuery.isLoading || logoutMutation.isPending) return;
if (state.user) return;
if (typeof window === "undefined") return;
if (window.location.pathname === redirectPath) return;
window.location.href = redirectPath
}, [
redirectOnUnauthenticated,
redirectPath,
logoutMutation.isPending,
meQuery.isLoading,
state.user,
]);
return {
...state,
refresh: () => meQuery.refetch(),
logout,
};
}

View File

@@ -0,0 +1,335 @@
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import { Loader2, Send, User, Sparkles } from "lucide-react";
import { useState, useEffect, useRef } from "react";
import { Streamdown } from "streamdown";
/**
* Message type matching server-side LLM Message interface
*/
export type Message = {
role: "system" | "user" | "assistant";
content: string;
};
export type AIChatBoxProps = {
/**
* Messages array to display in the chat.
* Should match the format used by invokeLLM on the server.
*/
messages: Message[];
/**
* Callback when user sends a message.
* Typically you'll call a tRPC mutation here to invoke the LLM.
*/
onSendMessage: (content: string) => void;
/**
* Whether the AI is currently generating a response
*/
isLoading?: boolean;
/**
* Placeholder text for the input field
*/
placeholder?: string;
/**
* Custom className for the container
*/
className?: string;
/**
* Height of the chat box (default: 600px)
*/
height?: string | number;
/**
* Empty state message to display when no messages
*/
emptyStateMessage?: string;
/**
* Suggested prompts to display in empty state
* Click to send directly
*/
suggestedPrompts?: string[];
};
/**
* A ready-to-use AI chat box component that integrates with the LLM system.
*
* Features:
* - Matches server-side Message interface for seamless integration
* - Markdown rendering with Streamdown
* - Auto-scrolls to latest message
* - Loading states
* - Uses global theme colors from index.css
*
* @example
* ```tsx
* const ChatPage = () => {
* const [messages, setMessages] = useState<Message[]>([
* { role: "system", content: "You are a helpful assistant." }
* ]);
*
* const chatMutation = trpc.ai.chat.useMutation({
* onSuccess: (response) => {
* // Assuming your tRPC endpoint returns the AI response as a string
* setMessages(prev => [...prev, {
* role: "assistant",
* content: response
* }]);
* },
* onError: (error) => {
* console.error("Chat error:", error);
* // Optionally show error message to user
* }
* });
*
* const handleSend = (content: string) => {
* const newMessages = [...messages, { role: "user", content }];
* setMessages(newMessages);
* chatMutation.mutate({ messages: newMessages });
* };
*
* return (
* <AIChatBox
* messages={messages}
* onSendMessage={handleSend}
* isLoading={chatMutation.isPending}
* suggestedPrompts={[
* "Explain quantum computing",
* "Write a hello world in Python"
* ]}
* />
* );
* };
* ```
*/
export function AIChatBox({
messages,
onSendMessage,
isLoading = false,
placeholder = "Type your message...",
className,
height = "600px",
emptyStateMessage = "Start a conversation with AI",
suggestedPrompts,
}: AIChatBoxProps) {
const [input, setInput] = useState("");
const scrollAreaRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const inputAreaRef = useRef<HTMLFormElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Filter out system messages
const displayMessages = messages.filter((msg) => msg.role !== "system");
// Calculate min-height for last assistant message to push user message to top
const [minHeightForLastMessage, setMinHeightForLastMessage] = useState(0);
useEffect(() => {
if (containerRef.current && inputAreaRef.current) {
const containerHeight = containerRef.current.offsetHeight;
const inputHeight = inputAreaRef.current.offsetHeight;
const scrollAreaHeight = containerHeight - inputHeight;
// Reserve space for:
// - padding (p-4 = 32px top+bottom)
// - user message: 40px (item height) + 16px (margin-top from space-y-4) = 56px
// Note: margin-bottom is not counted because it naturally pushes the assistant message down
const userMessageReservedHeight = 56;
const calculatedHeight = scrollAreaHeight - 32 - userMessageReservedHeight;
setMinHeightForLastMessage(Math.max(0, calculatedHeight));
}
}, []);
// Scroll to bottom helper function with smooth animation
const scrollToBottom = () => {
const viewport = scrollAreaRef.current?.querySelector(
'[data-radix-scroll-area-viewport]'
) as HTMLDivElement;
if (viewport) {
requestAnimationFrame(() => {
viewport.scrollTo({
top: viewport.scrollHeight,
behavior: 'smooth'
});
});
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const trimmedInput = input.trim();
if (!trimmedInput || isLoading) return;
onSendMessage(trimmedInput);
setInput("");
// Scroll immediately after sending
scrollToBottom();
// Keep focus on input
textareaRef.current?.focus();
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
};
return (
<div
ref={containerRef}
className={cn(
"flex flex-col bg-card text-card-foreground rounded-lg border shadow-sm",
className
)}
style={{ height }}
>
{/* Messages Area */}
<div ref={scrollAreaRef} className="flex-1 overflow-hidden">
{displayMessages.length === 0 ? (
<div className="flex h-full flex-col p-4">
<div className="flex flex-1 flex-col items-center justify-center gap-6 text-muted-foreground">
<div className="flex flex-col items-center gap-3">
<Sparkles className="size-12 opacity-20" />
<p className="text-sm">{emptyStateMessage}</p>
</div>
{suggestedPrompts && suggestedPrompts.length > 0 && (
<div className="flex max-w-2xl flex-wrap justify-center gap-2">
{suggestedPrompts.map((prompt, index) => (
<button
key={index}
onClick={() => onSendMessage(prompt)}
disabled={isLoading}
className="rounded-lg border border-border bg-card px-4 py-2 text-sm transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
>
{prompt}
</button>
))}
</div>
)}
</div>
</div>
) : (
<ScrollArea className="h-full">
<div className="flex flex-col space-y-4 p-4">
{displayMessages.map((message, index) => {
// Apply min-height to last message only if NOT loading (when loading, the loading indicator gets it)
const isLastMessage = index === displayMessages.length - 1;
const shouldApplyMinHeight =
isLastMessage && !isLoading && minHeightForLastMessage > 0;
return (
<div
key={index}
className={cn(
"flex gap-3",
message.role === "user"
? "justify-end items-start"
: "justify-start items-start"
)}
style={
shouldApplyMinHeight
? { minHeight: `${minHeightForLastMessage}px` }
: undefined
}
>
{message.role === "assistant" && (
<div className="size-8 shrink-0 mt-1 rounded-full bg-primary/10 flex items-center justify-center">
<Sparkles className="size-4 text-primary" />
</div>
)}
<div
className={cn(
"max-w-[80%] rounded-lg px-4 py-2.5",
message.role === "user"
? "bg-primary text-primary-foreground"
: "bg-muted text-foreground"
)}
>
{message.role === "assistant" ? (
<div className="prose prose-sm dark:prose-invert max-w-none">
<Streamdown>{message.content}</Streamdown>
</div>
) : (
<p className="whitespace-pre-wrap text-sm">
{message.content}
</p>
)}
</div>
{message.role === "user" && (
<div className="size-8 shrink-0 mt-1 rounded-full bg-secondary flex items-center justify-center">
<User className="size-4 text-secondary-foreground" />
</div>
)}
</div>
);
})}
{isLoading && (
<div
className="flex items-start gap-3"
style={
minHeightForLastMessage > 0
? { minHeight: `${minHeightForLastMessage}px` }
: undefined
}
>
<div className="size-8 shrink-0 mt-1 rounded-full bg-primary/10 flex items-center justify-center">
<Sparkles className="size-4 text-primary" />
</div>
<div className="rounded-lg bg-muted px-4 py-2.5">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
</div>
)}
</div>
</ScrollArea>
)}
</div>
{/* Input Area */}
<form
ref={inputAreaRef}
onSubmit={handleSubmit}
className="flex gap-2 p-4 border-t bg-background/50 items-end"
>
<Textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className="flex-1 max-h-32 resize-none min-h-9"
rows={1}
/>
<Button
type="submit"
size="icon"
disabled={!input.trim() || isLoading}
className="shrink-0 h-[38px] w-[38px]"
>
{isLoading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Send className="size-4" />
)}
</Button>
</form>
</div>
);
}

View File

@@ -0,0 +1,155 @@
import { trpc } from "@/lib/trpc";
import { AlertCircle, CheckCircle2, FileText, Shield } from "lucide-react";
import { useState } from "react";
interface CguModalProps {
onAccepted: () => void;
}
export default function CguModal({ onAccepted }: CguModalProps) {
const [checked, setChecked] = useState(false);
const acceptMutation = trpc.cgu.accept.useMutation({
onSuccess: () => {
// Marquer l'acceptation pour cette session (réinitialisé à chaque fermeture de navigateur)
sessionStorage.setItem("sonum_cgu_accepted", "1");
onAccepted();
},
});
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-card rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] flex flex-col border border-border">
{/* En-tête */}
<div className="px-8 py-6 border-b border-border">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center flex-shrink-0">
<FileText size={24} className="text-primary" />
</div>
<div>
<h2 className="text-xl font-bold text-foreground">Charte d'utilisation SONUM</h2>
<p className="text-sm text-muted-foreground mt-0.5">
Veuillez lire et accepter les conditions d'utilisation avant d'accéder à la plateforme.
</p>
</div>
</div>
</div>
{/* Contenu CGU */}
<div className="flex-1 overflow-y-auto px-8 py-6 space-y-5 text-sm text-foreground leading-relaxed">
<section>
<h3 className="font-semibold text-base text-primary mb-2 flex items-center gap-2">
<Shield size={16} /> Objet de la plateforme SONUM
</h3>
<p>
La plateforme SONUM (Solutions Numériques) est un outil collaboratif mis à disposition par la FEHAP
(Fédération des Établissements Hospitaliers et d'Aide à la Personne Privés Non Lucratifs) à destination
de ses établissements adhérents. Elle permet le référencement, le partage et la consultation des
solutions logicielles utilisées par les établissements membres.
</p>
</section>
<section>
<h3 className="font-semibold text-base text-primary mb-2">Données collectées et traitées</h3>
<p>
En utilisant SONUM, vous acceptez que les informations relatives aux logiciels utilisés par votre
établissement (nom de la solution, éditeur, état de déploiement, mode d'hébergement, etc.) soient
enregistrées et rendues accessibles aux autres référents numériques des établissements adhérents FEHAP,
selon les paramètres de visibilité que vous définissez.
</p>
<p className="mt-2">
Vos coordonnées professionnelles (nom, fonction, adresse email) pourront être partagées dans le cadre
des demandes de mise en relation entre établissements.
</p>
</section>
<section>
<h3 className="font-semibold text-base text-primary mb-2">Responsabilités de l'utilisateur</h3>
<p>
En tant que référent numérique, vous vous engagez à :
</p>
<ul className="mt-2 space-y-1 list-none">
{[
"Renseigner des informations exactes, à jour et complètes concernant les logiciels de vos établissements.",
"Mettre à jour régulièrement les informations saisies pour garantir la fiabilité des données.",
"Utiliser les fonctionnalités de mise en relation dans un cadre strictement professionnel.",
"Ne pas diffuser d'informations confidentielles ou sensibles via la plateforme.",
].map((item, i) => (
<li key={i} className="flex items-start gap-2">
<CheckCircle2 size={14} className="text-green-500 mt-0.5 flex-shrink-0" />
<span>{item}</span>
</li>
))}
</ul>
</section>
<section>
<h3 className="font-semibold text-base text-primary mb-2">Traçabilité et confidentialité</h3>
<p>
Chaque consultation d'une fiche logiciels est enregistrée à des fins de traçabilité. Ces données
sont accessibles uniquement au référent numérique responsable de l'établissement consulté et aux
gestionnaires SONUM de la FEHAP.
</p>
</section>
<section>
<h3 className="font-semibold text-base text-primary mb-2">Gestion des données personnelles</h3>
<p>
Conformément au Règlement Général sur la Protection des Données (RGPD), vous disposez d'un droit
d'accès, de rectification et de suppression de vos données personnelles. Pour exercer ces droits,
contactez : <a href="mailto:sonum@fehap.fr" className="text-primary hover:underline">sonum@fehap.fr</a>
</p>
</section>
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 flex items-start gap-3">
<AlertCircle size={18} className="text-amber-600 flex-shrink-0 mt-0.5" />
<p className="text-amber-800 text-xs">
L'accès à la plateforme SONUM est réservé aux référents numériques des établissements adhérents FEHAP.
Toute utilisation à des fins commerciales ou non professionnelles est strictement interdite.
</p>
</div>
</div>
{/* Pied de modale */}
<div className="px-8 py-5 border-t border-border bg-muted/30 rounded-b-2xl">
<label className="flex items-start gap-3 cursor-pointer mb-5">
<div className="relative mt-0.5">
<input
type="checkbox"
checked={checked}
onChange={(e) => setChecked(e.target.checked)}
className="sr-only"
/>
<div
className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-all ${
checked ? "bg-primary border-primary" : "border-border bg-white"
}`}
>
{checked && (
<svg width="10" height="8" viewBox="0 0 10 8" fill="none">
<path d="M1 4L3.5 6.5L9 1" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</div>
</div>
<span className="text-sm text-foreground leading-relaxed">
J'ai lu et j'accepte la charte d'utilisation de la plateforme SONUM, ainsi que les conditions
relatives au traitement de mes données personnelles.
</span>
</label>
<button
onClick={() => acceptMutation.mutate()}
disabled={!checked || acceptMutation.isPending}
className={`w-full py-3 px-6 rounded-lg font-semibold text-sm transition-all duration-200 ${
checked && !acceptMutation.isPending
? "bg-primary text-white hover:bg-primary/90 shadow-sm hover:shadow-md"
: "bg-muted text-muted-foreground cursor-not-allowed"
}`}
>
{acceptMutation.isPending ? "Enregistrement..." : "Accéder à SONUM"}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,96 @@
import { trpc } from "@/lib/trpc";
import { Building2, Mail, Send, X } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
interface ContactModalProps {
etablissementId: number;
etablissementNom: string;
onClose: () => void;
}
export default function ContactModal({ etablissementId, etablissementNom, onClose }: ContactModalProps) {
const [message, setMessage] = useState("");
const sendMutation = trpc.contact.envoyer.useMutation({
onSuccess: () => {
toast.success("Votre demande de contact a été envoyée avec succès.");
onClose();
},
onError: (err) => {
toast.error("Erreur lors de l'envoi : " + err.message);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!message.trim()) return;
sendMutation.mutate({ etablissementCibleId: etablissementId, message });
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-card rounded-2xl shadow-2xl max-w-lg w-full border border-border">
{/* En-tête */}
<div className="flex items-start justify-between px-6 py-5 border-b border-border">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center">
<Mail size={20} className="text-primary" />
</div>
<div>
<h2 className="text-base font-bold text-foreground">Demande de contact</h2>
<div className="flex items-center gap-1.5 text-sm text-muted-foreground mt-0.5">
<Building2 size={13} />
<span>{etablissementNom}</span>
</div>
</div>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
>
<X size={18} />
</button>
</div>
{/* Formulaire */}
<form onSubmit={handleSubmit} className="p-6">
<div className="mb-5">
<label className="block text-sm font-medium text-foreground mb-2">
Votre message <span className="text-destructive">*</span>
</label>
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Décrivez votre besoin ou votre question au référent numérique de cet établissement..."
rows={5}
className="w-full px-4 py-3 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-all resize-none"
required
/>
<p className="text-xs text-muted-foreground mt-1.5">
Le référent numérique de l'établissement et les gestionnaires SONUM recevront votre message.
</p>
</div>
<div className="flex gap-3">
<button
type="button"
onClick={onClose}
className="flex-1 py-2.5 px-4 rounded-lg text-sm font-medium border border-border text-foreground hover:bg-muted transition-colors"
>
Annuler
</button>
<button
type="submit"
disabled={!message.trim() || sendMutation.isPending}
className="flex-1 flex items-center justify-center gap-2 py-2.5 px-4 rounded-lg text-sm font-medium bg-primary text-white hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
>
<Send size={15} />
{sendMutation.isPending ? "Envoi..." : "Envoyer"}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,264 @@
import { useAuth } from "@/_core/hooks/useAuth";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarInset,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
SidebarTrigger,
useSidebar,
} from "@/components/ui/sidebar";
import { getLoginUrl } from "@/const";
import { useIsMobile } from "@/hooks/useMobile";
import { LayoutDashboard, LogOut, PanelLeft, Users } from "lucide-react";
import { CSSProperties, useEffect, useRef, useState } from "react";
import { useLocation } from "wouter";
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
import { Button } from "./ui/button";
const menuItems = [
{ icon: LayoutDashboard, label: "Page 1", path: "/" },
{ icon: Users, label: "Page 2", path: "/some-path" },
];
const SIDEBAR_WIDTH_KEY = "sidebar-width";
const DEFAULT_WIDTH = 280;
const MIN_WIDTH = 200;
const MAX_WIDTH = 480;
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const [sidebarWidth, setSidebarWidth] = useState(() => {
const saved = localStorage.getItem(SIDEBAR_WIDTH_KEY);
return saved ? parseInt(saved, 10) : DEFAULT_WIDTH;
});
const { loading, user } = useAuth();
useEffect(() => {
localStorage.setItem(SIDEBAR_WIDTH_KEY, sidebarWidth.toString());
}, [sidebarWidth]);
if (loading) {
return <DashboardLayoutSkeleton />
}
if (!user) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="flex flex-col items-center gap-8 p-8 max-w-md w-full">
<div className="flex flex-col items-center gap-6">
<h1 className="text-2xl font-semibold tracking-tight text-center">
Sign in to continue
</h1>
<p className="text-sm text-muted-foreground text-center max-w-sm">
Access to this dashboard requires authentication. Continue to launch the login flow.
</p>
</div>
<Button
onClick={() => {
window.location.href = getLoginUrl();
}}
size="lg"
className="w-full shadow-lg hover:shadow-xl transition-all"
>
Sign in
</Button>
</div>
</div>
);
}
return (
<SidebarProvider
style={
{
"--sidebar-width": `${sidebarWidth}px`,
} as CSSProperties
}
>
<DashboardLayoutContent setSidebarWidth={setSidebarWidth}>
{children}
</DashboardLayoutContent>
</SidebarProvider>
);
}
type DashboardLayoutContentProps = {
children: React.ReactNode;
setSidebarWidth: (width: number) => void;
};
function DashboardLayoutContent({
children,
setSidebarWidth,
}: DashboardLayoutContentProps) {
const { user, logout } = useAuth();
const [location, setLocation] = useLocation();
const { state, toggleSidebar } = useSidebar();
const isCollapsed = state === "collapsed";
const [isResizing, setIsResizing] = useState(false);
const sidebarRef = useRef<HTMLDivElement>(null);
const activeMenuItem = menuItems.find(item => item.path === location);
const isMobile = useIsMobile();
useEffect(() => {
if (isCollapsed) {
setIsResizing(false);
}
}, [isCollapsed]);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!isResizing) return;
const sidebarLeft = sidebarRef.current?.getBoundingClientRect().left ?? 0;
const newWidth = e.clientX - sidebarLeft;
if (newWidth >= MIN_WIDTH && newWidth <= MAX_WIDTH) {
setSidebarWidth(newWidth);
}
};
const handleMouseUp = () => {
setIsResizing(false);
};
if (isResizing) {
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
}
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
document.body.style.cursor = "";
document.body.style.userSelect = "";
};
}, [isResizing, setSidebarWidth]);
return (
<>
<div className="relative" ref={sidebarRef}>
<Sidebar
collapsible="icon"
className="border-r-0"
disableTransition={isResizing}
>
<SidebarHeader className="h-16 justify-center">
<div className="flex items-center gap-3 px-2 transition-all w-full">
<button
onClick={toggleSidebar}
className="h-8 w-8 flex items-center justify-center hover:bg-accent rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring shrink-0"
aria-label="Toggle navigation"
>
<PanelLeft className="h-4 w-4 text-muted-foreground" />
</button>
{!isCollapsed ? (
<div className="flex items-center gap-2 min-w-0">
<span className="font-semibold tracking-tight truncate">
Navigation
</span>
</div>
) : null}
</div>
</SidebarHeader>
<SidebarContent className="gap-0">
<SidebarMenu className="px-2 py-1">
{menuItems.map(item => {
const isActive = location === item.path;
return (
<SidebarMenuItem key={item.path}>
<SidebarMenuButton
isActive={isActive}
onClick={() => setLocation(item.path)}
tooltip={item.label}
className={`h-10 transition-all font-normal`}
>
<item.icon
className={`h-4 w-4 ${isActive ? "text-primary" : ""}`}
/>
<span>{item.label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarContent>
<SidebarFooter className="p-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-3 rounded-lg px-1 py-1 hover:bg-accent/50 transition-colors w-full text-left group-data-[collapsible=icon]:justify-center focus:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<Avatar className="h-9 w-9 border shrink-0">
<AvatarFallback className="text-xs font-medium">
{user?.name?.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0 group-data-[collapsible=icon]:hidden">
<p className="text-sm font-medium truncate leading-none">
{user?.name || "-"}
</p>
<p className="text-xs text-muted-foreground truncate mt-1.5">
{user?.email || "-"}
</p>
</div>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
onClick={logout}
className="cursor-pointer text-destructive focus:text-destructive"
>
<LogOut className="mr-2 h-4 w-4" />
<span>Sign out</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarFooter>
</Sidebar>
<div
className={`absolute top-0 right-0 w-1 h-full cursor-col-resize hover:bg-primary/20 transition-colors ${isCollapsed ? "hidden" : ""}`}
onMouseDown={() => {
if (isCollapsed) return;
setIsResizing(true);
}}
style={{ zIndex: 50 }}
/>
</div>
<SidebarInset>
{isMobile && (
<div className="flex border-b h-14 items-center justify-between bg-background/95 px-2 backdrop-blur supports-[backdrop-filter]:backdrop-blur sticky top-0 z-40">
<div className="flex items-center gap-2">
<SidebarTrigger className="h-9 w-9 rounded-lg bg-background" />
<div className="flex items-center gap-3">
<div className="flex flex-col gap-1">
<span className="tracking-tight text-foreground">
{activeMenuItem?.label ?? "Menu"}
</span>
</div>
</div>
</div>
</div>
)}
<main className="flex-1 p-4">{children}</main>
</SidebarInset>
</>
);
}

View File

@@ -0,0 +1,46 @@
import { Skeleton } from './ui/skeleton';
export function DashboardLayoutSkeleton() {
return (
<div className="flex min-h-screen bg-background">
{/* Sidebar skeleton */}
<div className="w-[280px] border-r border-border bg-background p-4 space-y-6">
{/* Logo area */}
<div className="flex items-center gap-3 px-2">
<Skeleton className="h-8 w-8 rounded-md" />
<Skeleton className="h-4 w-24" />
</div>
{/* Menu items */}
<div className="space-y-2 px-2">
<Skeleton className="h-10 w-full rounded-lg" />
<Skeleton className="h-10 w-full rounded-lg" />
<Skeleton className="h-10 w-full rounded-lg" />
</div>
{/* User profile area at bottom */}
<div className="absolute bottom-4 left-4 right-4">
<div className="flex items-center gap-3 px-1">
<Skeleton className="h-9 w-9 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-2 w-32" />
</div>
</div>
</div>
</div>
{/* Main content skeleton */}
<div className="flex-1 p-4 space-y-4">
{/* Content blocks */}
<Skeleton className="h-12 w-48 rounded-lg" />
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<Skeleton className="h-32 rounded-xl" />
<Skeleton className="h-32 rounded-xl" />
<Skeleton className="h-32 rounded-xl" />
</div>
<Skeleton className="h-64 rounded-xl" />
</div>
</div>
);
}

View File

@@ -0,0 +1,62 @@
import { cn } from "@/lib/utils";
import { AlertTriangle, RotateCcw } from "lucide-react";
import { Component, ReactNode } from "react";
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
render() {
if (this.state.hasError) {
return (
<div className="flex items-center justify-center min-h-screen p-8 bg-background">
<div className="flex flex-col items-center w-full max-w-2xl p-8">
<AlertTriangle
size={48}
className="text-destructive mb-6 flex-shrink-0"
/>
<h2 className="text-xl mb-4">An unexpected error occurred.</h2>
<div className="p-4 w-full rounded bg-muted overflow-auto mb-6">
<pre className="text-sm text-muted-foreground whitespace-break-spaces">
{this.state.error?.stack}
</pre>
</div>
<button
onClick={() => window.location.reload()}
className={cn(
"flex items-center gap-2 px-4 py-2 rounded-lg",
"bg-primary text-primary-foreground",
"hover:opacity-90 cursor-pointer"
)}
>
<RotateCcw size={16} />
Reload Page
</button>
</div>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;

View File

@@ -0,0 +1,35 @@
import { ETATS_DEPLOIEMENT, MODES_HEBERGEMENT, MODES_FACTURATION, INTEROPERABILITE } from "../../../shared/referentiel";
type EtatDeploiement = "demarrage" | "en_cours" | "operationnel" | "en_remplacement";
const etatColors: Record<EtatDeploiement, string> = {
demarrage: "bg-orange-100 text-orange-700 border-orange-200",
en_cours: "bg-blue-100 text-blue-700 border-blue-200",
operationnel: "bg-green-100 text-green-700 border-green-200",
en_remplacement: "bg-red-100 text-red-700 border-red-200",
};
export function EtatBadge({ etat }: { etat: string }) {
const color = etatColors[etat as EtatDeploiement] ?? "bg-gray-100 text-gray-700 border-gray-200";
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border ${color}`}>
{ETATS_DEPLOIEMENT[etat] ?? etat}
</span>
);
}
export function HebergementBadge({ mode }: { mode: string }) {
return (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-slate-100 text-slate-600 border border-slate-200">
{MODES_HEBERGEMENT[mode] ?? mode}
</span>
);
}
export function FacturationBadge({ mode }: { mode: string }) {
return (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-50 text-purple-700 border border-purple-200">
{MODES_FACTURATION[mode] ?? mode}
</span>
);
}

View File

@@ -0,0 +1,89 @@
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogTitle,
} from "@/components/ui/dialog";
interface ManusDialogProps {
title?: string;
logo?: string;
open?: boolean;
onLogin: () => void;
onOpenChange?: (open: boolean) => void;
onClose?: () => void;
}
export function ManusDialog({
title,
logo,
open = false,
onLogin,
onOpenChange,
onClose,
}: ManusDialogProps) {
const [internalOpen, setInternalOpen] = useState(open);
useEffect(() => {
if (!onOpenChange) {
setInternalOpen(open);
}
}, [open, onOpenChange]);
const handleOpenChange = (nextOpen: boolean) => {
if (onOpenChange) {
onOpenChange(nextOpen);
} else {
setInternalOpen(nextOpen);
}
if (!nextOpen) {
onClose?.();
}
};
return (
<Dialog
open={onOpenChange ? open : internalOpen}
onOpenChange={handleOpenChange}
>
<DialogContent className="py-5 bg-[#f8f8f7] rounded-[20px] w-[400px] shadow-[0px_4px_11px_0px_rgba(0,0,0,0.08)] border border-[rgba(0,0,0,0.08)] backdrop-blur-2xl p-0 gap-0 text-center">
<div className="flex flex-col items-center gap-2 p-5 pt-12">
{logo ? (
<div className="w-16 h-16 bg-white rounded-xl border border-[rgba(0,0,0,0.08)] flex items-center justify-center">
<img
src={logo}
alt="Dialog graphic"
className="w-10 h-10 rounded-md"
/>
</div>
) : null}
{/* Title and subtitle */}
{title ? (
<DialogTitle className="text-xl font-semibold text-[#34322d] leading-[26px] tracking-[-0.44px]">
{title}
</DialogTitle>
) : null}
<DialogDescription className="text-sm text-[#858481] leading-5 tracking-[-0.154px]">
Please login with Manus to continue
</DialogDescription>
</div>
<DialogFooter className="px-5 py-5">
{/* Login button */}
<Button
onClick={onLogin}
className="w-full h-10 bg-[#1a1a19] hover:bg-[#1a1a19]/90 text-white rounded-[10px] text-sm font-medium leading-5 tracking-[-0.154px]"
>
Login with Manus
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,155 @@
/**
* GOOGLE MAPS FRONTEND INTEGRATION - ESSENTIAL GUIDE
*
* USAGE FROM PARENT COMPONENT:
* ======
*
* const mapRef = useRef<google.maps.Map | null>(null);
*
* <MapView
* initialCenter={{ lat: 40.7128, lng: -74.0060 }}
* initialZoom={15}
* onMapReady={(map) => {
* mapRef.current = map; // Store to control map from parent anytime, google map itself is in charge of the re-rendering, not react state.
* </MapView>
*
* ======
* Available Libraries and Core Features:
* -------------------------------
* 📍 MARKER (from `marker` library)
* - Attaches to map using { map, position }
* new google.maps.marker.AdvancedMarkerElement({
* map,
* position: { lat: 37.7749, lng: -122.4194 },
* title: "San Francisco",
* });
*
* -------------------------------
* 🏢 PLACES (from `places` library)
* - Does not attach directly to map; use data with your map manually.
* const place = new google.maps.places.Place({ id: PLACE_ID });
* await place.fetchFields({ fields: ["displayName", "location"] });
* map.setCenter(place.location);
* new google.maps.marker.AdvancedMarkerElement({ map, position: place.location });
*
* -------------------------------
* 🧭 GEOCODER (from `geocoding` library)
* - Standalone service; manually apply results to map.
* const geocoder = new google.maps.Geocoder();
* geocoder.geocode({ address: "New York" }, (results, status) => {
* if (status === "OK" && results[0]) {
* map.setCenter(results[0].geometry.location);
* new google.maps.marker.AdvancedMarkerElement({
* map,
* position: results[0].geometry.location,
* });
* }
* });
*
* -------------------------------
* 📐 GEOMETRY (from `geometry` library)
* - Pure utility functions; not attached to map.
* const dist = google.maps.geometry.spherical.computeDistanceBetween(p1, p2);
*
* -------------------------------
* 🛣️ ROUTES (from `routes` library)
* - Combines DirectionsService (standalone) + DirectionsRenderer (map-attached)
* const directionsService = new google.maps.DirectionsService();
* const directionsRenderer = new google.maps.DirectionsRenderer({ map });
* directionsService.route(
* { origin, destination, travelMode: "DRIVING" },
* (res, status) => status === "OK" && directionsRenderer.setDirections(res)
* );
*
* -------------------------------
* 🌦️ MAP LAYERS (attach directly to map)
* - new google.maps.TrafficLayer().setMap(map);
* - new google.maps.TransitLayer().setMap(map);
* - new google.maps.BicyclingLayer().setMap(map);
*
* -------------------------------
* ✅ SUMMARY
* - “map-attached” → AdvancedMarkerElement, DirectionsRenderer, Layers.
* - “standalone” → Geocoder, DirectionsService, DistanceMatrixService, ElevationService.
* - “data-only” → Place, Geometry utilities.
*/
/// <reference types="@types/google.maps" />
import { useEffect, useRef } from "react";
import { usePersistFn } from "@/hooks/usePersistFn";
import { cn } from "@/lib/utils";
declare global {
interface Window {
google?: typeof google;
}
}
const API_KEY = import.meta.env.VITE_FRONTEND_FORGE_API_KEY;
const FORGE_BASE_URL =
import.meta.env.VITE_FRONTEND_FORGE_API_URL ||
"https://forge.butterfly-effect.dev";
const MAPS_PROXY_URL = `${FORGE_BASE_URL}/v1/maps/proxy`;
function loadMapScript() {
return new Promise(resolve => {
const script = document.createElement("script");
script.src = `${MAPS_PROXY_URL}/maps/api/js?key=${API_KEY}&v=weekly&libraries=marker,places,geocoding,geometry`;
script.async = true;
script.crossOrigin = "anonymous";
script.onload = () => {
resolve(null);
script.remove(); // Clean up immediately
};
script.onerror = () => {
console.error("Failed to load Google Maps script");
};
document.head.appendChild(script);
});
}
interface MapViewProps {
className?: string;
initialCenter?: google.maps.LatLngLiteral;
initialZoom?: number;
onMapReady?: (map: google.maps.Map) => void;
}
export function MapView({
className,
initialCenter = { lat: 37.7749, lng: -122.4194 },
initialZoom = 12,
onMapReady,
}: MapViewProps) {
const mapContainer = useRef<HTMLDivElement>(null);
const map = useRef<google.maps.Map | null>(null);
const init = usePersistFn(async () => {
await loadMapScript();
if (!mapContainer.current) {
console.error("Map container not found");
return;
}
map.current = new window.google.maps.Map(mapContainer.current, {
zoom: initialZoom,
center: initialCenter,
mapTypeControl: true,
fullscreenControl: true,
zoomControl: true,
streetViewControl: true,
mapId: "DEMO_MAP_ID",
});
if (onMapReady) {
onMapReady(map.current);
}
});
useEffect(() => {
init();
}, [init]);
return (
<div ref={mapContainer} className={cn("w-full h-[500px]", className)} />
);
}

View File

@@ -0,0 +1,417 @@
import { trpc } from "@/lib/trpc";
import { Check, ChevronRight, Plus, Search, X } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { ETATS_DEPLOIEMENT, MODES_HEBERGEMENT, MODES_FACTURATION, INTEROPERABILITE } from "../../../shared/referentiel";
interface Props {
etablissementId: number;
etablissementNom: string;
onClose: () => void;
onSuccess: () => void;
}
type Step = 1 | 2 | 3;
export default function RattacherSolutionModal({ etablissementId, etablissementNom, onClose, onSuccess }: Props) {
const [step, setStep] = useState<Step>(1);
const [searchSolution, setSearchSolution] = useState("");
const [selectedSolution, setSelectedSolution] = useState<{ id: number; nom: string; editeurNom: string } | null>(null);
const [newSolutionNom, setNewSolutionNom] = useState("");
const [newEditeurNom, setNewEditeurNom] = useState("");
const [selectedEditeurId, setSelectedEditeurId] = useState<number | null>(null);
const [showNewSolution, setShowNewSolution] = useState(false);
const [metadata, setMetadata] = useState({
etatDeploiement: "operationnel" as "demarrage" | "en_cours" | "operationnel" | "en_remplacement",
modeHebergement: "" as string,
modeFacturation: "" as string,
interoperabilite: "" as string,
versionMajeure: "",
commentaire: "",
});
const { user } = trpc.auth.me.useQuery().data ? { user: trpc.auth.me.useQuery().data } : { user: null };
const solutionsQuery = trpc.referentiel.solutions.useQuery(
{ search: searchSolution.length >= 2 ? searchSolution : undefined },
{ enabled: step === 1 }
);
const editeursQuery = trpc.referentiel.editeurs.useQuery();
const blocsQuery = trpc.referentiel.blocsFonctionnels.useQuery();
const createEditeurMutation = trpc.referentiel.createEditeur.useMutation();
const createSolutionMutation = trpc.referentiel.createSolution.useMutation();
const upsertLogicielMutation = trpc.logiciels.upsert.useMutation({
onSuccess: () => {
toast.success("Solution rattachée avec succès !");
onSuccess();
},
onError: (err) => toast.error("Erreur : " + err.message),
});
const handleSelectSolution = (sol: any) => {
setSelectedSolution({ id: sol.id, nom: sol.nom, editeurNom: sol.editeurNom ?? "" });
setStep(2);
};
const handleCreateAndContinue = async () => {
if (!newSolutionNom.trim()) return;
let editeurId = selectedEditeurId;
if (!editeurId && newEditeurNom.trim()) {
const result = await createEditeurMutation.mutateAsync({ nom: newEditeurNom });
editeurId = null; // Will be validated by FEHAP
}
if (!editeurId) {
toast.error("Veuillez sélectionner ou saisir un éditeur.");
return;
}
const sol = await createSolutionMutation.mutateAsync({ nom: newSolutionNom, editeurId });
const editeurNom = editeursQuery.data?.find(e => e.id === editeurId)?.nom ?? newEditeurNom;
setSelectedSolution({ id: sol as number, nom: newSolutionNom, editeurNom });
setStep(2);
};
const handleSubmit = () => {
if (!selectedSolution) return;
upsertLogicielMutation.mutate({
etablissementId,
solutionId: selectedSolution.id,
etatDeploiement: metadata.etatDeploiement,
modeHebergement: (metadata.modeHebergement || null) as any,
modeFacturation: (metadata.modeFacturation || null) as any,
interoperabilite: (metadata.interoperabilite || null) as any,
versionMajeure: metadata.versionMajeure || null,
commentaire: metadata.commentaire || null,
});
};
const steps = [
{ n: 1, label: "Solution" },
{ n: 2, label: "Métadonnées" },
{ n: 3, label: "Validation" },
];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-card rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] flex flex-col border border-border">
{/* En-tête */}
<div className="flex items-center justify-between px-6 py-5 border-b border-border">
<div>
<h2 className="text-base font-bold text-foreground">Rattacher une solution</h2>
<p className="text-sm text-muted-foreground mt-0.5">{etablissementNom}</p>
</div>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-muted transition-colors text-muted-foreground">
<X size={18} />
</button>
</div>
{/* Indicateur d'étapes */}
<div className="flex items-center px-6 py-4 border-b border-border bg-muted/20">
{steps.map((s, i) => (
<div key={s.n} className="flex items-center">
<div className={`flex items-center gap-2 ${step === s.n ? "text-primary" : step > s.n ? "text-green-600" : "text-muted-foreground"}`}>
<div className={`w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold border-2 transition-all ${
step === s.n ? "border-primary bg-primary text-white" :
step > s.n ? "border-green-500 bg-green-500 text-white" :
"border-border bg-background"
}`}>
{step > s.n ? <Check size={13} /> : s.n}
</div>
<span className="text-xs font-medium hidden sm:block">{s.label}</span>
</div>
{i < steps.length - 1 && (
<ChevronRight size={16} className="mx-3 text-muted-foreground/50" />
)}
</div>
))}
</div>
{/* Contenu */}
<div className="flex-1 overflow-y-auto p-6">
{/* Étape 1 : Recherche de solution */}
{step === 1 && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-foreground mb-2">
Rechercher une solution existante
</label>
<div className="relative">
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input
type="text"
placeholder="Nom du logiciel ou de l'éditeur..."
value={searchSolution}
onChange={(e) => setSearchSolution(e.target.value)}
className="w-full pl-9 pr-4 py-2.5 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary"
/>
</div>
</div>
{/* Résultats */}
{solutionsQuery.data && solutionsQuery.data.length > 0 && (
<div className="border border-border rounded-lg overflow-hidden max-h-52 overflow-y-auto">
{solutionsQuery.data.map((sol) => (
<button
key={sol.id}
onClick={() => handleSelectSolution(sol)}
className="w-full flex items-center justify-between px-4 py-3 hover:bg-muted/50 transition-colors text-left border-b border-border last:border-0"
>
<div>
<div className="font-medium text-sm text-foreground">{sol.nom}</div>
<div className="text-xs text-muted-foreground">{sol.editeurNom}</div>
</div>
{sol.blocFonctionnelNom && (
<span className="text-xs bg-secondary text-secondary-foreground px-2 py-0.5 rounded border border-border ml-2 flex-shrink-0">
{sol.blocFonctionnelNom}
</span>
)}
</button>
))}
</div>
)}
{/* Ajouter une nouvelle solution */}
<div className="border-t border-border pt-4">
<button
onClick={() => setShowNewSolution(!showNewSolution)}
className="flex items-center gap-2 text-sm font-medium text-primary hover:text-primary/80 transition-colors"
>
<Plus size={15} />
La solution n'existe pas ? L'ajouter
</button>
{showNewSolution && (
<div className="mt-4 space-y-3 p-4 bg-muted/30 rounded-lg border border-border">
<p className="text-xs text-muted-foreground">
La solution sera ajoutée au référentiel FEHAP après validation.
</p>
<div>
<label className="block text-xs font-medium text-foreground mb-1">Nom de la solution *</label>
<input
type="text"
value={newSolutionNom}
onChange={(e) => setNewSolutionNom(e.target.value)}
placeholder="Ex : Mon Logiciel RH"
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
</div>
<div>
<label className="block text-xs font-medium text-foreground mb-1">Éditeur *</label>
<select
value={selectedEditeurId ?? ""}
onChange={(e) => setSelectedEditeurId(e.target.value ? Number(e.target.value) : null)}
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30"
>
<option value="">Sélectionner un éditeur</option>
{editeursQuery.data?.map((e) => (
<option key={e.id} value={e.id}>{e.nom}</option>
))}
</select>
</div>
{!selectedEditeurId && (
<div>
<label className="block text-xs font-medium text-foreground mb-1">Ou saisir un nouvel éditeur</label>
<input
type="text"
value={newEditeurNom}
onChange={(e) => setNewEditeurNom(e.target.value)}
placeholder="Nom de l'éditeur"
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
</div>
)}
<button
onClick={handleCreateAndContinue}
disabled={!newSolutionNom.trim() || (!selectedEditeurId && !newEditeurNom.trim())}
className="w-full py-2 px-4 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
>
Continuer avec cette solution
</button>
</div>
)}
</div>
</div>
)}
{/* Étape 2 : Métadonnées */}
{step === 2 && selectedSolution && (
<div className="space-y-5">
<div className="bg-muted/30 rounded-lg p-3 border border-border">
<p className="text-xs text-muted-foreground">Solution sélectionnée</p>
<p className="font-semibold text-foreground">{selectedSolution.nom}</p>
<p className="text-sm text-muted-foreground">{selectedSolution.editeurNom}</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* État de déploiement - obligatoire */}
<div className="sm:col-span-2">
<label className="block text-sm font-medium text-foreground mb-1.5">
État de déploiement <span className="text-destructive">*</span>
</label>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{Object.entries(ETATS_DEPLOIEMENT).map(([val, label]) => (
<button
key={val}
onClick={() => setMetadata((m) => ({ ...m, etatDeploiement: val as any }))}
className={`py-2 px-3 rounded-lg text-xs font-medium border transition-all ${
metadata.etatDeploiement === val
? "bg-primary text-white border-primary shadow-sm"
: "bg-background border-border text-foreground hover:bg-muted"
}`}
>
{label}
</button>
))}
</div>
</div>
{/* Mode d'hébergement */}
<div>
<label className="block text-xs font-medium text-foreground mb-1.5">Mode d'hébergement</label>
<select
value={metadata.modeHebergement}
onChange={(e) => setMetadata((m) => ({ ...m, modeHebergement: e.target.value }))}
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30"
>
<option value="">Non renseigné</option>
{Object.entries(MODES_HEBERGEMENT).map(([val, label]) => (
<option key={val} value={val}>{label}</option>
))}
</select>
</div>
{/* Mode de facturation */}
<div>
<label className="block text-xs font-medium text-foreground mb-1.5">Mode de facturation</label>
<select
value={metadata.modeFacturation}
onChange={(e) => setMetadata((m) => ({ ...m, modeFacturation: e.target.value }))}
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30"
>
<option value="">Non renseigné</option>
{Object.entries(MODES_FACTURATION).map(([val, label]) => (
<option key={val} value={val}>{label}</option>
))}
</select>
</div>
{/* Interopérabilité */}
<div>
<label className="block text-xs font-medium text-foreground mb-1.5">Interopérabilité</label>
<select
value={metadata.interoperabilite}
onChange={(e) => setMetadata((m) => ({ ...m, interoperabilite: e.target.value }))}
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30"
>
<option value="">Non renseigné</option>
{Object.entries(INTEROPERABILITE).map(([val, label]) => (
<option key={val} value={val}>{label}</option>
))}
</select>
</div>
{/* Version majeure */}
<div>
<label className="block text-xs font-medium text-foreground mb-1.5">Version majeure</label>
<input
type="text"
value={metadata.versionMajeure}
onChange={(e) => setMetadata((m) => ({ ...m, versionMajeure: e.target.value }))}
placeholder="Ex : 5.2"
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
</div>
{/* Commentaire */}
<div className="sm:col-span-2">
<label className="block text-xs font-medium text-foreground mb-1.5">Commentaire libre</label>
<textarea
value={metadata.commentaire}
onChange={(e) => setMetadata((m) => ({ ...m, commentaire: e.target.value }))}
placeholder="Toute précision utile sur cette solution..."
rows={3}
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 resize-none"
/>
</div>
</div>
</div>
)}
{/* Étape 3 : Validation */}
{step === 3 && selectedSolution && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">Vérifiez les informations avant de valider.</p>
<div className="bg-muted/30 rounded-xl border border-border p-5 space-y-3">
<div className="flex justify-between items-center py-2 border-b border-border">
<span className="text-sm text-muted-foreground">Solution</span>
<span className="text-sm font-semibold text-foreground">{selectedSolution.nom}</span>
</div>
<div className="flex justify-between items-center py-2 border-b border-border">
<span className="text-sm text-muted-foreground">Éditeur</span>
<span className="text-sm text-foreground">{selectedSolution.editeurNom}</span>
</div>
<div className="flex justify-between items-center py-2 border-b border-border">
<span className="text-sm text-muted-foreground">État de déploiement</span>
<span className="text-sm font-medium text-foreground">{ETATS_DEPLOIEMENT[metadata.etatDeploiement]}</span>
</div>
{metadata.modeHebergement && (
<div className="flex justify-between items-center py-2 border-b border-border">
<span className="text-sm text-muted-foreground">Hébergement</span>
<span className="text-sm text-foreground">{MODES_HEBERGEMENT[metadata.modeHebergement]}</span>
</div>
)}
{metadata.modeFacturation && (
<div className="flex justify-between items-center py-2 border-b border-border">
<span className="text-sm text-muted-foreground">Facturation</span>
<span className="text-sm text-foreground">{MODES_FACTURATION[metadata.modeFacturation]}</span>
</div>
)}
{metadata.versionMajeure && (
<div className="flex justify-between items-center py-2 border-b border-border">
<span className="text-sm text-muted-foreground">Version</span>
<span className="text-sm text-foreground">{metadata.versionMajeure}</span>
</div>
)}
<div className="flex justify-between items-center py-2">
<span className="text-sm text-muted-foreground">Établissement</span>
<span className="text-sm font-medium text-foreground">{etablissementNom}</span>
</div>
</div>
</div>
)}
</div>
{/* Pied de modale */}
<div className="px-6 py-4 border-t border-border bg-muted/20 flex justify-between">
<button
onClick={() => step > 1 ? setStep((s) => (s - 1) as Step) : onClose()}
className="px-4 py-2 text-sm font-medium border border-border rounded-lg text-foreground hover:bg-muted transition-colors"
>
{step === 1 ? "Annuler" : "Retour"}
</button>
{step < 3 ? (
<button
onClick={() => step === 1 && selectedSolution ? setStep(2) : step === 2 ? setStep(3) : null}
disabled={step === 1 ? !selectedSolution : false}
className="px-5 py-2 text-sm font-medium bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
>
Continuer
</button>
) : (
<button
onClick={handleSubmit}
disabled={upsertLogicielMutation.isPending}
className="flex items-center gap-2 px-5 py-2 text-sm font-medium bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors shadow-sm disabled:opacity-50"
>
<Check size={15} />
{upsertLogicielMutation.isPending ? "Enregistrement..." : "Valider"}
</button>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,247 @@
import { useAuth } from "@/_core/hooks/useAuth";
import { getLoginUrl } from "@/const";
import { trpc } from "@/lib/trpc";
import {
BarChart2,
Bell,
Building2,
ChevronRight,
ExternalLink,
LayoutDashboard,
LogOut,
Mail,
Menu,
Package,
Search,
Settings,
Shield,
Users,
X,
} from "lucide-react";
import { useState } from "react";
import { Link, useLocation } from "wouter";
interface NavItem {
label: string;
href: string;
icon: React.ReactNode;
adminOnly?: boolean;
}
const navItems: NavItem[] = [
{ label: "Moteur de recherche", href: "/", icon: <Search size={18} /> },
{ label: "Mes Établissements", href: "/mes-etablissements", icon: <Building2 size={18} /> },
{ label: "Mes Solutions Numériques", href: "/mes-solutions", icon: <Package size={18} /> },
{ label: "Solutions Logicielles", href: "/solutions", icon: <BarChart2 size={18} /> },
{ label: "Mes Demandes de Contact", href: "/mes-demandes", icon: <Mail size={18} /> },
];
const adminNavItems: NavItem[] = [
{ label: "Tableau de bord statistiques", href: "/statistiques", icon: <LayoutDashboard size={18} />, adminOnly: true },
{ label: "Administration", href: "/admin", icon: <Shield size={18} />, adminOnly: true },
];
export default function SonumLayout({ children }: { children: React.ReactNode }) {
const { user, isAuthenticated, logout } = useAuth();
const [location] = useLocation();
const [sidebarOpen, setSidebarOpen] = useState(false);
const _cguQuery = trpc.cgu.status.useQuery(undefined, { enabled: isAuthenticated });
if (!isAuthenticated) {
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center max-w-md mx-auto px-6">
<div className="mb-8">
<div className="inline-flex items-center gap-3 mb-6">
<div className="w-12 h-12 rounded-xl bg-primary flex items-center justify-center">
<Search size={24} className="text-white" />
</div>
<div className="text-left">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-widest">FEHAP</div>
<div className="text-xl font-bold text-primary" style={{ fontFamily: "'Playfair Display', serif" }}>SONUM</div>
</div>
</div>
<h1 className="text-2xl font-bold text-foreground mb-2">Cartographie des Solutions Numériques</h1>
<p className="text-muted-foreground text-sm leading-relaxed">
Plateforme de référencement et de partage des logiciels utilisés par les établissements adhérents FEHAP.
</p>
</div>
<a
href="/login"
className="inline-flex items-center gap-2 px-6 py-3 bg-primary text-white rounded-lg font-medium hover:bg-primary/90 transition-colors shadow-sm"
>
Se connecter
<ExternalLink size={16} />
</a>
<p className="mt-4 text-xs text-muted-foreground">
Accès réservé aux utilisateurs SONUM
</p>
</div>
</div>
);
}
const isGestionnaire = user?.sonumRole === "gestionnaire" || user?.role === "admin";
const isAdherent = user?.sonumRole === "adherent";
const roleLabel = isGestionnaire ? "Gestionnaire SONUM" : isAdherent ? "Adhérent FEHAP" : "Référent numérique";
return (
<div className="min-h-screen flex bg-background">
{/* Overlay mobile */}
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/40 z-20 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Sidebar */}
<aside
className={`fixed top-0 left-0 h-full w-64 z-30 flex flex-col transition-transform duration-300 lg:translate-x-0 lg:static lg:z-auto ${
sidebarOpen ? "translate-x-0" : "-translate-x-full"
}`}
style={{ background: "var(--sidebar)" }}
>
{/* Logo */}
<div className="flex items-center justify-between px-5 py-5 border-b border-sidebar-border">
<Link href="/" className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-white/15 flex items-center justify-center">
<Search size={18} className="text-white" />
</div>
<div>
<div className="text-[10px] font-semibold text-white/50 uppercase tracking-widest">FEHAP</div>
<div className="text-base font-bold text-white leading-tight" style={{ fontFamily: "'Playfair Display', serif" }}>SONUM</div>
</div>
</Link>
<button
className="lg:hidden text-white/60 hover:text-white transition-colors"
onClick={() => setSidebarOpen(false)}
>
<X size={20} />
</button>
</div>
{/* Navigation */}
<nav className="flex-1 px-3 py-4 overflow-y-auto">
<div className="space-y-1">
{navItems.map((item) => {
const isActive = location === item.href;
return (
<Link
key={item.href}
href={item.href}
onClick={() => setSidebarOpen(false)}
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-150 ${
isActive
? "bg-white/20 text-white shadow-sm"
: "text-white/70 hover:bg-white/10 hover:text-white"
}`}
>
<span className={isActive ? "text-white" : "text-white/60"}>{item.icon}</span>
{item.label}
{isActive && <ChevronRight size={14} className="ml-auto text-white/60" />}
</Link>
);
})}
</div>
{isGestionnaire && (
<div className="mt-6">
<div className="px-3 mb-2">
<span className="text-[10px] font-semibold text-white/40 uppercase tracking-widest">Administration</span>
</div>
<div className="space-y-1">
{adminNavItems.map((item) => {
const isActive = location === item.href;
return (
<Link
key={item.href}
href={item.href}
onClick={() => setSidebarOpen(false)}
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-150 ${
isActive
? "bg-white/20 text-white shadow-sm"
: "text-white/70 hover:bg-white/10 hover:text-white"
}`}
>
<span className={isActive ? "text-white" : "text-white/60"}>{item.icon}</span>
{item.label}
</Link>
);
})}
</div>
</div>
)}
</nav>
{/* Footer sidebar */}
<div className="px-3 py-4 border-t border-sidebar-border space-y-2">
<a
href="https://www.fehap.fr/jcms/espace-candidats/mon-espace-glo_5528"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-white/70 hover:bg-white/10 hover:text-white transition-all duration-150"
>
<ExternalLink size={16} className="text-white/60" />
Espace adhérent FEHAP
</a>
<button
onClick={() => logout()}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-white/70 hover:bg-white/10 hover:text-white transition-all duration-150"
>
<LogOut size={16} className="text-white/60" />
Se déconnecter
</button>
</div>
</aside>
{/* Contenu principal */}
<div className="flex-1 flex flex-col min-w-0">
{/* Header */}
<header className="sticky top-0 z-10 bg-card border-b border-border px-4 lg:px-6 h-14 flex items-center justify-between shadow-sm">
<button
className="lg:hidden p-2 rounded-lg hover:bg-muted transition-colors"
onClick={() => setSidebarOpen(true)}
>
<Menu size={20} className="text-foreground" />
</button>
<div className="hidden lg:flex items-center gap-2 text-sm text-muted-foreground">
<span className="font-medium text-foreground">
{[...navItems, ...adminNavItems].find((n) => n.href === location)?.label ?? "SONUM"}
</span>
</div>
<div className="flex items-center gap-3 ml-auto">
{/* Badge rôle */}
<span
className={`hidden sm:inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${
isGestionnaire
? "bg-accent/10 text-accent border border-accent/20"
: "bg-primary/10 text-primary border border-primary/20"
}`}
>
{isGestionnaire ? <Shield size={11} /> : isAdherent ? <Building2 size={11} /> : <Users size={11} />}
{roleLabel}
</span>
{/* Avatar */}
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-primary flex items-center justify-center text-white text-xs font-semibold">
{user?.name?.charAt(0)?.toUpperCase() ?? "U"}
</div>
<span className="hidden md:block text-sm font-medium text-foreground max-w-32 truncate">
{user?.name}
</span>
</div>
</div>
</header>
{/* Page content */}
<main className="flex-1 overflow-auto">
{children}
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,64 @@
import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDownIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
);
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
);
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View File

@@ -0,0 +1,155 @@
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
);
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
);
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
);
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
);
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
);
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
);
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
);
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
);
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};

View File

@@ -0,0 +1,66 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
}
);
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
);
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
);
}
export { Alert, AlertTitle, AlertDescription };

View File

@@ -0,0 +1,9 @@
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
function AspectRatio({
...props
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />;
}
export { AspectRatio };

View File

@@ -0,0 +1,51 @@
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "@/lib/utils";
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
);
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
);
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className
)}
{...props}
/>
);
}
export { Avatar, AvatarImage, AvatarFallback };

View File

@@ -0,0 +1,46 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
);
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span";
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,109 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "@/lib/utils";
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className
)}
{...props}
/>
);
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
);
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "a";
return (
<Comp
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)}
{...props}
/>
);
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("text-foreground font-normal", className)}
{...props}
/>
);
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
);
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
);
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};

View File

@@ -0,0 +1,83 @@
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { Separator } from "@/components/ui/separator";
const buttonGroupVariants = cva(
"flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
{
variants: {
orientation: {
horizontal:
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
vertical:
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
},
},
defaultVariants: {
orientation: "horizontal",
},
}
);
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
);
}
function ButtonGroupText({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "div";
return (
<Comp
className={cn(
"bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function ButtonGroupSeparator({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
"bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto",
className
)}
{...props}
/>
);
}
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
};

View File

@@ -0,0 +1,60 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-transparent shadow-xs hover:bg-accent dark:bg-transparent dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };

View File

@@ -0,0 +1,211 @@
import * as React from "react";
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react";
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker";
import { cn } from "@/lib/utils";
import { Button, buttonVariants } from "@/components/ui/button";
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"];
}) {
const defaultClassNames = getDefaultClassNames();
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: date =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"flex gap-4 flex-col md:flex-row relative",
defaultClassNames.months
),
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
nav: cn(
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_next
),
month_caption: cn(
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute bg-popover inset-0 opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
defaultClassNames.weekday
),
week: cn("flex w-full mt-2", defaultClassNames.week),
week_number_header: cn(
"select-none w-(--cell-size)",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] select-none text-muted-foreground",
defaultClassNames.week_number
),
day: cn(
"relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
defaultClassNames.day
),
range_start: cn(
"rounded-l-md bg-accent",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
);
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
);
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
);
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
);
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
);
},
...components,
}}
{...props}
/>
);
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames();
const ref = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus();
}, [modifiers.focused]);
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
);
}
export { Calendar, CalendarDayButton };

View File

@@ -0,0 +1,92 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};

View File

@@ -0,0 +1,239 @@
import * as React from "react";
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react";
import { ArrowLeft, ArrowRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: "horizontal" | "vertical";
setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />");
}
return context;
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return;
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
scrollPrev();
} else if (event.key === "ArrowRight") {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext]
);
React.useEffect(() => {
if (!api || !setApi) return;
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) return;
onSelect(api);
api.on("reInit", onSelect);
api.on("select", onSelect);
return () => {
api?.off("select", onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel();
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
);
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel();
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
);
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -left-12 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft />
<span className="sr-only">Previous slide</span>
</Button>
);
}
function CarouselNext({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -right-12 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight />
<span className="sr-only">Next slide</span>
</Button>
);
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
};

View File

@@ -0,0 +1,355 @@
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { cn } from "@/lib/utils";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
);
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"];
}) {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
}) {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
);
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
className={cn(
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter(item => item.type !== "none")
.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
}
const ChartLegend = RechartsPrimitive.Legend;
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}) {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload
.filter(item => item.type !== "none")
.map(item => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string;
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
};

View File

@@ -0,0 +1,30 @@
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { CheckIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };

View File

@@ -0,0 +1,31 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
);
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
);
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View File

@@ -0,0 +1,184 @@
"use client";
import * as React from "react";
import { Command as CommandPrimitive } from "cmdk";
import { SearchIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
);
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string;
description?: string;
className?: string;
showCloseButton?: boolean;
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton}
>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
);
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
);
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
);
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
);
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
);
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
);
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};

View File

@@ -0,0 +1,250 @@
import * as React from "react";
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
}
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
);
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
);
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
);
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />;
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
);
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
);
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
);
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
);
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
);
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
);
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
);
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
);
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};

View File

@@ -0,0 +1,209 @@
import { cn } from "@/lib/utils";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import * as React from "react";
// Context to track composition state across dialog children
const DialogCompositionContext = React.createContext<{
isComposing: () => boolean;
setComposing: (composing: boolean) => void;
justEndedComposing: () => boolean;
markCompositionEnd: () => void;
}>({
isComposing: () => false,
setComposing: () => {},
justEndedComposing: () => false,
markCompositionEnd: () => {},
});
export const useDialogComposition = () =>
React.useContext(DialogCompositionContext);
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
const composingRef = React.useRef(false);
const justEndedRef = React.useRef(false);
const endTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const contextValue = React.useMemo(
() => ({
isComposing: () => composingRef.current,
setComposing: (composing: boolean) => {
composingRef.current = composing;
},
justEndedComposing: () => justEndedRef.current,
markCompositionEnd: () => {
justEndedRef.current = true;
if (endTimerRef.current) {
clearTimeout(endTimerRef.current);
}
endTimerRef.current = setTimeout(() => {
justEndedRef.current = false;
}, 150);
},
}),
[]
);
return (
<DialogCompositionContext.Provider value={contextValue}>
<DialogPrimitive.Root data-slot="dialog" {...props} />
</DialogCompositionContext.Provider>
);
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
);
}
DialogOverlay.displayName = "DialogOverlay";
function DialogContent({
className,
children,
showCloseButton = true,
onEscapeKeyDown,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
const { isComposing } = useDialogComposition();
const handleEscapeKeyDown = React.useCallback(
(e: KeyboardEvent) => {
// Check both the native isComposing property and our context state
// This handles Safari's timing issues with composition events
const isCurrentlyComposing = (e as any).isComposing || isComposing();
// If IME is composing, prevent dialog from closing
if (isCurrentlyComposing) {
e.preventDefault();
return;
}
// Call user's onEscapeKeyDown if provided
onEscapeKeyDown?.(e);
},
[isComposing, onEscapeKeyDown]
);
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
onEscapeKeyDown={handleEscapeKeyDown}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger
};

View File

@@ -0,0 +1,133 @@
import * as React from "react";
import { Drawer as DrawerPrimitive } from "vaul";
import { cn } from "@/lib/utils";
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
);
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
className
)}
{...props}
>
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
);
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn(
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
className
)}
{...props}
/>
);
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
);
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
);
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
};

View File

@@ -0,0 +1,255 @@
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
);
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
);
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
);
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
);
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
);
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
);
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};

View File

@@ -0,0 +1,104 @@
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
function Empty({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty"
className={cn(
"flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center text-balance md:p-12",
className
)}
{...props}
/>
);
}
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-header"
className={cn(
"flex max-w-sm flex-col items-center gap-2 text-center",
className
)}
{...props}
/>
);
}
const emptyMediaVariants = cva(
"flex shrink-0 items-center justify-center mb-2 [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
},
},
defaultVariants: {
variant: "default",
},
}
);
function EmptyMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
);
}
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn("text-lg font-medium tracking-tight", className)}
{...props}
/>
);
}
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<div
data-slot="empty-description"
className={cn(
"text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...props}
/>
);
}
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-content"
className={cn(
"flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance",
className
)}
{...props}
/>
);
}
export {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
EmptyMedia,
};

View File

@@ -0,0 +1,242 @@
import { useMemo } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-6",
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className
)}
{...props}
/>
);
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-3 font-medium",
"data-[variant=legend]:text-base",
"data-[variant=label]:text-sm",
className
)}
{...props}
/>
);
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
className
)}
{...props}
/>
);
}
const fieldVariants = cva(
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
horizontal: [
"flex-row items-center",
"[&>[data-slot=field-label]]:flex-auto",
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
responsive: [
"flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto",
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
},
},
defaultVariants: {
orientation: "vertical",
},
}
);
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
);
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
className
)}
{...props}
/>
);
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
"has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10",
className
)}
{...props}
/>
);
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
className
)}
{...props}
/>
);
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance",
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...props}
/>
);
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode;
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
);
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>;
}) {
const content = useMemo(() => {
if (children) {
return children;
}
if (!errors) {
return null;
}
if (errors?.length === 1 && errors[0]?.message) {
return errors[0].message;
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{errors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
)}
</ul>
);
}, [children, errors]);
if (!content) {
return null;
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-destructive text-sm font-normal", className)}
{...props}
>
{content}
</div>
);
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
};

View File

@@ -0,0 +1,168 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot";
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label";
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState } = useFormContext();
const formState = useFormState({ name: fieldContext.name });
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>");
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
);
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
);
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField();
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
);
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
);
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField();
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message ?? "") : props.children;
if (!body) {
return null;
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
</p>
);
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
};

View File

@@ -0,0 +1,42 @@
import * as React from "react";
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
import { cn } from "@/lib/utils";
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />;
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
);
}
function HoverCardContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</HoverCardPrimitive.Portal>
);
}
export { HoverCard, HoverCardTrigger, HoverCardContent };

View File

@@ -0,0 +1,168 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none",
"h-9 min-w-0 has-[>textarea]:h-auto",
// Variants based on alignment.
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
// Focus state.
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]",
// Error state.
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
className
)}
{...props}
/>
);
}
const inputGroupAddonVariants = cva(
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
{
variants: {
align: {
"inline-start":
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
"inline-end":
"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
"block-start":
"order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5",
"block-end":
"order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5",
},
},
defaultVariants: {
align: "inline-start",
},
}
);
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={e => {
if ((e.target as HTMLElement).closest("button")) {
return;
}
e.currentTarget.parentElement?.querySelector("input")?.focus();
}}
{...props}
/>
);
}
const inputGroupButtonVariants = cva(
"text-sm shadow-none flex gap-2 items-center",
{
variants: {
size: {
xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
);
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
);
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
);
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
);
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
};

View File

@@ -0,0 +1,75 @@
import * as React from "react";
import { OTPInput, OTPInputContext } from "input-otp";
import { MinusIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function InputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string;
}) {
return (
<OTPInput
data-slot="input-otp"
containerClassName={cn(
"flex items-center gap-2 has-disabled:opacity-50",
containerClassName
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
);
}
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-otp-group"
className={cn("flex items-center", className)}
{...props}
/>
);
}
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<"div"> & {
index: number;
}) {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
return (
<div
data-slot="input-otp-slot"
data-active={isActive}
className={cn(
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
</div>
)}
</div>
);
}
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="input-otp-separator" role="separator" {...props}>
<MinusIcon />
</div>
);
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };

View File

@@ -0,0 +1,70 @@
import { useDialogComposition } from "@/components/ui/dialog";
import { useComposition } from "@/hooks/useComposition";
import { cn } from "@/lib/utils";
import * as React from "react";
function Input({
className,
type,
onKeyDown,
onCompositionStart,
onCompositionEnd,
...props
}: React.ComponentProps<"input">) {
// Get dialog composition context if available (will be no-op if not inside Dialog)
const dialogComposition = useDialogComposition();
// Add composition event handlers to support input method editor (IME) for CJK languages.
const {
onCompositionStart: handleCompositionStart,
onCompositionEnd: handleCompositionEnd,
onKeyDown: handleKeyDown,
} = useComposition<HTMLInputElement>({
onKeyDown: (e) => {
// Check if this is an Enter key that should be blocked
const isComposing = (e.nativeEvent as any).isComposing || dialogComposition.justEndedComposing();
// If Enter key is pressed while composing or just after composition ended,
// don't call the user's onKeyDown (this blocks the business logic)
if (e.key === "Enter" && isComposing) {
return;
}
// Otherwise, call the user's onKeyDown
onKeyDown?.(e);
},
onCompositionStart: e => {
dialogComposition.setComposing(true);
onCompositionStart?.(e);
},
onCompositionEnd: e => {
// Mark that composition just ended - this helps handle the Enter key that confirms input
dialogComposition.markCompositionEnd();
// Delay setting composing to false to handle Safari's event order
// In Safari, compositionEnd fires before the ESC keydown event
setTimeout(() => {
dialogComposition.setComposing(false);
}, 100);
onCompositionEnd?.(e);
},
});
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onKeyDown={handleKeyDown}
{...props}
/>
);
}
export { Input };

View File

@@ -0,0 +1,193 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { Separator } from "@/components/ui/separator";
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
role="list"
data-slot="item-group"
className={cn("group/item-group flex flex-col", className)}
{...props}
/>
);
}
function ItemSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="item-separator"
orientation="horizontal"
className={cn("my-0", className)}
{...props}
/>
);
}
const itemVariants = cva(
"group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border-border",
muted: "bg-muted/50",
},
size: {
default: "p-4 gap-4 ",
sm: "py-3 px-4 gap-2.5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
function Item({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof itemVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div";
return (
<Comp
data-slot="item"
data-variant={variant}
data-size={size}
className={cn(itemVariants({ variant, size, className }))}
{...props}
/>
);
}
const itemMediaVariants = cva(
"flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5",
{
variants: {
variant: {
default: "bg-transparent",
icon: "size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4",
image:
"size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover",
},
},
defaultVariants: {
variant: "default",
},
}
);
function ItemMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
return (
<div
data-slot="item-media"
data-variant={variant}
className={cn(itemMediaVariants({ variant, className }))}
{...props}
/>
);
}
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-content"
className={cn(
"flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none",
className
)}
{...props}
/>
);
}
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-title"
className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium",
className
)}
{...props}
/>
);
}
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="item-description"
className={cn(
"text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...props}
/>
);
}
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-actions"
className={cn("flex items-center gap-2", className)}
{...props}
/>
);
}
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-header"
className={cn(
"flex basis-full items-center justify-between gap-2",
className
)}
{...props}
/>
);
}
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-footer"
className={cn(
"flex basis-full items-center justify-between gap-2",
className
)}
{...props}
/>
);
}
export {
Item,
ItemMedia,
ItemContent,
ItemActions,
ItemGroup,
ItemSeparator,
ItemTitle,
ItemDescription,
ItemHeader,
ItemFooter,
};

View File

@@ -0,0 +1,28 @@
import { cn } from "@/lib/utils";
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
return (
<kbd
data-slot="kbd"
className={cn(
"bg-muted text-muted-foreground pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none",
"[&_svg:not([class*='size-'])]:size-3",
"[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10",
className
)}
{...props}
/>
);
}
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<kbd
data-slot="kbd-group"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
);
}
export { Kbd, KbdGroup };

View File

@@ -0,0 +1,22 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "@/lib/utils";
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
);
}
export { Label };

View File

@@ -0,0 +1,274 @@
import * as React from "react";
import * as MenubarPrimitive from "@radix-ui/react-menubar";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Menubar({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Root>) {
return (
<MenubarPrimitive.Root
data-slot="menubar"
className={cn(
"bg-background flex h-9 items-center gap-1 rounded-md border p-1 shadow-xs",
className
)}
{...props}
/>
);
}
function MenubarMenu({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
return <MenubarPrimitive.Menu data-slot="menubar-menu" {...props} />;
}
function MenubarGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
return <MenubarPrimitive.Group data-slot="menubar-group" {...props} />;
}
function MenubarPortal({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
return <MenubarPrimitive.Portal data-slot="menubar-portal" {...props} />;
}
function MenubarRadioGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
return (
<MenubarPrimitive.RadioGroup data-slot="menubar-radio-group" {...props} />
);
}
function MenubarTrigger({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Trigger>) {
return (
<MenubarPrimitive.Trigger
data-slot="menubar-trigger"
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none",
className
)}
{...props}
/>
);
}
function MenubarContent({
className,
align = "start",
alignOffset = -4,
sideOffset = 8,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Content>) {
return (
<MenubarPortal>
<MenubarPrimitive.Content
data-slot="menubar-content"
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[12rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</MenubarPortal>
);
}
function MenubarItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof MenubarPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<MenubarPrimitive.Item
data-slot="menubar-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function MenubarCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof MenubarPrimitive.CheckboxItem>) {
return (
<MenubarPrimitive.CheckboxItem
data-slot="menubar-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
);
}
function MenubarRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioItem>) {
return (
<MenubarPrimitive.RadioItem
data-slot="menubar-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
);
}
function MenubarLabel({
className,
inset,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Label> & {
inset?: boolean;
}) {
return (
<MenubarPrimitive.Label
data-slot="menubar-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
);
}
function MenubarSeparator({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Separator>) {
return (
<MenubarPrimitive.Separator
data-slot="menubar-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function MenubarShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="menubar-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
);
}
function MenubarSub({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />;
}
function MenubarSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<MenubarPrimitive.SubTrigger
data-slot="menubar-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
);
}
function MenubarSubContent({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubContent>) {
return (
<MenubarPrimitive.SubContent
data-slot="menubar-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
);
}
export {
Menubar,
MenubarPortal,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarGroup,
MenubarSeparator,
MenubarLabel,
MenubarItem,
MenubarShortcut,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarSub,
MenubarSubTrigger,
MenubarSubContent,
};

View File

@@ -0,0 +1,168 @@
import * as React from "react";
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
import { cva } from "class-variance-authority";
import { ChevronDownIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function NavigationMenu({
className,
children,
viewport = true,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
viewport?: boolean;
}) {
return (
<NavigationMenuPrimitive.Root
data-slot="navigation-menu"
data-viewport={viewport}
className={cn(
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
{viewport && <NavigationMenuViewport />}
</NavigationMenuPrimitive.Root>
);
}
function NavigationMenuList({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
return (
<NavigationMenuPrimitive.List
data-slot="navigation-menu-list"
className={cn(
"group flex flex-1 list-none items-center justify-center gap-1",
className
)}
{...props}
/>
);
}
function NavigationMenuItem({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
return (
<NavigationMenuPrimitive.Item
data-slot="navigation-menu-item"
className={cn("relative", className)}
{...props}
/>
);
}
const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1"
);
function NavigationMenuTrigger({
className,
children,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
return (
<NavigationMenuPrimitive.Trigger
data-slot="navigation-menu-trigger"
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDownIcon
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
);
}
function NavigationMenuContent({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
return (
<NavigationMenuPrimitive.Content
data-slot="navigation-menu-content"
className={cn(
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
className
)}
{...props}
/>
);
}
function NavigationMenuViewport({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
return (
<div
className={cn(
"absolute top-full left-0 isolate z-50 flex justify-center"
)}
>
<NavigationMenuPrimitive.Viewport
data-slot="navigation-menu-viewport"
className={cn(
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
className
)}
{...props}
/>
</div>
);
}
function NavigationMenuLink({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
return (
<NavigationMenuPrimitive.Link
data-slot="navigation-menu-link"
className={cn(
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function NavigationMenuIndicator({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
return (
<NavigationMenuPrimitive.Indicator
data-slot="navigation-menu-indicator"
className={cn(
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
className
)}
{...props}
>
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
</NavigationMenuPrimitive.Indicator>
);
}
export {
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
navigationMenuTriggerStyle,
};

View File

@@ -0,0 +1,127 @@
import * as React from "react";
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Button, buttonVariants } from "@/components/ui/button";
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
);
}
function PaginationContent({
className,
...props
}: React.ComponentProps<"ul">) {
return (
<ul
data-slot="pagination-content"
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
);
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />;
}
type PaginationLinkProps = {
isActive?: boolean;
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">;
function PaginationLink({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) {
return (
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
);
}
function PaginationPrevious({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
{...props}
>
<ChevronLeftIcon />
<span className="hidden sm:block">Previous</span>
</PaginationLink>
);
}
function PaginationNext({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
{...props}
>
<span className="hidden sm:block">Next</span>
<ChevronRightIcon />
</PaginationLink>
);
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontalIcon className="size-4" />
<span className="sr-only">More pages</span>
</span>
);
}
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
};

View File

@@ -0,0 +1,46 @@
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@/lib/utils";
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
);
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };

View File

@@ -0,0 +1,29 @@
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "@/lib/utils";
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
);
}
export { Progress };

View File

@@ -0,0 +1,43 @@
import * as React from "react";
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import { CircleIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
);
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
}
export { RadioGroup, RadioGroupItem };

View File

@@ -0,0 +1,54 @@
import * as React from "react";
import { GripVerticalIcon } from "lucide-react";
import * as ResizablePrimitive from "react-resizable-panels";
import { cn } from "@/lib/utils";
function ResizablePanelGroup({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
return (
<ResizablePrimitive.PanelGroup
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
);
}
function ResizablePanel({
...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
}
function ResizableHandle({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean;
}) {
return (
<ResizablePrimitive.PanelResizeHandle
data-slot="resizable-handle"
className={cn(
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
<GripVerticalIcon className="size-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
);
}
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };

View File

@@ -0,0 +1,56 @@
import * as React from "react";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { cn } from "@/lib/utils";
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
);
}
export { ScrollArea, ScrollBar };

View File

@@ -0,0 +1,185 @@
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = "popper",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};

View File

@@ -0,0 +1,26 @@
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils";
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
);
}
export { Separator };

View File

@@ -0,0 +1,139 @@
"use client";
import * as React from "react";
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
);
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left";
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
);
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
);
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
);
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
);
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};

View File

@@ -0,0 +1,734 @@
"use client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useIsMobile } from "@/hooks/useMobile";
import { cn } from "@/lib/utils";
import { Slot } from "@radix-ui/react-slot";
import { cva, VariantProps } from "class-variance-authority";
import { PanelLeftIcon } from "lucide-react";
import * as React from "react";
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContextProps = {
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.");
}
return context;
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState);
} else {
_setOpen(openState);
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open]
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile(open => !open) : setOpen(open => !open);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
);
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
disableTransition = false,
className,
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
disableTransition?: boolean;
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className
)}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent",
disableTransition
? "transition-none"
: "transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) md:flex",
disableTransition
? "transition-none"
: "transition-[left,right,width] duration-200 ease-linear",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
);
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar();
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
onClick={event => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar();
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
);
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
);
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
);
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
);
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
);
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
);
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div";
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
);
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
);
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
);
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
);
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
);
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar();
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
);
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
showOnHover?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className
)}
{...props}
/>
);
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
);
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean;
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
);
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
);
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
);
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
}) {
const Comp = asChild ? Slot : "a";
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
);
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar
};

View File

@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
);
}
export { Skeleton };

View File

@@ -0,0 +1,61 @@
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@/lib/utils";
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max]
);
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
className
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn(
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
)}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
);
}
export { Slider };

View File

@@ -0,0 +1,23 @@
import { useTheme } from "next-themes";
import { Toaster as Sonner, type ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
} as React.CSSProperties
}
{...props}
/>
);
};
export { Toaster };

View File

@@ -0,0 +1,16 @@
import { Loader2Icon } from "lucide-react";
import { cn } from "@/lib/utils";
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<Loader2Icon
role="status"
aria-label="Loading"
className={cn("size-4 animate-spin", className)}
{...props}
/>
);
}
export { Spinner };

View File

@@ -0,0 +1,29 @@
import * as React from "react";
import * as SwitchPrimitive from "@radix-ui/react-switch";
import { cn } from "@/lib/utils";
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
);
}
export { Switch };

View File

@@ -0,0 +1,114 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
);
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
);
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
);
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
);
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...props}
/>
);
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
);
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
);
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
);
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};

View File

@@ -0,0 +1,64 @@
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils";
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
);
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
);
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
);
}
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@@ -0,0 +1,67 @@
import { useDialogComposition } from "@/components/ui/dialog";
import { useComposition } from "@/hooks/useComposition";
import { cn } from "@/lib/utils";
import * as React from "react";
function Textarea({
className,
onKeyDown,
onCompositionStart,
onCompositionEnd,
...props
}: React.ComponentProps<"textarea">) {
// Get dialog composition context if available (will be no-op if not inside Dialog)
const dialogComposition = useDialogComposition();
// Add composition event handlers to support input method editor (IME) for CJK languages.
const {
onCompositionStart: handleCompositionStart,
onCompositionEnd: handleCompositionEnd,
onKeyDown: handleKeyDown,
} = useComposition<HTMLTextAreaElement>({
onKeyDown: (e) => {
// Check if this is an Enter key that should be blocked
const isComposing = (e.nativeEvent as any).isComposing || dialogComposition.justEndedComposing();
// If Enter key is pressed while composing or just after composition ended,
// don't call the user's onKeyDown (this blocks the business logic)
// Note: For textarea, Shift+Enter should still work for newlines
if (e.key === "Enter" && !e.shiftKey && isComposing) {
return;
}
// Otherwise, call the user's onKeyDown
onKeyDown?.(e);
},
onCompositionStart: e => {
dialogComposition.setComposing(true);
onCompositionStart?.(e);
},
onCompositionEnd: e => {
// Mark that composition just ended - this helps handle the Enter key that confirms input
dialogComposition.markCompositionEnd();
// Delay setting composing to false to handle Safari's event order
// In Safari, compositionEnd fires before the ESC keydown event
setTimeout(() => {
dialogComposition.setComposing(false);
}, 100);
onCompositionEnd?.(e);
},
});
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onKeyDown={handleKeyDown}
{...props}
/>
);
}
export { Textarea };

View File

@@ -0,0 +1,73 @@
"use client";
import * as React from "react";
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
import { type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { toggleVariants } from "@/components/ui/toggle";
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: "default",
variant: "default",
});
function ToggleGroup({
className,
variant,
size,
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
className={cn(
"group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs",
className
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
);
}
function ToggleGroupItem({
className,
children,
variant,
size,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext);
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
"min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l",
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
);
}
export { ToggleGroup, ToggleGroupItem };

View File

@@ -0,0 +1,45 @@
import * as React from "react";
import * as TogglePrimitive from "@radix-ui/react-toggle";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8",
lg: "h-10 px-2.5 min-w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Toggle, toggleVariants };

View File

@@ -0,0 +1,59 @@
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
);
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

17
client/src/const.ts Normal file
View File

@@ -0,0 +1,17 @@
export { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const";
// Generate login URL at runtime so redirect URI reflects the current origin.
export const getLoginUrl = () => {
const oauthPortalUrl = import.meta.env.VITE_OAUTH_PORTAL_URL;
const appId = import.meta.env.VITE_APP_ID;
const redirectUri = `${window.location.origin}/api/oauth/callback`;
const state = btoa(redirectUri);
const url = new URL(`${oauthPortalUrl}/app-auth`);
url.searchParams.set("appId", appId);
url.searchParams.set("redirectUri", redirectUri);
url.searchParams.set("state", state);
url.searchParams.set("type", "signIn");
return url.toString();
};

View File

@@ -0,0 +1,64 @@
import React, { createContext, useContext, useEffect, useState } from "react";
type Theme = "light" | "dark";
interface ThemeContextType {
theme: Theme;
toggleTheme?: () => void;
switchable: boolean;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
interface ThemeProviderProps {
children: React.ReactNode;
defaultTheme?: Theme;
switchable?: boolean;
}
export function ThemeProvider({
children,
defaultTheme = "light",
switchable = false,
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(() => {
if (switchable) {
const stored = localStorage.getItem("theme");
return (stored as Theme) || defaultTheme;
}
return defaultTheme;
});
useEffect(() => {
const root = document.documentElement;
if (theme === "dark") {
root.classList.add("dark");
} else {
root.classList.remove("dark");
}
if (switchable) {
localStorage.setItem("theme", theme);
}
}, [theme, switchable]);
const toggleTheme = switchable
? () => {
setTheme(prev => (prev === "light" ? "dark" : "light"));
}
: undefined;
return (
<ThemeContext.Provider value={{ theme, toggleTheme, switchable }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error("useTheme must be used within ThemeProvider");
}
return context;
}

View File

@@ -0,0 +1,81 @@
import { useRef } from "react";
import { usePersistFn } from "./usePersistFn";
export interface UseCompositionReturn<
T extends HTMLInputElement | HTMLTextAreaElement,
> {
onCompositionStart: React.CompositionEventHandler<T>;
onCompositionEnd: React.CompositionEventHandler<T>;
onKeyDown: React.KeyboardEventHandler<T>;
isComposing: () => boolean;
}
export interface UseCompositionOptions<
T extends HTMLInputElement | HTMLTextAreaElement,
> {
onKeyDown?: React.KeyboardEventHandler<T>;
onCompositionStart?: React.CompositionEventHandler<T>;
onCompositionEnd?: React.CompositionEventHandler<T>;
}
type TimerResponse = ReturnType<typeof setTimeout>;
export function useComposition<
T extends HTMLInputElement | HTMLTextAreaElement = HTMLInputElement,
>(options: UseCompositionOptions<T> = {}): UseCompositionReturn<T> {
const {
onKeyDown: originalOnKeyDown,
onCompositionStart: originalOnCompositionStart,
onCompositionEnd: originalOnCompositionEnd,
} = options;
const c = useRef(false);
const timer = useRef<TimerResponse | null>(null);
const timer2 = useRef<TimerResponse | null>(null);
const onCompositionStart = usePersistFn((e: React.CompositionEvent<T>) => {
if (timer.current) {
clearTimeout(timer.current);
timer.current = null;
}
if (timer2.current) {
clearTimeout(timer2.current);
timer2.current = null;
}
c.current = true;
originalOnCompositionStart?.(e);
});
const onCompositionEnd = usePersistFn((e: React.CompositionEvent<T>) => {
// 使用两层 setTimeout 来处理 Safari 浏览器中 compositionEnd 先于 onKeyDown 触发的问题
timer.current = setTimeout(() => {
timer2.current = setTimeout(() => {
c.current = false;
});
});
originalOnCompositionEnd?.(e);
});
const onKeyDown = usePersistFn((e: React.KeyboardEvent<T>) => {
// 在 composition 状态下,阻止 ESC 和 Enter非 shift+Enter事件的冒泡
if (
c.current &&
(e.key === "Escape" || (e.key === "Enter" && !e.shiftKey))
) {
e.stopPropagation();
return;
}
originalOnKeyDown?.(e);
});
const isComposing = usePersistFn(() => {
return c.current;
});
return {
onCompositionStart,
onCompositionEnd,
onKeyDown,
isComposing,
};
}

View File

@@ -0,0 +1,21 @@
import * as React from "react";
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined
);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile;
}

View File

@@ -0,0 +1,20 @@
import { useRef } from "react";
type noop = (...args: any[]) => any;
/**
* usePersistFn instead of useCallback to reduce cognitive load
*/
export function usePersistFn<T extends noop>(fn: T) {
const fnRef = useRef<T>(fn);
fnRef.current = fn;
const persistFn = useRef<T>(null);
if (!persistFn.current) {
persistFn.current = function (this: unknown, ...args) {
return fnRef.current!.apply(this, args);
} as T;
}
return persistFn.current!;
}

187
client/src/index.css Normal file
View File

@@ -0,0 +1,187 @@
@import "tailwindcss";
@import "tw-animate-css";
@import url('https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,300..700;1,14..32,300..700&family=Playfair+Display:wght@600;700&display=swap');
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
/* Bleu marine FEHAP */
--primary: oklch(0.30 0.12 255);
--primary-foreground: oklch(0.98 0 0);
/* Rouge FEHAP */
--accent: oklch(0.52 0.20 20);
--accent-foreground: oklch(0.98 0 0);
--sidebar-primary: oklch(0.30 0.12 255);
--sidebar-primary-foreground: oklch(0.98 0 0);
--chart-1: oklch(0.55 0.14 255);
--chart-2: oklch(0.45 0.14 255);
--chart-3: oklch(0.35 0.12 255);
--chart-4: oklch(0.52 0.20 20);
--chart-5: oklch(0.60 0.15 145);
--radius: 0.5rem;
--background: oklch(0.975 0.004 240);
--foreground: oklch(0.18 0.02 250);
--card: oklch(1 0 0);
--card-foreground: oklch(0.18 0.02 250);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.18 0.02 250);
--secondary: oklch(0.93 0.04 240);
--secondary-foreground: oklch(0.30 0.12 255);
--muted: oklch(0.95 0.01 240);
--muted-foreground: oklch(0.52 0.02 250);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(0.88 0.01 240);
--input: oklch(0.88 0.01 240);
--ring: oklch(0.30 0.12 255);
--sidebar: oklch(0.25 0.10 255);
--sidebar-foreground: oklch(0.95 0.01 240);
--sidebar-accent: oklch(0.35 0.12 255);
--sidebar-accent-foreground: oklch(0.98 0 0);
--sidebar-border: oklch(0.38 0.10 255);
--sidebar-ring: oklch(0.55 0.14 255);
}
.dark {
--primary: var(--color-blue-700);
--primary-foreground: var(--color-blue-50);
--sidebar-primary: var(--color-blue-500);
--sidebar-primary-foreground: var(--color-blue-50);
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.85 0.005 65);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.85 0.005 65);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.85 0.005 65);
--secondary: oklch(0.24 0.006 286.033);
--secondary-foreground: oklch(0.7 0.005 65);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.92 0.005 65);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.488 0.243 264.376);
--chart-1: var(--color-blue-300);
--chart-2: var(--color-blue-500);
--chart-3: var(--color-blue-600);
--chart-4: var(--color-blue-700);
--chart-5: var(--color-blue-800);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.85 0.005 65);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.488 0.243 264.376);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
font-family: 'Inter', system-ui, sans-serif;
font-feature-settings: "cv11", "ss01";
}
h1, h2, h3 {
font-family: 'Playfair Display', Georgia, serif;
font-weight: 600;
letter-spacing: -0.02em;
}
button:not(:disabled),
[role="button"]:not([aria-disabled="true"]),
[type="button"]:not(:disabled),
[type="submit"]:not(:disabled),
[type="reset"]:not(:disabled),
a[href],
select:not(:disabled),
input[type="checkbox"]:not(:disabled),
input[type="radio"]:not(:disabled) {
@apply cursor-pointer;
}
}
@layer components {
/**
* Custom container utility that centers content and adds responsive padding.
*
* This overrides Tailwind's default container behavior to:
* - Auto-center content (mx-auto)
* - Add responsive horizontal padding
* - Set max-width for large screens
*
* Usage: <div className="container">...</div>
*
* For custom widths, use max-w-* utilities directly:
* <div className="max-w-6xl mx-auto px-4">...</div>
*/
.container {
width: 100%;
margin-left: auto;
margin-right: auto;
padding-left: 1rem; /* 16px - mobile padding */
padding-right: 1rem;
}
.flex {
min-height: 0;
min-width: 0;
}
@media (min-width: 640px) {
.container {
padding-left: 1.5rem; /* 24px - tablet padding */
padding-right: 1.5rem;
}
}
@media (min-width: 1024px) {
.container {
padding-left: 2rem; /* 32px - desktop padding */
padding-right: 2rem;
max-width: 1280px; /* Standard content width */
}
}
}

4
client/src/lib/trpc.ts Normal file
View File

@@ -0,0 +1,4 @@
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "../../../server/routers";
export const trpc = createTRPCReact<AppRouter>();

6
client/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

64
client/src/main.tsx Normal file
View File

@@ -0,0 +1,64 @@
import { trpc } from "@/lib/trpc";
import { UNAUTHED_ERR_MSG } from '@shared/const';
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink, TRPCClientError } from "@trpc/client";
import { createRoot } from "react-dom/client";
import superjson from "superjson";
import App from "./App";
import { getLoginUrl } from "./const";
import "./index.css";
const queryClient = new QueryClient();
const redirectToLoginIfUnauthorized = (error: unknown) => {
if (!(error instanceof TRPCClientError)) return;
if (typeof window === "undefined") return;
const isUnauthorized = error.message === UNAUTHED_ERR_MSG;
if (!isUnauthorized) return;
// Rediriger vers la page de choix de connexion, pas directement vers OAuth
if (!window.location.pathname.startsWith("/login")) {
window.location.href = "/login";
}
};
queryClient.getQueryCache().subscribe(event => {
if (event.type === "updated" && event.action.type === "error") {
const error = event.query.state.error;
redirectToLoginIfUnauthorized(error);
console.error("[API Query Error]", error);
}
});
queryClient.getMutationCache().subscribe(event => {
if (event.type === "updated" && event.action.type === "error") {
const error = event.mutation.state.error;
redirectToLoginIfUnauthorized(error);
console.error("[API Mutation Error]", error);
}
});
const trpcClient = trpc.createClient({
links: [
httpBatchLink({
url: "/api/trpc",
transformer: superjson,
fetch(input, init) {
return globalThis.fetch(input, {
...(init ?? {}),
credentials: "include",
});
},
}),
],
});
createRoot(document.getElementById("root")!).render(
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</trpc.Provider>
);

1595
client/src/pages/Admin.tsx Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,240 @@
import { useAuth } from "@/_core/hooks/useAuth";
import { EtatBadge, HebergementBadge, FacturationBadge } from "@/components/EtatBadge";
import SonumLayout from "@/components/SonumLayout";
import ContactModal from "@/components/ContactModal";
import { trpc } from "@/lib/trpc";
import {
ArrowLeft,
Building2,
Eye,
Mail,
MapPin,
Phone,
Server,
Tag,
Users,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useLocation } from "wouter";
import { INTEROPERABILITE } from "../../../shared/referentiel";
interface Props {
params: { id: string };
}
export default function FicheEtablissement({ params }: Props) {
const id = Number(params.id);
const { user } = useAuth();
const [, navigate] = useLocation();
const [showContact, setShowContact] = useState(false);
const etabQuery = trpc.etablissements.byId.useQuery({ id }, { enabled: !!id });
const logicielsQuery = trpc.logiciels.byEtablissement.useQuery({ etablissementId: id }, { enabled: !!id });
const recordConsultation = trpc.tracabilite.enregistrerConsultation.useMutation();
const isGestionnaire = user?.sonumRole === "gestionnaire" || user?.role === "admin";
const isReferent = etabQuery.data?.referentId === user?.id;
const canSeeCounter = isReferent || isGestionnaire;
const compteurQuery = trpc.tracabilite.compteur.useQuery(
{ etablissementId: id },
{ enabled: !!id && canSeeCounter }
);
useEffect(() => {
if (id) {
recordConsultation.mutate({ etablissementId: id });
}
}, [id]);
if (etabQuery.isLoading) {
return (
<SonumLayout>
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary border-t-transparent" />
</div>
</SonumLayout>
);
}
if (!etabQuery.data) {
return (
<SonumLayout>
<div className="p-8 text-center text-muted-foreground">Établissement introuvable.</div>
</SonumLayout>
);
}
const etab = etabQuery.data;
// Grouper les logiciels par bloc fonctionnel
const logicielsByBloc = (logicielsQuery.data ?? []).reduce((acc, l) => {
const bloc = l.blocFonctionnelNom ?? "Autres";
if (!acc[bloc]) acc[bloc] = [];
acc[bloc].push(l);
return acc;
}, {} as Record<string, typeof logicielsQuery.data>);
return (
<SonumLayout>
<div className="p-6 lg:p-8 max-w-5xl mx-auto">
{/* Retour */}
<button
onClick={() => navigate(-1 as any)}
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors mb-6"
>
<ArrowLeft size={16} />
Retour
</button>
{/* En-tête fiche */}
<div className="bg-card rounded-xl border border-border shadow-sm p-6 mb-6">
<div className="flex items-start justify-between flex-wrap gap-4">
<div className="flex items-start gap-4">
<div className="w-14 h-14 rounded-xl bg-primary/10 flex items-center justify-center flex-shrink-0">
<Building2 size={28} className="text-primary" />
</div>
<div>
<h1 className="text-xl font-bold text-foreground mb-1">{etab.nom}</h1>
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
{etab.finess && (
<span className="flex items-center gap-1">
<Tag size={13} />
FINESS : {etab.finess}
</span>
)}
{etab.region && (
<span className="flex items-center gap-1">
<MapPin size={13} />
{etab.region}
</span>
)}
{etab.typeActivite && (
<span className="bg-secondary text-secondary-foreground px-2.5 py-0.5 rounded-full text-xs font-medium border border-border">
{etab.typeActivite}
</span>
)}
{etab.tailleEffectifs && (
<span className="flex items-center gap-1">
<Users size={13} />
{etab.tailleEffectifs}
</span>
)}
</div>
</div>
</div>
<div className="flex items-center gap-3">
{/* Compteur de consultation (visible référent + gestionnaire) */}
{canSeeCounter && compteurQuery.data !== undefined && (
<div className="flex items-center gap-2 px-3 py-2 bg-muted rounded-lg border border-border text-sm">
<Eye size={15} className="text-muted-foreground" />
<span className="text-muted-foreground">Consultations :</span>
<span className="font-bold text-foreground">{compteurQuery.data.count}</span>
</div>
)}
{/* Bouton de contact */}
{etab.accepteMiseEnRelation && (
<button
onClick={() => setShowContact(true)}
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary/90 transition-colors shadow-sm"
>
<Mail size={15} />
Prendre contact
</button>
)}
</div>
</div>
</div>
{/* Logiciels par bloc fonctionnel */}
<div>
<h2 className="text-lg font-bold text-foreground mb-4">Solutions numériques</h2>
{logicielsQuery.isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-7 w-7 border-2 border-primary border-t-transparent" />
</div>
) : Object.keys(logicielsByBloc).length === 0 ? (
<div className="text-center py-12 bg-card rounded-xl border border-border">
<Server size={40} className="mx-auto text-muted-foreground/30 mb-3" />
<p className="text-muted-foreground">Aucun logiciel renseigné pour cet établissement.</p>
</div>
) : (
<div className="space-y-4">
{Object.entries(logicielsByBloc).map(([bloc, logiciels]) => (
<div key={bloc} className="bg-card rounded-xl border border-border shadow-sm overflow-hidden">
{/* En-tête bloc */}
<div className="px-5 py-3.5 bg-muted/30 border-b border-border">
<h3 className="text-sm font-semibold text-foreground flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-primary inline-block" />
{bloc}
<span className="text-xs text-muted-foreground font-normal ml-1">
({logiciels?.length} solution{(logiciels?.length ?? 0) > 1 ? "s" : ""})
</span>
</h3>
</div>
{/* Tableau logiciels */}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="text-left px-5 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider">Solution</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider hidden md:table-cell">Éditeur</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider">État</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider hidden lg:table-cell">Hébergement</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider hidden xl:table-cell">Facturation</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider hidden xl:table-cell">Interop.</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{logiciels?.map((l) => (
<tr key={l.id} className="hover:bg-muted/20 transition-colors">
<td className="px-5 py-3.5">
<div className="font-medium text-foreground">{l.solutionNom}</div>
{l.versionMajeure && (
<div className="text-xs text-muted-foreground">v{l.versionMajeure}</div>
)}
{l.commentaire && (
<div className="text-xs text-muted-foreground mt-0.5 italic">{l.commentaire}</div>
)}
</td>
<td className="px-4 py-3.5 hidden md:table-cell text-muted-foreground">{l.editeurNom}</td>
<td className="px-4 py-3.5">
<EtatBadge etat={l.etatDeploiement} />
</td>
<td className="px-4 py-3.5 hidden lg:table-cell">
{l.modeHebergement ? <HebergementBadge mode={l.modeHebergement} /> : <span className="text-muted-foreground text-xs"></span>}
</td>
<td className="px-4 py-3.5 hidden xl:table-cell">
{l.modeFacturation ? <FacturationBadge mode={l.modeFacturation} /> : <span className="text-muted-foreground text-xs"></span>}
</td>
<td className="px-4 py-3.5 hidden xl:table-cell">
{l.interoperabilite ? (
<span className="text-xs text-muted-foreground">{INTEROPERABILITE[l.interoperabilite]}</span>
) : <span className="text-muted-foreground text-xs"></span>}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
))}
</div>
)}
</div>
</div>
{showContact && (
<ContactModal
etablissementId={id}
etablissementNom={etab.nom}
onClose={() => setShowContact(false)}
/>
)}
</SonumLayout>
);
}

426
client/src/pages/Home.tsx Normal file
View File

@@ -0,0 +1,426 @@
import { useAuth } from "@/_core/hooks/useAuth";
import CguModal from "@/components/CguModal";
import { EtatBadge } from "@/components/EtatBadge";
import SonumLayout from "@/components/SonumLayout";
import { trpc } from "@/lib/trpc";
import {
ArrowUpDown,
Building2,
ChevronDown,
ChevronUp,
Filter,
Mail,
MapPin,
RotateCcw,
Search,
SlidersHorizontal,
X,
} from "lucide-react";
import { useState, useMemo, Fragment } from "react";
import { useLocation } from "wouter";
import { REGIONS, TYPES_ACTIVITE, TAILLES_EFFECTIFS } from "../../../shared/referentiel";
import ContactModal from "@/components/ContactModal";
export default function Home() {
const { user, isAuthenticated } = useAuth();
const [, navigate] = useLocation();
const [filters, setFilters] = useState({
blocFonctionnelId: undefined as number | undefined,
solutionId: undefined as number | undefined,
editeurId: undefined as number | undefined,
region: undefined as string | undefined,
typeActivite: undefined as string | undefined,
tailleEffectifs: undefined as string | undefined,
});
const [showAdvanced, setShowAdvanced] = useState(false);
const [searchText, setSearchText] = useState("");
const [contactEtab, setContactEtab] = useState<{ id: number; nom: string } | null>(null);
const [expandedEtab, setExpandedEtab] = useState<number | null>(null);
const [sortCol, setSortCol] = useState<"nom" | "region" | "typeActivite" | "tailleEffectifs">("nom");
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
const handleSort = (col: typeof sortCol) => {
if (sortCol === col) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
else { setSortCol(col); setSortDir("asc"); }
};
const cguQuery = trpc.cgu.status.useQuery(undefined, { enabled: isAuthenticated });
// La CGU doit être acceptée à chaque connexion : on stocke la clé "userId_cgu" dans sessionStorage
// sessionStorage est vidé à la fermeture du navigateur ET on utilise l'userId pour distinguer les sessions
const sessionKey = cguQuery.data?.userId ? `sonum_cgu_${cguQuery.data.userId}` : null;
const [sessionCguAccepted, setSessionCguAccepted] = useState(() => {
if (typeof window === "undefined" || !sessionKey) return false;
return sessionStorage.getItem(sessionKey) === "1";
});
const blocsQuery = trpc.referentiel.blocsFonctionnels.useQuery();
const editeursQuery = trpc.referentiel.editeurs.useQuery();
const solutionsQuery = trpc.referentiel.solutions.useQuery({ search: searchText.length >= 2 ? searchText : undefined });
const cguFullyAccepted = sessionCguAccepted && (cguQuery.data?.accepted ?? false);
const searchQuery = trpc.etablissements.search.useQuery(filters, { enabled: isAuthenticated && cguFullyAccepted });
const tracabiliteUtils = trpc.useUtils();
const recordConsultation = trpc.tracabilite.enregistrerConsultation.useMutation();
const sortedResults = useMemo(() => {
if (!searchQuery.data) return [];
return [...searchQuery.data].sort((a, b) => {
const aVal = (a[sortCol] ?? "").toString().toLowerCase();
const bVal = (b[sortCol] ?? "").toString().toLowerCase();
return sortDir === "asc" ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
});
}, [searchQuery.data, sortCol, sortDir]);
const handleViewEtab = (id: number) => {
setExpandedEtab(expandedEtab === id ? null : id);
if (expandedEtab !== id) {
recordConsultation.mutate({ etablissementId: id });
}
};
const resetFilters = () => {
setFilters({ blocFonctionnelId: undefined, solutionId: undefined, editeurId: undefined, region: undefined, typeActivite: undefined, tailleEffectifs: undefined });
setSearchText("");
};
const activeFilterCount = Object.values(filters).filter(Boolean).length;
if (!isAuthenticated) {
return (
<SonumLayout>
<div />
</SonumLayout>
);
}
if (cguQuery.isLoading) {
return (
<SonumLayout>
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary border-t-transparent" />
</div>
</SonumLayout>
);
}
if (!cguFullyAccepted) {
return (
<SonumLayout>
<CguModal onAccepted={() => {
if (sessionKey) sessionStorage.setItem(sessionKey, "1");
setSessionCguAccepted(true);
cguQuery.refetch();
}} />
</SonumLayout>
);
}
return (
<SonumLayout>
<div className="p-6 lg:p-8 max-w-7xl mx-auto">
{/* En-tête */}
<div className="mb-8">
<h1 className="text-2xl font-bold text-foreground mb-1">Moteur de recherche SONUM</h1>
<p className="text-muted-foreground text-sm">
Recherchez les établissements adhérents et leurs solutions numériques
</p>
</div>
{/* Panneau de filtres */}
<div className="bg-card rounded-xl border border-border shadow-sm mb-6">
{/* Barre de recherche principale */}
<div className="p-5 border-b border-border">
<div className="flex gap-3">
<div className="relative flex-1">
<Search size={16} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input
type="text"
placeholder="Rechercher un logiciel, un éditeur..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-all"
/>
{searchText && (
<button onClick={() => setSearchText("")} className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
<X size={14} />
</button>
)}
</div>
<button
onClick={() => setShowAdvanced(!showAdvanced)}
className={`flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium border transition-all ${
showAdvanced || activeFilterCount > 0
? "bg-primary text-white border-primary"
: "bg-background border-border text-foreground hover:bg-muted"
}`}
>
<SlidersHorizontal size={15} />
Filtres
{activeFilterCount > 0 && (
<span className="bg-white/20 text-white text-xs px-1.5 py-0.5 rounded-full font-semibold">
{activeFilterCount}
</span>
)}
<ChevronDown size={14} className={`transition-transform ${showAdvanced ? "rotate-180" : ""}`} />
</button>
{activeFilterCount > 0 && (
<button
onClick={resetFilters}
className="flex items-center gap-1.5 px-3 py-2.5 rounded-lg text-sm text-muted-foreground hover:text-foreground hover:bg-muted border border-border transition-all"
>
<RotateCcw size={14} />
Réinitialiser
</button>
)}
</div>
</div>
{/* Filtres avancés */}
{showAdvanced && (
<div className="p-5 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{/* Bloc fonctionnel */}
<div>
<label className="block text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">
Bloc fonctionnel
</label>
<select
value={filters.blocFonctionnelId ?? ""}
onChange={(e) => setFilters((f) => ({ ...f, blocFonctionnelId: e.target.value ? Number(e.target.value) : undefined }))}
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary"
>
<option value="">Tous les blocs</option>
{blocsQuery.data?.map((b) => (
<option key={b.id} value={b.id}>{b.nom}</option>
))}
</select>
</div>
{/* Éditeur */}
<div>
<label className="block text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">
Éditeur
</label>
<select
value={filters.editeurId ?? ""}
onChange={(e) => setFilters((f) => ({ ...f, editeurId: e.target.value ? Number(e.target.value) : undefined }))}
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary"
>
<option value="">Tous les éditeurs</option>
{editeursQuery.data?.map((e) => (
<option key={e.id} value={e.id}>{e.nom}</option>
))}
</select>
</div>
{/* Région */}
<div>
<label className="block text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">
Région
</label>
<select
value={filters.region ?? ""}
onChange={(e) => setFilters((f) => ({ ...f, region: e.target.value || undefined }))}
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary"
>
<option value="">Toutes les régions</option>
{REGIONS.map((r) => (
<option key={r} value={r}>{r}</option>
))}
</select>
</div>
{/* Type d'activité */}
<div>
<label className="block text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">
Type d'activité
</label>
<select
value={filters.typeActivite ?? ""}
onChange={(e) => setFilters((f) => ({ ...f, typeActivite: e.target.value || undefined }))}
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary"
>
<option value="">Tous les types</option>
{TYPES_ACTIVITE.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
</div>
{/* Taille */}
<div>
<label className="block text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">
Taille d'établissement
</label>
<select
value={filters.tailleEffectifs ?? ""}
onChange={(e) => setFilters((f) => ({ ...f, tailleEffectifs: e.target.value || undefined }))}
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary"
>
<option value="">Toutes les tailles</option>
{TAILLES_EFFECTIFS.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
</div>
</div>
)}
</div>
{/* Résultats */}
<div>
{searchQuery.isLoading ? (
<div className="flex items-center justify-center py-16">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary border-t-transparent" />
</div>
) : searchQuery.data && searchQuery.data.length === 0 ? (
<div className="text-center py-16">
<Building2 size={48} className="mx-auto text-muted-foreground/30 mb-4" />
<p className="text-muted-foreground font-medium">Aucun établissement trouvé</p>
<p className="text-muted-foreground text-sm mt-1">Essayez de modifier vos critères de recherche</p>
</div>
) : (
<>
{searchQuery.data && (
<div className="flex items-center justify-between mb-4">
<p className="text-sm text-muted-foreground">
<span className="font-semibold text-foreground">{searchQuery.data.length}</span> établissement{searchQuery.data.length > 1 ? "s" : ""} trouvé{searchQuery.data.length > 1 ? "s" : ""}
</p>
<p className="text-xs text-muted-foreground">Cliquez sur un en-tête pour trier</p>
</div>
)}
<div className="bg-card rounded-xl border border-border shadow-sm overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/40">
{(["nom", "region", "typeActivite", "tailleEffectifs"] as const).map((col, i) => (
<th
key={col}
onClick={() => handleSort(col)}
className={`text-left px-${i === 0 ? 5 : 4} py-3.5 font-semibold text-muted-foreground text-xs uppercase tracking-wider cursor-pointer hover:text-foreground select-none transition-colors ${
i > 0 && i < 2 ? "hidden md:table-cell" : i >= 2 ? "hidden lg:table-cell" : ""
}`}
>
<span className="inline-flex items-center gap-1">
{["Établissement", "Région", "Type d'activité", "Taille"][i]}
{sortCol === col ? (
sortDir === "asc" ? <ChevronUp size={12} /> : <ChevronDown size={12} />
) : <ArrowUpDown size={11} className="opacity-40" />}
</span>
</th>
))}
<th className="text-right px-5 py-3.5 font-semibold text-muted-foreground text-xs uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{sortedResults.map((etab) => (
<Fragment key={etab.id}>
<tr
className="hover:bg-muted/30 transition-colors cursor-pointer"
onClick={() => handleViewEtab(etab.id)}
>
<td className="px-5 py-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
<Building2 size={15} className="text-primary" />
</div>
<div>
<div className="font-medium text-foreground">{etab.nom}</div>
{etab.finess && <div className="text-xs text-muted-foreground">FINESS : {etab.finess}</div>}
</div>
</div>
</td>
<td className="px-4 py-4 hidden md:table-cell">
<div className="flex items-center gap-1.5 text-muted-foreground">
<MapPin size={13} />
<span>{etab.region ?? "—"}</span>
</div>
</td>
<td className="px-4 py-4 hidden lg:table-cell">
<span className="text-muted-foreground">{etab.typeActivite ?? "—"}</span>
</td>
<td className="px-4 py-4 hidden lg:table-cell">
<span className="text-muted-foreground text-xs">{etab.tailleEffectifs ?? "—"}</span>
</td>
<td className="px-5 py-4 text-right">
<div className="flex items-center justify-end gap-2">
{etab.accepteMiseEnRelation && (
<button
onClick={(e) => { e.stopPropagation(); setContactEtab({ id: etab.id, nom: etab.nom }); }}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-primary/10 text-primary hover:bg-primary hover:text-white rounded-lg transition-all border border-primary/20"
>
<Mail size={12} />
Contacter
</button>
)}
<button
onClick={(e) => { e.stopPropagation(); navigate(`/etablissement/${etab.id}`); }}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-muted text-foreground hover:bg-secondary rounded-lg transition-all border border-border"
>
Voir la fiche
</button>
</div>
</td>
</tr>
{expandedEtab === etab.id && (
<tr className="bg-muted/20">
<td colSpan={5} className="px-5 py-4">
<LogicielsInline etablissementId={etab.id} />
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
</div>
</>
)}
</div>
</div>
{/* Modale de contact */}
{contactEtab && (
<ContactModal
etablissementId={contactEtab.id}
etablissementNom={contactEtab.nom}
onClose={() => setContactEtab(null)}
/>
)}
</SonumLayout>
);
}
function LogicielsInline({ etablissementId }: { etablissementId: number }) {
const logicielsQuery = trpc.logiciels.byEtablissement.useQuery({ etablissementId });
if (logicielsQuery.isLoading) {
return <div className="py-3 text-sm text-muted-foreground">Chargement...</div>;
}
if (!logicielsQuery.data?.length) {
return <div className="py-3 text-sm text-muted-foreground italic">Aucun logiciel renseigné pour cet établissement.</div>;
}
return (
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Logiciels de l'établissement</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{logicielsQuery.data.map((l) => (
<div key={l.id} className="bg-card rounded-lg border border-border p-3">
<div className="font-medium text-sm text-foreground mb-1">{l.solutionNom}</div>
<div className="text-xs text-muted-foreground mb-2">{l.editeurNom}</div>
<div className="flex flex-wrap gap-1">
<EtatBadge etat={l.etatDeploiement} />
{l.blocFonctionnelNom && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs bg-secondary text-secondary-foreground border border-border">
{l.blocFonctionnelNom}
</span>
)}
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,88 @@
import { getLoginUrl } from "@/const";
import { useLocation } from "wouter";
import { Building2, KeyRound, ExternalLink } from "lucide-react";
export default function Login() {
const [, navigate] = useLocation();
return (
<div className="min-h-screen flex items-center justify-center bg-background px-4">
<div className="w-full max-w-md">
{/* En-tête */}
<div className="text-center mb-10">
<div className="inline-flex items-center gap-3 mb-5">
<div className="w-14 h-14 rounded-2xl bg-primary flex items-center justify-center shadow-lg">
<Building2 size={26} className="text-white" />
</div>
<div className="text-left">
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-widest">FEHAP</div>
<div
className="text-3xl font-bold text-primary leading-tight"
style={{ fontFamily: "'Playfair Display', serif" }}
>
SONUM
</div>
</div>
</div>
<h1 className="text-2xl font-semibold text-foreground">Bienvenue</h1>
<p className="text-sm text-muted-foreground mt-2 max-w-xs mx-auto">
Cartographie des Solutions Numériques des établissements FEHAP
</p>
</div>
{/* Options de connexion */}
<div className="space-y-4">
{/* Connexion via espace adhérent FEHAP */}
<a
href={getLoginUrl()}
className="group flex items-center gap-4 p-5 bg-primary text-white rounded-2xl shadow-md hover:bg-primary/90 transition-all hover:shadow-lg hover:-translate-y-0.5"
>
<div className="w-10 h-10 rounded-xl bg-white/20 flex items-center justify-center flex-shrink-0">
<ExternalLink size={20} className="text-white" />
</div>
<div className="flex-1 text-left">
<div className="font-semibold text-base">Espace adhérent FEHAP</div>
<div className="text-sm text-white/75 mt-0.5">
Connexion via votre compte FEHAP existant
</div>
</div>
<div className="text-white/50 group-hover:text-white/80 transition-colors">
</div>
</a>
{/* Séparateur */}
<div className="flex items-center gap-3 py-1">
<div className="flex-1 h-px bg-border" />
<span className="text-xs text-muted-foreground font-medium">ou</span>
<div className="flex-1 h-px bg-border" />
</div>
{/* Connexion locale */}
<button
onClick={() => navigate("/login/local")}
className="group w-full flex items-center gap-4 p-5 bg-card border border-border rounded-2xl shadow-sm hover:border-primary/40 hover:shadow-md transition-all hover:-translate-y-0.5 text-left"
>
<div className="w-10 h-10 rounded-xl bg-muted flex items-center justify-center flex-shrink-0">
<KeyRound size={20} className="text-muted-foreground" />
</div>
<div className="flex-1">
<div className="font-semibold text-base text-foreground">Connexion locale</div>
<div className="text-sm text-muted-foreground mt-0.5">
Email et mot de passe fournis par un gestionnaire SONUM
</div>
</div>
<div className="text-muted-foreground group-hover:text-primary transition-colors">
</div>
</button>
</div>
{/* Pied de page */}
<p className="text-center text-xs text-muted-foreground mt-8">
En vous connectant, vous acceptez les conditions générales d'utilisation de la plateforme SONUM.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,151 @@
import { trpc } from "@/lib/trpc";
import { getLoginUrl } from "@/const";
import { useState } from "react";
import { useLocation } from "wouter";
import { toast } from "sonner";
import { Eye, EyeOff, Lock, Mail, ArrowLeft, ExternalLink } from "lucide-react";
export default function LoginLocal() {
const [, navigate] = useLocation();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const loginMutation = trpc.auth.loginLocal.useMutation({
onSuccess: () => {
// Forcer un rechargement complet pour réinitialiser le contexte auth
window.location.href = "/";
},
onError: (err) => {
toast.error(err.message || "Email ou mot de passe incorrect");
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!email || !password) {
toast.error("Veuillez renseigner votre email et votre mot de passe");
return;
}
loginMutation.mutate({ email, password });
};
return (
<div className="min-h-screen flex items-center justify-center bg-background px-4">
<div className="w-full max-w-md">
{/* Logo */}
<div className="text-center mb-8">
<div className="inline-flex items-center gap-3 mb-4">
<div className="w-12 h-12 rounded-xl bg-primary flex items-center justify-center shadow-md">
<Lock size={22} className="text-white" />
</div>
<div className="text-left">
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-widest">FEHAP</div>
<div
className="text-2xl font-bold text-primary leading-tight"
style={{ fontFamily: "'Playfair Display', serif" }}
>
SONUM
</div>
</div>
</div>
<h1 className="text-xl font-semibold text-foreground">Connexion locale</h1>
<p className="text-sm text-muted-foreground mt-1">
Connectez-vous avec votre email et votre mot de passe
</p>
</div>
{/* Formulaire */}
<div className="bg-card rounded-2xl border border-border shadow-sm p-8">
<form onSubmit={handleSubmit} className="space-y-5">
{/* Email */}
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">
Adresse email
</label>
<div className="relative">
<Mail size={16} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="prenom.nom@etablissement.fr"
autoComplete="email"
className="w-full pl-10 pr-4 py-2.5 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-all"
required
/>
</div>
</div>
{/* Mot de passe */}
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">
Mot de passe
</label>
<div className="relative">
<Lock size={16} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
autoComplete="current-password"
className="w-full pl-10 pr-10 py-2.5 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-all"
required
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
{/* Bouton connexion */}
<button
type="submit"
disabled={loginMutation.isPending}
className="w-full py-2.5 px-4 bg-primary text-white rounded-lg font-medium text-sm hover:bg-primary/90 transition-colors shadow-sm disabled:opacity-60 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{loginMutation.isPending ? (
<>
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
Connexion en cours...
</>
) : (
"Se connecter"
)}
</button>
</form>
</div>
{/* Liens */}
<div className="mt-6 space-y-3 text-center">
<button
onClick={() => navigate("/login")}
className="flex items-center justify-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors mx-auto"
>
<ArrowLeft size={14} />
Retour aux options de connexion
</button>
<div className="flex items-center gap-3">
<div className="flex-1 h-px bg-border" />
<span className="text-xs text-muted-foreground">ou</span>
<div className="flex-1 h-px bg-border" />
</div>
<a
href={getLoginUrl()}
className="flex items-center justify-center gap-2 text-sm text-primary hover:text-primary/80 font-medium transition-colors"
>
<ExternalLink size={14} />
Se connecter via l'espace adhérent FEHAP
</a>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,248 @@
import { useAuth } from "@/_core/hooks/useAuth";
import SonumLayout from "@/components/SonumLayout";
import { trpc } from "@/lib/trpc";
import {
Building2,
CheckCircle2,
ChevronDown,
Clock,
Mail,
MessageSquare,
Send,
User,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { STATUTS_DEMANDE } from "../../../shared/referentiel";
export default function MesDemandes() {
const { user } = useAuth();
const isGestionnaire = user?.sonumRole === "gestionnaire" || user?.role === "admin";
const mesDemandes = trpc.contact.mesDemandes.useQuery();
const demandesRecues = trpc.contact.demandesRecues.useQuery();
const toutesLesDemandes = trpc.contact.toutesLesDemandes.useQuery(undefined, {
enabled: isGestionnaire,
});
const [activeTab, setActiveTab] = useState<"envoyees" | "recues" | "toutes">("recues");
const [replyingTo, setReplyingTo] = useState<number | null>(null);
const tabs = [
{ id: "recues" as const, label: "Reçues", count: demandesRecues.data?.length ?? 0 },
{ id: "envoyees" as const, label: "Envoyées", count: mesDemandes.data?.length ?? 0 },
...(isGestionnaire ? [{ id: "toutes" as const, label: "Toutes (admin)", count: toutesLesDemandes.data?.length ?? 0 }] : []),
];
const currentData =
activeTab === "envoyees"
? mesDemandes.data
: activeTab === "toutes"
? toutesLesDemandes.data
: demandesRecues.data;
const isLoading =
activeTab === "envoyees"
? mesDemandes.isLoading
: activeTab === "toutes"
? toutesLesDemandes.isLoading
: demandesRecues.isLoading;
return (
<SonumLayout>
<div className="p-6 lg:p-8 max-w-4xl mx-auto">
{/* En-tête */}
<div className="mb-8">
<h1 className="text-2xl font-bold text-foreground mb-1">Mes Demandes de Contact</h1>
<p className="text-muted-foreground text-sm">
Suivez vos échanges avec les référents numériques des établissements
</p>
</div>
{/* Onglets */}
<div className="flex gap-1 p-1 bg-muted rounded-xl mb-6 w-fit">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
activeTab === tab.id
? "bg-card text-foreground shadow-sm border border-border"
: "text-muted-foreground hover:text-foreground"
}`}
>
{tab.label}
{tab.count > 0 && (
<span className={`text-xs px-1.5 py-0.5 rounded-full font-semibold ${
activeTab === tab.id ? "bg-primary text-white" : "bg-border text-muted-foreground"
}`}>
{tab.count}
</span>
)}
</button>
))}
</div>
{/* Liste des demandes */}
{isLoading ? (
<div className="flex items-center justify-center py-16">
<div className="animate-spin rounded-full h-7 w-7 border-2 border-primary border-t-transparent" />
</div>
) : !currentData?.length ? (
<div className="text-center py-16 bg-card rounded-xl border border-border">
<Mail size={48} className="mx-auto text-muted-foreground/30 mb-4" />
<p className="text-muted-foreground font-medium">Aucune demande</p>
<p className="text-muted-foreground text-sm mt-1">
{activeTab === "envoyees"
? "Vous n'avez pas encore envoyé de demande de contact."
: "Vous n'avez pas encore reçu de demande de contact."}
</p>
</div>
) : (
<div className="space-y-3">
{currentData.map((demande) => (
<DemandeCard
key={demande.id}
demande={demande}
isEnvoyee={activeTab === "envoyees"}
isReplying={replyingTo === demande.id}
onReply={() => setReplyingTo(replyingTo === demande.id ? null : demande.id)}
onReplied={() => {
setReplyingTo(null);
demandesRecues.refetch();
toutesLesDemandes.refetch();
}}
/>
))}
</div>
)}
</div>
</SonumLayout>
);
}
function DemandeCard({
demande,
isEnvoyee,
isReplying,
onReply,
onReplied,
}: {
demande: any;
isEnvoyee: boolean;
isReplying: boolean;
onReply: () => void;
onReplied: () => void;
}) {
const [reponse, setReponse] = useState("");
const repondreMutation = trpc.contact.repondre.useMutation({
onSuccess: () => {
toast.success("Réponse envoyée avec succès.");
setReponse("");
onReplied();
},
onError: (err) => toast.error("Erreur : " + err.message),
});
const statusConfig: Record<string, { icon: React.ReactNode; color: string; label: string }> = {
en_attente: { icon: <Clock size={13} />, color: "bg-amber-100 text-amber-700 border-amber-200", label: "En attente" },
repondu: { icon: <CheckCircle2 size={13} />, color: "bg-green-100 text-green-700 border-green-200", label: "Répondu" },
ferme: { icon: <CheckCircle2 size={13} />, color: "bg-gray-100 text-gray-600 border-gray-200", label: "Fermé" },
};
const status = statusConfig[demande.statut] ?? statusConfig.en_attente;
return (
<div className="bg-card rounded-xl border border-border shadow-sm overflow-hidden">
{/* En-tête */}
<div className="flex items-start justify-between px-5 py-4 border-b border-border">
<div className="flex items-start gap-3">
<div className="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<User size={16} className="text-primary" />
</div>
<div>
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-sm text-foreground">{demande.demandeurNom}</span>
<span className="text-muted-foreground text-xs">{demande.demandeurEmail}</span>
</div>
<div className="flex items-center gap-2 mt-0.5">
<Building2 size={12} className="text-muted-foreground" />
<span className="text-xs text-muted-foreground">{demande.etablissementNom}</span>
</div>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<span className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium border ${status.color}`}>
{status.icon}
{status.label}
</span>
<span className="text-xs text-muted-foreground">
{new Date(demande.createdAt).toLocaleDateString("fr-FR", { day: "2-digit", month: "short", year: "numeric" })}
</span>
</div>
</div>
{/* Message */}
<div className="px-5 py-4">
<div className="flex items-start gap-2 mb-3">
<MessageSquare size={14} className="text-muted-foreground mt-0.5 flex-shrink-0" />
<p className="text-sm text-foreground leading-relaxed">{demande.message}</p>
</div>
{/* Réponse existante */}
{demande.reponse && (
<div className="mt-3 ml-5 pl-4 border-l-2 border-primary/30">
<p className="text-xs font-semibold text-primary mb-1">Réponse</p>
<p className="text-sm text-foreground leading-relaxed">{demande.reponse}</p>
{demande.reponduAt && (
<p className="text-xs text-muted-foreground mt-1">
{new Date(demande.reponduAt).toLocaleDateString("fr-FR", { day: "2-digit", month: "short", year: "numeric" })}
</p>
)}
</div>
)}
</div>
{/* Actions */}
{!isEnvoyee && demande.statut === "en_attente" && (
<div className="px-5 pb-4">
{!isReplying ? (
<button
onClick={onReply}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-primary/10 text-primary hover:bg-primary hover:text-white rounded-lg transition-all border border-primary/20"
>
<Send size={12} />
Répondre
</button>
) : (
<div className="space-y-3">
<textarea
value={reponse}
onChange={(e) => setReponse(e.target.value)}
placeholder="Votre réponse..."
rows={3}
className="w-full px-3 py-2.5 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 resize-none"
/>
<div className="flex gap-2">
<button
onClick={() => onReply()}
className="px-3 py-1.5 text-xs font-medium border border-border rounded-lg text-foreground hover:bg-muted transition-colors"
>
Annuler
</button>
<button
onClick={() => repondreMutation.mutate({ id: demande.id, reponse })}
disabled={!reponse.trim() || repondreMutation.isPending}
className="flex items-center gap-1.5 px-4 py-1.5 text-xs font-medium bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 shadow-sm"
>
<Send size={12} />
{repondreMutation.isPending ? "Envoi..." : "Envoyer la réponse"}
</button>
</div>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,267 @@
import { useAuth } from "@/_core/hooks/useAuth";
import { EtatBadge, HebergementBadge } from "@/components/EtatBadge";
import SonumLayout from "@/components/SonumLayout";
import { trpc } from "@/lib/trpc";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Building2, ChevronDown, ChevronRight, Eye, Pencil, Plus, Trash2 } from "lucide-react";
import { useState } from "react";
import { useLocation } from "wouter";
import { toast } from "sonner";
import RattacherSolutionModal from "@/components/RattacherSolutionModal";
export default function MesEtablissements() {
const { user } = useAuth();
const [, navigate] = useLocation();
const [openEtab, setOpenEtab] = useState<number | null>(null);
const [rattacherEtab, setRattacherEtab] = useState<{ id: number; nom: string } | null>(null);
const etablissementsQuery = trpc.etablissements.mesEtablissements.useQuery();
const utils = trpc.useUtils();
const toggleAccordion = (id: number) => setOpenEtab(openEtab === id ? null : id);
if (etablissementsQuery.isLoading) {
return (
<SonumLayout>
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary border-t-transparent" />
</div>
</SonumLayout>
);
}
const etablissements = etablissementsQuery.data ?? [];
return (
<SonumLayout>
<div className="p-6 lg:p-8 max-w-5xl mx-auto">
<div className="mb-8 flex items-start justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground mb-1">Mes Établissements</h1>
<p className="text-muted-foreground text-sm">Gérez les logiciels de vos établissements rattachés</p>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-muted px-3 py-1.5 rounded-lg border border-border">
<Building2 size={15} />
<span>{etablissements.length} établissement{etablissements.length > 1 ? "s" : ""}</span>
</div>
</div>
{etablissements.length === 0 ? (
<div className="text-center py-16 bg-card rounded-xl border border-border">
<Building2 size={48} className="mx-auto text-muted-foreground/30 mb-4" />
<p className="text-muted-foreground font-medium">Aucun établissement rattaché</p>
<p className="text-muted-foreground text-sm mt-1">Contactez un gestionnaire SONUM pour rattacher vos établissements.</p>
</div>
) : (
<div className="space-y-3">
{etablissements.map((etab) => (
<EtablissementAccordion
key={etab.id}
etab={etab}
isOpen={openEtab === etab.id}
onToggle={() => toggleAccordion(etab.id)}
onRattacher={() => setRattacherEtab({ id: etab.id, nom: etab.nom })}
onViewFiche={() => navigate(`/etablissement/${etab.id}`)}
/>
))}
</div>
)}
</div>
{rattacherEtab && (
<RattacherSolutionModal
etablissementId={rattacherEtab.id}
etablissementNom={rattacherEtab.nom}
onClose={() => setRattacherEtab(null)}
onSuccess={() => {
setRattacherEtab(null);
utils.logiciels.byEtablissement.invalidate({ etablissementId: rattacherEtab.id });
}}
/>
)}
</SonumLayout>
);
}
function EtablissementAccordion({ etab, isOpen, onToggle, onRattacher, onViewFiche }: {
etab: any; isOpen: boolean; onToggle: () => void; onRattacher: () => void; onViewFiche: () => void;
}) {
const logicielsQuery = trpc.logiciels.byEtablissement.useQuery({ etablissementId: etab.id }, { enabled: isOpen });
const deleteMutation = trpc.logiciels.delete.useMutation({
onSuccess: () => { toast.success("Logiciel supprimé"); logicielsQuery.refetch(); },
});
return (
<div className="bg-card rounded-xl border border-border shadow-sm overflow-hidden">
<button className="w-full flex items-center justify-between px-5 py-4 hover:bg-muted/30 transition-colors text-left" onClick={onToggle}>
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
<Building2 size={17} className="text-primary" />
</div>
<div>
<div className="font-semibold text-foreground">{etab.nom}</div>
<div className="flex items-center gap-3 mt-0.5">
{etab.finess && <span className="text-xs text-muted-foreground">FINESS : {etab.finess}</span>}
{etab.region && <span className="text-xs text-muted-foreground">{etab.region}</span>}
{etab.typeActivite && <span className="text-xs bg-secondary text-secondary-foreground px-2 py-0.5 rounded border border-border">{etab.typeActivite}</span>}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground hidden sm:block">{isOpen ? "Masquer" : "Voir les logiciels"}</span>
{isOpen ? <ChevronDown size={18} className="text-muted-foreground" /> : <ChevronRight size={18} className="text-muted-foreground" />}
</div>
</button>
{isOpen && (
<div className="border-t border-border">
<div className="flex items-center justify-between px-5 py-3 bg-muted/20 border-b border-border">
<button onClick={onRattacher} className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors shadow-sm">
<Plus size={13} /> Rattacher une solution
</button>
<button onClick={onViewFiche} className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted rounded-lg transition-colors border border-border">
<Eye size={13} /> Voir la fiche
</button>
</div>
{logicielsQuery.isLoading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-2 border-primary border-t-transparent" />
</div>
) : !logicielsQuery.data?.length ? (
<div className="text-center py-8 text-muted-foreground text-sm">
<p>Aucun logiciel renseigné.</p>
<button onClick={onRattacher} className="mt-2 text-primary hover:underline text-sm font-medium">Rattacher une première solution </button>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/20">
<th className="text-left px-5 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider">Solution</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider hidden md:table-cell">Éditeur</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider hidden lg:table-cell">Bloc fonctionnel</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider">État</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider hidden xl:table-cell">Hébergement</th>
<th className="text-right px-5 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{logicielsQuery.data.map((logiciel) => (
<LogicielRow
key={logiciel.id}
logiciel={logiciel}
etablissementId={etab.id}
onDelete={() => deleteMutation.mutate({ id: logiciel.id, etablissementId: etab.id })}
onUpdated={() => logicielsQuery.refetch()}
/>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
);
}
function LogicielRow({ logiciel, etablissementId, onDelete, onUpdated }: {
logiciel: any; etablissementId: number; onDelete: () => void; onUpdated: () => void;
}) {
const [open, setOpen] = useState(false);
const [etat, setEtat] = useState(logiciel.etatDeploiement ?? "operationnel");
const [hebergement, setHebergement] = useState(logiciel.modeHebergement ?? "");
const [facturation, setFacturation] = useState(logiciel.modeFacturation ?? "");
const [commentaire, setCommentaire] = useState(logiciel.commentaire ?? "");
const updateMutation = trpc.logiciels.upsert.useMutation({
onSuccess: () => { toast.success("Logiciel mis à jour"); setOpen(false); onUpdated(); },
onError: (e) => toast.error("Erreur : " + e.message),
});
const handleOpen = () => {
setEtat(logiciel.etatDeploiement ?? "operationnel");
setHebergement(logiciel.modeHebergement ?? "");
setFacturation(logiciel.modeFacturation ?? "");
setCommentaire(logiciel.commentaire ?? "");
setOpen(true);
};
return (
<>
<tr className="hover:bg-muted/20 transition-colors">
<td className="px-5 py-3.5 font-medium text-foreground">{logiciel.solutionNom}</td>
<td className="px-4 py-3.5 hidden md:table-cell text-muted-foreground">{logiciel.editeurNom}</td>
<td className="px-4 py-3.5 hidden lg:table-cell">
{logiciel.blocFonctionnelNom && <span className="text-xs bg-secondary text-secondary-foreground px-2 py-0.5 rounded border border-border">{logiciel.blocFonctionnelNom}</span>}
</td>
<td className="px-4 py-3.5"><EtatBadge etat={logiciel.etatDeploiement} /></td>
<td className="px-4 py-3.5 hidden xl:table-cell">
{logiciel.modeHebergement ? <HebergementBadge mode={logiciel.modeHebergement} /> : <span className="text-muted-foreground text-xs"></span>}
</td>
<td className="px-5 py-3.5 text-right">
<div className="flex items-center justify-end gap-1.5">
<button onClick={handleOpen} className="p-1.5 rounded bg-blue-50 text-blue-600 hover:bg-blue-100 transition-colors" title="Modifier">
<Pencil size={13} />
</button>
<button onClick={onDelete} className="p-1.5 rounded bg-red-50 text-red-600 hover:bg-red-100 transition-colors" title="Supprimer">
<Trash2 size={13} />
</button>
</div>
</td>
</tr>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Modifier {logiciel.solutionNom}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div>
<Label className="text-xs font-semibold uppercase text-muted-foreground mb-1.5 block">État de déploiement</Label>
<select value={etat} onChange={(e) => setEtat(e.target.value)} className="w-full border border-border rounded-lg px-3 py-2 text-sm bg-background">
<option value="demarrage">Démarrage</option>
<option value="en_cours">En cours</option>
<option value="operationnel">Opérationnel</option>
<option value="en_remplacement">En remplacement</option>
</select>
</div>
<div>
<Label className="text-xs font-semibold uppercase text-muted-foreground mb-1.5 block">Mode d'hébergement</Label>
<select value={hebergement} onChange={(e) => setHebergement(e.target.value)} className="w-full border border-border rounded-lg px-3 py-2 text-sm bg-background">
<option value=""> Non renseigné </option>
<option value="hds">HDS (Cloud)</option>
<option value="on_premise">On Premise</option>
<option value="hybride">Hybride</option>
</select>
</div>
<div>
<Label className="text-xs font-semibold uppercase text-muted-foreground mb-1.5 block">Mode de facturation</Label>
<select value={facturation} onChange={(e) => setFacturation(e.target.value)} className="w-full border border-border rounded-lg px-3 py-2 text-sm bg-background">
<option value=""> Non renseigné </option>
<option value="saas">SaaS</option>
<option value="achat_maintenance">Achat + Maintenance</option>
<option value="location">Location</option>
</select>
</div>
<div>
<Label className="text-xs font-semibold uppercase text-muted-foreground mb-1.5 block">Commentaire</Label>
<textarea value={commentaire} onChange={(e) => setCommentaire(e.target.value)} rows={3} placeholder="Informations complémentaires..." className="w-full border border-border rounded-lg px-3 py-2 text-sm bg-background resize-none" />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Annuler</Button>
<Button
onClick={() => updateMutation.mutate({ id: logiciel.id, etablissementId, solutionId: logiciel.solutionId, etatDeploiement: etat as any, modeHebergement: hebergement as any || null, modeFacturation: facturation as any || null, commentaire: commentaire || null })}
disabled={updateMutation.isPending}
>
{updateMutation.isPending ? "Enregistrement..." : "Enregistrer"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,179 @@
import { trpc } from "@/lib/trpc";
import { useAuth } from "@/_core/hooks/useAuth";
import SonumLayout from "@/components/SonumLayout";
import { ChevronDown, ChevronRight, Building2, Package, Search, Filter } from "lucide-react";
import { useState, useMemo } from "react";
import { EtatBadge } from "@/components/EtatBadge";
export default function MesSolutions() {
const { isAuthenticated } = useAuth();
const [openIds, setOpenIds] = useState<Set<number>>(new Set());
const [search, setSearch] = useState("");
const [filterBloc, setFilterBloc] = useState("");
const { data: solutions, isLoading } = trpc.logiciels.mesSolutions.useQuery(undefined, {
enabled: isAuthenticated,
});
const { data: blocs } = trpc.referentiel.blocsFonctionnels.useQuery();
const toggle = (id: number) => {
setOpenIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const filtered = useMemo(() => {
if (!solutions) return [];
return solutions.filter((s) => {
const matchSearch =
!search ||
s.solutionNom.toLowerCase().includes(search.toLowerCase()) ||
s.editeurNom.toLowerCase().includes(search.toLowerCase());
const matchBloc = !filterBloc || s.blocFonctionnelNom === filterBloc;
return matchSearch && matchBloc;
});
}, [solutions, search, filterBloc]);
const blocsUniques = useMemo(() => {
if (!solutions) return [];
const set = new Set(solutions.map((s) => s.blocFonctionnelNom).filter(Boolean) as string[]);
return Array.from(set);
}, [solutions]);
return (
<SonumLayout>
<div className="max-w-5xl mx-auto px-4 py-8">
{/* En-tête */}
<div className="mb-8">
<div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center">
<Package size={20} className="text-primary" />
</div>
<div>
<h1 className="text-2xl font-bold text-foreground">Mes Solutions Numériques</h1>
<p className="text-sm text-muted-foreground">
{solutions ? `${solutions.length} solution${solutions.length > 1 ? "s" : ""} référencée${solutions.length > 1 ? "s" : ""}` : "Chargement…"}
</p>
</div>
</div>
</div>
{/* Filtres */}
<div className="flex flex-col sm:flex-row gap-3 mb-6">
<div className="relative flex-1">
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input
type="text"
placeholder="Rechercher par solution ou éditeur…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-9 pr-4 py-2.5 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary"
/>
</div>
<div className="relative">
<Filter size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<select
value={filterBloc}
onChange={(e) => setFilterBloc(e.target.value)}
className="pl-9 pr-8 py-2.5 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 appearance-none min-w-[200px]"
>
<option value="">Tous les blocs fonctionnels</option>
{blocsUniques.map((b) => (
<option key={b} value={b}>{b}</option>
))}
</select>
</div>
</div>
{/* Liste */}
{isLoading ? (
<div className="space-y-3">
{[...Array(4)].map((_, i) => (
<div key={i} className="h-16 bg-muted/30 rounded-xl animate-pulse" />
))}
</div>
) : filtered.length === 0 ? (
<div className="text-center py-16 text-muted-foreground">
<Package size={40} className="mx-auto mb-3 opacity-30" />
<p className="font-medium">Aucune solution trouvée</p>
<p className="text-sm mt-1">Rattachez des solutions à vos établissements pour les voir apparaître ici.</p>
</div>
) : (
<div className="space-y-2">
{filtered.map((sol) => {
const isOpen = openIds.has(sol.solutionId);
return (
<div
key={sol.solutionId}
className="border border-border rounded-xl overflow-hidden bg-card shadow-sm hover:shadow-md transition-shadow"
>
{/* En-tête accordéon */}
<button
onClick={() => toggle(sol.solutionId)}
className="w-full flex items-center justify-between px-5 py-4 text-left hover:bg-muted/30 transition-colors"
>
<div className="flex items-center gap-4 min-w-0">
<div className="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
<Package size={16} className="text-primary" />
</div>
<div className="min-w-0">
<div className="font-semibold text-foreground truncate">{sol.solutionNom}</div>
<div className="text-xs text-muted-foreground">{sol.editeurNom}</div>
</div>
{sol.blocFonctionnelNom && (
<span className="hidden sm:inline-flex text-xs bg-secondary text-secondary-foreground px-2.5 py-1 rounded-full border border-border flex-shrink-0">
{sol.blocFonctionnelNom}
</span>
)}
</div>
<div className="flex items-center gap-3 flex-shrink-0 ml-3">
<span className="text-xs text-muted-foreground bg-muted px-2.5 py-1 rounded-full">
{sol.etablissements.length} établissement{sol.etablissements.length > 1 ? "s" : ""}
</span>
{isOpen ? (
<ChevronDown size={16} className="text-muted-foreground" />
) : (
<ChevronRight size={16} className="text-muted-foreground" />
)}
</div>
</button>
{/* Contenu accordéon */}
{isOpen && (
<div className="border-t border-border bg-muted/10">
<div className="px-5 py-3">
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">
Établissements équipés
</div>
<div className="space-y-2">
{sol.etablissements.map((etab) => (
<div
key={etab.id}
className="flex items-center justify-between py-2 px-3 bg-background rounded-lg border border-border"
>
<div className="flex items-center gap-2 min-w-0">
<Building2 size={14} className="text-muted-foreground flex-shrink-0" />
<span className="text-sm font-medium text-foreground truncate">{etab.nom}</span>
{etab.region && (
<span className="text-xs text-muted-foreground hidden sm:block"> {etab.region}</span>
)}
</div>
<EtatBadge etat={etab.etatDeploiement} />
</div>
))}
</div>
</div>
</div>
)}
</div>
);
})}
</div>
)}
</div>
</SonumLayout>
);
}

View File

@@ -0,0 +1,24 @@
import { ArrowLeft, Search } from "lucide-react";
import { useLocation } from "wouter";
export default function NotFound() {
const [, navigate] = useLocation();
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center max-w-sm mx-auto px-6">
<div className="w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center mx-auto mb-6">
<Search size={32} className="text-primary" />
</div>
<h1 className="text-4xl font-bold text-foreground mb-2">404</h1>
<p className="text-muted-foreground mb-6">Cette page n'existe pas ou vous n'y avez pas accès.</p>
<button
onClick={() => navigate("/")}
className="inline-flex items-center gap-2 px-5 py-2.5 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary/90 transition-colors shadow-sm"
>
<ArrowLeft size={15} />
Retour à l'accueil
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,401 @@
import { trpc } from "@/lib/trpc";
import { useAuth } from "@/_core/hooks/useAuth";
import SonumLayout from "@/components/SonumLayout";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
ChevronDown, ChevronRight, Building2, Package, Search, Filter,
BarChart2, Plus, Pencil, Trash2, LayoutList, Rows3
} from "lucide-react";
import { useState, useMemo } from "react";
import { EtatBadge } from "@/components/EtatBadge";
import { toast } from "sonner";
type ViewMode = "accordion" | "flat";
export default function SolutionsLogicielles() {
const { user, isAuthenticated } = useAuth();
const isGestionnaire = user?.sonumRole === "gestionnaire" || user?.role === "admin";
const [viewMode, setViewMode] = useState<ViewMode>("accordion");
const [openIds, setOpenIds] = useState<Set<number>>(new Set());
const [search, setSearch] = useState("");
const [filterBloc, setFilterBloc] = useState("");
const [dialogMode, setDialogMode] = useState<"add" | "edit" | null>(null);
const [editTarget, setEditTarget] = useState<any>(null);
const [formNom, setFormNom] = useState("");
const [formEditeurId, setFormEditeurId] = useState<number | "">("");
const [formBlocId, setFormBlocId] = useState<number | "">("");
const { data: solutions, isLoading, refetch } = trpc.logiciels.toutesLesSolutions.useQuery(undefined, { enabled: isAuthenticated });
const { data: editeursList } = trpc.referentiel.editeurs.useQuery();
const { data: blocsList } = trpc.referentiel.blocsFonctionnels.useQuery();
const createMutation = trpc.referentiel.createSolution.useMutation({
onSuccess: () => { toast.success("Solution ajoutée"); setDialogMode(null); refetch(); },
onError: (e) => toast.error(e.message),
});
const updateMutation = trpc.referentiel.updateSolution.useMutation({
onSuccess: () => { toast.success("Solution mise à jour"); setDialogMode(null); refetch(); },
onError: (e) => toast.error(e.message),
});
const deleteMutation = trpc.referentiel.deleteSolution.useMutation({
onSuccess: () => { toast.success("Solution supprimée"); refetch(); },
onError: (e) => toast.error(e.message),
});
const toggle = (id: number) => setOpenIds((prev) => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n; });
const openAdd = () => { setFormNom(""); setFormEditeurId(""); setFormBlocId(""); setEditTarget(null); setDialogMode("add"); };
const openEdit = (sol: any) => {
setFormNom(sol.solutionNom);
setFormEditeurId(sol.editeurId ?? "");
setFormBlocId(sol.blocFonctionnelId ?? "");
setEditTarget(sol);
setDialogMode("edit");
};
const handleSubmit = () => {
if (!formNom.trim() || !formEditeurId) { toast.error("Nom et éditeur obligatoires"); return; }
if (dialogMode === "add") {
createMutation.mutate({ nom: formNom.trim(), editeurId: Number(formEditeurId), blocFonctionnelId: formBlocId ? Number(formBlocId) : null });
} else if (dialogMode === "edit" && editTarget) {
updateMutation.mutate({ id: editTarget.solutionId, nom: formNom.trim(), editeurId: Number(formEditeurId), blocFonctionnelId: formBlocId ? Number(formBlocId) : null });
}
};
const filtered = useMemo(() => {
if (!solutions) return [];
return solutions.filter((s) => {
const matchSearch = !search || s.solutionNom.toLowerCase().includes(search.toLowerCase()) || s.editeurNom.toLowerCase().includes(search.toLowerCase());
const matchBloc = !filterBloc || s.blocFonctionnelNom === filterBloc;
return matchSearch && matchBloc;
});
}, [solutions, search, filterBloc]);
const blocsUniques = useMemo(() => {
if (!solutions) return [];
return Array.from(new Set(solutions.map((s) => s.blocFonctionnelNom).filter(Boolean) as string[])).sort();
}, [solutions]);
const totalEtablissements = useMemo(() => {
if (!solutions) return 0;
const ids = new Set<number>();
solutions.forEach((s) => s.etablissements.forEach((e) => ids.add(e.id)));
return ids.size;
}, [solutions]);
return (
<SonumLayout>
<div className="max-w-6xl mx-auto px-4 py-8">
{/* En-tête */}
<div className="mb-8">
<div className="flex items-center justify-between gap-3 mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-blue-500/10 flex items-center justify-center">
<BarChart2 size={20} className="text-blue-600" />
</div>
<div>
<h1 className="text-2xl font-bold text-foreground">Solutions Logicielles</h1>
<p className="text-sm text-muted-foreground">Référentiel complet des solutions numériques FEHAP</p>
</div>
</div>
{isGestionnaire && (
<Button onClick={openAdd} className="flex items-center gap-2">
<Plus size={15} /> Ajouter une solution
</Button>
)}
</div>
{solutions && (
<div className="grid grid-cols-3 gap-3 mb-2">
<div className="bg-card border border-border rounded-xl p-4 text-center">
<div className="text-2xl font-bold text-primary">{solutions.length}</div>
<div className="text-xs text-muted-foreground mt-0.5">Solutions référencées</div>
</div>
<div className="bg-card border border-border rounded-xl p-4 text-center">
<div className="text-2xl font-bold text-blue-600">{totalEtablissements}</div>
<div className="text-xs text-muted-foreground mt-0.5">Établissements équipés</div>
</div>
<div className="bg-card border border-border rounded-xl p-4 text-center">
<div className="text-2xl font-bold text-green-600">{blocsUniques.length}</div>
<div className="text-xs text-muted-foreground mt-0.5">Blocs fonctionnels</div>
</div>
</div>
)}
</div>
{/* Barre de filtres + toggle vue */}
<div className="flex flex-col sm:flex-row gap-3 mb-6">
<div className="relative flex-1">
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input
type="text"
placeholder="Rechercher par solution ou éditeur…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-9 pr-4 py-2.5 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary"
/>
</div>
<div className="relative">
<Filter size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<select
value={filterBloc}
onChange={(e) => setFilterBloc(e.target.value)}
className="pl-9 pr-8 py-2.5 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 appearance-none min-w-[200px]"
>
<option value="">Tous les blocs fonctionnels</option>
{blocsUniques.map((b) => <option key={b} value={b}>{b}</option>)}
</select>
</div>
{/* Toggle vue */}
<div className="flex items-center bg-muted rounded-lg p-1 gap-1 flex-shrink-0">
<button
onClick={() => setViewMode("accordion")}
title="Vue accordéon"
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${viewMode === "accordion" ? "bg-background shadow text-foreground" : "text-muted-foreground hover:text-foreground"}`}
>
<LayoutList size={14} /> Accordéon
</button>
<button
onClick={() => setViewMode("flat")}
title="Vue liste à plat"
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${viewMode === "flat" ? "bg-background shadow text-foreground" : "text-muted-foreground hover:text-foreground"}`}
>
<Rows3 size={14} /> Liste
</button>
</div>
</div>
{(search || filterBloc) && (
<p className="text-xs text-muted-foreground mb-4">
{filtered.length} solution{filtered.length > 1 ? "s" : ""} correspondant aux critères
</p>
)}
{isLoading ? (
<div className="space-y-3">{[...Array(6)].map((_, i) => <div key={i} className="h-16 bg-muted/30 rounded-xl animate-pulse" />)}</div>
) : filtered.length === 0 ? (
<div className="text-center py-16 text-muted-foreground">
<Package size={40} className="mx-auto mb-3 opacity-30" />
<p className="font-medium">Aucune solution trouvée</p>
</div>
) : viewMode === "accordion" ? (
/* ── VUE ACCORDÉON ─────────────────────────────────────────── */
<div className="space-y-2">
{filtered.map((sol) => {
const isOpen = openIds.has(sol.solutionId);
return (
<div key={sol.solutionId} className="border border-border rounded-xl overflow-hidden bg-card shadow-sm hover:shadow-md transition-shadow">
<div className="flex items-center">
<button
onClick={() => toggle(sol.solutionId)}
className="flex-1 flex items-center justify-between px-5 py-4 text-left hover:bg-muted/30 transition-colors"
>
<div className="flex items-center gap-4 min-w-0">
<div className="w-9 h-9 rounded-lg bg-blue-500/10 flex items-center justify-center flex-shrink-0">
<Package size={16} className="text-blue-600" />
</div>
<div className="min-w-0">
<div className="font-semibold text-foreground truncate">{sol.solutionNom}</div>
<div className="text-xs text-muted-foreground">{sol.editeurNom}</div>
</div>
{sol.blocFonctionnelNom && (
<span className="hidden sm:inline-flex text-xs bg-secondary text-secondary-foreground px-2.5 py-1 rounded-full border border-border flex-shrink-0">
{sol.blocFonctionnelNom}
</span>
)}
</div>
<div className="flex items-center gap-3 flex-shrink-0 ml-3">
{sol.nbEtablissements > 0 ? (
<span className="text-xs text-muted-foreground bg-muted px-2.5 py-1 rounded-full">
{sol.nbEtablissements} établissement{sol.nbEtablissements > 1 ? "s" : ""}
</span>
) : (
<span className="text-xs text-muted-foreground/50 bg-muted/50 px-2.5 py-1 rounded-full italic">Non déployée</span>
)}
{isOpen ? <ChevronDown size={16} className="text-muted-foreground" /> : <ChevronRight size={16} className="text-muted-foreground" />}
</div>
</button>
{isGestionnaire && (
<div className="flex items-center gap-1 pr-4 flex-shrink-0">
<button onClick={(e) => { e.stopPropagation(); openEdit(sol); }} className="p-1.5 rounded bg-blue-50 text-blue-600 hover:bg-blue-100 transition-colors" title="Modifier">
<Pencil size={13} />
</button>
<button
onClick={(e) => { e.stopPropagation(); if (confirm(`Supprimer "${sol.solutionNom}" ?`)) deleteMutation.mutate({ id: sol.solutionId }); }}
className="p-1.5 rounded bg-red-50 text-red-600 hover:bg-red-100 transition-colors"
title="Supprimer"
>
<Trash2 size={13} />
</button>
</div>
)}
</div>
{isOpen && (
<div className="border-t border-border bg-muted/10 px-5 py-3">
{sol.etablissements.length === 0 ? (
<p className="text-sm text-muted-foreground italic py-2">Aucun établissement n'utilise encore cette solution.</p>
) : (
<>
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Établissements équipés</div>
<div className="space-y-2">
{sol.etablissements.map((etab) => (
<div key={etab.id} className="flex items-center justify-between py-2 px-3 bg-background rounded-lg border border-border">
<div className="flex items-center gap-2 min-w-0">
<Building2 size={14} className="text-muted-foreground flex-shrink-0" />
<span className="text-sm font-medium text-foreground truncate">{etab.nom}</span>
{etab.region && <span className="text-xs text-muted-foreground hidden sm:block"> {etab.region}</span>}
</div>
<EtatBadge etat={etab.etatDeploiement} />
</div>
))}
</div>
</>
)}
</div>
)}
</div>
);
})}
</div>
) : (
/* ── VUE LISTE À PLAT ──────────────────────────────────────── */
<div className="border border-border rounded-xl overflow-hidden bg-card shadow-sm">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 border-b border-border">
<th className="text-left px-4 py-3 text-xs font-semibold text-muted-foreground uppercase tracking-wider">Solution</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-muted-foreground uppercase tracking-wider">Éditeur</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-muted-foreground uppercase tracking-wider hidden sm:table-cell">Bloc fonctionnel</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-muted-foreground uppercase tracking-wider">Établissement</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-muted-foreground uppercase tracking-wider hidden md:table-cell">Région</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-muted-foreground uppercase tracking-wider">État</th>
{isGestionnaire && <th className="px-4 py-3 w-16"></th>}
</tr>
</thead>
<tbody>
{filtered.map((sol, solIdx) => {
// Couleurs alternées : pair = blanc/gris très clair, impair = bleu très clair
const isEven = solIdx % 2 === 0;
const rowBg = isEven ? "bg-white hover:bg-blue-50/40" : "bg-blue-50/60 hover:bg-blue-100/50";
const rowBgSpan = isEven ? "bg-white" : "bg-blue-50/60";
if (sol.etablissements.length === 0) {
return (
<tr key={`sol-${sol.solutionId}-empty`} className={`border-b border-border last:border-0 transition-colors ${rowBg}`}>
<td className="px-4 py-3 font-medium text-foreground">{sol.solutionNom}</td>
<td className="px-4 py-3 text-muted-foreground">{sol.editeurNom}</td>
<td className="px-4 py-3 hidden sm:table-cell">
{sol.blocFonctionnelNom && (
<span className="text-xs bg-secondary text-secondary-foreground px-2 py-0.5 rounded-full border border-border">{sol.blocFonctionnelNom}</span>
)}
</td>
<td className="px-4 py-3 text-muted-foreground italic text-xs" colSpan={2}>Non déployée</td>
<td className="px-4 py-3"></td>
{isGestionnaire && (
<td className="px-4 py-3">
<div className="flex items-center gap-1">
<button onClick={() => openEdit(sol)} className="p-1.5 rounded bg-blue-50 text-blue-600 hover:bg-blue-100 transition-colors" title="Modifier"><Pencil size={12} /></button>
<button onClick={() => { if (confirm(`Supprimer "${sol.solutionNom}" ?`)) deleteMutation.mutate({ id: sol.solutionId }); }} className="p-1.5 rounded bg-red-50 text-red-600 hover:bg-red-100 transition-colors" title="Supprimer"><Trash2 size={12} /></button>
</div>
</td>
)}
</tr>
);
}
return sol.etablissements.map((etab, idx) => (
<tr key={`sol-${sol.solutionId}-etab-${etab.id}`} className={`border-b border-border last:border-0 transition-colors ${idx === 0 ? rowBg : rowBgSpan + " hover:brightness-95"}`}>
{idx === 0 ? (
<>
<td className={`px-4 py-3 font-semibold text-foreground align-top ${rowBgSpan}`} rowSpan={sol.etablissements.length}>
<div className="flex items-center gap-2">
<Package size={13} className="text-blue-500 flex-shrink-0" />
{sol.solutionNom}
</div>
</td>
<td className={`px-4 py-3 text-muted-foreground align-top ${rowBgSpan}`} rowSpan={sol.etablissements.length}>{sol.editeurNom}</td>
<td className={`px-4 py-3 hidden sm:table-cell align-top ${rowBgSpan}`} rowSpan={sol.etablissements.length}>
{sol.blocFonctionnelNom && (
<span className="text-xs bg-secondary text-secondary-foreground px-2 py-0.5 rounded-full border border-border">{sol.blocFonctionnelNom}</span>
)}
</td>
</>
) : null}
<td className="px-4 py-3">
<div className="flex items-center gap-1.5">
<Building2 size={13} className="text-muted-foreground flex-shrink-0" />
<span className="text-foreground">{etab.nom}</span>
</div>
</td>
<td className="px-4 py-3 text-muted-foreground hidden md:table-cell">{etab.region ?? "—"}</td>
<td className="px-4 py-3"><EtatBadge etat={etab.etatDeploiement} /></td>
{isGestionnaire && idx === 0 ? (
<td className={`px-4 py-3 align-top ${rowBgSpan}`} rowSpan={sol.etablissements.length}>
<div className="flex items-center gap-1">
<button onClick={() => openEdit(sol)} className="p-1.5 rounded bg-blue-50 text-blue-600 hover:bg-blue-100 transition-colors" title="Modifier"><Pencil size={12} /></button>
<button onClick={() => { if (confirm(`Supprimer "${sol.solutionNom}" ?`)) deleteMutation.mutate({ id: sol.solutionId }); }} className="p-1.5 rounded bg-red-50 text-red-600 hover:bg-red-100 transition-colors" title="Supprimer"><Trash2 size={12} /></button>
</div>
</td>
) : isGestionnaire ? <td className="px-4 py-3"></td> : null}
</tr>
));
})}
</tbody>
</table>
</div>
)}
</div>
{/* Dialog ajout / modification */}
<Dialog open={dialogMode !== null} onOpenChange={(o) => !o && setDialogMode(null)}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{dialogMode === "add" ? "Ajouter une solution" : `Modifier — ${editTarget?.solutionNom}`}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div>
<Label className="text-xs font-semibold uppercase text-muted-foreground mb-1.5 block">Nom de la solution *</Label>
<input
value={formNom}
onChange={(e) => setFormNom(e.target.value)}
placeholder="Ex : DPI, Logiciel RH…"
className="w-full border border-border rounded-lg px-3 py-2 text-sm bg-background focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
</div>
<div>
<Label className="text-xs font-semibold uppercase text-muted-foreground mb-1.5 block">Éditeur *</Label>
<select
value={formEditeurId}
onChange={(e) => setFormEditeurId(e.target.value ? Number(e.target.value) : "")}
className="w-full border border-border rounded-lg px-3 py-2 text-sm bg-background"
>
<option value=""> Sélectionner un éditeur </option>
{editeursList?.map((e: any) => <option key={e.id} value={e.id}>{e.nom}</option>)}
</select>
</div>
<div>
<Label className="text-xs font-semibold uppercase text-muted-foreground mb-1.5 block">Bloc fonctionnel</Label>
<select
value={formBlocId}
onChange={(e) => setFormBlocId(e.target.value ? Number(e.target.value) : "")}
className="w-full border border-border rounded-lg px-3 py-2 text-sm bg-background"
>
<option value=""> Non renseigné </option>
{blocsList?.map((b: any) => <option key={b.id} value={b.id}>{b.nom}</option>)}
</select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogMode(null)}>Annuler</Button>
<Button onClick={handleSubmit} disabled={createMutation.isPending || updateMutation.isPending}>
{createMutation.isPending || updateMutation.isPending ? "Enregistrement..." : dialogMode === "add" ? "Ajouter" : "Enregistrer"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</SonumLayout>
);
}

View File

@@ -0,0 +1,348 @@
import { useAuth } from "@/_core/hooks/useAuth";
import SonumLayout from "@/components/SonumLayout";
import { trpc } from "@/lib/trpc";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
PieChart,
Pie,
Cell,
Legend,
} from "recharts";
import {
Building2,
LayoutGrid,
FileText,
TrendingUp,
Shield,
CheckCircle,
} from "lucide-react";
// ─── Palette de couleurs ──────────────────────────────────────────────────────
const COLORS = [
"#1e40af", // bleu foncé
"#3b82f6", // bleu
"#60a5fa", // bleu clair
"#93c5fd", // bleu très clair
"#1d4ed8",
"#2563eb",
"#6366f1",
"#818cf8",
"#a5b4fc",
"#c7d2fe",
];
const ETAT_COLORS: Record<string, string> = {
"en production": "#16a34a",
"en cours de déploiement": "#ca8a04",
"en projet": "#2563eb",
"abandonné": "#dc2626",
"Inconnu": "#9ca3af",
};
// ─── Composant carte KPI ──────────────────────────────────────────────────────
function KpiCard({
icon,
label,
value,
sub,
color = "primary",
}: {
icon: React.ReactNode;
label: string;
value: string | number;
sub?: string;
color?: "primary" | "green" | "amber" | "blue";
}) {
const colorMap = {
primary: "bg-primary/10 text-primary",
green: "bg-emerald-50 text-emerald-600",
amber: "bg-amber-50 text-amber-600",
blue: "bg-blue-50 text-blue-600",
};
return (
<div className="bg-card border border-border rounded-xl p-5 shadow-sm flex items-start gap-4">
<div className={`p-3 rounded-xl ${colorMap[color]} shrink-0`}>{icon}</div>
<div className="min-w-0">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-0.5">{label}</p>
<p className="text-2xl font-bold text-foreground">{value}</p>
{sub && <p className="text-xs text-muted-foreground mt-0.5">{sub}</p>}
</div>
</div>
);
}
// ─── Tooltip personnalisé ─────────────────────────────────────────────────────
function CustomTooltip({ active, payload, label }: any) {
if (active && payload && payload.length) {
return (
<div className="bg-card border border-border rounded-lg shadow-lg px-3 py-2 text-sm">
<p className="font-medium text-foreground mb-1">{label}</p>
<p className="text-primary font-semibold">{payload[0].value} établissement{payload[0].value !== 1 ? "s" : ""}</p>
</div>
);
}
return null;
}
// ─── Page principale ──────────────────────────────────────────────────────────
export default function Statistiques() {
const { user } = useAuth();
const isGestionnaire = user?.sonumRole === "gestionnaire" || user?.role === "admin";
const statsQuery = trpc.referentiel.statistiques.useQuery(undefined, {
enabled: isGestionnaire,
});
if (!isGestionnaire) {
return (
<SonumLayout>
<div className="p-8 text-center">
<Shield size={48} className="mx-auto text-muted-foreground/30 mb-4" />
<p className="text-muted-foreground font-medium">Accès réservé aux gestionnaires SONUM</p>
</div>
</SonumLayout>
);
}
const stats = statsQuery.data;
return (
<SonumLayout>
<div className="p-6 lg:p-8 max-w-7xl mx-auto">
{/* En-tête */}
<div className="mb-8">
<h1 className="text-2xl font-bold text-foreground mb-1">Tableau de bord statistiques</h1>
<p className="text-muted-foreground text-sm">
Vue d'ensemble de la cartographie des solutions numériques FEHAP
</p>
</div>
{statsQuery.isLoading && (
<div className="flex items-center justify-center py-20">
<div className="animate-spin w-8 h-8 border-4 border-primary/20 border-t-primary rounded-full" />
</div>
)}
{statsQuery.isError && (
<div className="bg-destructive/10 border border-destructive/20 rounded-xl p-6 text-center">
<p className="text-destructive font-medium">Erreur lors du chargement des statistiques</p>
<p className="text-sm text-muted-foreground mt-1">{statsQuery.error?.message}</p>
</div>
)}
{stats && (
<div className="space-y-8">
{/* KPIs */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<KpiCard
icon={<Building2 size={22} />}
label="Établissements"
value={stats.totalEtablissements}
sub="adhérents FEHAP référencés"
color="primary"
/>
<KpiCard
icon={<LayoutGrid size={22} />}
label="Solutions distinctes"
value={stats.totalSolutions}
sub="logiciels référencés"
color="blue"
/>
<KpiCard
icon={<FileText size={22} />}
label="Fiches logiciels"
value={stats.totalFiches}
sub="rattachements établissement / solution"
color="amber"
/>
<KpiCard
icon={<TrendingUp size={22} />}
label="Taux de remplissage"
value={`${stats.tauxRemplissage} %`}
sub={`${stats.etabAvecLogiciel} étab. avec au moins 1 logiciel`}
color="green"
/>
</div>
{/* Graphiques ligne 1 : Blocs fonctionnels + Régions */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Répartition par bloc fonctionnel */}
<div className="bg-card border border-border rounded-xl p-5 shadow-sm">
<h2 className="text-base font-semibold text-foreground mb-4 flex items-center gap-2">
<LayoutGrid size={16} className="text-primary" />
Répartition par bloc fonctionnel
</h2>
{stats.parBloc.length === 0 ? (
<div className="flex items-center justify-center h-48 text-muted-foreground text-sm">
Aucune donnée disponible
</div>
) : (
<ResponsiveContainer width="100%" height={280}>
<BarChart
data={stats.parBloc}
layout="vertical"
margin={{ top: 0, right: 20, left: 0, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" horizontal={false} stroke="hsl(var(--border))" />
<XAxis type="number" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} />
<YAxis
type="category"
dataKey="nom"
width={130}
tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
/>
<Tooltip content={<CustomTooltip />} />
<Bar dataKey="count" fill="hsl(var(--primary))" radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</div>
{/* Répartition par région */}
<div className="bg-card border border-border rounded-xl p-5 shadow-sm">
<h2 className="text-base font-semibold text-foreground mb-4 flex items-center gap-2">
<Building2 size={16} className="text-primary" />
Répartition par région
</h2>
{stats.parRegion.length === 0 ? (
<div className="flex items-center justify-center h-48 text-muted-foreground text-sm">
Aucune donnée disponible
</div>
) : (
<ResponsiveContainer width="100%" height={280}>
<BarChart
data={stats.parRegion}
layout="vertical"
margin={{ top: 0, right: 20, left: 0, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" horizontal={false} stroke="hsl(var(--border))" />
<XAxis type="number" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} />
<YAxis
type="category"
dataKey="nom"
width={130}
tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
/>
<Tooltip content={<CustomTooltip />} />
<Bar dataKey="count" radius={[0, 4, 4, 0]}>
{stats.parRegion.map((_: { nom: string; count: number }, index: number) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
)}
</div>
</div>
{/* Graphiques ligne 2 : État de déploiement + Top solutions */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Répartition par état de déploiement */}
<div className="bg-card border border-border rounded-xl p-5 shadow-sm">
<h2 className="text-base font-semibold text-foreground mb-4 flex items-center gap-2">
<CheckCircle size={16} className="text-primary" />
État de déploiement
</h2>
{stats.parEtat.length === 0 ? (
<div className="flex items-center justify-center h-48 text-muted-foreground text-sm">
Aucune donnée disponible
</div>
) : (
<div className="flex items-center justify-center">
<ResponsiveContainer width="100%" height={260}>
<PieChart>
<Pie
data={stats.parEtat}
dataKey="count"
nameKey="nom"
cx="50%"
cy="45%"
outerRadius={90}
innerRadius={50}
paddingAngle={3}
>
{stats.parEtat.map((entry: { nom: string; count: number }, index: number) => (
<Cell
key={`cell-${index}`}
fill={ETAT_COLORS[entry.nom] ?? COLORS[index % COLORS.length]}
/>
))}
</Pie>
<Tooltip
formatter={(value: number, name: string) => [`${value} fiche${value !== 1 ? "s" : ""}`, name]}
contentStyle={{
background: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: "8px",
fontSize: "12px",
}}
/>
<Legend
iconType="circle"
iconSize={8}
wrapperStyle={{ fontSize: "12px", paddingTop: "8px" }}
/>
</PieChart>
</ResponsiveContainer>
</div>
)}
</div>
{/* Top 10 solutions */}
<div className="bg-card border border-border rounded-xl p-5 shadow-sm">
<h2 className="text-base font-semibold text-foreground mb-4 flex items-center gap-2">
<TrendingUp size={16} className="text-primary" />
Top 10 solutions les plus utilisées
</h2>
{stats.topSolutions.length === 0 ? (
<div className="flex items-center justify-center h-48 text-muted-foreground text-sm">
Aucune donnée disponible
</div>
) : (
<div className="space-y-2 overflow-y-auto max-h-64">
{stats.topSolutions.map((sol: { nom: string; editeur: string; count: number }, index: number) => (
<div key={index} className="flex items-center gap-3">
<span className="text-xs font-bold text-muted-foreground w-5 shrink-0 text-right">
{index + 1}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2 mb-0.5">
<span className="text-sm font-medium text-foreground truncate">{sol.nom}</span>
<span className="text-xs font-semibold text-primary shrink-0">
{sol.count} étab.
</span>
</div>
<div className="flex items-center gap-2">
<div className="flex-1 bg-muted rounded-full h-1.5">
<div
className="bg-primary rounded-full h-1.5 transition-all"
style={{
width: `${Math.round((sol.count / (stats.topSolutions[0]?.count || 1)) * 100)}%`,
}}
/>
</div>
<span className="text-xs text-muted-foreground shrink-0">{sol.editeur}</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
)}
</div>
</SonumLayout>
);
}

19
components.json Normal file
View File

@@ -0,0 +1,19 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"css": "client/src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}

15
drizzle.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from "drizzle-kit";
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error("DATABASE_URL is required to run drizzle commands");
}
export default defineConfig({
schema: "./drizzle/schema.ts",
out: "./drizzle",
dialect: "mysql",
dbCredentials: {
url: connectionString,
},
});

View File

@@ -0,0 +1,13 @@
CREATE TABLE `users` (
`id` int AUTO_INCREMENT NOT NULL,
`openId` varchar(64) NOT NULL,
`name` text,
`email` varchar(320),
`loginMethod` varchar(64),
`role` enum('user','admin') NOT NULL DEFAULT 'user',
`createdAt` timestamp NOT NULL DEFAULT (now()),
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
`lastSignedIn` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `users_id` PRIMARY KEY(`id`),
CONSTRAINT `users_openId_unique` UNIQUE(`openId`)
);

View File

@@ -0,0 +1,89 @@
CREATE TABLE `blocs_fonctionnels` (
`id` int AUTO_INCREMENT NOT NULL,
`nom` varchar(255) NOT NULL,
`estValide` boolean NOT NULL DEFAULT true,
`createdAt` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `blocs_fonctionnels_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `consultations` (
`id` int AUTO_INCREMENT NOT NULL,
`etablissementId` int NOT NULL,
`consultePar` int NOT NULL,
`consulteParNom` varchar(255),
`createdAt` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `consultations_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `demandes_contact` (
`id` int AUTO_INCREMENT NOT NULL,
`etablissementCibleId` int NOT NULL,
`demandeurId` int NOT NULL,
`demandeurNom` varchar(255),
`demandeurEmail` varchar(320),
`message` text NOT NULL,
`statut` enum('en_attente','repondu','ferme') NOT NULL DEFAULT 'en_attente',
`reponse` text,
`reponsePar` int,
`reponduAt` timestamp,
`createdAt` timestamp NOT NULL DEFAULT (now()),
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `demandes_contact_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `editeurs` (
`id` int AUTO_INCREMENT NOT NULL,
`nom` varchar(255) NOT NULL,
`estValide` boolean NOT NULL DEFAULT true,
`createdAt` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `editeurs_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `etablissements` (
`id` int AUTO_INCREMENT NOT NULL,
`finess` varchar(20),
`nom` varchar(255) NOT NULL,
`region` varchar(100),
`departement` varchar(100),
`typeActivite` varchar(100),
`tailleEffectifs` varchar(50),
`referentId` int,
`visibilite` enum('tous','gestionnaires') NOT NULL DEFAULT 'tous',
`accepteMiseEnRelation` boolean NOT NULL DEFAULT true,
`createdAt` timestamp NOT NULL DEFAULT (now()),
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `etablissements_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `logiciels_etablissements` (
`id` int AUTO_INCREMENT NOT NULL,
`etablissementId` int NOT NULL,
`solutionId` int NOT NULL,
`etatDeploiement` enum('demarrage','en_cours','operationnel','en_remplacement') NOT NULL,
`modeHebergement` enum('hds','on_premise','hybride'),
`modeFacturation` enum('saas','achat_maintenance','location'),
`interoperabilite` enum('non','oui_interface','oui_eai'),
`versionMajeure` varchar(50),
`commentaire` text,
`contactNom` varchar(255),
`contactFonction` varchar(255),
`contactEmail` varchar(320),
`saisiePar` int,
`createdAt` timestamp NOT NULL DEFAULT (now()),
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `logiciels_etablissements_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `solutions` (
`id` int AUTO_INCREMENT NOT NULL,
`nom` varchar(255) NOT NULL,
`editeurId` int NOT NULL,
`blocFonctionnelId` int,
`estValide` boolean NOT NULL DEFAULT true,
`createdAt` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `solutions_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
ALTER TABLE `users` ADD `sonumRole` enum('referent','gestionnaire') DEFAULT 'referent' NOT NULL;--> statement-breakpoint
ALTER TABLE `users` ADD `cguAccepted` boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE `users` ADD `cguAcceptedAt` timestamp;

View File

@@ -0,0 +1,20 @@
CREATE TABLE `local_credentials` (
`id` int AUTO_INCREMENT NOT NULL,
`userId` int NOT NULL,
`passwordHash` varchar(255) NOT NULL,
`createdAt` timestamp NOT NULL DEFAULT (now()),
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `local_credentials_id` PRIMARY KEY(`id`),
CONSTRAINT `local_credentials_userId_unique` UNIQUE(`userId`)
);
--> statement-breakpoint
CREATE TABLE `user_etablissements` (
`id` int AUTO_INCREMENT NOT NULL,
`userId` int NOT NULL,
`etablissementId` int NOT NULL,
`createdAt` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `user_etablissements_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
ALTER TABLE `users` MODIFY COLUMN `openId` varchar(64);--> statement-breakpoint
ALTER TABLE `users` MODIFY COLUMN `sonumRole` enum('referent','gestionnaire','adherent') NOT NULL DEFAULT 'referent';

View File

@@ -0,0 +1,110 @@
{
"version": "5",
"dialect": "mysql",
"id": "73e59d3e-6b7c-4c4b-b26e-ab4839c0f17d",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"openId": {
"name": "openId",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "varchar(320)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"loginMethod": {
"name": "loginMethod",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"role": {
"name": "role",
"type": "enum('user','admin')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'user'"
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
},
"lastSignedIn": {
"name": "lastSignedIn",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"users_id": {
"name": "users_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"users_openId_unique": {
"name": "users_openId_unique",
"columns": [
"openId"
]
}
},
"checkConstraint": {}
}
},
"views": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"tables": {},
"indexes": {}
}
}

Some files were not shown because too many files have changed in this diff Show More