feat: Implement dark mode styling across UI components and update README with roadmap and system requirements.
This commit is contained in:
@@ -58,13 +58,13 @@ El proyecto ha sido optimizado para reducir la complejidad de la infraestructura
|
|||||||
|
|
||||||
OpenCCB es altamente escalable. A continuación se detallan los requisitos recomendados según la carga de usuarios concurrentes:
|
OpenCCB es altamente escalable. A continuación se detallan los requisitos recomendados según la carga de usuarios concurrentes:
|
||||||
|
|
||||||
| Componente | **Pequeño (100 u.)** | **Mediano (500 u.)** | **Grande (1000+ u.)** |
|
| Componente | **Pequeño (100 u.)** | **Mediano (500 u. concurrentes)** | **Grande (1000+ u.)** |
|
||||||
| :--- | :--- | :--- | :--- |
|
| :--- | :--- | :--- | :--- |
|
||||||
| **CPU** | 4 vCPUs | 8 vCPUs (AVX2) | 16+ vCPUs |
|
| **CPU** | 4 vCPUs | 8-12 vCPUs (AVX2/AVX-512) | 16-32+ vCPUs |
|
||||||
| **RAM** | 8 GB | 16 GB | 32 GB+ |
|
| **RAM** | 8 GB | 16-32 GB (Recomendado 24GB+) | 64 GB+ |
|
||||||
| **Almacenamiento** | 50 GB SSD | 200 GB NVMe | 500 GB+ NVMe |
|
| **Almacenamiento** | 50 GB SSD | 250 GB+ NVMe (RAID-1) | 1 TB+ NVMe (S3 Backup) |
|
||||||
| **AI (Opcional)** | N/A (Solo CPU) | NVIDIA 8GB+ VRAM | Multi-GPU / Cloud API |
|
| **AI (Opcional)** | N/A (Solo CPU) | NVIDIA RTX 3060+ (12GB VRAM) | Multi-GPU (A100/H100) |
|
||||||
| **OS** | Ubuntu 22.04 LTS | Ubuntu 22.04+ | Cloud Managed (K8s) |
|
| **OS** | Ubuntu 22.04 LTS | Ubuntu 24.04 LTS / Debian | Cloud Native (K8s / Terraform) |
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> Los requisitos de AI son específicos para la función de transcripción local (Whisper). Si se utiliza una API externa, el requisito de GPU desaparece.
|
> Los requisitos de AI son específicos para la función de transcripción local (Whisper). Si se utiliza una API externa, el requisito de GPU desaparece.
|
||||||
@@ -645,5 +645,33 @@ Obtiene una lista de todas las organizaciones registradas.
|
|||||||
- **Global Asset Manager**: Interfaz avanzada para la administración masiva de archivos con previsualización inteligente y filtros por curso o tipo.
|
- **Global Asset Manager**: Interfaz avanzada para la administración masiva de archivos con previsualización inteligente y filtros por curso o tipo.
|
||||||
- **Predictive Risk Dashboard**: Panel de control para instructores que visualiza el riesgo de deserción escolar mediante semáforos de color y motivos detallados del riesgo.
|
- **Predictive Risk Dashboard**: Panel de control para instructores que visualiza el riesgo de deserción escolar mediante semáforos de color y motivos detallados del riesgo.
|
||||||
|
|
||||||
## 📄 Licencia
|
## �️ Próximos Pasos (Roadmap 2024-2025)
|
||||||
|
|
||||||
|
OpenCCB evoluciona constantemente. Estos son los pilares de nuestro desarrollo futuro:
|
||||||
|
|
||||||
|
### 📱 Movilidad Nativa
|
||||||
|
- **Apps Android/iOS**: Aplicaciones nativas desarrolladas con Flutter para aprendizaje offline y notificaciones push críticas.
|
||||||
|
- **Offline Sync**: Capacidad de descargar lecciones y sincronizar progreso al recuperar conexión.
|
||||||
|
|
||||||
|
### 🧠 Inteligencia Artificial Avanzada
|
||||||
|
- **AI Proctoring**: Monitoreo basado en visión artificial para exámenes de alta integridad, 100% privado y local.
|
||||||
|
- **Multimodal Tutoring**: El tutor de IA podrá analizar imágenes y videos subidos por el alumno para dar feedback.
|
||||||
|
- **Automated Grading for Open Questions**: Evaluación masiva de ensayos y respuestas abiertas con rúbricas personalizadas.
|
||||||
|
|
||||||
|
### 🔌 Interoperabilidad y Estándares
|
||||||
|
- **SCORM 1.2 / 2004 Support**: Player nativo para contenidos legados de la industria.
|
||||||
|
- **Advanced xAPI (Tin Can)**: Recolección detallada de experiencias de aprendizaje granulares.
|
||||||
|
- **Microsoft Teams / Slack Integration**: Recibe anuncios y tareas directamente en tus herramientas de trabajo.
|
||||||
|
|
||||||
|
### 🏗️ Infraestructura y Escalabilidad
|
||||||
|
- **Multi-Cloud Terraform Provider**: Despliegues automatizados en AWS, GCP y Azure.
|
||||||
|
- **Edge Content Delivery**: Caché de videos y assets en el borde para mínima latencia global.
|
||||||
|
|
||||||
|
### 🎮 Gamificación y Comunidad
|
||||||
|
- **Real-time Leaderboards**: Tablas de clasificación en vivo por cohorte y organización.
|
||||||
|
- **Social Learning Groups**: Grupos de estudio auto-organizados con chats integrados.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## �📄 Licencia
|
||||||
Este proyecto es código abierto y está disponible bajo los términos de la licencia especificada en el repositorio.
|
Este proyecto es código abierto y está disponible bajo los términos de la licencia especificada en el repositorio.
|
||||||
@@ -1721,6 +1721,7 @@ pub struct AdminCreateUserPayload {
|
|||||||
pub password: String,
|
pub password: String,
|
||||||
pub full_name: String,
|
pub full_name: String,
|
||||||
pub role: String,
|
pub role: String,
|
||||||
|
pub organization_id: Option<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn register(
|
pub async fn register(
|
||||||
@@ -1809,6 +1810,15 @@ pub async fn admin_create_user(
|
|||||||
let password_hash = hash(payload.password, DEFAULT_COST)
|
let password_hash = hash(payload.password, DEFAULT_COST)
|
||||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Hashing failed".into()))?;
|
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Hashing failed".into()))?;
|
||||||
|
|
||||||
|
let is_super_admin = claims.role == "admin"
|
||||||
|
&& claims.org == Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap();
|
||||||
|
|
||||||
|
let target_org_id = if is_super_admin {
|
||||||
|
payload.organization_id.unwrap_or(org_ctx.id)
|
||||||
|
} else {
|
||||||
|
org_ctx.id
|
||||||
|
};
|
||||||
|
|
||||||
let user = sqlx::query_as::<_, User>(
|
let user = sqlx::query_as::<_, User>(
|
||||||
"INSERT INTO users (email, password_hash, full_name, role, organization_id) VALUES ($1, $2, $3, $4, $5) RETURNING *"
|
"INSERT INTO users (email, password_hash, full_name, role, organization_id) VALUES ($1, $2, $3, $4, $5) RETURNING *"
|
||||||
)
|
)
|
||||||
@@ -1816,7 +1826,7 @@ pub async fn admin_create_user(
|
|||||||
.bind(password_hash)
|
.bind(password_hash)
|
||||||
.bind(&payload.full_name)
|
.bind(&payload.full_name)
|
||||||
.bind(&payload.role)
|
.bind(&payload.role)
|
||||||
.bind(org_ctx.id)
|
.bind(target_org_id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
@@ -2662,9 +2672,39 @@ pub async fn update_user(
|
|||||||
.get("organization_id")
|
.get("organization_id")
|
||||||
.and_then(|o| o.as_str())
|
.and_then(|o| o.as_str())
|
||||||
.and_then(|o| Uuid::parse_str(o).ok());
|
.and_then(|o| Uuid::parse_str(o).ok());
|
||||||
|
let is_super_admin = claims.role == "admin"
|
||||||
|
&& claims.org == Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap();
|
||||||
|
|
||||||
let user = sqlx::query_as::<_, User>(
|
let user = if is_super_admin {
|
||||||
"UPDATE users SET role = COALESCE($1, role), organization_id = COALESCE($2, organization_id), full_name = COALESCE($3, full_name), avatar_url = COALESCE($4, avatar_url), bio = COALESCE($5, bio), language = COALESCE($6, language) WHERE id = $7 AND organization_id = $8 RETURNING *"
|
sqlx::query_as::<_, User>(
|
||||||
|
"UPDATE users SET
|
||||||
|
role = COALESCE($1, role),
|
||||||
|
organization_id = COALESCE($2, organization_id),
|
||||||
|
full_name = COALESCE($3, full_name),
|
||||||
|
avatar_url = COALESCE($4, avatar_url),
|
||||||
|
bio = COALESCE($5, bio),
|
||||||
|
language = COALESCE($6, language)
|
||||||
|
WHERE id = $7 RETURNING *"
|
||||||
|
)
|
||||||
|
.bind(role)
|
||||||
|
.bind(organization_id)
|
||||||
|
.bind(full_name)
|
||||||
|
.bind(avatar_url)
|
||||||
|
.bind(bio)
|
||||||
|
.bind(language)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
} else {
|
||||||
|
sqlx::query_as::<_, User>(
|
||||||
|
"UPDATE users SET
|
||||||
|
role = COALESCE($1, role),
|
||||||
|
organization_id = COALESCE($2, organization_id),
|
||||||
|
full_name = COALESCE($3, full_name),
|
||||||
|
avatar_url = COALESCE($4, avatar_url),
|
||||||
|
bio = COALESCE($5, bio),
|
||||||
|
language = COALESCE($6, language)
|
||||||
|
WHERE id = $7 AND organization_id = $8 RETURNING *"
|
||||||
)
|
)
|
||||||
.bind(role)
|
.bind(role)
|
||||||
.bind(organization_id)
|
.bind(organization_id)
|
||||||
@@ -2676,6 +2716,7 @@ pub async fn update_user(
|
|||||||
.bind(org_ctx.id)
|
.bind(org_ctx.id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
|
}
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
log_action(
|
log_action(
|
||||||
@@ -2790,6 +2831,44 @@ pub async fn create_organization(
|
|||||||
Ok(Json(org))
|
Ok(Json(org))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn update_organization(
|
||||||
|
claims: common::auth::Claims,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(payload): Json<serde_json::Value>,
|
||||||
|
) -> Result<Json<Organization>, (StatusCode, String)> {
|
||||||
|
// Only super admins or admins of the same org?
|
||||||
|
// Usually editing other orgs is a Super Admin only task.
|
||||||
|
let is_super_admin = claims.role == "admin"
|
||||||
|
&& claims.org == Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap();
|
||||||
|
|
||||||
|
if !is_super_admin {
|
||||||
|
return Err((StatusCode::FORBIDDEN, "Super Admin access required".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = payload.get("name").and_then(|v| v.as_str());
|
||||||
|
let domain = payload.get("domain").and_then(|v| v.as_str());
|
||||||
|
|
||||||
|
let org = sqlx::query_as::<_, Organization>(
|
||||||
|
"UPDATE organizations SET
|
||||||
|
name = COALESCE($1, name),
|
||||||
|
domain = COALESCE($2, domain),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $3 RETURNING *"
|
||||||
|
)
|
||||||
|
.bind(name)
|
||||||
|
.bind(domain)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!("Failed to update organization: {}", e);
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, "Failed to update organization".into())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Json(org))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn provision_organization(
|
pub async fn provision_organization(
|
||||||
claims: common::auth::Claims,
|
claims: common::auth::Claims,
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ async fn main() {
|
|||||||
"/organizations",
|
"/organizations",
|
||||||
get(handlers::get_organizations).post(handlers::create_organization),
|
get(handlers::get_organizations).post(handlers::create_organization),
|
||||||
)
|
)
|
||||||
|
.route("/organizations/{id}", put(handlers::update_organization))
|
||||||
.route("/admin/provision", post(handlers::provision_organization))
|
.route("/admin/provision", post(handlers::provision_organization))
|
||||||
.route(
|
.route(
|
||||||
"/webhooks",
|
"/webhooks",
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ export default function CatalogPage() {
|
|||||||
<div className="w-24 h-24 rounded-3xl bg-blue-600 flex items-center justify-center shadow-2xl shadow-blue-500/40 rotate-3 group-hover:rotate-0 transition-transform duration-500">
|
<div className="w-24 h-24 rounded-3xl bg-blue-600 flex items-center justify-center shadow-2xl shadow-blue-500/40 rotate-3 group-hover:rotate-0 transition-transform duration-500">
|
||||||
<Zap className="text-white fill-white/20" size={48} />
|
<Zap className="text-white fill-white/20" size={48} />
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute -bottom-2 -right-2 w-10 h-10 rounded-full bg-white dark:bg-black text-black dark:text-gray-400 flex items-center justify-center font-black text-xs border-4 border-gray-50 dark:border-[#050505]">
|
<div className="absolute -bottom-2 -right-2 w-10 h-10 rounded-full bg-white dark:bg-black text-slate-900 dark:text-gray-400 flex items-center justify-center font-black text-xs border-4 border-slate-50 dark:border-[#050505]">
|
||||||
{gamification.level}
|
{gamification.level}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -134,19 +134,19 @@ export default function CatalogPage() {
|
|||||||
<div className="flex-1 space-y-4">
|
<div className="flex-1 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[10px] font-black uppercase tracking-[0.3em] text-blue-600 dark:text-blue-400 mb-1">Posición Actual</div>
|
<div className="text-[10px] font-black uppercase tracking-[0.3em] text-blue-600 dark:text-blue-400 mb-1">Posición Actual</div>
|
||||||
<h2 className="text-3xl font-black text-gray-900 dark:text-white">Nivel {gamification.level} Pionero</h2>
|
<h2 className="text-3xl font-black text-slate-900 dark:text-white">Nivel {gamification.level} Pionero</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex justify-between items-end">
|
<div className="flex justify-between items-end">
|
||||||
<div className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">
|
<div className="text-[10px] font-bold text-slate-500 uppercase tracking-widest">
|
||||||
{gamification.points} / {Math.pow(gamification.level, 2) * 100} XP
|
{gamification.points} / {Math.pow(gamification.level, 2) * 100} XP
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] font-black text-blue-400 uppercase tracking-widest">
|
<div className="text-[10px] font-black text-blue-500 dark:text-blue-400 uppercase tracking-widest">
|
||||||
{Math.floor(((gamification.points - Math.pow(gamification.level - 1, 2) * 100) / (Math.pow(gamification.level, 2) * 100 - Math.pow(gamification.level - 1, 2) * 100)) * 100)}% para el Nivel {gamification.level + 1}
|
{Math.floor(((gamification.points - Math.pow(gamification.level - 1, 2) * 100) / (Math.pow(gamification.level, 2) * 100 - Math.pow(gamification.level - 1, 2) * 100)) * 100)}% para el Nivel {gamification.level + 1}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-2 w-full bg-black/5 dark:bg-white/5 rounded-full overflow-hidden border border-black/5 dark:border-white/5">
|
<div className="h-2 w-full bg-slate-100 dark:bg-white/5 rounded-full overflow-hidden border border-slate-200 dark:border-white/5">
|
||||||
<div
|
<div
|
||||||
className="h-full bg-gradient-to-r from-blue-500 to-indigo-500 shadow-[0_0_20px_rgba(59,130,246,0.3)] dark:shadow-[0_0_20px_rgba(59,130,246,0.5)] transition-all duration-1000"
|
className="h-full bg-gradient-to-r from-blue-500 to-indigo-500 shadow-[0_0_20px_rgba(59,130,246,0.3)] dark:shadow-[0_0_20px_rgba(59,130,246,0.5)] transition-all duration-1000"
|
||||||
style={{ width: `${Math.min(100, Math.max(0, ((gamification.points - Math.pow(gamification.level - 1, 2) * 100) / (Math.pow(gamification.level, 2) * 100 - Math.pow(gamification.level - 1, 2) * 100)) * 100))}%` }}
|
style={{ width: `${Math.min(100, Math.max(0, ((gamification.points - Math.pow(gamification.level - 1, 2) * 100) / (Math.pow(gamification.level, 2) * 100 - Math.pow(gamification.level - 1, 2) * 100)) * 100))}%` }}
|
||||||
@@ -224,7 +224,7 @@ export default function CatalogPage() {
|
|||||||
const isEnrolled = enrollments.includes(course.id);
|
const isEnrolled = enrollments.includes(course.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={course.id} className="glass-card group relative overflow-hidden h-full flex flex-col p-8 border-black/5 dark:border-white/5 bg-black/[0.01] dark:bg-white/[0.02] hover:bg-black/[0.03] dark:hover:bg-white/[0.04] transition-all duration-500 rounded-3xl">
|
<div key={course.id} className="glass-card group relative overflow-hidden h-full flex flex-col p-8 border-slate-200 dark:border-white/5 bg-white dark:bg-white/[0.02] hover:bg-slate-50 dark:hover:bg-white/[0.04] transition-all duration-500 rounded-3xl shadow-sm hover:shadow-xl hover:shadow-slate-200/50 dark:hover:shadow-none">
|
||||||
<div className="mb-8 flex items-start justify-between">
|
<div className="mb-8 flex items-start justify-between">
|
||||||
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-blue-600 to-indigo-700 flex items-center justify-center shadow-2xl shadow-blue-500/20 group-hover:scale-110 transition-transform duration-500">
|
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-blue-600 to-indigo-700 flex items-center justify-center shadow-2xl shadow-blue-500/20 group-hover:scale-110 transition-transform duration-500">
|
||||||
<Rocket size={24} className="text-white fill-white/10" />
|
<Rocket size={24} className="text-white fill-white/10" />
|
||||||
@@ -246,7 +246,7 @@ export default function CatalogPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-8 border-t border-black/5 dark:border-white/5 flex items-center justify-between mt-auto">
|
<div className="pt-8 border-t border-slate-100 dark:border-white/5 flex items-center justify-between mt-auto">
|
||||||
{isEnrolled ? (
|
{isEnrolled ? (
|
||||||
<Link href={`/courses/${course.id}`} className="btn-premium w-full !bg-blue-600/10 !text-blue-400 border border-blue-500/20 hover:!bg-blue-600/20 !shadow-none gap-2">
|
<Link href={`/courses/${course.id}`} className="btn-premium w-full !bg-blue-600/10 !text-blue-400 border border-blue-500/20 hover:!bg-blue-600/20 !shadow-none gap-2">
|
||||||
Continuar Aprendiendo <ArrowRight size={16} />
|
Continuar Aprendiendo <ArrowRight size={16} />
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export default function Leaderboard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="glass-card p-8 border-white/5 bg-white/[0.01] rounded-3xl overflow-hidden relative">
|
<div className="glass-card p-8 border-slate-200 dark:border-white/5 bg-white dark:bg-white/[0.01] rounded-3xl overflow-hidden relative shadow-sm">
|
||||||
<h3 className="text-xs font-black uppercase tracking-widest text-gray-500 mb-6 flex items-center gap-2">
|
<h3 className="text-xs font-black uppercase tracking-widest text-gray-500 mb-6 flex items-center gap-2">
|
||||||
<Trophy size={14} className="text-amber-500" aria-hidden="true" /> Tabla de Clasificación
|
<Trophy size={14} className="text-amber-500" aria-hidden="true" /> Tabla de Clasificación
|
||||||
</h3>
|
</h3>
|
||||||
@@ -41,24 +41,24 @@ export default function Leaderboard() {
|
|||||||
<li
|
<li
|
||||||
key={user.id}
|
key={user.id}
|
||||||
className={`flex items-center gap-4 p-4 rounded-2xl transition-all ${index === 0 ? 'bg-gradient-to-r from-amber-500/10 to-transparent border border-amber-500/20 shadow-lg shadow-amber-500/5' :
|
className={`flex items-center gap-4 p-4 rounded-2xl transition-all ${index === 0 ? 'bg-gradient-to-r from-amber-500/10 to-transparent border border-amber-500/20 shadow-lg shadow-amber-500/5' :
|
||||||
'bg-white/5 border border-white/5 hover:bg-white/10'
|
'bg-slate-50 dark:bg-white/5 border border-slate-100 dark:border-white/5 hover:bg-slate-100 dark:hover:bg-white/10'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex-shrink-0 w-8 text-center font-black text-xs text-gray-600" aria-label={`Posición ${index + 1}`}>
|
<div className="flex-shrink-0 w-8 text-center font-black text-xs text-slate-400 dark:text-gray-600" aria-label={`Posición ${index + 1}`}>
|
||||||
{index === 0 ? <Medal className="text-amber-500 mx-auto" size={18} aria-hidden="true" /> :
|
{index === 0 ? <Medal className="text-amber-500 mx-auto" size={18} aria-hidden="true" /> :
|
||||||
index === 1 ? <Medal className="text-gray-400 mx-auto" size={18} aria-hidden="true" /> :
|
index === 1 ? <Medal className="text-slate-400 dark:text-gray-400 mx-auto" size={18} aria-hidden="true" /> :
|
||||||
index === 2 ? <Medal className="text-amber-700 mx-auto" size={18} aria-hidden="true" /> :
|
index === 2 ? <Medal className="text-amber-700 dark:text-amber-700 mx-auto" size={18} aria-hidden="true" /> :
|
||||||
index + 1}
|
index + 1}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="text-sm font-bold text-gray-200 line-clamp-1">{user.full_name}</div>
|
<div className="text-sm font-bold text-slate-900 dark:text-gray-200 line-clamp-1">{user.full_name}</div>
|
||||||
<div className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">Nivel {user.level || 1}</div>
|
<div className="text-[10px] font-bold text-slate-500 dark:text-gray-500 uppercase tracking-widest">Nivel {user.level || 1}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="text-sm font-black text-white">{user.xp || 0}</div>
|
<div className="text-sm font-black text-slate-900 dark:text-white">{user.xp || 0}</div>
|
||||||
<div className="text-[8px] font-black text-blue-500 uppercase tracking-widest">XP</div>
|
<div className="text-[8px] font-black text-blue-600 dark:text-blue-500 uppercase tracking-widest">XP</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -25,16 +25,16 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen bg-transparent text-gray-900 dark:text-white transition-colors duration-300">
|
<div className="flex min-h-screen bg-slate-50 dark:bg-transparent text-slate-900 dark:text-white transition-colors duration-300">
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<aside className="w-64 border-r border-black/5 dark:border-white/5 bg-gray-50/50 dark:bg-black/20 backdrop-blur-xl p-6 flex flex-col gap-8">
|
<aside className="w-64 border-r border-slate-200 dark:border-white/5 bg-white dark:bg-black/20 backdrop-blur-xl p-6 flex flex-col gap-8 shadow-sm dark:shadow-none">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-10 h-10 rounded-xl bg-indigo-600 flex items-center justify-center shadow-lg shadow-indigo-500/20">
|
<div className="w-10 h-10 rounded-xl bg-indigo-600 flex items-center justify-center shadow-lg shadow-indigo-500/20">
|
||||||
<ShieldCheck className="text-white" size={24} />
|
<ShieldCheck className="text-white" size={24} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="font-black text-xs uppercase tracking-widest text-gray-500 leading-tight">Control Panel</h2>
|
<h2 className="font-black text-[10px] uppercase tracking-widest text-slate-400 dark:text-gray-500 leading-tight">Control Panel</h2>
|
||||||
<h1 className="font-black text-lg tracking-tighter">SUPER ADMIN</h1>
|
<h1 className="font-black text-lg tracking-tighter text-slate-900 dark:text-white">SUPER ADMIN</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -44,8 +44,8 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
key={item.href}
|
key={item.href}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className={`flex items-center gap-3 px-4 py-3 rounded-xl font-bold text-sm transition-all ${pathname === item.href
|
className={`flex items-center gap-3 px-4 py-3 rounded-xl font-bold text-sm transition-all ${pathname === item.href
|
||||||
? "bg-indigo-600/10 text-indigo-400 border border-indigo-500/20 shadow-glow-sm"
|
? "bg-indigo-600 text-white shadow-lg shadow-indigo-500/30"
|
||||||
: "text-gray-500 hover:text-white hover:bg-white/5 border border-transparent"
|
: "text-slate-500 hover:text-slate-900 dark:text-gray-400 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/5 border border-transparent"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<item.icon size={18} />
|
<item.icon size={18} />
|
||||||
@@ -54,10 +54,10 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="pt-6 border-t border-white/5">
|
<div className="pt-6 border-t border-slate-200 dark:border-white/5">
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
className="flex items-center gap-2 text-xs font-black uppercase tracking-widest text-gray-600 hover:text-white transition-colors"
|
className="flex items-center gap-2 text-xs font-black uppercase tracking-widest text-slate-400 dark:text-gray-600 hover:text-slate-900 dark:hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
<ArrowLeft size={14} />
|
<ArrowLeft size={14} />
|
||||||
Back to Studio
|
Back to Studio
|
||||||
@@ -66,7 +66,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className="flex-1 p-12 overflow-y-auto">
|
<main className="flex-1 p-8 md:p-12 overflow-y-auto">
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-6xl mx-auto">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,16 +4,20 @@ import { useState, useEffect } from 'react';
|
|||||||
import { cmsApi, Organization, getImageUrl, API_BASE_URL } from '@/lib/api';
|
import { cmsApi, Organization, getImageUrl, API_BASE_URL } from '@/lib/api';
|
||||||
import { useAuth } from '@/context/AuthContext';
|
import { useAuth } from '@/context/AuthContext';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { Plus, Building2, Globe, Calendar, ExternalLink, ShieldCheck, Palette, Upload, Save, X, Fingerprint, Key, Settings2 } from 'lucide-react';
|
import { Plus, Building2, Globe, Calendar, ExternalLink, ShieldCheck, Palette, Upload, Save, X, Fingerprint, Key, Settings2, Edit2 } from 'lucide-react';
|
||||||
|
|
||||||
export default function OrganizationsPage() {
|
export default function OrganizationsPage() {
|
||||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
|
||||||
|
// Create/Edit States
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [editingOrgId, setEditingOrgId] = useState<string | null>(null);
|
||||||
const [newName, setNewName] = useState('');
|
const [newName, setNewName] = useState('');
|
||||||
const [newDomain, setNewDomain] = useState('');
|
const [newDomain, setNewDomain] = useState('');
|
||||||
|
|
||||||
// Admin User States
|
// Admin User States (Only for creation)
|
||||||
const [adminFullName, setAdminFullName] = useState('');
|
const [adminFullName, setAdminFullName] = useState('');
|
||||||
const [adminEmail, setAdminEmail] = useState('');
|
const [adminEmail, setAdminEmail] = useState('');
|
||||||
const [adminPassword, setAdminPassword] = useState('');
|
const [adminPassword, setAdminPassword] = useState('');
|
||||||
@@ -54,6 +58,12 @@ export default function OrganizationsPage() {
|
|||||||
const handleCreate = async (e: React.FormEvent) => {
|
const handleCreate = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
try {
|
||||||
|
if (isEditing && editingOrgId) {
|
||||||
|
await cmsApi.updateOrganization(editingOrgId, {
|
||||||
|
name: newName,
|
||||||
|
domain: newDomain || undefined
|
||||||
|
});
|
||||||
|
} else {
|
||||||
await cmsApi.provisionOrganization({
|
await cmsApi.provisionOrganization({
|
||||||
org_name: newName,
|
org_name: newName,
|
||||||
org_domain: newDomain || undefined,
|
org_domain: newDomain || undefined,
|
||||||
@@ -61,17 +71,32 @@ export default function OrganizationsPage() {
|
|||||||
admin_email: adminEmail,
|
admin_email: adminEmail,
|
||||||
admin_password: adminPassword
|
admin_password: adminPassword
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
resetForm();
|
||||||
|
loadOrganizations();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save organization', error);
|
||||||
|
alert('Failed to save organization. Please check the details.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
setNewName('');
|
setNewName('');
|
||||||
setNewDomain('');
|
setNewDomain('');
|
||||||
setAdminFullName('');
|
setAdminFullName('');
|
||||||
setAdminEmail('');
|
setAdminEmail('');
|
||||||
setAdminPassword('');
|
setAdminPassword('');
|
||||||
|
setIsEditing(false);
|
||||||
|
setEditingOrgId(null);
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
loadOrganizations();
|
};
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to create organization', error);
|
const openEdit = (org: Organization) => {
|
||||||
alert('Failed to provision organization. Please ensure the email is unique.');
|
setNewName(org.name);
|
||||||
}
|
setNewDomain(org.domain || '');
|
||||||
|
setEditingOrgId(org.id);
|
||||||
|
setIsEditing(true);
|
||||||
|
setIsModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openBranding = (org: Organization) => {
|
const openBranding = (org: Organization) => {
|
||||||
@@ -170,7 +195,7 @@ export default function OrganizationsPage() {
|
|||||||
<ShieldCheck className="w-12 h-12 text-red-500" />
|
<ShieldCheck className="w-12 h-12 text-red-500" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold mb-2">Access Denied</h1>
|
<h1 className="text-2xl font-bold mb-2">Access Denied</h1>
|
||||||
<p className="text-gray-400">Only system administrators can access this page.</p>
|
<p className="text-gray-600 dark:text-gray-400">Only system administrators can access this page.</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -179,11 +204,11 @@ export default function OrganizationsPage() {
|
|||||||
<div className="space-y-6 md:space-y-8 animate-in fade-in duration-500 p-4 md:p-0">
|
<div className="space-y-6 md:space-y-8 animate-in fade-in duration-500 p-4 md:p-0">
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl md:text-3xl font-bold tracking-tight text-gray-900 dark:text-white">Organizations</h1>
|
<h1 className="text-2xl md:text-3xl font-bold tracking-tight text-slate-900 dark:text-white">Organizations</h1>
|
||||||
<p className="text-gray-600 dark:text-gray-400 mt-1 text-sm">Manage tenants and isolated environments.</p>
|
<p className="text-slate-600 dark:text-gray-400 mt-1 text-sm">Manage tenants and isolated environments.</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsModalOpen(true)}
|
onClick={() => { resetForm(); setIsModalOpen(true); }}
|
||||||
className="w-full sm:w-auto flex items-center justify-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-all shadow-lg shadow-blue-500/20 shadow-glow"
|
className="w-full sm:w-auto flex items-center justify-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-all shadow-lg shadow-blue-500/20 shadow-glow"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
@@ -194,7 +219,7 @@ export default function OrganizationsPage() {
|
|||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{[1, 2, 3].map(i => (
|
{[1, 2, 3].map(i => (
|
||||||
<div key={i} className="h-48 rounded-xl glass animate-pulse" />
|
<div key={i} className="h-48 rounded-xl bg-slate-100 dark:bg-white/5 border border-slate-200 dark:border-white/10 animate-pulse" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -202,59 +227,68 @@ export default function OrganizationsPage() {
|
|||||||
{organizations.map((org) => (
|
{organizations.map((org) => (
|
||||||
<div
|
<div
|
||||||
key={org.id}
|
key={org.id}
|
||||||
className="group relative p-6 rounded-xl bg-black/5 dark:bg-white/5 border border-black/10 dark:border-white/10 hover:border-blue-500/50 transition-all hover:translate-y-[-2px] overflow-hidden"
|
className="group relative p-6 rounded-xl bg-white dark:bg-white/5 border border-slate-200 dark:border-white/10 hover:border-blue-500/50 transition-all hover:translate-y-[-2px] overflow-hidden shadow-sm dark:shadow-none"
|
||||||
>
|
>
|
||||||
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
|
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity text-slate-400 dark:text-white">
|
||||||
<Building2 className="w-16 h-16" />
|
<Building2 className="w-16 h-16" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-start gap-4 mb-4">
|
<div className="flex items-start gap-4 mb-4">
|
||||||
<div className="p-3 rounded-lg bg-blue-500/10 text-blue-400 overflow-hidden w-12 h-12 flex items-center justify-center relative">
|
<div className="p-3 rounded-lg bg-blue-500/10 text-blue-600 dark:text-blue-400 overflow-hidden w-12 h-12 flex items-center justify-center relative border border-blue-500/20">
|
||||||
{org.logo_url ? (
|
{org.logo_url ? (
|
||||||
<Image src={getImageUrl(org.logo_url)} alt={org.name} fill className="object-contain" unoptimized />
|
<Image src={getImageUrl(org.logo_url)} alt={org.name} fill className="object-contain" unoptimized />
|
||||||
) : (
|
) : (
|
||||||
<Building2 className="w-6 h-6" />
|
<Building2 className="w-6 h-6" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="font-semibold text-lg text-gray-900 dark:text-white">{org.name}</h3>
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="flex items-center gap-1.5 text-sm text-gray-400">
|
<h3 className="font-semibold text-lg text-slate-900 dark:text-white truncate">{org.name}</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => openEdit(org)}
|
||||||
|
className="p-1.5 rounded-md hover:bg-slate-100 dark:hover:bg-white/10 text-slate-400 hover:text-slate-600 dark:hover:text-white transition-colors"
|
||||||
|
title="Edit Organization"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 text-sm text-slate-500 dark:text-gray-400">
|
||||||
<Globe className="w-3 h-3" />
|
<Globe className="w-3 h-3" />
|
||||||
{org.domain || 'No custom domain'}
|
<span className="truncate">{org.domain || 'No custom domain'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 mt-4 mb-2">
|
<div className="flex gap-2 mt-4 mb-2">
|
||||||
<div className="flex-1 h-1 rounded-full" style={{ backgroundColor: org.primary_color || '#3B82F6' }} title="Primary Color" />
|
<div className="flex-1 h-1 rounded-full opacity-60" style={{ backgroundColor: org.primary_color || '#3B82F6' }} title="Primary Color" />
|
||||||
<div className="flex-1 h-1 rounded-full" style={{ backgroundColor: org.secondary_color || '#8B5CF6' }} title="Secondary Color" />
|
<div className="flex-1 h-1 rounded-full opacity-60" style={{ backgroundColor: org.secondary_color || '#8B5CF6' }} title="Secondary Color" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3 mt-4">
|
<div className="space-y-3 mt-4">
|
||||||
<div className="flex items-center justify-between text-xs text-gray-600 dark:text-gray-500 bg-black/5 dark:bg-black/20 p-2 rounded-lg">
|
<div className="flex items-center justify-between text-xs text-slate-500 dark:text-gray-500 bg-slate-50 dark:bg-black/20 p-2 rounded-lg border border-slate-100 dark:border-white/5">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Calendar className="w-3 h-3" />
|
<Calendar className="w-3 h-3" />
|
||||||
Created: {new Date(org.created_at).toLocaleDateString()}
|
{new Date(org.created_at).toLocaleDateString()}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-blue-500 font-mono">
|
<div className="text-blue-600 dark:text-blue-500 font-mono">
|
||||||
{org.id.split('-')[0]}...
|
{org.id.split('-')[0]}...
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => openBranding(org)}
|
onClick={() => openBranding(org)}
|
||||||
className="py-2 px-2 text-[10px] font-medium border border-blue-500/20 bg-blue-500/5 hover:bg-blue-500/10 text-blue-400 rounded-lg transition-colors flex items-center justify-center gap-1"
|
className="py-2 px-2 text-[10px] font-bold border border-blue-500/20 bg-blue-50 dark:bg-blue-500/5 hover:bg-blue-100 dark:hover:bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-lg transition-colors flex items-center justify-center gap-1"
|
||||||
>
|
>
|
||||||
<Palette className="w-3 h-3" /> Brand
|
<Palette className="w-3 h-3" /> BRAND
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => openSSOConfig(org)}
|
onClick={() => openSSOConfig(org)}
|
||||||
className="py-2 px-2 text-[10px] font-medium border border-blue-500/20 bg-blue-500/5 hover:bg-blue-500/10 text-blue-400 rounded-lg transition-colors flex items-center justify-center gap-1"
|
className="py-2 px-2 text-[10px] font-bold border border-indigo-500/20 bg-indigo-50 dark:bg-indigo-500/5 hover:bg-indigo-100 dark:hover:bg-indigo-500/10 text-indigo-600 dark:text-indigo-400 rounded-lg transition-colors flex items-center justify-center gap-1"
|
||||||
>
|
>
|
||||||
<Fingerprint className="w-3 h-3" /> SSO
|
<Fingerprint className="w-3 h-3" /> SSO
|
||||||
</button>
|
</button>
|
||||||
<button className="py-2 px-2 text-[10px] font-medium border border-black/10 dark:border-white/5 bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10 rounded-lg transition-colors flex items-center justify-center gap-1 text-gray-600 dark:text-gray-400">
|
<button className="py-2 px-2 text-[10px] font-bold border border-slate-200 dark:border-white/5 bg-slate-50 dark:bg-white/5 hover:bg-slate-100 dark:hover:bg-white/10 rounded-lg transition-colors flex items-center justify-center gap-1 text-slate-600 dark:text-gray-400">
|
||||||
Docs <ExternalLink className="w-3 h-3" />
|
DOCS <ExternalLink className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -263,85 +297,99 @@ export default function OrganizationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Create Organization Modal */}
|
{/* Create/Edit Organization Modal */}
|
||||||
{isModalOpen && (
|
{isModalOpen && (
|
||||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-in fade-in duration-200">
|
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-sm animate-in fade-in duration-200">
|
||||||
<div className="w-full max-w-md bg-white dark:bg-gray-900 border border-black/10 dark:border-white/10 rounded-2xl p-8 shadow-2xl">
|
<div className="w-full max-w-md bg-white dark:bg-slate-900 border border-slate-200 dark:border-white/10 rounded-2xl p-8 shadow-2xl overflow-y-auto max-h-[90vh]">
|
||||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-6">Create New Organization</h2>
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h2 className="text-xl font-bold text-slate-900 dark:text-white">
|
||||||
|
{isEditing ? 'Edit Organization' : 'Create New Organization'}
|
||||||
|
</h2>
|
||||||
|
<button onClick={resetForm} className="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-white rounded-full transition-colors">
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleCreate} className="space-y-4">
|
<form onSubmit={handleCreate} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-400 mb-1.5">Organization Name</label>
|
<label className="block text-sm font-medium text-slate-600 dark:text-gray-400 mb-1.5">Organization Name</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
value={newName}
|
value={newName}
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
className="w-full bg-black/40 border border-white/10 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all"
|
className="w-full bg-slate-50 dark:bg-black/40 border border-slate-200 dark:border-white/10 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all text-slate-900 dark:text-white placeholder-slate-400"
|
||||||
placeholder="e.g. Acme Corp"
|
placeholder="e.g. Acme Corp"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-400 mb-1.5">Domain (Optional)</label>
|
<label className="block text-sm font-medium text-slate-600 dark:text-gray-400 mb-1.5">Domain (Optional)</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Globe className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={newDomain}
|
value={newDomain}
|
||||||
onChange={(e) => setNewDomain(e.target.value)}
|
onChange={(e) => setNewDomain(e.target.value)}
|
||||||
className="w-full bg-black/40 border border-white/10 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all font-mono text-sm"
|
className="w-full bg-slate-50 dark:bg-black/40 border border-slate-200 dark:border-white/10 rounded-lg pl-10 pr-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all font-mono text-sm text-slate-900 dark:text-white placeholder-slate-400"
|
||||||
placeholder="e.g. acme.com"
|
placeholder="e.g. acme.com"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="pt-4 border-t border-white/5">
|
{!isEditing && (
|
||||||
<h3 className="text-xs font-black uppercase tracking-widest text-blue-500 mb-4">Initial Administrator</h3>
|
<div className="pt-4 border-t border-slate-100 dark:border-white/5">
|
||||||
|
<h3 className="text-xs font-black uppercase tracking-widest text-blue-600 dark:text-blue-500 mb-4">Initial Administrator</h3>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-400 mb-1.5">Admin Full Name</label>
|
<label className="block text-sm font-medium text-slate-600 dark:text-gray-400 mb-1.5">Admin Full Name</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
value={adminFullName}
|
value={adminFullName}
|
||||||
onChange={(e) => setAdminFullName(e.target.value)}
|
onChange={(e) => setAdminFullName(e.target.value)}
|
||||||
className="w-full bg-black/40 border border-white/10 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all"
|
className="w-full bg-slate-50 dark:bg-black/40 border border-slate-200 dark:border-white/10 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all text-slate-900 dark:text-white placeholder-slate-400"
|
||||||
placeholder="e.g. John Doe"
|
placeholder="e.g. John Doe"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-400 mb-1.5">Admin Email</label>
|
<label className="block text-sm font-medium text-slate-600 dark:text-gray-400 mb-1.5">Admin Email</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
required
|
required
|
||||||
value={adminEmail}
|
value={adminEmail}
|
||||||
onChange={(e) => setAdminEmail(e.target.value)}
|
onChange={(e) => setAdminEmail(e.target.value)}
|
||||||
className="w-full bg-black/5 dark:bg-black/40 border border-black/10 dark:border-white/10 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all text-gray-900 dark:text-white"
|
className="w-full bg-slate-50 dark:bg-black/40 border border-slate-200 dark:border-white/10 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all text-slate-900 dark:text-white placeholder-slate-400"
|
||||||
placeholder="admin@acme.com"
|
placeholder="admin@acme.com"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-400 mb-1.5">Admin Password</label>
|
<label className="block text-sm font-medium text-slate-600 dark:text-gray-400 mb-1.5">Admin Password</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
required
|
required
|
||||||
value={adminPassword}
|
value={adminPassword}
|
||||||
onChange={(e) => setAdminPassword(e.target.value)}
|
onChange={(e) => setAdminPassword(e.target.value)}
|
||||||
className="w-full bg-black/40 border border-white/10 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all"
|
className="w-full bg-slate-50 dark:bg-black/40 border border-slate-200 dark:border-white/10 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all text-slate-900 dark:text-white placeholder-slate-400"
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex gap-3 mt-8">
|
<div className="flex gap-3 mt-8">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsModalOpen(false)}
|
onClick={resetForm}
|
||||||
className="flex-1 px-4 py-2.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded-lg transition-all"
|
className="flex-1 px-4 py-2.5 bg-slate-50 dark:bg-white/5 hover:bg-slate-100 dark:hover:bg-white/10 border border-slate-200 dark:border-white/10 rounded-lg transition-all text-slate-600 dark:text-white font-medium"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="flex-1 px-4 py-2.5 bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-all shadow-lg shadow-blue-500/20"
|
className="flex-1 px-4 py-2.5 bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-all shadow-lg shadow-blue-500/20 font-bold"
|
||||||
>
|
>
|
||||||
Create
|
{isEditing ? 'Save Changes' : 'Create'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -351,14 +399,14 @@ export default function OrganizationsPage() {
|
|||||||
|
|
||||||
{/* Branding Management Modal */}
|
{/* Branding Management Modal */}
|
||||||
{isBrandingModalOpen && selectedOrg && (
|
{isBrandingModalOpen && selectedOrg && (
|
||||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-in fade-in duration-200">
|
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-sm animate-in fade-in duration-200">
|
||||||
<div className="w-full max-w-2xl bg-white dark:bg-gray-900 border border-black/10 dark:border-white/10 rounded-2xl p-8 shadow-2xl">
|
<div className="w-full max-w-2xl bg-white dark:bg-slate-900 border border-slate-200 dark:border-white/10 rounded-2xl p-8 shadow-2xl">
|
||||||
<div className="flex justify-between items-center mb-6">
|
<div className="flex justify-between items-center mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white">Branding Management</h2>
|
<h2 className="text-xl font-bold text-slate-900 dark:text-white">Branding Management</h2>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">{selectedOrg.name}</p>
|
<p className="text-sm text-slate-600 dark:text-gray-400">{selectedOrg.name}</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setIsBrandingModalOpen(false)} className="p-2 text-gray-500 hover:bg-black/5 dark:hover:bg-white/5 rounded-full transition-colors">
|
<button onClick={() => setIsBrandingModalOpen(false)} className="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-white rounded-full transition-colors">
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -367,22 +415,22 @@ export default function OrganizationsPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Logo Upload */}
|
{/* Logo Upload */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-400 mb-3 text-brand">Organization Logo</label>
|
<label className="block text-sm font-medium text-slate-600 dark:text-gray-400 mb-3 uppercase tracking-wider text-[10px] font-black">Organization Logo</label>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="w-20 h-20 rounded-xl bg-black/40 border border-white/10 flex items-center justify-center overflow-hidden relative">
|
<div className="w-20 h-20 rounded-xl bg-slate-50 dark:bg-black/40 border border-slate-200 dark:border-white/10 flex items-center justify-center overflow-hidden relative">
|
||||||
{selectedOrg.logo_url ? (
|
{selectedOrg.logo_url ? (
|
||||||
<Image src={getImageUrl(selectedOrg.logo_url)} alt="Preview" fill className="object-contain" unoptimized />
|
<Image src={getImageUrl(selectedOrg.logo_url)} alt="Preview" fill className="object-contain" unoptimized />
|
||||||
) : (
|
) : (
|
||||||
<Building2 className="w-8 h-8 text-gray-600" />
|
<Building2 className="w-8 h-8 text-slate-300 dark:text-gray-600" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<label className="relative flex items-center justify-center gap-2 px-4 py-2 bg-blue-600/10 hover:bg-blue-600/20 text-blue-400 rounded-lg cursor-pointer transition-all border border-blue-500/20">
|
<label className="relative flex items-center justify-center gap-2 px-4 py-2 bg-blue-600/10 hover:bg-blue-600/20 text-blue-600 dark:text-blue-400 rounded-lg cursor-pointer transition-all border border-blue-500/20 font-bold text-xs uppercase">
|
||||||
<Upload className="w-4 h-4" />
|
<Upload className="w-4 h-4" />
|
||||||
{uploadingLogo ? 'Uploading...' : 'Upload Logo'}
|
{uploadingLogo ? 'Uploading...' : 'Upload Logo'}
|
||||||
<input type="file" className="hidden" accept="image/*" onChange={handleLogoUpload} disabled={uploadingLogo} />
|
<input type="file" className="hidden" accept="image/*" onChange={handleLogoUpload} disabled={uploadingLogo} />
|
||||||
</label>
|
</label>
|
||||||
<p className="text-[10px] text-gray-500 mt-2">PNG, JPG or SVG. Max 2MB.</p>
|
<p className="text-[10px] text-slate-500 mt-2">PNG, JPG or SVG. Max 2MB.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -390,36 +438,36 @@ export default function OrganizationsPage() {
|
|||||||
{/* Colors */}
|
{/* Colors */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-400 mb-2">Primary Color</label>
|
<label className="block text-sm font-medium text-slate-600 dark:text-gray-400 mb-2">Primary Color</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
type="color"
|
type="color"
|
||||||
value={primaryColor}
|
value={primaryColor}
|
||||||
onChange={(e) => setPrimaryColor(e.target.value)}
|
onChange={(e) => setPrimaryColor(e.target.value)}
|
||||||
className="w-10 h-10 rounded cursor-pointer bg-transparent border-none"
|
className="w-10 h-10 rounded cursor-pointer border border-slate-200 dark:border-white/10 p-1 bg-white dark:bg-black/40"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={primaryColor}
|
value={primaryColor}
|
||||||
onChange={(e) => setPrimaryColor(e.target.value)}
|
onChange={(e) => setPrimaryColor(e.target.value)}
|
||||||
className="flex-1 bg-black/40 border border-white/10 rounded-lg px-3 py-2 text-sm font-mono"
|
className="flex-1 bg-slate-50 dark:bg-black/40 border border-slate-200 dark:border-white/10 rounded-lg px-3 py-2 text-sm font-mono text-slate-900 dark:text-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-400 mb-2">Secondary Color</label>
|
<label className="block text-sm font-medium text-slate-600 dark:text-gray-400 mb-2">Secondary Color</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
type="color"
|
type="color"
|
||||||
value={secondaryColor}
|
value={secondaryColor}
|
||||||
onChange={(e) => setSecondaryColor(e.target.value)}
|
onChange={(e) => setSecondaryColor(e.target.value)}
|
||||||
className="w-10 h-10 rounded cursor-pointer bg-transparent border-none"
|
className="w-10 h-10 rounded cursor-pointer border border-slate-200 dark:border-white/10 p-1 bg-white dark:bg-black/40"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={secondaryColor}
|
value={secondaryColor}
|
||||||
onChange={(e) => setSecondaryColor(e.target.value)}
|
onChange={(e) => setSecondaryColor(e.target.value)}
|
||||||
className="flex-1 bg-black/40 border border-white/10 rounded-lg px-3 py-2 text-sm font-mono"
|
className="flex-1 bg-slate-50 dark:bg-black/40 border border-slate-200 dark:border-white/10 rounded-lg px-3 py-2 text-sm font-mono text-slate-900 dark:text-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -428,10 +476,10 @@ export default function OrganizationsPage() {
|
|||||||
|
|
||||||
{/* Live Preview */}
|
{/* Live Preview */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<label className="block text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">Experience Portal Preview</label>
|
<label className="block text-sm font-medium text-slate-700 dark:text-gray-400 mb-2 uppercase tracking-wider text-[10px] font-black">Portal Preview</label>
|
||||||
<div className="rounded-xl border border-black/10 dark:border-white/10 overflow-hidden bg-gray-50 dark:bg-slate-900 shadow-inner">
|
<div className="rounded-xl border border-slate-200 dark:border-white/10 overflow-hidden bg-white dark:bg-slate-950 shadow-inner">
|
||||||
{/* Mock Experience Header */}
|
{/* Mock Experience Header */}
|
||||||
<div className="h-10 px-4 flex items-center justify-between border-b border-white/5" style={{ backgroundColor: primaryColor }}>
|
<div className="h-10 px-4 flex items-center justify-between border-b border-black/5" style={{ backgroundColor: primaryColor }}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-5 h-5 bg-white/20 rounded flex items-center justify-center overflow-hidden relative">
|
<div className="w-5 h-5 bg-white/20 rounded flex items-center justify-center overflow-hidden relative">
|
||||||
{selectedOrg.logo_url ? (
|
{selectedOrg.logo_url ? (
|
||||||
@@ -446,24 +494,24 @@ export default function OrganizationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Mock Experience Content */}
|
{/* Mock Experience Content */}
|
||||||
<div className="p-4 space-y-3 bg-gray-50 dark:bg-[#0a0c10]">
|
<div className="p-4 space-y-3 bg-slate-50 dark:bg-[#0a0c10]">
|
||||||
<div className="w-2/3 h-4 bg-white/10 rounded mb-2" />
|
<div className="w-2/3 h-4 bg-slate-200 dark:bg-white/10 rounded mb-2" />
|
||||||
<div className="w-full h-24 bg-white/5 rounded-lg border border-white/5 p-3">
|
<div className="w-full h-24 bg-white dark:bg-white/5 rounded-lg border border-slate-200 dark:border-white/5 p-3 shadow-sm">
|
||||||
<div className="w-1/3 h-3 rounded mb-2" style={{ backgroundColor: secondaryColor }} />
|
<div className="w-1/3 h-3 rounded mb-2" style={{ backgroundColor: secondaryColor }} />
|
||||||
<div className="w-full h-2 bg-white/5 rounded mb-1" />
|
<div className="w-full h-2 bg-slate-100 dark:bg-white/5 rounded mb-1" />
|
||||||
<div className="w-full h-2 bg-white/5 rounded mb-1" />
|
<div className="w-full h-2 bg-slate-100 dark:bg-white/5 rounded mb-1" />
|
||||||
<div className="w-1/2 h-2 bg-white/5 rounded" />
|
<div className="w-1/2 h-2 bg-slate-100 dark:bg-white/5 rounded" />
|
||||||
<div className="mt-4 flex justify-end">
|
<div className="mt-4 flex justify-end">
|
||||||
<div className="px-3 py-1.5 rounded text-[8px] font-bold text-white" style={{ backgroundColor: primaryColor }}>
|
<div className="px-3 py-1.5 rounded text-[8px] font-bold text-white shadow-sm" style={{ backgroundColor: primaryColor }}>
|
||||||
GET STARTED
|
GET STARTED
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 rounded-lg bg-blue-500/10 border border-blue-500/20">
|
<div className="p-3 rounded-lg bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/20">
|
||||||
<p className="text-[10px] text-blue-400 leading-relaxed">
|
<p className="text-[10px] text-blue-700 dark:text-blue-400 leading-relaxed font-medium">
|
||||||
This is a real-time preview of how the brand identity will apply to the student's learning experience.
|
Real-time preview of the brand application.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -472,14 +520,14 @@ export default function OrganizationsPage() {
|
|||||||
<div className="flex gap-3 mt-10">
|
<div className="flex gap-3 mt-10">
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsBrandingModalOpen(false)}
|
onClick={() => setIsBrandingModalOpen(false)}
|
||||||
className="flex-1 px-4 py-3 bg-white/5 hover:bg-white/10 border border-white/10 rounded-xl transition-all font-medium"
|
className="flex-1 px-4 py-3 bg-slate-50 dark:bg-white/5 hover:bg-slate-100 dark:hover:bg-white/10 border border-slate-200 dark:border-white/10 rounded-xl transition-all font-medium text-slate-600 dark:text-white"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleBrandingSave}
|
onClick={handleBrandingSave}
|
||||||
disabled={isSavingBranding}
|
disabled={isSavingBranding}
|
||||||
className="flex-[2] px-8 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-xl transition-all shadow-lg shadow-blue-500/20 font-bold flex items-center justify-center gap-2"
|
className="flex-[2] px-8 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-xl transition-all shadow-lg shadow-blue-500/20 font-bold flex items-center justify-center gap-2 uppercase tracking-wide"
|
||||||
>
|
>
|
||||||
{isSavingBranding ? <div className="w-5 h-5 border-2 border-white/20 border-t-white rounded-full animate-spin" /> : <Save className="w-5 h-5" />}
|
{isSavingBranding ? <div className="w-5 h-5 border-2 border-white/20 border-t-white rounded-full animate-spin" /> : <Save className="w-5 h-5" />}
|
||||||
Save Branding
|
Save Branding
|
||||||
@@ -490,32 +538,32 @@ export default function OrganizationsPage() {
|
|||||||
)}
|
)}
|
||||||
{/* SSO Configuration Modal */}
|
{/* SSO Configuration Modal */}
|
||||||
{isSSOModalOpen && selectedOrg && (
|
{isSSOModalOpen && selectedOrg && (
|
||||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-in fade-in duration-200">
|
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-sm animate-in fade-in duration-200">
|
||||||
<div className="w-full max-w-xl glass border border-white/10 rounded-2xl p-8 shadow-2xl">
|
<div className="w-full max-w-xl bg-white dark:bg-slate-900 border border-slate-200 dark:border-white/10 rounded-2xl p-8 shadow-2xl">
|
||||||
<div className="flex justify-between items-center mb-6">
|
<div className="flex justify-between items-center mb-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-2 rounded-lg bg-blue-500/10 text-blue-400">
|
<div className="p-2 rounded-lg bg-indigo-500/10 text-indigo-600 dark:text-indigo-400">
|
||||||
<Fingerprint className="w-6 h-6" />
|
<Fingerprint className="w-6 h-6" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-bold">Single Sign-On (OIDC)</h2>
|
<h2 className="text-xl font-bold text-slate-900 dark:text-white">Single Sign-On (OIDC)</h2>
|
||||||
<p className="text-sm text-gray-400">{selectedOrg.name}</p>
|
<p className="text-sm text-slate-600 dark:text-gray-400">{selectedOrg.name}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setIsSSOModalOpen(false)} className="p-2 hover:bg-white/5 rounded-full transition-colors">
|
<button onClick={() => setIsSSOModalOpen(false)} className="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-white rounded-full transition-colors">
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between p-4 rounded-xl bg-white/5 border border-white/10">
|
<div className="flex items-center justify-between p-4 rounded-xl bg-slate-50 dark:bg-white/5 border border-slate-200 dark:border-white/10">
|
||||||
<div>
|
<div className="pr-4">
|
||||||
<h3 className="font-medium text-white">Enable OIDC SSO</h3>
|
<h3 className="font-semibold text-slate-900 dark:text-white">Enable OIDC SSO</h3>
|
||||||
<p className="text-xs text-gray-500">Allow users to log in via your identity provider.</p>
|
<p className="text-xs text-slate-500 dark:text-gray-500">Allow users to log in via your identity provider.</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setSsoEnabled(!ssoEnabled)}
|
onClick={() => setSsoEnabled(!ssoEnabled)}
|
||||||
className={`w-12 h-6 rounded-full transition-colors relative ${ssoEnabled ? 'bg-blue-600' : 'bg-gray-700'}`}
|
className={`w-12 h-6 rounded-full transition-colors relative shrink-0 ${ssoEnabled ? 'bg-blue-600' : 'bg-slate-300 dark:bg-gray-700'}`}
|
||||||
>
|
>
|
||||||
<div className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-all ${ssoEnabled ? 'right-1' : 'left-1'}`} />
|
<div className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-all ${ssoEnabled ? 'right-1' : 'left-1'}`} />
|
||||||
</button>
|
</button>
|
||||||
@@ -523,55 +571,55 @@ export default function OrganizationsPage() {
|
|||||||
|
|
||||||
<div className="space-y-4 pt-2">
|
<div className="space-y-4 pt-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-400 mb-1.5">Issuer URL</label>
|
<label className="block text-sm font-medium text-slate-600 dark:text-gray-400 mb-1.5">Issuer URL</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Globe className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
<Globe className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||||
<input
|
<input
|
||||||
type="url"
|
type="url"
|
||||||
value={issuerUrl}
|
value={issuerUrl}
|
||||||
onChange={(e) => setIssuerUrl(e.target.value)}
|
onChange={(e) => setIssuerUrl(e.target.value)}
|
||||||
className="w-full bg-black/40 border border-white/10 rounded-lg pl-10 pr-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all font-mono text-sm"
|
className="w-full bg-slate-50 dark:bg-black/40 border border-slate-200 dark:border-white/10 rounded-lg pl-10 pr-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all font-mono text-sm text-slate-900 dark:text-white placeholder-slate-400"
|
||||||
placeholder="https://accounts.google.com or https://okta.com/..."
|
placeholder="https://accounts.google.com"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-400 mb-1.5">Client ID</label>
|
<label className="block text-sm font-medium text-slate-600 dark:text-gray-400 mb-1.5">Client ID</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<ShieldCheck className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
<ShieldCheck className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={clientId}
|
value={clientId}
|
||||||
onChange={(e) => setClientId(e.target.value)}
|
onChange={(e) => setClientId(e.target.value)}
|
||||||
className="w-full bg-black/40 border border-white/10 rounded-lg pl-10 pr-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all font-mono text-sm"
|
className="w-full bg-slate-50 dark:bg-black/40 border border-slate-200 dark:border-white/10 rounded-lg pl-10 pr-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all font-mono text-sm text-slate-900 dark:text-white placeholder-slate-400"
|
||||||
placeholder="Your OIDC Client ID"
|
placeholder="Your OIDC Client ID"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-400 mb-1.5">Client Secret</label>
|
<label className="block text-sm font-medium text-slate-600 dark:text-gray-400 mb-1.5">Client Secret</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Key className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
<Key className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={clientSecret}
|
value={clientSecret}
|
||||||
onChange={(e) => setClientSecret(e.target.value)}
|
onChange={(e) => setClientSecret(e.target.value)}
|
||||||
className="w-full bg-black/40 border border-white/10 rounded-lg pl-10 pr-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all font-mono text-sm"
|
className="w-full bg-slate-50 dark:bg-black/40 border border-slate-200 dark:border-white/10 rounded-lg pl-10 pr-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all font-mono text-sm text-slate-900 dark:text-white placeholder-slate-400"
|
||||||
placeholder="••••••••••••••••"
|
placeholder="••••••••••••••••"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 rounded-xl bg-blue-500/10 border border-blue-500/20 space-y-2">
|
<div className="p-4 rounded-xl bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/20 space-y-2">
|
||||||
<div className="flex items-center gap-2 text-blue-400 text-xs font-bold">
|
<div className="flex items-center gap-2 text-blue-700 dark:text-blue-400 text-xs font-black uppercase tracking-wider">
|
||||||
<Settings2 className="w-4 h-4" /> CONFIGURATION STEPS
|
<Settings2 className="w-4 h-4" /> CONFIGURATION STEPS
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-blue-300 leading-relaxed">
|
<p className="text-[10px] text-blue-800 dark:text-blue-300 leading-relaxed font-medium">
|
||||||
1. Register OpenCCB as an application in your Identity Provider (Okta, Google, Azure AD).<br />
|
1. Register OpenCCB in your IDP.<br />
|
||||||
2. Set the Redirect URI to: <span className="font-mono bg-blue-500/20 px-1">{API_BASE_URL}/auth/sso/callback</span><br />
|
2. Redirect URI: <span className="font-mono bg-blue-200 dark:bg-blue-500/20 px-1 rounded">{API_BASE_URL}/auth/sso/callback</span><br />
|
||||||
3. Copy the Issuer URL, Client ID, and Client Secret here.
|
3. Copy the Issuer, ID and Secret here.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -580,14 +628,14 @@ export default function OrganizationsPage() {
|
|||||||
<div className="flex gap-3 mt-8">
|
<div className="flex gap-3 mt-8">
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsSSOModalOpen(false)}
|
onClick={() => setIsSSOModalOpen(false)}
|
||||||
className="flex-1 px-4 py-3 bg-white/5 hover:bg-white/10 border border-white/10 rounded-xl transition-all font-medium"
|
className="flex-1 px-4 py-3 bg-slate-50 dark:bg-white/5 hover:bg-slate-100 dark:hover:bg-white/10 border border-slate-200 dark:border-white/10 rounded-xl transition-all font-medium text-slate-600 dark:text-white"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleSSOSave}
|
onClick={handleSSOSave}
|
||||||
disabled={isSavingSSO}
|
disabled={isSavingSSO}
|
||||||
className="flex-[2] px-8 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-xl transition-all shadow-lg shadow-blue-500/20 font-bold flex items-center justify-center gap-2"
|
className="flex-[2] px-8 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-xl transition-all shadow-lg shadow-blue-500/20 font-bold flex items-center justify-center gap-2 uppercase tracking-wide"
|
||||||
>
|
>
|
||||||
{isSavingSSO ? <div className="w-5 h-5 border-2 border-white/20 border-t-white rounded-full animate-spin" /> : <Save className="w-5 h-5" />}
|
{isSavingSSO ? <div className="w-5 h-5 border-2 border-white/20 border-t-white rounded-full animate-spin" /> : <Save className="w-5 h-5" />}
|
||||||
Save SSO Settings
|
Save SSO Settings
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export default function AdminDashboard() {
|
|||||||
label: "Organizations",
|
label: "Organizations",
|
||||||
value: stats.orgs,
|
value: stats.orgs,
|
||||||
icon: Building2,
|
icon: Building2,
|
||||||
color: "text-blue-400",
|
color: "text-blue-600 dark:text-blue-400",
|
||||||
bg: "bg-blue-500/10",
|
bg: "bg-blue-500/10",
|
||||||
desc: "Active institutional tenants"
|
desc: "Active institutional tenants"
|
||||||
},
|
},
|
||||||
@@ -57,7 +57,7 @@ export default function AdminDashboard() {
|
|||||||
label: "Total Users",
|
label: "Total Users",
|
||||||
value: stats.users,
|
value: stats.users,
|
||||||
icon: Users,
|
icon: Users,
|
||||||
color: "text-purple-400",
|
color: "text-purple-600 dark:text-purple-400",
|
||||||
bg: "bg-purple-500/10",
|
bg: "bg-purple-500/10",
|
||||||
desc: "Registered globally"
|
desc: "Registered globally"
|
||||||
},
|
},
|
||||||
@@ -65,7 +65,7 @@ export default function AdminDashboard() {
|
|||||||
label: "Global Courses",
|
label: "Global Courses",
|
||||||
value: stats.courses,
|
value: stats.courses,
|
||||||
icon: BookOpen,
|
icon: BookOpen,
|
||||||
color: "text-green-400",
|
color: "text-green-600 dark:text-green-400",
|
||||||
bg: "bg-green-500/10",
|
bg: "bg-green-500/10",
|
||||||
desc: "Managed across all orgs"
|
desc: "Managed across all orgs"
|
||||||
},
|
},
|
||||||
@@ -74,21 +74,21 @@ export default function AdminDashboard() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-12 animate-in fade-in slide-in-from-bottom-4 duration-700">
|
<div className="space-y-12 animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-4xl font-black tracking-tight mb-2">System Overview</h1>
|
<h1 className="text-4xl font-black tracking-tight mb-2 text-slate-900 dark:text-white">System Overview</h1>
|
||||||
<p className="text-gray-400">Holistic view of the OpenCCB ecosystem.</p>
|
<p className="text-slate-500 dark:text-gray-400">Holistic view of the OpenCCB ecosystem.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stat Grid */}
|
{/* Stat Grid */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
{cards.map((card) => (
|
{cards.map((card) => (
|
||||||
<div key={card.label} className="p-8 rounded-3xl glass-card border-white/5 bg-white/[0.02] flex flex-col gap-4">
|
<div key={card.label} className="p-8 rounded-3xl bg-white dark:bg-white/[0.02] border border-slate-200 dark:border-white/5 flex flex-col gap-4 shadow-sm dark:shadow-none">
|
||||||
<div className={`w-12 h-12 rounded-xl ${card.bg} flex items-center justify-center ${card.color}`}>
|
<div className={`w-12 h-12 rounded-xl ${card.bg} flex items-center justify-center ${card.color}`}>
|
||||||
<card.icon size={24} />
|
<card.icon size={24} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-500 mb-1">{card.label}</div>
|
<div className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-400 dark:text-gray-500 mb-1">{card.label}</div>
|
||||||
<div className="text-4xl font-black">{loading ? "..." : card.value}</div>
|
<div className="text-4xl font-black text-slate-900 dark:text-white">{loading ? "..." : card.value}</div>
|
||||||
<p className="text-xs text-gray-500 mt-2">{card.desc}</p>
|
<p className="text-xs text-slate-500 dark:text-gray-500 mt-2">{card.desc}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -97,56 +97,56 @@ export default function AdminDashboard() {
|
|||||||
{/* System Health */}
|
{/* System Health */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-sm font-black uppercase tracking-widest text-gray-500">Service Status</h2>
|
<h2 className="text-sm font-black uppercase tracking-widest text-slate-400 dark:text-gray-500">Service Status</h2>
|
||||||
<div className="flex items-center gap-2 text-green-400 text-xs font-bold">
|
<div className="flex items-center gap-2 text-green-600 dark:text-green-400 text-xs font-bold">
|
||||||
<div className="w-2 h-2 rounded-full bg-green-400 animate-pulse" />
|
<div className="w-2 h-2 rounded-full bg-green-500 dark:bg-green-400 animate-pulse" />
|
||||||
All systems operational
|
All systems operational
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="p-6 rounded-2xl border border-white/5 bg-white/[0.01] flex items-center justify-between">
|
<div className="p-6 rounded-2xl border border-slate-200 dark:border-white/5 bg-white dark:bg-white/[0.01] flex items-center justify-between shadow-sm dark:shadow-none">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Server className="text-blue-400" size={20} />
|
<Server className="text-blue-600 dark:text-blue-400" size={20} />
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-bold">API Services (CMS/LMS)</div>
|
<div className="text-sm font-bold text-slate-900 dark:text-white">API Services (CMS/LMS)</div>
|
||||||
<div className="text-[10px] text-gray-500 font-bold uppercase tracking-widest">Rust Axum Cluster</div>
|
<div className="text-[10px] text-slate-400 dark:text-gray-500 font-bold uppercase tracking-widest">Rust Axum Cluster</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] bg-green-500/10 text-green-400 px-2 py-1 rounded-full font-black uppercase">Online</span>
|
<span className="text-[10px] bg-green-500/10 text-green-600 dark:text-green-400 px-2 py-1 rounded-full font-black uppercase">Online</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6 rounded-2xl border border-white/5 bg-white/[0.01] flex items-center justify-between">
|
<div className="p-6 rounded-2xl border border-slate-200 dark:border-white/5 bg-white dark:bg-white/[0.01] flex items-center justify-between shadow-sm dark:shadow-none">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Zap className="text-amber-400" size={20} />
|
<Zap className="text-amber-600 dark:text-amber-400" size={20} />
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-bold">Local AI Services</div>
|
<div className="text-sm font-bold text-slate-900 dark:text-white">Local AI Services</div>
|
||||||
<div className="text-[10px] text-gray-500 font-bold uppercase tracking-widest">Whisper + Ollama</div>
|
<div className="text-[10px] text-slate-400 dark:text-gray-500 font-bold uppercase tracking-widest">Whisper + Ollama</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] bg-green-500/10 text-green-400 px-2 py-1 rounded-full font-black uppercase">Online</span>
|
<span className="text-[10px] bg-green-500/10 text-green-600 dark:text-green-400 px-2 py-1 rounded-full font-black uppercase">Online</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6 rounded-2xl border border-white/5 bg-white/[0.01] flex items-center justify-between">
|
<div className="p-6 rounded-2xl border border-slate-200 dark:border-white/5 bg-white dark:bg-white/[0.01] flex items-center justify-between shadow-sm dark:shadow-none">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Clock className="text-indigo-400" size={20} />
|
<Clock className="text-indigo-600 dark:text-indigo-400" size={20} />
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-bold">Background Workers</div>
|
<div className="text-sm font-bold text-slate-900 dark:text-white">Background Workers</div>
|
||||||
<div className="text-[10px] text-gray-500 font-bold uppercase tracking-widest">Notification Scheduler</div>
|
<div className="text-[10px] text-slate-400 dark:text-gray-500 font-bold uppercase tracking-widest">Notification Scheduler</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] bg-green-500/10 text-green-400 px-2 py-1 rounded-full font-black uppercase">Running</span>
|
<span className="text-[10px] bg-green-500/10 text-green-600 dark:text-green-400 px-2 py-1 rounded-full font-black uppercase">Running</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6 rounded-2xl border border-white/5 bg-white/[0.01] flex items-center justify-between">
|
<div className="p-6 rounded-2xl border border-slate-200 dark:border-white/5 bg-white dark:bg-white/[0.01] flex items-center justify-between shadow-sm dark:shadow-none">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<ShieldAlert className="text-red-400" size={20} />
|
<ShieldAlert className="text-red-600 dark:text-red-400" size={20} />
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-bold">Security Engine</div>
|
<div className="text-sm font-bold text-slate-900 dark:text-white">Security Engine</div>
|
||||||
<div className="text-[10px] text-gray-500 font-bold uppercase tracking-widest">JWT & RBAC Middleware</div>
|
<div className="text-[10px] text-slate-400 dark:text-gray-500 font-bold uppercase tracking-widest">JWT & RBAC Middleware</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] bg-green-500/10 text-green-400 px-2 py-1 rounded-full font-black uppercase">Active</span>
|
<span className="text-[10px] bg-green-500/10 text-green-600 dark:text-green-400 px-2 py-1 rounded-full font-black uppercase">Active</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { cmsApi, User, Organization } from '@/lib/api';
|
import { cmsApi, User, Organization } from '@/lib/api';
|
||||||
import { useAuth } from '@/context/AuthContext';
|
import { useAuth } from '@/context/AuthContext';
|
||||||
import { UserCog, Mail, Search, Filter, ShieldCheck, Plus, X, UserPlus, Key, User as UserIcon } from 'lucide-react';
|
import { UserCog, Mail, Search, Filter, ShieldCheck, Plus, X, UserPlus, Key, User as UserIcon, Building2 } from 'lucide-react';
|
||||||
|
|
||||||
export default function UsersPage() {
|
export default function UsersPage() {
|
||||||
const [users, setUsers] = useState<User[]>([]);
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
@@ -19,7 +19,8 @@ export default function UsersPage() {
|
|||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
full_name: '',
|
full_name: '',
|
||||||
role: 'student'
|
role: 'student',
|
||||||
|
organization_id: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -54,7 +55,7 @@ export default function UsersPage() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
try {
|
||||||
await cmsApi.createUser(newUser);
|
await cmsApi.createUser(newUser);
|
||||||
setNewUser({ email: '', password: '', full_name: '', role: 'student' });
|
setNewUser({ email: '', password: '', full_name: '', role: 'student', organization_id: '' });
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
loadData();
|
loadData();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -72,50 +73,50 @@ export default function UsersPage() {
|
|||||||
|
|
||||||
if (currentUser?.role !== 'admin') {
|
if (currentUser?.role !== 'admin') {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center">
|
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center p-4">
|
||||||
<div className="p-4 rounded-full bg-red-500/10 mb-4">
|
<div className="p-4 rounded-full bg-red-500/10 mb-4">
|
||||||
<ShieldCheck className="w-12 h-12 text-red-500" />
|
<ShieldCheck className="w-12 h-12 text-red-500" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold mb-2">Access Denied</h1>
|
<h1 className="text-2xl font-bold mb-2 text-slate-900 dark:text-white">Access Denied</h1>
|
||||||
<p className="text-gray-400">Only system administrators can access this page.</p>
|
<p className="text-slate-600 dark:text-gray-400">Only system administrators can access this page.</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 animate-in fade-in duration-500">
|
<div className="space-y-6 md:space-y-8 animate-in fade-in duration-500 p-4 md:p-0">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">User Management</h1>
|
<h1 className="text-2xl md:text-3xl font-bold tracking-tight text-slate-900 dark:text-white">User Management</h1>
|
||||||
<p className="text-gray-400 mt-1">Manage users, roles, and organization assignments.</p>
|
<p className="text-slate-600 dark:text-gray-400 mt-1 text-sm">Manage users, roles, and organization assignments.</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsModalOpen(true)}
|
onClick={() => setIsModalOpen(true)}
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-all shadow-lg shadow-blue-500/20"
|
className="w-full sm:w-auto flex items-center justify-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-all shadow-lg shadow-blue-500/20"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
Add User
|
<span>Add User</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col md:flex-row gap-4">
|
<div className="flex flex-col md:flex-row gap-4">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search users..."
|
placeholder="Search users..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
className="w-full bg-black/40 border border-white/10 rounded-lg pl-10 pr-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500/50"
|
className="w-full bg-white dark:bg-black/40 border border-slate-200 dark:border-white/10 rounded-lg pl-10 pr-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500/50 text-slate-900 dark:text-white shadow-sm dark:shadow-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Filter className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
<Filter className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||||
<select
|
<select
|
||||||
value={roleFilter}
|
value={roleFilter}
|
||||||
onChange={(e) => setRoleFilter(e.target.value)}
|
onChange={(e) => setRoleFilter(e.target.value)}
|
||||||
className="bg-black/40 border border-white/10 rounded-lg pl-10 pr-8 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500/50 appearance-none cursor-pointer"
|
className="bg-white dark:bg-black/40 border border-slate-200 dark:border-white/10 rounded-lg pl-10 pr-8 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500/50 appearance-none cursor-pointer text-slate-900 dark:text-white h-full shadow-sm dark:shadow-none"
|
||||||
>
|
>
|
||||||
<option value="">All Roles</option>
|
<option value="">All Roles</option>
|
||||||
<option value="admin">Admin</option>
|
<option value="admin">Admin</option>
|
||||||
@@ -126,36 +127,37 @@ export default function UsersPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="glass border border-white/10 rounded-xl overflow-hidden">
|
<div className="bg-white dark:bg-white/[0.02] border border-slate-200 dark:border-white/10 rounded-xl overflow-hidden shadow-sm dark:shadow-none">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-left">
|
<table className="w-full text-left">
|
||||||
<thead className="bg-white/5 border-b border-white/10">
|
<thead className="bg-slate-50 dark:bg-white/5 border-b border-slate-200 dark:border-white/10">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-4 text-sm font-semibold">User</th>
|
<th className="px-6 py-4 text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-gray-400">User</th>
|
||||||
<th className="px-6 py-4 text-sm font-semibold">Role</th>
|
<th className="px-6 py-4 text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-gray-400">Role</th>
|
||||||
<th className="px-6 py-4 text-sm font-semibold">Organization</th>
|
<th className="px-6 py-4 text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-gray-400">Organization</th>
|
||||||
<th className="px-6 py-4 text-sm font-semibold text-right">Actions</th>
|
<th className="px-6 py-4 text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-gray-400 text-right">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-white/5">
|
<tbody className="divide-y divide-slate-100 dark:divide-white/5">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
[1, 2, 3, 4, 5].map(i => (
|
[1, 2, 3, 4, 5].map(i => (
|
||||||
<tr key={i} className="animate-pulse">
|
<tr key={i} className="animate-pulse">
|
||||||
<td className="px-6 py-4"><div className="h-4 w-32 bg-white/10 rounded" /></td>
|
<td className="px-6 py-4"><div className="h-4 w-32 bg-slate-100 dark:bg-white/10 rounded" /></td>
|
||||||
<td className="px-6 py-4"><div className="h-4 w-16 bg-white/10 rounded" /></td>
|
<td className="px-6 py-4"><div className="h-4 w-16 bg-slate-100 dark:bg-white/10 rounded" /></td>
|
||||||
<td className="px-6 py-4"><div className="h-4 w-24 bg-white/10 rounded" /></td>
|
<td className="px-6 py-4"><div className="h-4 w-24 bg-slate-100 dark:bg-white/10 rounded" /></td>
|
||||||
<td className="px-6 py-4 text-right"><div className="h-4 w-8 bg-white/10 ml-auto rounded" /></td>
|
<td className="px-6 py-4 text-right"><div className="h-4 w-8 bg-slate-100 dark:bg-white/10 ml-auto rounded" /></td>
|
||||||
</tr>
|
</tr>
|
||||||
))
|
))
|
||||||
) : filteredUsers.map((u) => (
|
) : filteredUsers.map((u) => (
|
||||||
<tr key={u.id} className="hover:bg-white/[0.02] transition-colors group">
|
<tr key={u.id} className="hover:bg-slate-50 dark:hover:bg-white/[0.02] transition-colors group">
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-8 h-8 rounded-full bg-blue-500/10 flex items-center justify-center text-blue-400">
|
<div className="w-8 h-8 rounded-full bg-blue-500/10 flex items-center justify-center text-blue-600 dark:text-blue-400 font-bold border border-blue-500/20">
|
||||||
{u.full_name[0]}
|
{u.full_name[0].toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium">{u.full_name}</div>
|
<div className="font-bold text-slate-900 dark:text-white text-sm">{u.full_name}</div>
|
||||||
<div className="text-xs text-gray-500 flex items-center gap-1">
|
<div className="text-[10px] text-slate-500 dark:text-gray-500 flex items-center gap-1 font-mono">
|
||||||
<Mail className="w-3 h-3" /> {u.email}
|
<Mail className="w-3 h-3" /> {u.email}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -165,7 +167,7 @@ export default function UsersPage() {
|
|||||||
<select
|
<select
|
||||||
value={u.role}
|
value={u.role}
|
||||||
onChange={(e) => handleUpdateUser(u.id, e.target.value, u.organization_id || '00000000-0000-0000-0000-000000000000')}
|
onChange={(e) => handleUpdateUser(u.id, e.target.value, u.organization_id || '00000000-0000-0000-0000-000000000000')}
|
||||||
className="bg-black/20 border border-white/5 rounded px-2 py-1 text-xs focus:ring-1 focus:ring-blue-500/50"
|
className="bg-white dark:bg-black/20 border border-slate-200 dark:border-white/5 rounded px-2 py-1 text-xs focus:ring-1 focus:ring-blue-500/50 text-slate-900 dark:text-white"
|
||||||
>
|
>
|
||||||
<option value="admin">Admin</option>
|
<option value="admin">Admin</option>
|
||||||
<option value="instructor">Instructor</option>
|
<option value="instructor">Instructor</option>
|
||||||
@@ -173,18 +175,21 @@ export default function UsersPage() {
|
|||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Building2 className="w-3 h-3 text-slate-400" />
|
||||||
<select
|
<select
|
||||||
value={u.organization_id || '00000000-0000-0000-0000-000000000000'}
|
value={u.organization_id || '00000000-0000-0000-0000-000000000000'}
|
||||||
onChange={(e) => handleUpdateUser(u.id, u.role, e.target.value)}
|
onChange={(e) => handleUpdateUser(u.id, u.role, e.target.value)}
|
||||||
className="bg-black/20 border border-white/5 rounded px-2 py-1 text-xs focus:ring-1 focus:ring-blue-500/50 max-w-[150px]"
|
className="bg-white dark:bg-black/20 border border-slate-200 dark:border-white/5 rounded px-2 py-1 text-xs focus:ring-1 focus:ring-blue-500/50 max-w-[150px] text-slate-900 dark:text-white"
|
||||||
>
|
>
|
||||||
{organizations.map(org => (
|
{organizations.map(org => (
|
||||||
<option key={org.id} value={org.id}>{org.name}</option>
|
<option key={org.id} value={org.id}>{org.name}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-right">
|
<td className="px-6 py-4 text-right">
|
||||||
<button className="p-2 hover:bg-white/10 rounded-lg transition-all text-gray-400 hover:text-white opacity-0 group-hover:opacity-100">
|
<button className="p-2 hover:bg-slate-200 dark:hover:bg-white/10 rounded-lg transition-all text-slate-400 hover:text-slate-900 dark:hover:text-white opacity-0 group-hover:opacity-100 shadow-sm">
|
||||||
<UserCog className="w-4 h-4" />
|
<UserCog className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
@@ -192,8 +197,9 @@ export default function UsersPage() {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
{!loading && filteredUsers.length === 0 && (
|
{!loading && filteredUsers.length === 0 && (
|
||||||
<div className="p-12 text-center text-gray-500">
|
<div className="p-12 text-center text-slate-500 dark:text-gray-500">
|
||||||
No users found matching your search.
|
No users found matching your search.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -201,72 +207,72 @@ export default function UsersPage() {
|
|||||||
|
|
||||||
{/* Create User Modal */}
|
{/* Create User Modal */}
|
||||||
{isModalOpen && (
|
{isModalOpen && (
|
||||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-in fade-in duration-200">
|
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-sm animate-in fade-in duration-200">
|
||||||
<div className="w-full max-w-md glass border border-white/10 rounded-2xl p-8 shadow-2xl">
|
<div className="w-full max-w-md bg-white dark:bg-slate-900 border border-slate-200 dark:border-white/10 rounded-2xl p-8 shadow-2xl">
|
||||||
<div className="flex justify-between items-center mb-6">
|
<div className="flex justify-between items-center mb-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-2 rounded-lg bg-blue-500/10 text-blue-400">
|
<div className="p-2 rounded-lg bg-blue-500/10 text-blue-600 dark:text-blue-400">
|
||||||
<UserPlus className="w-5 h-5" />
|
<UserPlus className="w-5 h-5" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-xl font-bold">Add New User</h2>
|
<h2 className="text-xl font-bold text-slate-900 dark:text-white">Add New User</h2>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setIsModalOpen(false)} className="p-2 hover:bg-white/5 rounded-full transition-colors text-gray-500">
|
<button onClick={() => setIsModalOpen(false)} className="p-2 hover:bg-slate-100 dark:hover:bg-white/5 rounded-full transition-colors text-slate-400">
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleCreateUser} className="space-y-4">
|
<form onSubmit={handleCreateUser} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-400 mb-1.5">Full Name</label>
|
<label className="block text-sm font-medium text-slate-600 dark:text-gray-400 mb-1.5">Full Name</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<UserIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
<UserIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
value={newUser.full_name}
|
value={newUser.full_name}
|
||||||
onChange={(e) => setNewUser({ ...newUser, full_name: e.target.value })}
|
onChange={(e) => setNewUser({ ...newUser, full_name: e.target.value })}
|
||||||
className="w-full bg-black/40 border border-white/10 rounded-lg pl-10 pr-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all"
|
className="w-full bg-slate-50 dark:bg-black/40 border border-slate-200 dark:border-white/10 rounded-lg pl-10 pr-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all text-slate-900 dark:text-white placeholder-slate-400"
|
||||||
placeholder="e.g. John Doe"
|
placeholder="e.g. John Doe"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-400 mb-1.5">Email Address</label>
|
<label className="block text-sm font-medium text-slate-600 dark:text-gray-400 mb-1.5">Email Address</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
required
|
required
|
||||||
value={newUser.email}
|
value={newUser.email}
|
||||||
onChange={(e) => setNewUser({ ...newUser, email: e.target.value })}
|
onChange={(e) => setNewUser({ ...newUser, email: e.target.value })}
|
||||||
className="w-full bg-black/40 border border-white/10 rounded-lg pl-10 pr-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all font-mono text-sm"
|
className="w-full bg-slate-50 dark:bg-black/40 border border-slate-200 dark:border-white/10 rounded-lg pl-10 pr-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all font-mono text-sm text-slate-900 dark:text-white placeholder-slate-400"
|
||||||
placeholder="user@example.com"
|
placeholder="user@example.com"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-400 mb-1.5">Initial Password</label>
|
<label className="block text-sm font-medium text-slate-600 dark:text-gray-400 mb-1.5">Initial Password</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Key className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
<Key className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
required
|
required
|
||||||
value={newUser.password}
|
value={newUser.password}
|
||||||
onChange={(e) => setNewUser({ ...newUser, password: e.target.value })}
|
onChange={(e) => setNewUser({ ...newUser, password: e.target.value })}
|
||||||
className="w-full bg-black/40 border border-white/10 rounded-lg pl-10 pr-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all"
|
className="w-full bg-slate-50 dark:bg-black/40 border border-slate-200 dark:border-white/10 rounded-lg pl-10 pr-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all text-slate-900 dark:text-white placeholder-slate-400"
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-400 mb-1.5">User Role</label>
|
<label className="block text-sm font-medium text-slate-600 dark:text-gray-400 mb-1.5">User Role</label>
|
||||||
<select
|
<select
|
||||||
value={newUser.role}
|
value={newUser.role}
|
||||||
onChange={(e) => setNewUser({ ...newUser, role: e.target.value })}
|
onChange={(e) => setNewUser({ ...newUser, role: e.target.value })}
|
||||||
className="w-full bg-black/40 border border-white/10 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all appearance-none cursor-pointer"
|
className="w-full bg-slate-50 dark:bg-black/40 border border-slate-200 dark:border-white/10 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all appearance-none cursor-pointer text-slate-900 dark:text-white"
|
||||||
>
|
>
|
||||||
<option value="student">Student</option>
|
<option value="student">Student</option>
|
||||||
<option value="instructor">Instructor</option>
|
<option value="instructor">Instructor</option>
|
||||||
@@ -274,11 +280,25 @@ export default function UsersPage() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-600 dark:text-gray-400 mb-1.5">Organization</label>
|
||||||
|
<select
|
||||||
|
value={newUser.organization_id}
|
||||||
|
onChange={(e) => setNewUser({ ...newUser, organization_id: e.target.value })}
|
||||||
|
className="w-full bg-slate-50 dark:bg-black/40 border border-slate-200 dark:border-white/10 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all appearance-none cursor-pointer text-slate-900 dark:text-white"
|
||||||
|
>
|
||||||
|
<option value="">Default (Current Context)</option>
|
||||||
|
{organizations.map(org => (
|
||||||
|
<option key={org.id} value={org.id}>{org.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-3 mt-8">
|
<div className="flex gap-3 mt-8">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsModalOpen(false)}
|
onClick={() => setIsModalOpen(false)}
|
||||||
className="flex-1 px-4 py-2.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded-lg transition-all"
|
className="flex-1 px-4 py-2.5 bg-slate-50 dark:bg-white/5 hover:bg-slate-100 dark:hover:bg-white/10 border border-slate-200 dark:border-white/10 rounded-lg transition-all text-slate-600 dark:text-white font-medium"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
--accent-secondary: #6366f1;
|
--accent-secondary: #6366f1;
|
||||||
|
|
||||||
--glass-bg: rgba(255, 255, 255, 0.8);
|
--glass-bg: rgba(255, 255, 255, 0.8);
|
||||||
--glass-border: rgba(0, 0, 0, 0.1);
|
--glass-border: rgba(0, 0, 0, 0.15);
|
||||||
--glass-blur: blur(16px);
|
--glass-blur: blur(16px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +47,10 @@ body {
|
|||||||
@apply glass rounded-2xl p-4 md:p-6;
|
@apply glass rounded-2xl p-4 md:p-6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-link-standard {
|
||||||
|
@apply text-xs font-bold uppercase tracking-widest transition-colors flex items-center gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-premium {
|
.btn-premium {
|
||||||
@apply relative px-6 py-2.5 rounded-xl font-bold transition-all duration-300;
|
@apply relative px-6 py-2.5 rounded-xl font-bold transition-all duration-300;
|
||||||
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
|
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ export default function AssetLibraryPage() {
|
|||||||
placeholder="Search by filename..."
|
placeholder="Search by filename..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
className="w-full bg-black/5 dark:bg-white/5 border border-black/10 dark:border-white/10 rounded-2xl pl-12 pr-4 py-4 text-sm font-medium focus:outline-none focus:border-blue-500/50 focus:bg-black/10 dark:focus:bg-white/10 transition-all text-gray-900 dark:text-white"
|
className="w-full bg-slate-50 dark:bg-black/40 border border-slate-200 dark:border-white/10 rounded-2xl pl-12 pr-4 py-4 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all text-slate-900 dark:text-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -150,7 +150,7 @@ export default function AssetLibraryPage() {
|
|||||||
<select
|
<select
|
||||||
value={filterType}
|
value={filterType}
|
||||||
onChange={(e) => setFilterType(e.target.value)}
|
onChange={(e) => setFilterType(e.target.value)}
|
||||||
className="w-full bg-black/5 dark:bg-white/5 border border-black/10 dark:border-white/10 rounded-2xl pl-12 pr-4 py-4 text-sm font-bold uppercase tracking-widest focus:outline-none focus:border-blue-500/50 appearance-none cursor-pointer hover:bg-black/10 dark:hover:bg-white/10 transition-all text-gray-900 dark:text-white"
|
className="w-full bg-slate-50 dark:bg-black/40 border border-slate-200 dark:border-white/10 rounded-2xl pl-12 pr-4 py-4 text-sm font-bold uppercase tracking-widest focus:outline-none focus:ring-2 focus:ring-blue-500/50 appearance-none cursor-pointer hover:bg-slate-100 dark:hover:bg-white/10 transition-all text-slate-900 dark:text-white"
|
||||||
>
|
>
|
||||||
<option value="all" className="bg-white dark:bg-gray-900">All Types</option>
|
<option value="all" className="bg-white dark:bg-gray-900">All Types</option>
|
||||||
<option value="image/" className="bg-white dark:bg-gray-900">Images</option>
|
<option value="image/" className="bg-white dark:bg-gray-900">Images</option>
|
||||||
@@ -185,7 +185,7 @@ export default function AssetLibraryPage() {
|
|||||||
{assets.map((asset) => (
|
{assets.map((asset) => (
|
||||||
<div
|
<div
|
||||||
key={asset.id}
|
key={asset.id}
|
||||||
className="group bg-black/5 dark:bg-white/5 border border-black/5 dark:border-white/5 rounded-[32px] overflow-hidden hover:bg-black/10 dark:hover:bg-white/10 hover:border-black/10 dark:hover:border-white/10 transition-all duration-300 hover:-translate-y-1 relative"
|
className="group bg-white dark:bg-black/20 border border-slate-200 dark:border-white/10 rounded-[32px] overflow-hidden hover:shadow-xl hover:shadow-slate-200/50 dark:hover:shadow-none transition-all duration-300 hover:-translate-y-1 relative"
|
||||||
>
|
>
|
||||||
{/* Preview Area */}
|
{/* Preview Area */}
|
||||||
<div className="aspect-video w-full bg-black/40 flex items-center justify-center relative overflow-hidden">
|
<div className="aspect-video w-full bg-black/40 flex items-center justify-center relative overflow-hidden">
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ export default function StudioDashboard() {
|
|||||||
<p className="text-gray-600 dark:text-gray-400 mt-2">{t('dashboard.title')}</p>
|
<p className="text-gray-600 dark:text-gray-400 mt-2">{t('dashboard.title')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<label className="flex items-center gap-2 px-6 py-3 bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10 border border-black/5 dark:border-white/10 rounded-xl font-bold transition-all cursor-pointer active:scale-95">
|
<label className="flex items-center gap-2 px-6 py-3 bg-slate-50 dark:bg-white/5 hover:bg-slate-100 dark:hover:bg-white/10 border border-slate-200 dark:border-white/10 rounded-xl font-bold transition-all cursor-pointer active:scale-95 text-slate-900 dark:text-white">
|
||||||
<Upload size={18} />
|
<Upload size={18} />
|
||||||
Importar
|
Importar
|
||||||
<input type="file" accept=".json" onChange={handleImport} className="hidden" />
|
<input type="file" accept=".json" onChange={handleImport} className="hidden" />
|
||||||
@@ -183,7 +183,7 @@ export default function StudioDashboard() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleCreateCourse}
|
onClick={handleCreateCourse}
|
||||||
className="flex items-center gap-2 px-6 py-3 bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10 border border-black/10 dark:border-white/10 rounded-xl font-bold transition-all active:scale-95"
|
className="flex items-center gap-2 px-6 py-3 bg-slate-50 dark:bg-white/5 hover:bg-slate-100 dark:hover:bg-white/10 border border-slate-200 dark:border-white/10 rounded-xl font-bold transition-all active:scale-95 text-slate-900 dark:text-white"
|
||||||
>
|
>
|
||||||
<Plus size={20} />
|
<Plus size={20} />
|
||||||
Manual
|
Manual
|
||||||
@@ -213,7 +213,7 @@ export default function StudioDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => handleExport(e, course.id, course.title)}
|
onClick={(e) => handleExport(e, course.id, course.title)}
|
||||||
className="p-2 hover:bg-black/5 dark:hover:bg-white/10 rounded-lg text-gray-500 hover:text-gray-900 dark:hover:text-white transition-all"
|
className="p-2 hover:bg-slate-100 dark:hover:bg-white/10 rounded-lg text-slate-500 hover:text-slate-900 dark:hover:text-white transition-all"
|
||||||
title="Export Course"
|
title="Export Course"
|
||||||
>
|
>
|
||||||
<Download size={18} />
|
<Download size={18} />
|
||||||
@@ -229,7 +229,7 @@ export default function StudioDashboard() {
|
|||||||
<h3 className="font-bold text-lg mb-2 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors text-gray-900 dark:text-white">{course.title}</h3>
|
<h3 className="font-bold text-lg mb-2 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors text-gray-900 dark:text-white">{course.title}</h3>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">{course.description || "Sin descripción disponible."}</p>
|
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">{course.description || "Sin descripción disponible."}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between mt-6 pt-4 border-t border-black/5 dark:border-white/5 text-xs text-gray-500 dark:text-gray-400">
|
<div className="flex items-center justify-between mt-6 pt-4 border-t border-slate-100 dark:border-white/5 text-xs text-slate-500 dark:text-gray-400">
|
||||||
<span>Última actualización: {new Date(course.updated_at).toLocaleDateString()}</span>
|
<span>Última actualización: {new Date(course.updated_at).toLocaleDateString()}</span>
|
||||||
<span>ID: {course.id.slice(0, 4)}...</span>
|
<span>ID: {course.id.slice(0, 4)}...</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -61,45 +61,45 @@ export default function BrandingSettings() {
|
|||||||
if (!org) return <div className="p-8 text-center text-red-400">Failed to load organization settings.</div>;
|
if (!org) return <div className="p-8 text-center text-red-400">Failed to load organization settings.</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 max-w-4xl mx-auto">
|
<div className="space-y-8 max-w-4xl mx-auto pb-12">
|
||||||
<fieldset className="border border-white/10 rounded-2xl p-6 bg-white/5 backdrop-blur-sm">
|
<fieldset className="border border-slate-200 dark:border-white/10 rounded-2xl p-6 bg-white dark:bg-white/5 backdrop-blur-sm shadow-sm">
|
||||||
<legend className="px-2 text-xl font-bold flex items-center gap-2">
|
<legend className="px-2 text-xl font-bold flex items-center gap-2 text-slate-900 dark:text-white">
|
||||||
<span aria-hidden="true">🎨</span> Brand Identity
|
<span aria-hidden="true">🎨</span> Brand Identity
|
||||||
</legend>
|
</legend>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
{/* Organization Name */}
|
{/* Organization Name */}
|
||||||
<div className="col-span-full">
|
<div className="col-span-full">
|
||||||
<label htmlFor="org-name" className="block text-sm font-medium text-gray-400 mb-2">Organization Name</label>
|
<label htmlFor="org-name" className="block text-sm font-medium text-slate-600 dark:text-gray-400 mb-2">Organization Name</label>
|
||||||
<input
|
<input
|
||||||
id="org-name"
|
id="org-name"
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.name || ""}
|
value={formData.name || ""}
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
className="w-full bg-black/20 border border-white/10 rounded-lg px-4 py-3 text-white focus:outline-none focus:border-blue-500/50 transition-colors"
|
className="w-full bg-slate-50 dark:bg-black/20 border border-slate-200 dark:border-white/10 rounded-lg px-4 py-3 text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all"
|
||||||
placeholder="My Organization"
|
placeholder="My Organization"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-gray-500 mt-2">The official name of your organization.</p>
|
<p className="text-xs text-slate-500 dark:text-gray-500 mt-2">The official name of your organization.</p>
|
||||||
</div>
|
</div>
|
||||||
{/* Platform Name */}
|
{/* Platform Name */}
|
||||||
<div className="col-span-full">
|
<div className="col-span-full">
|
||||||
<label htmlFor="platform-name" className="block text-sm font-medium text-gray-400 mb-2">Platform Name</label>
|
<label htmlFor="platform-name" className="block text-sm font-medium text-slate-600 dark:text-gray-400 mb-2">Platform Name</label>
|
||||||
<input
|
<input
|
||||||
id="platform-name"
|
id="platform-name"
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.platform_name || ""}
|
value={formData.platform_name || ""}
|
||||||
onChange={(e) => setFormData({ ...formData, platform_name: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, platform_name: e.target.value })}
|
||||||
placeholder={org.name}
|
placeholder={org.name}
|
||||||
className="w-full bg-black/20 border border-white/10 rounded-lg px-4 py-3 text-white focus:outline-none focus:border-blue-500/50 transition-colors"
|
className="w-full bg-slate-50 dark:bg-black/20 border border-slate-200 dark:border-white/10 rounded-lg px-4 py-3 text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-gray-500 mt-2">Appears in the browser tab and page titles.</p>
|
<p className="text-xs text-slate-500 dark:text-gray-500 mt-2">Appears in the browser tab and page titles.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Logo Section */}
|
{/* Logo Section */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<span className="block text-sm font-medium text-gray-400">Logo</span>
|
<span className="block text-sm font-medium text-slate-600 dark:text-gray-400">Logo</span>
|
||||||
<div className="p-4 bg-black/20 rounded-xl border border-white/5 flex items-center justify-center min-h-[120px] relative">
|
<div className="p-4 bg-slate-50 dark:bg-black/20 rounded-xl border border-slate-200 dark:border-white/5 flex items-center justify-center min-h-[120px] relative overflow-hidden">
|
||||||
{org.logo_url ? (
|
{org.logo_url ? (
|
||||||
<Image
|
<Image
|
||||||
src={getImageUrl(org.logo_url)}
|
src={getImageUrl(org.logo_url)}
|
||||||
@@ -109,7 +109,7 @@ export default function BrandingSettings() {
|
|||||||
sizes="100px"
|
sizes="100px"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-gray-600 text-sm">No logo uploaded</span>
|
<span className="text-slate-400 dark:text-gray-600 text-sm">No logo uploaded</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<FileUpload
|
<FileUpload
|
||||||
@@ -130,8 +130,8 @@ export default function BrandingSettings() {
|
|||||||
|
|
||||||
{/* Favicon Section */}
|
{/* Favicon Section */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<span className="block text-sm font-medium text-gray-400">Favicon</span>
|
<span className="block text-sm font-medium text-slate-600 dark:text-gray-400">Favicon</span>
|
||||||
<div className="p-4 bg-black/20 rounded-xl border border-white/5 flex items-center justify-center min-h-[120px] relative">
|
<div className="p-4 bg-slate-50 dark:bg-black/20 rounded-xl border border-slate-200 dark:border-white/5 flex items-center justify-center min-h-[120px] relative overflow-hidden">
|
||||||
{org.favicon_url ? (
|
{org.favicon_url ? (
|
||||||
<div className="w-8 h-8 relative">
|
<div className="w-8 h-8 relative">
|
||||||
<Image
|
<Image
|
||||||
@@ -143,7 +143,7 @@ export default function BrandingSettings() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-gray-600 text-sm">No favicon</span>
|
<span className="text-slate-400 dark:text-gray-600 text-sm">No favicon</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<FileUpload
|
<FileUpload
|
||||||
@@ -163,47 +163,47 @@ export default function BrandingSettings() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Logo Variant Selection */}
|
{/* Logo Variant Selection */}
|
||||||
<div className="col-span-full border-t border-white/5 pt-6 mt-2">
|
<div className="col-span-full border-t border-slate-100 dark:border-white/5 pt-6 mt-2">
|
||||||
<label className="block text-sm font-medium text-gray-400 mb-4">Logo Display Style (Header)</label>
|
<label className="block text-sm font-medium text-slate-600 dark:text-gray-400 mb-4">Logo Display Style (Header)</label>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setFormData({ ...formData, logo_variant: "standard" })}
|
onClick={() => setFormData({ ...formData, logo_variant: "standard" })}
|
||||||
className={`flex flex-col gap-3 p-4 rounded-xl border transition-all text-left ${formData.logo_variant === "standard" ? "bg-blue-600/10 border-blue-500/50" : "bg-black/20 border-white/10 hover:border-white/20"}`}
|
className={`flex flex-col gap-3 p-4 rounded-xl border transition-all text-left ${formData.logo_variant === "standard" ? "bg-blue-600/10 border-blue-500/50" : "bg-slate-50 dark:bg-black/20 border-slate-200 dark:border-white/10 hover:border-blue-500/30 dark:hover:border-white/20"}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-8 h-8 rounded-lg bg-blue-600 flex items-center justify-center font-bold text-xs">O</div>
|
<div className="w-8 h-8 rounded-lg bg-blue-600 flex items-center justify-center font-bold text-xs text-white">O</div>
|
||||||
<span className="text-sm font-bold">Standard</span>
|
<span className="text-sm font-bold text-slate-900 dark:text-white">Standard</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500">Small icon next to the organization name. Best for square logos.</p>
|
<p className="text-xs text-slate-500 dark:text-gray-500">Small icon next to the organization name. Best for square logos.</p>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setFormData({ ...formData, logo_variant: "wide" })}
|
onClick={() => setFormData({ ...formData, logo_variant: "wide" })}
|
||||||
className={`flex flex-col gap-3 p-4 rounded-xl border transition-all text-left ${formData.logo_variant === "wide" ? "bg-blue-600/10 border-blue-500/50" : "bg-black/20 border-white/10 hover:border-white/20"}`}
|
className={`flex flex-col gap-3 p-4 rounded-xl border transition-all text-left ${formData.logo_variant === "wide" ? "bg-blue-600/10 border-blue-500/50" : "bg-slate-50 dark:bg-black/20 border-slate-200 dark:border-white/10 hover:border-blue-500/30 dark:hover:border-white/20"}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-20 h-6 bg-blue-600/20 rounded-md border border-blue-500/30 flex items-center justify-center">
|
<div className="w-20 h-6 bg-blue-600/20 rounded-md border border-blue-500/30 flex items-center justify-center">
|
||||||
<div className="w-12 h-2 bg-blue-500 rounded-full" />
|
<div className="w-12 h-2 bg-blue-500 rounded-full" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-bold">Wide / Horizontal</span>
|
<span className="text-sm font-bold text-slate-900 dark:text-white">Wide / Horizontal</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500">Shows the full logo without text. Best for horizontal images like yours.</p>
|
<p className="text-xs text-slate-500 dark:text-gray-500">Shows the full logo without text. Best for horizontal images like yours.</p>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset className="border border-white/10 rounded-2xl p-6 bg-white/5 backdrop-blur-sm">
|
<fieldset className="border border-slate-200 dark:border-white/10 rounded-2xl p-6 bg-white dark:bg-white/5 backdrop-blur-sm shadow-sm">
|
||||||
<legend className="px-2 text-xl font-bold flex items-center gap-2">
|
<legend className="px-2 text-xl font-bold flex items-center gap-2 text-slate-900 dark:text-white">
|
||||||
<span aria-hidden="true">🌈</span> Brand Colors
|
<span aria-hidden="true">🌈</span> Brand Colors
|
||||||
</legend>
|
</legend>
|
||||||
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-8">
|
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
{/* Primary Color */}
|
{/* Primary Color */}
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="primary-color" className="block text-sm font-medium text-gray-400 mb-2">Primary Color</label>
|
<label htmlFor="primary-color" className="block text-sm font-medium text-slate-600 dark:text-gray-400 mb-2">Primary Color</label>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<input
|
<input
|
||||||
id="primary-color-picker"
|
id="primary-color-picker"
|
||||||
@@ -219,17 +219,17 @@ export default function BrandingSettings() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={formData.primary_color}
|
value={formData.primary_color}
|
||||||
onChange={(e) => setFormData({ ...formData, primary_color: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, primary_color: e.target.value })}
|
||||||
className="w-full bg-black/20 border border-white/10 rounded-lg px-4 py-2 text-white font-mono uppercase"
|
className="w-full bg-slate-50 dark:bg-black/20 border border-slate-200 dark:border-white/10 rounded-lg px-4 py-2 text-slate-900 dark:text-white font-mono uppercase focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all font-bold"
|
||||||
aria-describedby="primary-color-desc"
|
aria-describedby="primary-color-desc"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p id="primary-color-desc" className="text-xs text-gray-500 mt-2">Used for main buttons, active states, and highlights.</p>
|
<p id="primary-color-desc" className="text-xs text-slate-500 dark:text-gray-500 mt-2">Used for main buttons, active states, and highlights.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Secondary Color */}
|
{/* Secondary Color */}
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="secondary-color" className="block text-sm font-medium text-gray-400 mb-2">Secondary Color</label>
|
<label htmlFor="secondary-color" className="block text-sm font-medium text-slate-600 dark:text-gray-400 mb-2">Secondary Color</label>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<input
|
<input
|
||||||
id="secondary-color-picker"
|
id="secondary-color-picker"
|
||||||
@@ -245,12 +245,12 @@ export default function BrandingSettings() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={formData.secondary_color}
|
value={formData.secondary_color}
|
||||||
onChange={(e) => setFormData({ ...formData, secondary_color: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, secondary_color: e.target.value })}
|
||||||
className="w-full bg-black/20 border border-white/10 rounded-lg px-4 py-2 text-white font-mono uppercase"
|
className="w-full bg-slate-50 dark:bg-black/20 border border-slate-200 dark:border-white/10 rounded-lg px-4 py-2 text-slate-900 dark:text-white font-mono uppercase focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all font-bold"
|
||||||
aria-describedby="secondary-color-desc"
|
aria-describedby="secondary-color-desc"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p id="secondary-color-desc" className="text-xs text-gray-500 mt-2">Used for accents and gradients.</p>
|
<p id="secondary-color-desc" className="text-xs text-slate-500 dark:text-gray-500 mt-2">Used for accents and gradients.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|||||||
@@ -42,9 +42,9 @@ export default function CourseEditorLayout({ children, activeTab }: CourseEditor
|
|||||||
<Link
|
<Link
|
||||||
href={tab.href}
|
href={tab.href}
|
||||||
aria-current={isActive ? "page" : undefined}
|
aria-current={isActive ? "page" : undefined}
|
||||||
className={`flex items-center gap-1.5 px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap flex-shrink-0 ${isActive
|
className={`flex items-center gap-1.5 px-4 py-3 text-xs font-bold uppercase tracking-widest transition-all whitespace-nowrap flex-shrink-0 border-b-2 ${isActive
|
||||||
? "border-b-2 border-blue-600 dark:border-blue-500 bg-black/5 dark:bg-white/5 text-blue-600 dark:text-white"
|
? "border-blue-600 dark:border-blue-500 bg-black/5 dark:bg-white/5 text-blue-600 dark:text-white"
|
||||||
: "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
|
: "border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Icon className="w-4 h-4 flex-shrink-0" aria-hidden="true" />
|
<Icon className="w-4 h-4 flex-shrink-0" aria-hidden="true" />
|
||||||
|
|||||||
@@ -596,6 +596,7 @@ export const cmsApi = {
|
|||||||
getOrganization: (): Promise<Organization> => apiFetch('/organization'),
|
getOrganization: (): Promise<Organization> => apiFetch('/organization'),
|
||||||
getOrganizations: (): Promise<Organization[]> => apiFetch('/organizations'),
|
getOrganizations: (): Promise<Organization[]> => apiFetch('/organizations'),
|
||||||
createOrganization: (name: string, domain?: string): Promise<Organization> => apiFetch('/organizations', { method: 'POST', body: JSON.stringify({ name, domain }) }),
|
createOrganization: (name: string, domain?: string): Promise<Organization> => apiFetch('/organizations', { method: 'POST', body: JSON.stringify({ name, domain }) }),
|
||||||
|
updateOrganization: (id: string, payload: { name?: string, domain?: string }): Promise<Organization> => apiFetch(`/organizations/${id}`, { method: 'PUT', body: JSON.stringify(payload) }),
|
||||||
provisionOrganization: (data: ProvisionPayload): Promise<Organization> => apiFetch('/admin/provision', { method: 'POST', body: JSON.stringify(data) }),
|
provisionOrganization: (data: ProvisionPayload): Promise<Organization> => apiFetch('/admin/provision', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
|
||||||
// Auth
|
// Auth
|
||||||
|
|||||||
Reference in New Issue
Block a user