diff --git a/services/cms-service/migrations/20260226000001_add_logo_variant.sql b/services/cms-service/migrations/20260226000001_add_logo_variant.sql new file mode 100644 index 0000000..40959a2 --- /dev/null +++ b/services/cms-service/migrations/20260226000001_add_logo_variant.sql @@ -0,0 +1,3 @@ +-- Add logo_variant to organizations +ALTER TABLE organizations ADD COLUMN IF NOT EXISTS logo_variant VARCHAR(20) DEFAULT 'standard'; +COMMENT ON COLUMN organizations.logo_variant IS 'Header logo display style (standard or wide)'; diff --git a/services/cms-service/src/handlers.rs b/services/cms-service/src/handlers.rs index acd37f1..fbfbc97 100644 --- a/services/cms-service/src/handlers.rs +++ b/services/cms-service/src/handlers.rs @@ -2700,6 +2700,42 @@ pub async fn get_organizations( Ok(Json(orgs)) } +#[derive(Deserialize)] +pub struct OrgSearchQuery { + pub q: String, +} + +#[derive(Serialize, sqlx::FromRow)] +pub struct OrgSearchResult { + pub id: Uuid, + pub name: String, + pub domain: Option, +} + +pub async fn search_organizations( + State(pool): State, + Query(query): Query, +) -> Result>, StatusCode> { + if query.q.trim().is_empty() { + return Ok(Json(vec![])); + } + + let search_term = format!("%{}%", query.q.trim()); + + let orgs = sqlx::query_as::<_, OrgSearchResult>( + "SELECT id, name, domain FROM organizations WHERE name ILIKE $1 OR domain ILIKE $1 ORDER BY name ASC LIMIT 10" + ) + .bind(search_term) + .fetch_all(&pool) + .await + .map_err(|e| { + tracing::error!("Failed to search organizations: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(Json(orgs)) +} + pub async fn create_organization( claims: common::auth::Claims, State(pool): State, diff --git a/services/cms-service/src/handlers_branding.rs b/services/cms-service/src/handlers_branding.rs index b6c2038..5eb2bb1 100644 --- a/services/cms-service/src/handlers_branding.rs +++ b/services/cms-service/src/handlers_branding.rs @@ -15,9 +15,11 @@ use super::handlers::log_action; #[derive(Deserialize, Serialize)] pub struct BrandingPayload { + pub name: Option, pub primary_color: Option, pub secondary_color: Option, pub platform_name: Option, + pub logo_variant: Option, } #[derive(Serialize)] @@ -25,6 +27,7 @@ pub struct BrandingResponse { pub logo_url: Option, pub favicon_url: Option, pub platform_name: Option, + pub logo_variant: Option, pub primary_color: String, pub secondary_color: String, } @@ -293,16 +296,20 @@ pub async fn update_organization_branding( // Update organization let org = sqlx::query_as::<_, Organization>( "UPDATE organizations - SET primary_color = COALESCE($1, primary_color), - secondary_color = COALESCE($2, secondary_color), - platform_name = COALESCE($3, platform_name), + SET name = COALESCE($1, name), + primary_color = COALESCE($2, primary_color), + secondary_color = COALESCE($3, secondary_color), + platform_name = COALESCE($4, platform_name), + logo_variant = COALESCE($5, logo_variant), updated_at = NOW() - WHERE id = $4 + WHERE id = $6 RETURNING *", ) + .bind(&payload.name) .bind(&payload.primary_color) .bind(&payload.secondary_color) .bind(&payload.platform_name) + .bind(&payload.logo_variant) .bind(org_id) .fetch_one(&pool) .await @@ -342,6 +349,7 @@ pub async fn get_organization_branding( logo_url: org.logo_url, favicon_url: org.favicon_url, platform_name: org.platform_name, + logo_variant: org.logo_variant, primary_color: org.primary_color.unwrap_or_else(|| "#3B82F6".to_string()), secondary_color: org.secondary_color.unwrap_or_else(|| "#8B5CF6".to_string()), })) diff --git a/services/cms-service/src/main.rs b/services/cms-service/src/main.rs index b66e5ee..b72586f 100644 --- a/services/cms-service/src/main.rs +++ b/services/cms-service/src/main.rs @@ -282,6 +282,10 @@ async fn main() { .route("/auth/login", post(handlers::login)) .route("/auth/sso/login/{org_id}", get(handlers::sso_login_init)) .route("/auth/sso/callback", get(handlers::sso_callback)) + .route( + "/organizations/search", + get(handlers::search_organizations), + ) .route( "/organizations/{id}/branding", get(handlers_branding::get_organization_branding), diff --git a/shared/common/src/models.rs b/shared/common/src/models.rs index 8efc2f0..ef66ce0 100644 --- a/shared/common/src/models.rs +++ b/shared/common/src/models.rs @@ -345,6 +345,7 @@ pub struct Organization { pub certificate_template: Option, pub platform_name: Option, pub favicon_url: Option, + pub logo_variant: Option, pub created_at: DateTime, pub updated_at: DateTime, } diff --git a/web/experience/next.config.mjs b/web/experience/next.config.mjs index 3f63e30..f9ba127 100644 --- a/web/experience/next.config.mjs +++ b/web/experience/next.config.mjs @@ -9,6 +9,7 @@ const nextConfig = { ignoreBuildErrors: true, }, images: { + unoptimized: true, remotePatterns: [ { protocol: 'http', diff --git a/web/experience/src/app/auth/login/page.tsx b/web/experience/src/app/auth/login/page.tsx index b5134da..eafc467 100644 --- a/web/experience/src/app/auth/login/page.tsx +++ b/web/experience/src/app/auth/login/page.tsx @@ -4,6 +4,7 @@ import React, { useState } from "react"; import { useRouter } from "next/navigation"; import { lmsApi } from "@/lib/api"; import { useAuth } from "@/context/AuthContext"; +import AsyncCombobox from "@/components/AsyncCombobox"; import { GraduationCap, Lock, Mail, User, Building2, ChevronLeft, ArrowRight } from "lucide-react"; type ViewMode = 'selection' | 'personal' | 'enterprise'; @@ -194,34 +195,25 @@ export default function ExperienceLoginPage() {
-
- -
- - setOrganizationName(e.target.value)} className="w-full bg-slate-900/50 border border-white/10 rounded-xl py-3 pl-10 pr-4 text-white text-sm focus:border-emerald-500 focus:outline-none transition-colors font-mono" placeholder="acme-corp" /> -
-
- -
- -
- - setEmail(e.target.value)} className="w-full bg-slate-900/50 border border-white/10 rounded-xl py-3 pl-10 pr-4 text-white text-sm focus:border-emerald-500 focus:outline-none transition-colors" placeholder="usuario@empresa.com" /> -
-
- -
- -
- - setPassword(e.target.value)} className="w-full bg-slate-900/50 border border-white/10 rounded-xl py-3 pl-10 pr-4 text-white text-sm focus:border-emerald-500 focus:outline-none transition-colors" placeholder="••••••••" /> -
+
+ + { + const res = await lmsApi.searchOrganizations(q); + return res.map(o => ({ id: o.id, name: o.name })); + }} + placeholder="Busca tu empresa..." + leftIcon={} + />
{error &&
{error}
} -
diff --git a/web/experience/src/app/layout.tsx b/web/experience/src/app/layout.tsx index 6e29713..f69b17a 100644 --- a/web/experience/src/app/layout.tsx +++ b/web/experience/src/app/layout.tsx @@ -6,6 +6,7 @@ import { AuthProvider } from "@/context/AuthContext"; import { I18nProvider } from "@/context/I18nContext"; import { BrandingProvider } from "@/context/BrandingContext"; import AuthGuard from "@/components/AuthGuard"; +import { ThemeProvider } from "@/context/ThemeContext"; const inter = Inter({ subsets: ["latin"] }); @@ -22,25 +23,27 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - - - - - - - -
- {children} -
-
-

- Desarrollado Por el Departamento de Informática © 2026. OpenCCB. -

-
-
-
-
-
+ + + + + + + + +
+ {children} +
+
+

+ Desarrollado Por el Departamento de Informática © 2026. OpenCCB. +

+
+
+
+
+
+
); diff --git a/web/experience/src/components/AppHeader.tsx b/web/experience/src/components/AppHeader.tsx index 383cb40..827bbeb 100644 --- a/web/experience/src/components/AppHeader.tsx +++ b/web/experience/src/components/AppHeader.tsx @@ -5,9 +5,10 @@ import Image from "next/image"; import { useBranding } from "@/context/BrandingContext"; import { useAuth } from "@/context/AuthContext"; import { useTranslation } from "@/context/I18nContext"; -import { LogOut, Globe, Menu, X } from "lucide-react"; +import { LogOut, Globe, Menu, X, Sun, Moon } from "lucide-react"; import NotificationCenter from "./NotificationCenter"; import { useState } from "react"; +import { useTheme } from "@/context/ThemeContext"; import { lmsApi, getImageUrl } from "@/lib/api"; @@ -15,6 +16,7 @@ export default function AppHeader() { const { t, language, setLanguage } = useTranslation(); const { branding } = useBranding(); const { user, logout } = useAuth(); + const { theme, toggleTheme } = useTheme(); const [isMenuOpen, setIsMenuOpen] = useState(false); // Use platform_name if available, otherwise name, otherwise default @@ -22,22 +24,24 @@ export default function AppHeader() { return (
- -
+ +
{branding?.logo_url ? ( - + ) : ( )}
- + {branding?.logo_variant !== 'wide' && ( + + )}
@@ -76,6 +80,14 @@ export default function AppHeader() {
+ +
@@ -161,19 +173,27 @@ export default function AppHeader() { )}
-
-
+
diff --git a/web/experience/src/components/AsyncCombobox.tsx b/web/experience/src/components/AsyncCombobox.tsx new file mode 100644 index 0000000..6935da8 --- /dev/null +++ b/web/experience/src/components/AsyncCombobox.tsx @@ -0,0 +1,147 @@ +"use client"; + +import React, { useState, useRef, useEffect } from "react"; +import { Search, ChevronDown, Check, Loader2 } from "lucide-react"; + +interface Option { + id: string; + name: string; +} + +interface AsyncComboboxProps { + value: string; + onChange: (value: string) => void; + onSearch: (query: string) => Promise; + placeholder?: string; + id?: string; + leftIcon?: React.ReactNode; + defaultOptions?: Option[]; +} + +export default function AsyncCombobox({ value, onChange, onSearch, placeholder = "Search...", id, leftIcon, defaultOptions = [] }: AsyncComboboxProps) { + const [isOpen, setIsOpen] = useState(false); + const [search, setSearch] = useState(""); + const [options, setOptions] = useState(defaultOptions); + const [loading, setLoading] = useState(false); + const containerRef = useRef(null); + + const selectedOption = options.find(o => o.id === value) || defaultOptions.find(o => o.id === value); + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setIsOpen(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + useEffect(() => { + if (!isOpen) return; + + const delayDebounceFn = setTimeout(async () => { + if (!search) { + setOptions(defaultOptions); + return; + } + setLoading(true); + try { + const results = await onSearch(search); + setOptions(results); + } catch (error) { + console.error("Search failed", error); + } finally { + setLoading(false); + } + }, 300); + + return () => clearTimeout(delayDebounceFn); + }, [search, onSearch, isOpen, defaultOptions]); + + return ( +
+ + + {isOpen && ( +
+
+
+
+
+
+ {!loading && options.length === 0 ? ( +
No se encontraron resultados
+ ) : ( + options.map(option => ( +
{ + onChange(option.id); + setIsOpen(false); + setSearch(""); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + onChange(option.id); + setIsOpen(false); + setSearch(""); + } + }} + className={`flex items-center justify-between px-3 py-2.5 rounded-lg cursor-pointer transition-colors outline-none focus:bg-indigo-600 focus:text-white ${value === option.id ? "bg-indigo-600 text-white" : "hover:bg-white/5 text-gray-300" + }`} + > + {option.name} + {value === option.id &&
+ )) + )} +
+
+ )} +
+ ); +} diff --git a/web/experience/src/context/BrandingContext.tsx b/web/experience/src/context/BrandingContext.tsx index 31f4d69..23627ba 100644 --- a/web/experience/src/context/BrandingContext.tsx +++ b/web/experience/src/context/BrandingContext.tsx @@ -2,6 +2,7 @@ import React, { createContext, useContext, useEffect, useState } from 'react'; import { lmsApi, Organization, getImageUrl } from '@/lib/api'; +import { usePathname } from 'next/navigation'; interface BrandingContextType { branding: Organization | null; @@ -21,42 +22,14 @@ export const BrandingProvider: React.FC<{ children: React.ReactNode }> = ({ chil const orgId = process.env.NEXT_PUBLIC_ORG_ID || '00000000-0000-0000-0000-000000000001'; + const pathname = usePathname(); + useEffect(() => { const loadBranding = async () => { try { const data = await lmsApi.getBranding(orgId); setBranding(data); - - // Apply CSS variables - if (data.primary_color) { - document.documentElement.style.setProperty('--primary-color', data.primary_color); - } - if (data.secondary_color) { - document.documentElement.style.setProperty('--secondary-color', data.secondary_color); - } - - // Update Title - if (data.platform_name) { - document.title = `${data.platform_name} | Experiencia de Aprendizaje`; - } - - // Update Favicon - if (data.favicon_url) { - // Import getImageUrl logic locally or assume it needs import - // Since I can't easily add import at top with replace_file, I will assume getImageUrl handles the path or do logic here. - // Actually I need to import getImageUrl at the top. Instead of complicating, I'll update imports too. - - const faviconUrl = getImageUrl(data.favicon_url); - const link: HTMLLinkElement | null = document.querySelector("link[rel*='icon']"); - if (link) { - link.href = faviconUrl; - } else { - const newLink = document.createElement("link"); - newLink.rel = "shortcut icon"; - newLink.href = faviconUrl; - document.head.appendChild(newLink); - } - } + console.log('Branding loaded in Experience:', data); } catch (error) { console.error('Failed to load branding', error); } finally { @@ -67,6 +40,38 @@ export const BrandingProvider: React.FC<{ children: React.ReactNode }> = ({ chil loadBranding(); }, [orgId]); + useEffect(() => { + if (!branding) return; + console.log('Applying branding in Experience for path:', pathname); + + // Apply CSS variables + if (branding.primary_color) { + document.documentElement.style.setProperty('--primary-color', branding.primary_color); + } + if (branding.secondary_color) { + document.documentElement.style.setProperty('--secondary-color', branding.secondary_color); + } + + // Update Title + if (branding.platform_name) { + document.title = `${branding.platform_name} | Experiencia de Aprendizaje`; + } + + // Update Favicon + if (branding.favicon_url) { + const faviconUrl = getImageUrl(branding.favicon_url); + const link: HTMLLinkElement | null = document.querySelector("link[rel*='icon']"); + if (link) { + link.href = faviconUrl; + } else { + const newLink = document.createElement("link"); + newLink.rel = "shortcut icon"; + newLink.href = faviconUrl; + document.head.appendChild(newLink); + } + } + }, [branding, pathname]); + return ( {children} diff --git a/web/experience/src/context/ThemeContext.tsx b/web/experience/src/context/ThemeContext.tsx new file mode 100644 index 0000000..6d8ed93 --- /dev/null +++ b/web/experience/src/context/ThemeContext.tsx @@ -0,0 +1,55 @@ +'use client'; + +import React, { createContext, useContext, useEffect, useState } from 'react'; + +type Theme = 'dark' | 'light'; + +interface ThemeContextType { + theme: Theme; + toggleTheme: () => void; + setTheme: (theme: Theme) => void; +} + +const ThemeContext = createContext({ + theme: 'dark', + toggleTheme: () => { }, + setTheme: () => { }, +}); + +export const useTheme = () => useContext(ThemeContext); + +export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [theme, setThemeState] = useState('dark'); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + const savedTheme = localStorage.getItem('theme') as Theme | null; + if (savedTheme) { + setThemeState(savedTheme); + } + setMounted(true); + }, []); + + useEffect(() => { + if (!mounted) return; + + const root = window.document.documentElement; + root.classList.remove('light', 'dark'); + root.classList.add(theme); + localStorage.setItem('theme', theme); + }, [theme, mounted]); + + const toggleTheme = () => { + setThemeState(prev => (prev === 'dark' ? 'light' : 'dark')); + }; + + const setTheme = (newTheme: Theme) => { + setThemeState(newTheme); + }; + + return ( + + {children} + + ); +}; diff --git a/web/experience/src/lib/api.ts b/web/experience/src/lib/api.ts index beb0bb3..6239392 100644 --- a/web/experience/src/lib/api.ts +++ b/web/experience/src/lib/api.ts @@ -26,6 +26,7 @@ export interface Organization { platform_name?: string; primary_color?: string; secondary_color?: string; + logo_variant?: string; } export interface Recommendation { title: string; @@ -446,6 +447,10 @@ const apiFetch = async (url: string, options: RequestInit = {}, isCMS: boolean = }; export const lmsApi = { + async searchOrganizations(query: string): Promise<{ id: string, name: string, domain?: string }[]> { + return apiFetch(`/organizations/search?q=${encodeURIComponent(query)}`, {}, true); + }, + async getCatalog(orgId?: string, userId?: string): Promise { const params = new URLSearchParams(); if (orgId) params.append('organization_id', orgId); diff --git a/web/experience/tailwind.config.ts b/web/experience/tailwind.config.ts index 3f066ce..a39020b 100644 --- a/web/experience/tailwind.config.ts +++ b/web/experience/tailwind.config.ts @@ -14,6 +14,7 @@ const config: Config = { }, }, }, + darkMode: 'class', plugins: [], }; export default config; diff --git a/web/studio/next.config.mjs b/web/studio/next.config.mjs index 3dc04b1..2813912 100644 --- a/web/studio/next.config.mjs +++ b/web/studio/next.config.mjs @@ -9,20 +9,21 @@ const nextConfig = { ignoreBuildErrors: true, }, images: { + unoptimized: true, remotePatterns: [ { protocol: 'http', hostname: 'localhost', port: '3001', - pathname: '/uploads/**', + pathname: '/assets/**', }, ], }, async rewrites() { return [ { - source: '/uploads/:path*', - destination: 'http://localhost:3001/uploads/:path*', + source: '/assets/:path*', + destination: 'http://localhost:3001/assets/:path*', }, ]; }, diff --git a/web/studio/src/app/auth/login/page.tsx b/web/studio/src/app/auth/login/page.tsx index 55770f9..e08bfaf 100644 --- a/web/studio/src/app/auth/login/page.tsx +++ b/web/studio/src/app/auth/login/page.tsx @@ -4,6 +4,7 @@ import React, { useState } from "react"; import { useRouter } from "next/navigation"; import { cmsApi } from "@/lib/api"; import { useAuth } from "@/context/AuthContext"; +import AsyncCombobox from "@/components/AsyncCombobox"; import { BookOpen, Lock, Mail, User, Building2 } from "lucide-react"; export default function StudioLoginPage() { @@ -168,23 +169,23 @@ export default function StudioLoginPage() {
) : ( -
+
-
- - setOrgIdForSSO(e.target.value)} - className="w-full bg-white/5 border border-white/10 rounded-xl py-3 pl-11 pr-4 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500" - placeholder="00000000-0000-0000-0000-000000000000" - required - /> -
+ { + const res = await cmsApi.searchOrganizations(q); + return res.map(o => ({ id: o.id, name: o.name })); + }} + placeholder="Search for your organization..." + leftIcon={} + />

- Contact your administrator if you don't know your Organization ID. + Please search and select your organization to continue.

)} diff --git a/web/studio/src/app/layout.tsx b/web/studio/src/app/layout.tsx index 62ef9c9..c8f1765 100644 --- a/web/studio/src/app/layout.tsx +++ b/web/studio/src/app/layout.tsx @@ -6,6 +6,8 @@ import { AuthProvider } from "@/context/AuthContext"; import { I18nProvider } from "@/context/I18nContext"; import { BookOpen } from "lucide-react"; import AuthGuard from "@/components/AuthGuard"; +import { BrandingProvider } from "@/context/BrandingContext"; +import { ThemeProvider } from "@/context/ThemeContext"; const inter = Inter({ subsets: ["latin"] }); @@ -14,7 +16,7 @@ export const metadata: Metadata = { description: "Create and manage high-fidelity educational content.", }; -import AuthHeader from "@/components/AuthHeader"; +import { Navbar } from "@/components/Navbar"; import BrandingManager from "@/components/BrandingManager"; export default function RootLayout({ @@ -23,25 +25,21 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - - - - - - -
- -
- -
- STUDIO - - -
-
{children}
-
-
-
+ + + + + + + + + +
{children}
+
+
+
+
+
); diff --git a/web/studio/src/components/AsyncCombobox.tsx b/web/studio/src/components/AsyncCombobox.tsx new file mode 100644 index 0000000..c92e78e --- /dev/null +++ b/web/studio/src/components/AsyncCombobox.tsx @@ -0,0 +1,147 @@ +"use client"; + +import React, { useState, useRef, useEffect } from "react"; +import { Search, ChevronDown, Check, Loader2 } from "lucide-react"; + +interface Option { + id: string; + name: string; +} + +interface AsyncComboboxProps { + value: string; + onChange: (value: string) => void; + onSearch: (query: string) => Promise; + placeholder?: string; + id?: string; + leftIcon?: React.ReactNode; + defaultOptions?: Option[]; +} + +export default function AsyncCombobox({ value, onChange, onSearch, placeholder = "Search...", id, leftIcon, defaultOptions = [] }: AsyncComboboxProps) { + const [isOpen, setIsOpen] = useState(false); + const [search, setSearch] = useState(""); + const [options, setOptions] = useState(defaultOptions); + const [loading, setLoading] = useState(false); + const containerRef = useRef(null); + + const selectedOption = options.find(o => o.id === value) || defaultOptions.find(o => o.id === value); + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setIsOpen(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + useEffect(() => { + if (!isOpen) return; + + const delayDebounceFn = setTimeout(async () => { + if (!search) { + setOptions(defaultOptions); + return; + } + setLoading(true); + try { + const results = await onSearch(search); + setOptions(results); + } catch (error) { + console.error("Search failed", error); + } finally { + setLoading(false); + } + }, 300); + + return () => clearTimeout(delayDebounceFn); + }, [search, onSearch, isOpen, defaultOptions]); + + return ( +
+ + + {isOpen && ( +
+
+
+
+
+
+ {!loading && options.length === 0 ? ( +
No results found
+ ) : ( + options.map(option => ( +
{ + onChange(option.id); + setIsOpen(false); + setSearch(""); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + onChange(option.id); + setIsOpen(false); + setSearch(""); + } + }} + className={`flex items-center justify-between px-3 py-2 rounded-md cursor-pointer transition-colors outline-none focus:bg-blue-600 focus:text-white ${value === option.id ? "bg-blue-600 text-white" : "hover:bg-white/5 text-gray-300" + }`} + > + {option.name} + {value === option.id &&
+ )) + )} +
+
+ )} +
+ ); +} diff --git a/web/studio/src/components/BrandingSettings.tsx b/web/studio/src/components/BrandingSettings.tsx index a266de7..0887006 100644 --- a/web/studio/src/components/BrandingSettings.tsx +++ b/web/studio/src/components/BrandingSettings.tsx @@ -12,9 +12,11 @@ export default function BrandingSettings() { const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [formData, setFormData] = useState({ + name: "", primary_color: "#3B82F6", secondary_color: "#8B5CF6", platform_name: "", + logo_variant: "standard", }); useEffect(() => { @@ -26,9 +28,11 @@ export default function BrandingSettings() { const data = await cmsApi.getOrganization(); setOrg(data); setFormData({ + name: data.name || "", primary_color: data.primary_color || "#3B82F6", secondary_color: data.secondary_color || "#8B5CF6", platform_name: data.platform_name || "", + logo_variant: data.logo_variant || "standard", }); } catch (error) { console.error("Failed to fetch organization:", error); @@ -64,6 +68,20 @@ export default function BrandingSettings() {
+ {/* Organization Name */} +
+ + 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" + placeholder="My Organization" + required + /> +

The official name of your organization.

+
{/* Platform Name */}
@@ -143,6 +161,38 @@ export default function BrandingSettings() { />

Recommended: ICO or PNG, 32x32px.

+ + {/* Logo Variant Selection */} +
+ +
+ + + +
+
diff --git a/web/studio/src/components/Navbar.tsx b/web/studio/src/components/Navbar.tsx index f03f0d0..2231284 100644 --- a/web/studio/src/components/Navbar.tsx +++ b/web/studio/src/components/Navbar.tsx @@ -3,19 +3,41 @@ import Link from 'next/link'; import { useAuth } from '@/context/AuthContext'; import { useTranslation } from '@/context/I18nContext'; -import { LayoutDashboard, ShieldCheck, LogOut, Webhook, Settings, Globe, Library } from 'lucide-react'; +import { LayoutDashboard, ShieldCheck, LogOut, Webhook, Settings, Globe, Library, BookOpen, Sun, Moon } from 'lucide-react'; +import { useBranding } from '@/context/BrandingContext'; +import { useTheme } from '@/context/ThemeContext'; +import { getImageUrl } from '@/lib/api'; +import Image from 'next/image'; export function Navbar() { const { t, language, setLanguage } = useTranslation(); const { user, logout } = useAuth(); + const { branding } = useBranding(); + const { theme, toggleTheme } = useTheme(); + + const platformName = branding?.platform_name || 'OpenCCB'; return ( -