From 661be22e824acf2bbb2ee0ffa25c84ceead6a32b Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Thu, 19 Feb 2026 12:03:11 +0100 Subject: [PATCH] feat(ui): add dashboard sections with sidebar navigation and mock views Add seven sidebar sections (Dashboard, Providers, Chat, Tools, Knowledge Base, Developer, Organization) with fully rendered mock views, nested sub-shells for Developer and Organization, and SearXNG container for future news feed integration. Replaces the previous OverviewPage with a news feed dashboard. Co-Authored-By: Claude Opus 4.6 --- assets/main.css | 1088 +++++++++++++++++++++++++++ docker-compose.yml | 13 +- src/app.rs | 32 +- src/components/chat_bubble.rs | 41 + src/components/file_row.rs | 54 ++ src/components/member_row.rs | 38 + src/components/mod.rs | 16 + src/components/news_card.rs | 129 ++++ src/components/page_header.rs | 23 + src/components/pricing_card.rs | 46 ++ src/components/sidebar.rs | 59 +- src/components/sub_nav.rs | 44 ++ src/components/tool_card.rs | 40 + src/models/chat.rs | 71 ++ src/models/developer.rs | 47 ++ src/models/knowledge.rs | 60 ++ src/models/mod.rs | 14 + src/models/news.rs | 62 ++ src/models/organization.rs | 84 +++ src/models/provider.rs | 74 ++ src/models/tool.rs | 73 ++ src/pages/chat.rs | 147 ++++ src/pages/dashboard.rs | 71 ++ src/pages/developer/agents.rs | 24 + src/pages/developer/analytics.rs | 70 ++ src/pages/developer/flow.rs | 24 + src/pages/developer/mod.rs | 41 + src/pages/knowledge.rs | 124 +++ src/pages/mod.rs | 16 +- src/pages/organization/dashboard.rs | 177 +++++ src/pages/organization/mod.rs | 35 + src/pages/organization/pricing.rs | 88 +++ src/pages/overview.rs | 102 --- src/pages/providers.rs | 227 ++++++ src/pages/tools.rs | 120 +++ 35 files changed, 3244 insertions(+), 130 deletions(-) create mode 100644 src/components/chat_bubble.rs create mode 100644 src/components/file_row.rs create mode 100644 src/components/member_row.rs create mode 100644 src/components/news_card.rs create mode 100644 src/components/page_header.rs create mode 100644 src/components/pricing_card.rs create mode 100644 src/components/sub_nav.rs create mode 100644 src/components/tool_card.rs create mode 100644 src/models/chat.rs create mode 100644 src/models/developer.rs create mode 100644 src/models/knowledge.rs create mode 100644 src/models/news.rs create mode 100644 src/models/organization.rs create mode 100644 src/models/provider.rs create mode 100644 src/models/tool.rs create mode 100644 src/pages/chat.rs create mode 100644 src/pages/dashboard.rs create mode 100644 src/pages/developer/agents.rs create mode 100644 src/pages/developer/analytics.rs create mode 100644 src/pages/developer/flow.rs create mode 100644 src/pages/developer/mod.rs create mode 100644 src/pages/knowledge.rs create mode 100644 src/pages/organization/dashboard.rs create mode 100644 src/pages/organization/mod.rs create mode 100644 src/pages/organization/pricing.rs delete mode 100644 src/pages/overview.rs create mode 100644 src/pages/providers.rs create mode 100644 src/pages/tools.rs diff --git a/assets/main.css b/assets/main.css index bb48a7d..5d05441 100644 --- a/assets/main.css +++ b/assets/main.css @@ -761,3 +761,1091 @@ h1, h2, h3, h4, h5, h6 { font-size: 28px; } } + +/* ===== Shared UI Elements ===== */ +.btn-primary { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 10px 20px; + background: linear-gradient(135deg, #91a4d2, #6d85c6); + color: #0a0c10; + border: none; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: opacity 0.15s ease; + font-family: 'Inter', sans-serif; +} + +.btn-primary:hover { + opacity: 0.9; +} + +.btn-primary:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.btn-secondary { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 10px 20px; + background-color: #1e222d; + color: #e2e8f0; + border: 1px solid #2a2f3d; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: border-color 0.15s ease; + font-family: 'Inter', sans-serif; +} + +.btn-secondary:hover { + border-color: #91a4d2; +} + +.btn-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + background-color: transparent; + color: #8892a8; + border: 1px solid #2a2f3d; + border-radius: 8px; + cursor: pointer; + font-size: 16px; + transition: color 0.15s ease, border-color 0.15s ease; +} + +.btn-icon:hover { + color: #e2e8f0; + border-color: #91a4d2; +} + +.btn-danger { + color: #f87171; + border-color: rgba(248, 113, 113, 0.3); + font-size: 12px; + padding: 4px 12px; + width: auto; + height: auto; +} + +.btn-danger:hover { + background-color: rgba(248, 113, 113, 0.08); + border-color: #f87171; + color: #f87171; +} + +/* ===== Form Elements ===== */ +.form-group { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 16px; +} + +.form-group label { + font-size: 13px; + font-weight: 500; + color: #8892a8; +} + +.form-select, +.form-input { + padding: 10px 14px; + background-color: #1a1d26; + border: 1px solid #2a2f3d; + border-radius: 8px; + color: #e2e8f0; + font-size: 14px; + font-family: 'Inter', sans-serif; + outline: none; + transition: border-color 0.15s ease; +} + +.form-select:focus, +.form-input:focus { + border-color: #91a4d2; +} + +.form-success { + font-size: 13px; + color: #4ade80; + margin-top: 8px; +} + +/* ===== Page Header ===== */ +.page-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 32px; + gap: 24px; +} + +.page-header-text { + flex: 1; +} + +.page-title { + font-size: 28px; + font-weight: 700; + color: #f1f5f9; + margin: 0 0 4px; +} + +.page-subtitle { + font-size: 15px; + color: #8892a8; + margin: 0; +} + +/* ===== Sub Navigation (Developer/Org tabs) ===== */ +.sub-nav { + display: flex; + gap: 4px; + padding: 0 0 20px; + border-bottom: 1px solid #1e222d; + margin-bottom: 24px; +} + +.sub-nav-item { + padding: 8px 16px; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + color: #8892a8; + text-decoration: none; + transition: background-color 0.15s ease, color 0.15s ease; +} + +.sub-nav-item:hover { + background-color: #1e222d; + color: #e2e8f0; +} + +.sub-nav-item--active { + background-color: rgba(145, 164, 210, 0.12); + color: #91a4d2; +} + +/* ===== Shell Content (Developer/Org) ===== */ +.developer-shell, +.org-shell { + max-width: 1200px; +} + +.shell-content { + min-height: 400px; +} + +/* ===== Dashboard Page (News Feed) ===== */ +.dashboard-page { + max-width: 1200px; +} + +.dashboard-filters { + display: flex; + gap: 8px; + margin-bottom: 24px; + flex-wrap: wrap; +} + +.filter-tab { + padding: 6px 16px; + border-radius: 20px; + border: 1px solid #2a2f3d; + background-color: transparent; + color: #8892a8; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; + font-family: 'Inter', sans-serif; +} + +.filter-tab:hover { + border-color: #91a4d2; + color: #e2e8f0; +} + +.filter-tab--active { + background-color: rgba(145, 164, 210, 0.12); + border-color: #91a4d2; + color: #91a4d2; +} + +/* ===== News Card ===== */ +.news-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20px; +} + +.news-card { + background-color: #1a1d26; + border: 1px solid #2a2f3d; + border-radius: 12px; + overflow: hidden; + transition: border-color 0.2s ease, transform 0.2s ease; +} + +.news-card:hover { + border-color: #91a4d2; + transform: translateY(-2px); +} + +.news-card-thumb img { + width: 100%; + height: 140px; + object-fit: cover; +} + +.news-card-body { + padding: 20px; +} + +.news-card-meta { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 10px; + flex-wrap: wrap; +} + +.news-badge { + font-size: 11px; + font-weight: 600; + padding: 2px 8px; + border-radius: 4px; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.news-badge--llm { background-color: rgba(99, 102, 241, 0.15); color: #818cf8; } +.news-badge--agents { background-color: rgba(168, 85, 247, 0.15); color: #c084fc; } +.news-badge--privacy { background-color: rgba(34, 197, 94, 0.15); color: #4ade80; } +.news-badge--infrastructure { background-color: rgba(234, 179, 8, 0.15); color: #facc15; } +.news-badge--open-source { background-color: rgba(236, 72, 153, 0.15); color: #f472b6; } + +.news-card-source { + font-size: 12px; + color: #5a6478; +} + +.news-card-date { + font-size: 12px; + color: #3d4556; +} + +.news-card-title { + font-size: 16px; + font-weight: 600; + color: #f1f5f9; + margin: 0 0 8px; + line-height: 1.3; +} + +.news-card-title a { + color: inherit; + text-decoration: none; +} + +.news-card-title a:hover { + color: #91a4d2; +} + +.news-card-summary { + font-size: 13px; + line-height: 1.5; + color: #8892a8; + margin: 0; +} + +/* ===== Providers Page ===== */ +.providers-page { + max-width: 960px; +} + +.providers-layout { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 32px; +} + +.providers-form { + background-color: #1a1d26; + border: 1px solid #2a2f3d; + border-radius: 12px; + padding: 28px; +} + +.providers-status { + position: sticky; + top: 40px; + align-self: start; +} + +.providers-status h3 { + font-size: 18px; + font-weight: 600; + color: #f1f5f9; + margin: 0 0 16px; +} + +.status-card { + background-color: #1a1d26; + border: 1px solid #2a2f3d; + border-radius: 12px; + padding: 20px; +} + +.status-row { + display: flex; + justify-content: space-between; + padding: 10px 0; + border-bottom: 1px solid #1e222d; +} + +.status-row:last-child { + border-bottom: none; +} + +.status-label { + font-size: 13px; + color: #8892a8; +} + +.status-value { + font-size: 13px; + font-weight: 500; + color: #e2e8f0; +} + +/* ===== Chat Page ===== */ +.chat-page { + display: flex; + height: calc(100vh - 80px); + margin: -40px -48px; + overflow: hidden; +} + +.chat-sidebar-panel { + width: 260px; + min-width: 260px; + border-right: 1px solid #1e222d; + display: flex; + flex-direction: column; + background-color: #0d0f14; +} + +.chat-sidebar-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px; + border-bottom: 1px solid #1e222d; +} + +.chat-sidebar-header h3 { + font-size: 16px; + font-weight: 600; + color: #f1f5f9; + margin: 0; +} + +.chat-session-list { + flex: 1; + overflow-y: auto; + padding: 8px; +} + +.chat-session-item { + width: 100%; + text-align: left; + padding: 12px; + border-radius: 8px; + border: none; + background-color: transparent; + cursor: pointer; + transition: background-color 0.15s ease; + font-family: 'Inter', sans-serif; +} + +.chat-session-item:hover { + background-color: #1e222d; +} + +.chat-session-item--active { + background-color: rgba(145, 164, 210, 0.12); +} + +.chat-session-title { + font-size: 14px; + font-weight: 500; + color: #e2e8f0; + margin-bottom: 4px; +} + +.chat-session-date { + font-size: 12px; + color: #5a6478; +} + +.chat-main-panel { + flex: 1; + display: flex; + flex-direction: column; +} + +.chat-messages { + flex: 1; + overflow-y: auto; + padding: 24px 32px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.chat-empty { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + color: #5a6478; +} + +/* ===== Chat Bubble ===== */ +.chat-bubble { + max-width: 72%; + padding: 14px 18px; + border-radius: 12px; + font-size: 14px; + line-height: 1.6; +} + +.chat-bubble--user { + align-self: flex-end; + background-color: rgba(145, 164, 210, 0.15); + color: #e2e8f0; + border-bottom-right-radius: 4px; +} + +.chat-bubble--assistant { + align-self: flex-start; + background-color: #1a1d26; + border: 1px solid #2a2f3d; + color: #e2e8f0; + border-bottom-left-radius: 4px; +} + +.chat-bubble--system { + align-self: center; + background-color: transparent; + color: #5a6478; + font-size: 13px; + font-style: italic; +} + +.chat-bubble-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 6px; +} + +.chat-bubble-role { + font-size: 12px; + font-weight: 600; + color: #91a4d2; +} + +.chat-bubble-time { + font-size: 11px; + color: #5a6478; +} + +.chat-bubble-content { + white-space: pre-wrap; +} + +.chat-bubble-attachments { + display: flex; + gap: 8px; + margin-top: 8px; + flex-wrap: wrap; +} + +.chat-attachment { + font-size: 12px; + padding: 4px 10px; + background-color: rgba(145, 164, 210, 0.1); + border-radius: 4px; + color: #91a4d2; +} + +/* -- Chat Input Bar -- */ +.chat-input-bar { + display: flex; + align-items: center; + gap: 8px; + padding: 16px 24px; + border-top: 1px solid #1e222d; + background-color: #0d0f14; +} + +.chat-input { + flex: 1; + padding: 10px 14px; + background-color: #1a1d26; + border: 1px solid #2a2f3d; + border-radius: 8px; + color: #e2e8f0; + font-size: 14px; + font-family: 'Inter', sans-serif; + outline: none; +} + +.chat-input:focus { + border-color: #91a4d2; +} + +.chat-attach-btn { + font-size: 18px; +} + +.chat-send-btn { + padding: 10px 20px; +} + +/* ===== Tools Page ===== */ +.tools-page { + max-width: 1200px; +} + +.tools-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20px; +} + +/* ===== Tool Card ===== */ +.tool-card { + background-color: #1a1d26; + border: 1px solid #2a2f3d; + border-radius: 12px; + padding: 24px; + transition: border-color 0.2s ease; +} + +.tool-card:hover { + border-color: #91a4d2; +} + +.tool-card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.tool-card-icon { + font-size: 24px; + color: #91a4d2; +} + +.tool-status { + width: 8px; + height: 8px; + border-radius: 50%; +} + +.tool-status--active { background-color: #4ade80; } +.tool-status--inactive { background-color: #5a6478; } +.tool-status--error { background-color: #f87171; } + +.tool-card-name { + font-size: 16px; + font-weight: 600; + color: #f1f5f9; + margin: 0 0 6px; +} + +.tool-card-desc { + font-size: 13px; + line-height: 1.5; + color: #8892a8; + margin: 0 0 16px; +} + +.tool-card-footer { + display: flex; + justify-content: space-between; + align-items: center; +} + +.tool-card-category { + font-size: 11px; + font-weight: 500; + color: #5a6478; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.tool-toggle { + padding: 4px 14px; + border-radius: 12px; + font-size: 11px; + font-weight: 600; + border: none; + cursor: pointer; + font-family: 'Inter', sans-serif; + transition: all 0.15s ease; +} + +.tool-toggle--on { + background-color: rgba(74, 222, 128, 0.15); + color: #4ade80; +} + +.tool-toggle--off { + background-color: #1e222d; + color: #5a6478; +} + +/* ===== Knowledge Base Page ===== */ +.knowledge-page { + max-width: 1200px; +} + +.knowledge-toolbar { + margin-bottom: 20px; +} + +.knowledge-search { + max-width: 320px; +} + +.knowledge-table-wrapper { + overflow-x: auto; +} + +.knowledge-table { + width: 100%; + border-collapse: collapse; +} + +.knowledge-table thead th { + font-size: 12px; + font-weight: 600; + color: #5a6478; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 12px 16px; + text-align: left; + border-bottom: 1px solid #1e222d; +} + +.knowledge-table tbody td { + font-size: 14px; + color: #e2e8f0; + padding: 14px 16px; + border-bottom: 1px solid #1e222d; +} + +.file-row:hover { + background-color: rgba(145, 164, 210, 0.04); +} + +.file-row-name { + display: flex; + align-items: center; + gap: 8px; + font-weight: 500; +} + +.file-row-icon { + color: #91a4d2; + font-size: 12px; +} + +/* ===== Placeholder Pages (Developer) ===== */ +.placeholder-page { + display: flex; + flex-direction: column; + align-items: center; + padding: 40px 0; + gap: 32px; +} + +.placeholder-card { + text-align: center; + max-width: 480px; + padding: 48px 40px; + background-color: #1a1d26; + border: 1px solid #2a2f3d; + border-radius: 16px; +} + +.placeholder-icon { + width: 64px; + height: 64px; + border-radius: 16px; + background: linear-gradient(135deg, rgba(145, 164, 210, 0.15), rgba(109, 133, 198, 0.08)); + display: flex; + align-items: center; + justify-content: center; + margin: 0 auto 20px; + font-family: 'Space Grotesk', sans-serif; + font-size: 28px; + font-weight: 700; + color: #91a4d2; +} + +.placeholder-card h2 { + font-size: 24px; + font-weight: 700; + color: #f1f5f9; + margin: 0 0 12px; +} + +.placeholder-desc { + font-size: 14px; + line-height: 1.6; + color: #8892a8; + margin: 0 0 24px; +} + +.placeholder-badge { + display: inline-block; + margin-top: 12px; + font-size: 12px; + font-weight: 600; + color: #91a4d2; + padding: 4px 12px; + border: 1px solid rgba(145, 164, 210, 0.3); + border-radius: 20px; +} + +/* ===== Analytics Stats Bar ===== */ +.analytics-stats-bar { + display: flex; + gap: 24px; + width: 100%; + max-width: 720px; +} + +.analytics-stat { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + padding: 16px; + background-color: #1a1d26; + border: 1px solid #2a2f3d; + border-radius: 12px; +} + +.analytics-stat-value { + font-family: 'Space Grotesk', sans-serif; + font-size: 22px; + font-weight: 700; + color: #f1f5f9; +} + +.analytics-stat-label { + font-size: 12px; + color: #5a6478; +} + +.analytics-stat-change { + font-size: 12px; + font-weight: 500; +} + +.analytics-stat-change--up { color: #4ade80; } +.analytics-stat-change--down { color: #f87171; } + +/* ===== Pricing Page ===== */ +.pricing-page { + max-width: 1200px; +} + +.pricing-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 24px; + align-items: start; +} + +/* ===== Pricing Card ===== */ +.pricing-card { + background-color: #1a1d26; + border: 1px solid #2a2f3d; + border-radius: 16px; + padding: 32px 28px; + text-align: center; + transition: border-color 0.2s ease, transform 0.2s ease; +} + +.pricing-card:hover { + transform: translateY(-2px); +} + +.pricing-card--highlighted { + border-color: #91a4d2; + box-shadow: 0 0 30px rgba(145, 164, 210, 0.1); + position: relative; +} + +.pricing-card-name { + font-size: 20px; + font-weight: 700; + color: #f1f5f9; + margin: 0 0 16px; +} + +.pricing-card-price { + margin-bottom: 8px; +} + +.pricing-card-amount { + font-family: 'Space Grotesk', sans-serif; + font-size: 40px; + font-weight: 700; + color: #f1f5f9; +} + +.pricing-card-period { + font-size: 14px; + color: #5a6478; +} + +.pricing-card-seats { + font-size: 13px; + color: #8892a8; + margin: 0 0 24px; +} + +.pricing-card-features { + list-style: none; + padding: 0; + margin: 0 0 28px; + text-align: left; +} + +.pricing-card-features li { + font-size: 14px; + color: #8892a8; + padding: 6px 0; + border-bottom: 1px solid #1e222d; +} + +.pricing-card-features li:last-child { + border-bottom: none; +} + +.pricing-card-cta { + width: 100%; + padding: 12px; + background: linear-gradient(135deg, #91a4d2, #6d85c6); + color: #0a0c10; + border: none; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + font-family: 'Inter', sans-serif; + transition: opacity 0.15s ease; +} + +.pricing-card-cta:hover { + opacity: 0.9; +} + +/* ===== Organization Dashboard ===== */ +.org-dashboard-page { + max-width: 1200px; +} + +.org-stats-bar { + display: flex; + gap: 20px; + margin-bottom: 32px; +} + +.org-stat { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 20px; + background-color: #1a1d26; + border: 1px solid #2a2f3d; + border-radius: 12px; +} + +.org-stat-value { + font-family: 'Space Grotesk', sans-serif; + font-size: 24px; + font-weight: 700; + color: #f1f5f9; +} + +.org-stat-label { + font-size: 12px; + color: #5a6478; +} + +/* ===== Organization Table ===== */ +.org-table-wrapper { + overflow-x: auto; +} + +.org-table { + width: 100%; + border-collapse: collapse; +} + +.org-table thead th { + font-size: 12px; + font-weight: 600; + color: #5a6478; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 12px 16px; + text-align: left; + border-bottom: 1px solid #1e222d; +} + +.org-table tbody td { + font-size: 14px; + color: #e2e8f0; + padding: 14px 16px; + border-bottom: 1px solid #1e222d; +} + +.member-row:hover { + background-color: rgba(145, 164, 210, 0.04); +} + +.member-role-select { + padding: 6px 10px; + background-color: #1a1d26; + border: 1px solid #2a2f3d; + border-radius: 6px; + color: #e2e8f0; + font-size: 13px; + font-family: 'Inter', sans-serif; + outline: none; +} + +.member-role-select:focus { + border-color: #91a4d2; +} + +/* ===== Modal ===== */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal-content { + background-color: #1a1d26; + border: 1px solid #2a2f3d; + border-radius: 16px; + padding: 32px; + min-width: 400px; + max-width: 500px; +} + +.modal-content h3 { + font-size: 20px; + font-weight: 700; + color: #f1f5f9; + margin: 0 0 20px; +} + +.modal-actions { + display: flex; + gap: 12px; + justify-content: flex-end; + margin-top: 24px; +} + +/* ===== Responsive: Dashboard Pages ===== */ +@media (max-width: 1024px) { + .news-grid, + .tools-grid, + .pricing-grid { + grid-template-columns: repeat(2, 1fr); + } + + .providers-layout { + grid-template-columns: 1fr; + } + + .analytics-stats-bar { + flex-wrap: wrap; + } + + .org-stats-bar { + flex-wrap: wrap; + } +} + +@media (max-width: 768px) { + .news-grid, + .tools-grid, + .pricing-grid { + grid-template-columns: 1fr; + } + + .chat-page { + flex-direction: column; + height: auto; + } + + .chat-sidebar-panel { + width: 100%; + min-width: unset; + max-height: 200px; + border-right: none; + border-bottom: 1px solid #1e222d; + } + + .page-header { + flex-direction: column; + gap: 12px; + } + + .analytics-stats-bar { + flex-direction: column; + } + + .org-stats-bar { + flex-direction: column; + } + + .modal-content { + min-width: unset; + margin: 16px; + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 36d1ed7..28a4111 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,4 +28,15 @@ services: - 27017:27017 environment: MONGO_INITDB_ROOT_USERNAME: root - MONGO_INITDB_ROOT_PASSWORD: example \ No newline at end of file + MONGO_INITDB_ROOT_PASSWORD: example + + searxng: + image: searxng/searxng:latest + container_name: certifai-searxng + restart: unless-stopped + ports: + - "8888:8080" + environment: + - SEARXNG_BASE_URL=http://localhost:8888 + volumes: + - ./searxng:/etc/searxng:rw \ No newline at end of file diff --git a/src/app.rs b/src/app.rs index de04a9f..87a5486 100644 --- a/src/app.rs +++ b/src/app.rs @@ -4,8 +4,9 @@ use dioxus::prelude::*; /// Application routes. /// /// Public pages (`LandingPage`, `ImpressumPage`, `PrivacyPage`) live -/// outside the `AppShell` layout. Authenticated pages like `OverviewPage` -/// are wrapped in `AppShell` which renders the sidebar. +/// outside the `AppShell` layout. Authenticated pages are wrapped in +/// `AppShell` which renders the sidebar. `DeveloperShell` and `OrgShell` +/// provide nested tab navigation within the app shell. #[derive(Debug, Clone, Routable, PartialEq)] #[rustfmt::skip] pub enum Route { @@ -17,8 +18,33 @@ pub enum Route { PrivacyPage {}, #[layout(AppShell)] #[route("/dashboard")] - OverviewPage {}, + DashboardPage {}, + #[route("/providers")] + ProvidersPage {}, + #[route("/chat")] + ChatPage {}, + #[route("/tools")] + ToolsPage {}, + #[route("/knowledge")] + KnowledgePage {}, + + #[layout(DeveloperShell)] + #[route("/developer/agents")] + AgentsPage {}, + #[route("/developer/flow")] + FlowPage {}, + #[route("/developer/analytics")] + AnalyticsPage {}, + #[end_layout] + + #[layout(OrgShell)] + #[route("/organization/pricing")] + OrgPricingPage {}, + #[route("/organization/dashboard")] + OrgDashboardPage {}, + #[end_layout] #[end_layout] + #[route("/login?:redirect_url")] Login { redirect_url: String }, } diff --git a/src/components/chat_bubble.rs b/src/components/chat_bubble.rs new file mode 100644 index 0000000..c6e022e --- /dev/null +++ b/src/components/chat_bubble.rs @@ -0,0 +1,41 @@ +use crate::models::{ChatMessage, ChatRole}; +use dioxus::prelude::*; + +/// Renders a single chat message bubble with role-based styling. +/// +/// User messages are right-aligned; assistant messages are left-aligned. +/// +/// # Arguments +/// +/// * `message` - The chat message to render +#[component] +pub fn ChatBubble(message: ChatMessage) -> Element { + let bubble_class = match message.role { + ChatRole::User => "chat-bubble chat-bubble--user", + ChatRole::Assistant => "chat-bubble chat-bubble--assistant", + ChatRole::System => "chat-bubble chat-bubble--system", + }; + + let role_label = match message.role { + ChatRole::User => "You", + ChatRole::Assistant => "Assistant", + ChatRole::System => "System", + }; + + rsx! { + div { class: "{bubble_class}", + div { class: "chat-bubble-header", + span { class: "chat-bubble-role", "{role_label}" } + span { class: "chat-bubble-time", "{message.timestamp}" } + } + div { class: "chat-bubble-content", "{message.content}" } + if !message.attachments.is_empty() { + div { class: "chat-bubble-attachments", + for att in &message.attachments { + span { class: "chat-attachment", "{att.name}" } + } + } + } + } + } +} diff --git a/src/components/file_row.rs b/src/components/file_row.rs new file mode 100644 index 0000000..1042da6 --- /dev/null +++ b/src/components/file_row.rs @@ -0,0 +1,54 @@ +use crate::models::KnowledgeFile; +use dioxus::prelude::*; + +/// Renders a table row for a knowledge base file. +/// +/// # Arguments +/// +/// * `file` - The knowledge file data to render +/// * `on_delete` - Callback fired when the delete button is clicked +#[component] +pub fn FileRow(file: KnowledgeFile, on_delete: EventHandler) -> Element { + // Format file size for human readability (Python devs: similar to humanize.naturalsize) + let size_display = format_size(file.size_bytes); + + rsx! { + tr { class: "file-row", + td { class: "file-row-name", + span { class: "file-row-icon", "{file.kind.icon()}" } + "{file.name}" + } + td { "{file.kind.label()}" } + td { "{size_display}" } + td { "{file.chunk_count} chunks" } + td { "{file.uploaded_at}" } + td { + button { + class: "btn-icon btn-danger", + onclick: { + let id = file.id.clone(); + move |_| on_delete.call(id.clone()) + }, + "Delete" + } + } + } + } +} + +/// Formats a byte count into a human-readable string (e.g. "1.2 MB"). +fn format_size(bytes: u64) -> String { + const KB: u64 = 1024; + const MB: u64 = KB * 1024; + const GB: u64 = MB * 1024; + + if bytes >= GB { + format!("{:.1} GB", bytes as f64 / GB as f64) + } else if bytes >= MB { + format!("{:.1} MB", bytes as f64 / MB as f64) + } else if bytes >= KB { + format!("{:.1} KB", bytes as f64 / KB as f64) + } else { + format!("{bytes} B") + } +} diff --git a/src/components/member_row.rs b/src/components/member_row.rs new file mode 100644 index 0000000..a8e0a39 --- /dev/null +++ b/src/components/member_row.rs @@ -0,0 +1,38 @@ +use crate::models::{MemberRole, OrgMember}; +use dioxus::prelude::*; + +/// Renders a table row for an organization member with a role dropdown. +/// +/// # Arguments +/// +/// * `member` - The organization member data to render +/// * `on_role_change` - Callback fired with (member_id, new_role) when role changes +#[component] +pub fn MemberRow(member: OrgMember, on_role_change: EventHandler<(String, String)>) -> Element { + rsx! { + tr { class: "member-row", + td { class: "member-row-name", "{member.name}" } + td { "{member.email}" } + td { + select { + class: "member-role-select", + value: "{member.role.label()}", + onchange: { + let id = member.id.clone(); + move |evt: Event| { + on_role_change.call((id.clone(), evt.value())); + } + }, + for role in MemberRole::all() { + option { + value: "{role.label()}", + selected: *role == member.role, + "{role.label()}" + } + } + } + } + td { "{member.joined_at}" } + } + } +} diff --git a/src/components/mod.rs b/src/components/mod.rs index c6f7d01..05438d7 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -1,8 +1,24 @@ mod app_shell; mod card; +mod chat_bubble; +mod file_row; mod login; +mod member_row; +pub mod news_card; +mod page_header; +mod pricing_card; pub mod sidebar; +pub mod sub_nav; +mod tool_card; pub use app_shell::*; pub use card::*; +pub use chat_bubble::*; +pub use file_row::*; pub use login::*; +pub use member_row::*; +pub use news_card::*; +pub use page_header::*; +pub use pricing_card::*; +pub use sub_nav::*; +pub use tool_card::*; diff --git a/src/components/news_card.rs b/src/components/news_card.rs new file mode 100644 index 0000000..41ff41b --- /dev/null +++ b/src/components/news_card.rs @@ -0,0 +1,129 @@ +use crate::models::{NewsCard as NewsCardModel, NewsCategory}; +use dioxus::prelude::*; + +/// Renders a news feed card with title, source, category badge, and summary. +/// +/// # Arguments +/// +/// * `card` - The news card model data to render +#[component] +pub fn NewsCardView(card: NewsCardModel) -> Element { + let badge_class = format!("news-badge news-badge--{}", card.category.css_class()); + + rsx! { + article { class: "news-card", + if let Some(ref thumb) = card.thumbnail_url { + div { class: "news-card-thumb", + img { src: "{thumb}", alt: "{card.title}", loading: "lazy" } + } + } + div { class: "news-card-body", + div { class: "news-card-meta", + span { class: "{badge_class}", "{card.category.label()}" } + span { class: "news-card-source", "{card.source}" } + span { class: "news-card-date", "{card.published_at}" } + } + h3 { class: "news-card-title", + a { href: "{card.url}", target: "_blank", rel: "noopener", "{card.title}" } + } + p { class: "news-card-summary", "{card.summary}" } + } + } + } +} + +/// Returns mock news data for the dashboard. +pub fn mock_news() -> Vec { + vec![ + NewsCardModel { + title: "Llama 4 Released with 1M Context Window".into(), + source: "Meta AI Blog".into(), + summary: "Meta releases Llama 4 with a 1 million token context window.".into(), + category: NewsCategory::Llm, + url: "#".into(), + thumbnail_url: None, + published_at: "2026-02-18".into(), + }, + NewsCardModel { + title: "EU AI Act Enforcement Begins".into(), + source: "TechCrunch".into(), + summary: "The EU AI Act enters its enforcement phase across member states.".into(), + category: NewsCategory::Privacy, + url: "#".into(), + thumbnail_url: None, + published_at: "2026-02-17".into(), + }, + NewsCardModel { + title: "LangChain v0.4 Introduces Native MCP Support".into(), + source: "LangChain Blog".into(), + summary: "New version adds first-class MCP server integration.".into(), + category: NewsCategory::Agents, + url: "#".into(), + thumbnail_url: None, + published_at: "2026-02-16".into(), + }, + NewsCardModel { + title: "Ollama Adds Multi-GPU Scheduling".into(), + source: "Ollama".into(), + summary: "Run large models across multiple GPUs with automatic sharding.".into(), + category: NewsCategory::Infrastructure, + url: "#".into(), + thumbnail_url: None, + published_at: "2026-02-15".into(), + }, + NewsCardModel { + title: "Mistral Open Sources Codestral 2".into(), + source: "Mistral AI".into(), + summary: "Codestral 2 achieves state-of-the-art on HumanEval benchmarks.".into(), + category: NewsCategory::OpenSource, + url: "#".into(), + thumbnail_url: None, + published_at: "2026-02-14".into(), + }, + NewsCardModel { + title: "NVIDIA Releases NeMo 3.0 Framework".into(), + source: "NVIDIA Developer".into(), + summary: "Updated framework simplifies enterprise LLM fine-tuning.".into(), + category: NewsCategory::Infrastructure, + url: "#".into(), + thumbnail_url: None, + published_at: "2026-02-13".into(), + }, + NewsCardModel { + title: "Anthropic Claude 4 Sets New Reasoning Records".into(), + source: "Anthropic".into(), + summary: "Claude 4 achieves top scores across major reasoning benchmarks.".into(), + category: NewsCategory::Llm, + url: "#".into(), + thumbnail_url: None, + published_at: "2026-02-12".into(), + }, + NewsCardModel { + title: "CrewAI Raises $52M for Agent Orchestration".into(), + source: "VentureBeat".into(), + summary: "Series B funding to expand multi-agent orchestration platform.".into(), + category: NewsCategory::Agents, + url: "#".into(), + thumbnail_url: None, + published_at: "2026-02-11".into(), + }, + NewsCardModel { + title: "DeepSeek V4 Released Under Apache 2.0".into(), + source: "DeepSeek".into(), + summary: "Latest open-weight model competes with proprietary offerings.".into(), + category: NewsCategory::OpenSource, + url: "#".into(), + thumbnail_url: None, + published_at: "2026-02-10".into(), + }, + NewsCardModel { + title: "GDPR Fines for AI Training Data Reach Record High".into(), + source: "Reuters".into(), + summary: "European regulators issue largest penalties yet for AI data misuse.".into(), + category: NewsCategory::Privacy, + url: "#".into(), + thumbnail_url: None, + published_at: "2026-02-09".into(), + }, + ] +} diff --git a/src/components/page_header.rs b/src/components/page_header.rs new file mode 100644 index 0000000..92e0c34 --- /dev/null +++ b/src/components/page_header.rs @@ -0,0 +1,23 @@ +use dioxus::prelude::*; + +/// Reusable page header with title, subtitle, and an optional action slot. +/// +/// # Arguments +/// +/// * `title` - Main heading text for the page +/// * `subtitle` - Secondary descriptive text below the title +/// * `actions` - Optional element rendered on the right side (e.g. buttons) +#[component] +pub fn PageHeader(title: String, subtitle: String, actions: Option) -> Element { + rsx! { + div { class: "page-header", + div { class: "page-header-text", + h1 { class: "page-title", "{title}" } + p { class: "page-subtitle", "{subtitle}" } + } + if let Some(actions) = actions { + div { class: "page-header-actions", {actions} } + } + } + } +} diff --git a/src/components/pricing_card.rs b/src/components/pricing_card.rs new file mode 100644 index 0000000..82532e0 --- /dev/null +++ b/src/components/pricing_card.rs @@ -0,0 +1,46 @@ +use crate::models::PricingPlan; +use dioxus::prelude::*; + +/// Renders a pricing plan card with features list and call-to-action button. +/// +/// # Arguments +/// +/// * `plan` - The pricing plan data to render +/// * `on_select` - Callback fired when the CTA button is clicked +#[component] +pub fn PricingCard(plan: PricingPlan, on_select: EventHandler) -> Element { + let card_class = if plan.highlighted { + "pricing-card pricing-card--highlighted" + } else { + "pricing-card" + }; + + let seats_label = match plan.max_seats { + Some(n) => format!("Up to {n} seats"), + None => "Unlimited seats".to_string(), + }; + + rsx! { + div { class: "{card_class}", + h3 { class: "pricing-card-name", "{plan.name}" } + div { class: "pricing-card-price", + span { class: "pricing-card-amount", "{plan.price_eur}" } + span { class: "pricing-card-period", " EUR / month" } + } + p { class: "pricing-card-seats", "{seats_label}" } + ul { class: "pricing-card-features", + for feature in &plan.features { + li { "{feature}" } + } + } + button { + class: "pricing-card-cta", + onclick: { + let id = plan.id.clone(); + move |_| on_select.call(id.clone()) + }, + "Get Started" + } + } + } +} diff --git a/src/components/sidebar.rs b/src/components/sidebar.rs index c4fbe8a..4997acb 100644 --- a/src/components/sidebar.rs +++ b/src/components/sidebar.rs @@ -1,8 +1,8 @@ use dioxus::prelude::*; use dioxus_free_icons::icons::bs_icons::{ - BsBoxArrowRight, BsFileEarmarkText, BsGear, BsGithub, BsGrid, BsHouseDoor, BsRobot, + BsBoxArrowRight, BsBuilding, BsChatDots, BsCloudArrowUp, BsCodeSlash, BsCollection, BsGithub, + BsGrid, BsHouseDoor, BsPuzzle, }; -use dioxus_free_icons::icons::fa_solid_icons::FaCubes; use dioxus_free_icons::Icon; use crate::Route; @@ -25,29 +25,39 @@ struct NavItem { pub fn Sidebar(email: String, avatar_url: String) -> Element { let nav_items: Vec = vec![ NavItem { - label: "Overview", - route: Route::OverviewPage {}, + label: "Dashboard", + route: Route::DashboardPage {}, icon: rsx! { Icon { icon: BsHouseDoor, width: 18, height: 18 } }, }, NavItem { - label: "Documentation", - route: Route::OverviewPage {}, - icon: rsx! { Icon { icon: BsFileEarmarkText, width: 18, height: 18 } }, + label: "Providers", + route: Route::ProvidersPage {}, + icon: rsx! { Icon { icon: BsCloudArrowUp, width: 18, height: 18 } }, }, NavItem { - label: "Agents", - route: Route::OverviewPage {}, - icon: rsx! { Icon { icon: BsRobot, width: 18, height: 18 } }, + label: "Chat", + route: Route::ChatPage {}, + icon: rsx! { Icon { icon: BsChatDots, width: 18, height: 18 } }, }, NavItem { - label: "Models", - route: Route::OverviewPage {}, - icon: rsx! { Icon { icon: FaCubes, width: 18, height: 18 } }, + label: "Tools", + route: Route::ToolsPage {}, + icon: rsx! { Icon { icon: BsPuzzle, width: 18, height: 18 } }, }, NavItem { - label: "Settings", - route: Route::OverviewPage {}, - icon: rsx! { Icon { icon: BsGear, width: 18, height: 18 } }, + label: "Knowledge Base", + route: Route::KnowledgePage {}, + icon: rsx! { Icon { icon: BsCollection, width: 18, height: 18 } }, + }, + NavItem { + label: "Developer", + route: Route::AgentsPage {}, + icon: rsx! { Icon { icon: BsCodeSlash, width: 18, height: 18 } }, + }, + NavItem { + label: "Organization", + route: Route::OrgPricingPage {}, + icon: rsx! { Icon { icon: BsBuilding, width: 18, height: 18 } }, }, ]; @@ -56,15 +66,22 @@ pub fn Sidebar(email: String, avatar_url: String) -> Element { rsx! { aside { class: "sidebar", - // -- Header: avatar circle + email -- SidebarHeader { email: email.clone(), avatar_url } - // -- Navigation links -- nav { class: "sidebar-nav", for item in nav_items { { - // Simple active check: highlight Overview only when on `/`. - let is_active = item.route == current_route; + // Active detection for nested routes: highlight the parent nav + // item when any child route within the nested shell is active. + let is_active = match ¤t_route { + Route::AgentsPage {} | Route::FlowPage {} | Route::AnalyticsPage {} => { + item.label == "Developer" + } + Route::OrgPricingPage {} | Route::OrgDashboardPage {} => { + item.label == "Organization" + } + _ => item.route == current_route, + }; let cls = if is_active { "sidebar-link active" } else { "sidebar-link" }; rsx! { Link { to: item.route, class: cls, @@ -76,7 +93,6 @@ pub fn Sidebar(email: String, avatar_url: String) -> Element { } } - // -- Logout button -- div { class: "sidebar-logout", Link { to: NavigationTarget::::External("/auth/logout".into()), @@ -86,7 +102,6 @@ pub fn Sidebar(email: String, avatar_url: String) -> Element { } } - // -- Footer: version + social links -- SidebarFooter {} } } diff --git a/src/components/sub_nav.rs b/src/components/sub_nav.rs new file mode 100644 index 0000000..37d09a3 --- /dev/null +++ b/src/components/sub_nav.rs @@ -0,0 +1,44 @@ +use crate::app::Route; +use dioxus::prelude::*; + +/// A single tab item for the sub-navigation bar. +/// +/// # Fields +/// +/// * `label` - Display text for the tab +/// * `route` - Route to navigate to when clicked +#[derive(Clone, PartialEq)] +pub struct SubNavItem { + pub label: &'static str, + pub route: Route, +} + +/// Horizontal tab navigation bar used inside nested shell layouts. +/// +/// Highlights the active tab based on the current route. +/// +/// # Arguments +/// +/// * `items` - List of tab items to render +#[component] +pub fn SubNav(items: Vec) -> Element { + let current_route = use_route::(); + + rsx! { + nav { class: "sub-nav", + for item in &items { + { + let is_active = item.route == current_route; + let class = if is_active { "sub-nav-item sub-nav-item--active" } else { "sub-nav-item" }; + rsx! { + Link { + class: "{class}", + to: item.route.clone(), + "{item.label}" + } + } + } + } + } + } +} diff --git a/src/components/tool_card.rs b/src/components/tool_card.rs new file mode 100644 index 0000000..21e9ce3 --- /dev/null +++ b/src/components/tool_card.rs @@ -0,0 +1,40 @@ +use crate::models::McpTool; +use dioxus::prelude::*; + +/// Renders an MCP tool card with name, description, status indicator, and toggle. +/// +/// # Arguments +/// +/// * `tool` - The MCP tool data to render +/// * `on_toggle` - Callback fired when the enable/disable toggle is clicked +#[component] +pub fn ToolCard(tool: McpTool, on_toggle: EventHandler) -> Element { + let status_class = format!("tool-status tool-status--{}", tool.status.css_class()); + let toggle_class = if tool.enabled { + "tool-toggle tool-toggle--on" + } else { + "tool-toggle tool-toggle--off" + }; + + rsx! { + div { class: "tool-card", + div { class: "tool-card-header", + div { class: "tool-card-icon", "\u{2699}" } + span { class: "{status_class}", "" } + } + h3 { class: "tool-card-name", "{tool.name}" } + p { class: "tool-card-desc", "{tool.description}" } + div { class: "tool-card-footer", + span { class: "tool-card-category", "{tool.category.label()}" } + button { + class: "{toggle_class}", + onclick: { + let id = tool.id.clone(); + move |_| on_toggle.call(id.clone()) + }, + if tool.enabled { "ON" } else { "OFF" } + } + } + } + } +} diff --git a/src/models/chat.rs b/src/models/chat.rs new file mode 100644 index 0000000..e60420e --- /dev/null +++ b/src/models/chat.rs @@ -0,0 +1,71 @@ +use serde::{Deserialize, Serialize}; + +/// The role of a participant in a chat conversation. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum ChatRole { + /// Message sent by the human user + User, + /// Message generated by the AI assistant + Assistant, + /// System-level instruction (not displayed in UI) + System, +} + +/// The type of file attached to a chat message. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum AttachmentKind { + /// Image file (png, jpg, webp, etc.) + Image, + /// Document file (pdf, docx, txt, etc.) + Document, + /// Source code file + Code, +} + +/// A file attachment on a chat message. +/// +/// # Fields +/// +/// * `name` - Original filename +/// * `kind` - Type of attachment for rendering +/// * `size_bytes` - File size in bytes +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Attachment { + pub name: String, + pub kind: AttachmentKind, + pub size_bytes: u64, +} + +/// A single message in a chat conversation. +/// +/// # Fields +/// +/// * `id` - Unique message identifier +/// * `role` - Who sent this message +/// * `content` - The message text content +/// * `attachments` - Optional file attachments +/// * `timestamp` - ISO 8601 timestamp string +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ChatMessage { + pub id: String, + pub role: ChatRole, + pub content: String, + pub attachments: Vec, + pub timestamp: String, +} + +/// A chat session containing a conversation history. +/// +/// # Fields +/// +/// * `id` - Unique session identifier +/// * `title` - Display title (usually derived from first message) +/// * `messages` - Ordered list of messages in the session +/// * `created_at` - ISO 8601 creation timestamp +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ChatSession { + pub id: String, + pub title: String, + pub messages: Vec, + pub created_at: String, +} diff --git a/src/models/developer.rs b/src/models/developer.rs new file mode 100644 index 0000000..1138e96 --- /dev/null +++ b/src/models/developer.rs @@ -0,0 +1,47 @@ +use serde::{Deserialize, Serialize}; + +/// An AI agent entry managed through the developer tools. +/// +/// # Fields +/// +/// * `id` - Unique agent identifier +/// * `name` - Human-readable agent name +/// * `description` - What this agent does +/// * `status` - Current running status label +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AgentEntry { + pub id: String, + pub name: String, + pub description: String, + pub status: String, +} + +/// A workflow/flow entry from the flow builder. +/// +/// # Fields +/// +/// * `id` - Unique flow identifier +/// * `name` - Human-readable flow name +/// * `node_count` - Number of nodes in the flow graph +/// * `last_run` - ISO 8601 timestamp of the last execution +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct FlowEntry { + pub id: String, + pub name: String, + pub node_count: u32, + pub last_run: Option, +} + +/// A single analytics metric for the developer dashboard. +/// +/// # Fields +/// +/// * `label` - Display name of the metric +/// * `value` - Current value as a formatted string +/// * `change_pct` - Percentage change from previous period (positive = increase) +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AnalyticsMetric { + pub label: String, + pub value: String, + pub change_pct: f64, +} diff --git a/src/models/knowledge.rs b/src/models/knowledge.rs new file mode 100644 index 0000000..1a507bf --- /dev/null +++ b/src/models/knowledge.rs @@ -0,0 +1,60 @@ +use serde::{Deserialize, Serialize}; + +/// The type of file stored in the knowledge base. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum FileKind { + /// PDF document + Pdf, + /// Plain text or markdown file + Text, + /// Spreadsheet (csv, xlsx) + Spreadsheet, + /// Source code file + Code, + /// Image file + Image, +} + +impl FileKind { + /// Returns the display label for a file kind. + pub fn label(&self) -> &'static str { + match self { + Self::Pdf => "PDF", + Self::Text => "Text", + Self::Spreadsheet => "Spreadsheet", + Self::Code => "Code", + Self::Image => "Image", + } + } + + /// Returns an icon identifier for rendering. + pub fn icon(&self) -> &'static str { + match self { + Self::Pdf => "file-pdf", + Self::Text => "file-text", + Self::Spreadsheet => "file-spreadsheet", + Self::Code => "file-code", + Self::Image => "file-image", + } + } +} + +/// A file stored in the knowledge base for RAG retrieval. +/// +/// # Fields +/// +/// * `id` - Unique file identifier +/// * `name` - Original filename +/// * `kind` - Type classification of the file +/// * `size_bytes` - File size in bytes +/// * `uploaded_at` - ISO 8601 upload timestamp +/// * `chunk_count` - Number of vector chunks created from this file +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct KnowledgeFile { + pub id: String, + pub name: String, + pub kind: FileKind, + pub size_bytes: u64, + pub uploaded_at: String, + pub chunk_count: u32, +} diff --git a/src/models/mod.rs b/src/models/mod.rs index 2478900..c50bb0c 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,3 +1,17 @@ +mod chat; +mod developer; +mod knowledge; +mod news; +mod organization; +mod provider; +mod tool; mod user; +pub use chat::*; +pub use developer::*; +pub use knowledge::*; +pub use news::*; +pub use organization::*; +pub use provider::*; +pub use tool::*; pub use user::*; diff --git a/src/models/news.rs b/src/models/news.rs new file mode 100644 index 0000000..1c145c5 --- /dev/null +++ b/src/models/news.rs @@ -0,0 +1,62 @@ +use serde::{Deserialize, Serialize}; + +/// Categories for classifying AI news articles. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum NewsCategory { + /// Large language model announcements and updates + Llm, + /// AI agent frameworks and tooling + Agents, + /// Data privacy and regulatory compliance + Privacy, + /// AI infrastructure and deployment + Infrastructure, + /// Open-source AI project releases + OpenSource, +} + +impl NewsCategory { + /// Returns the display label for a news category. + pub fn label(&self) -> &'static str { + match self { + Self::Llm => "LLM", + Self::Agents => "Agents", + Self::Privacy => "Privacy", + Self::Infrastructure => "Infrastructure", + Self::OpenSource => "Open Source", + } + } + + /// Returns the CSS class suffix for styling category badges. + pub fn css_class(&self) -> &'static str { + match self { + Self::Llm => "llm", + Self::Agents => "agents", + Self::Privacy => "privacy", + Self::Infrastructure => "infrastructure", + Self::OpenSource => "open-source", + } + } +} + +/// A single news feed card representing an AI-related article. +/// +/// # Fields +/// +/// * `title` - Headline of the article +/// * `source` - Publishing outlet or author +/// * `summary` - Brief summary text +/// * `category` - Classification category +/// * `url` - Link to the full article +/// * `thumbnail_url` - Optional thumbnail image URL +/// * `published_at` - ISO 8601 date string +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct NewsCard { + pub title: String, + pub source: String, + pub summary: String, + pub category: NewsCategory, + pub url: String, + pub thumbnail_url: Option, + pub published_at: String, +} diff --git a/src/models/organization.rs b/src/models/organization.rs new file mode 100644 index 0000000..8013182 --- /dev/null +++ b/src/models/organization.rs @@ -0,0 +1,84 @@ +use serde::{Deserialize, Serialize}; + +/// Role assigned to an organization member. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum MemberRole { + /// Full administrative access + Admin, + /// Standard user access + Member, + /// Read-only access + Viewer, +} + +impl MemberRole { + /// Returns the display label for a member role. + pub fn label(&self) -> &'static str { + match self { + Self::Admin => "Admin", + Self::Member => "Member", + Self::Viewer => "Viewer", + } + } + + /// Returns all available roles for populating dropdowns. + pub fn all() -> &'static [Self] { + &[Self::Admin, Self::Member, Self::Viewer] + } +} + +/// A member of the organization. +/// +/// # Fields +/// +/// * `id` - Unique member identifier +/// * `name` - Display name +/// * `email` - Email address +/// * `role` - Assigned role within the organization +/// * `joined_at` - ISO 8601 join date +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct OrgMember { + pub id: String, + pub name: String, + pub email: String, + pub role: MemberRole, + pub joined_at: String, +} + +/// A pricing plan tier. +/// +/// # Fields +/// +/// * `id` - Unique plan identifier +/// * `name` - Plan display name (e.g. "Starter", "Team", "Enterprise") +/// * `price_eur` - Monthly price in euros +/// * `features` - List of included features +/// * `highlighted` - Whether this plan should be visually emphasized +/// * `max_seats` - Maximum number of seats, None for unlimited +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PricingPlan { + pub id: String, + pub name: String, + pub price_eur: u32, + pub features: Vec, + pub highlighted: bool, + pub max_seats: Option, +} + +/// Billing usage statistics for the current cycle. +/// +/// # Fields +/// +/// * `seats_used` - Number of active seats +/// * `seats_total` - Total seats in the plan +/// * `tokens_used` - Tokens consumed this billing cycle +/// * `tokens_limit` - Token limit for the billing cycle +/// * `billing_cycle_end` - ISO 8601 date when the current cycle ends +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct BillingUsage { + pub seats_used: u32, + pub seats_total: u32, + pub tokens_used: u64, + pub tokens_limit: u64, + pub billing_cycle_end: String, +} diff --git a/src/models/provider.rs b/src/models/provider.rs new file mode 100644 index 0000000..a08a637 --- /dev/null +++ b/src/models/provider.rs @@ -0,0 +1,74 @@ +use serde::{Deserialize, Serialize}; + +/// Supported LLM provider backends. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum LlmProvider { + /// Self-hosted models via Ollama + Ollama, + /// Hugging Face Inference API + HuggingFace, + /// OpenAI-compatible endpoints + OpenAi, + /// Anthropic Claude API + Anthropic, +} + +impl LlmProvider { + /// Returns the display name for a provider. + pub fn label(&self) -> &'static str { + match self { + Self::Ollama => "Ollama", + Self::HuggingFace => "Hugging Face", + Self::OpenAi => "OpenAI", + Self::Anthropic => "Anthropic", + } + } +} + +/// A model available from a provider. +/// +/// # Fields +/// +/// * `id` - Unique model identifier (e.g. "llama3.1:8b") +/// * `name` - Human-readable display name +/// * `provider` - Which provider hosts this model +/// * `context_window` - Maximum context length in tokens +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ModelEntry { + pub id: String, + pub name: String, + pub provider: LlmProvider, + pub context_window: u32, +} + +/// An embedding model available from a provider. +/// +/// # Fields +/// +/// * `id` - Unique embedding model identifier +/// * `name` - Human-readable display name +/// * `provider` - Which provider hosts this model +/// * `dimensions` - Output embedding dimensions +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct EmbeddingEntry { + pub id: String, + pub name: String, + pub provider: LlmProvider, + pub dimensions: u32, +} + +/// Active provider configuration state. +/// +/// # Fields +/// +/// * `provider` - Currently selected provider +/// * `selected_model` - ID of the active chat model +/// * `selected_embedding` - ID of the active embedding model +/// * `api_key_set` - Whether an API key has been configured +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ProviderConfig { + pub provider: LlmProvider, + pub selected_model: String, + pub selected_embedding: String, + pub api_key_set: bool, +} diff --git a/src/models/tool.rs b/src/models/tool.rs new file mode 100644 index 0000000..263404c --- /dev/null +++ b/src/models/tool.rs @@ -0,0 +1,73 @@ +use serde::{Deserialize, Serialize}; + +/// Category grouping for MCP tools. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum ToolCategory { + /// Web search and browsing tools + Search, + /// File and document processing tools + FileSystem, + /// Computation and math tools + Compute, + /// Code execution and analysis tools + Code, + /// Communication and notification tools + Communication, +} + +impl ToolCategory { + /// Returns the display label for a tool category. + pub fn label(&self) -> &'static str { + match self { + Self::Search => "Search", + Self::FileSystem => "File System", + Self::Compute => "Compute", + Self::Code => "Code", + Self::Communication => "Communication", + } + } +} + +/// Status of an MCP tool instance. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum ToolStatus { + /// Tool is running and available + Active, + /// Tool is installed but not running + Inactive, + /// Tool encountered an error + Error, +} + +impl ToolStatus { + /// Returns the CSS class suffix for status styling. + pub fn css_class(&self) -> &'static str { + match self { + Self::Active => "active", + Self::Inactive => "inactive", + Self::Error => "error", + } + } +} + +/// An MCP (Model Context Protocol) tool entry. +/// +/// # Fields +/// +/// * `id` - Unique tool identifier +/// * `name` - Human-readable display name +/// * `description` - Brief description of what the tool does +/// * `category` - Classification category +/// * `status` - Current running status +/// * `enabled` - Whether the tool is toggled on by the user +/// * `icon` - Icon identifier for rendering +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct McpTool { + pub id: String, + pub name: String, + pub description: String, + pub category: ToolCategory, + pub status: ToolStatus, + pub enabled: bool, + pub icon: String, +} diff --git a/src/pages/chat.rs b/src/pages/chat.rs new file mode 100644 index 0000000..1173cd3 --- /dev/null +++ b/src/pages/chat.rs @@ -0,0 +1,147 @@ +use dioxus::prelude::*; + +use crate::components::ChatBubble; +use crate::models::{ChatMessage, ChatRole, ChatSession}; + +/// ChatGPT-style chat interface with session list and message area. +/// +/// Full-height layout: left panel shows session history, +/// right panel shows messages and input bar. +#[component] +pub fn ChatPage() -> Element { + let sessions = use_signal(mock_sessions); + let mut active_session_id = use_signal(|| "session-1".to_string()); + let mut input_text = use_signal(String::new); + + // Clone data out of signals before entering the rsx! block to avoid + // holding a `Signal::read()` borrow across potential await points. + let sessions_list = sessions.read().clone(); + let current_id = active_session_id.read().clone(); + let active_session = sessions_list.iter().find(|s| s.id == current_id).cloned(); + + rsx! { + section { class: "chat-page", + div { class: "chat-sidebar-panel", + div { class: "chat-sidebar-header", + h3 { "Conversations" } + button { class: "btn-icon", "+" } + } + div { class: "chat-session-list", + for session in &sessions_list { + { + let is_active = session.id == current_id; + let class = if is_active { + "chat-session-item chat-session-item--active" + } else { + "chat-session-item" + }; + let id = session.id.clone(); + rsx! { + button { + class: "{class}", + onclick: move |_| active_session_id.set(id.clone()), + div { class: "chat-session-title", "{session.title}" } + div { class: "chat-session-date", "{session.created_at}" } + } + } + } + } + } + } + div { class: "chat-main-panel", + if let Some(session) = &active_session { + div { class: "chat-messages", + for msg in &session.messages { + ChatBubble { key: "{msg.id}", message: msg.clone() } + } + } + } else { + div { class: "chat-empty", + p { "Select a conversation or start a new one." } + } + } + div { class: "chat-input-bar", + button { class: "btn-icon chat-attach-btn", "+" } + input { + class: "chat-input", + r#type: "text", + placeholder: "Type a message...", + value: "{input_text}", + oninput: move |evt: Event| { + input_text.set(evt.value()); + }, + } + button { class: "btn-primary chat-send-btn", "Send" } + } + } + } + } +} + +/// Returns mock chat sessions with sample messages. +fn mock_sessions() -> Vec { + vec![ + ChatSession { + id: "session-1".into(), + title: "RAG Pipeline Setup".into(), + messages: vec![ + ChatMessage { + id: "msg-1".into(), + role: ChatRole::User, + content: "How do I set up a RAG pipeline with Ollama?".into(), + attachments: vec![], + timestamp: "10:30".into(), + }, + ChatMessage { + id: "msg-2".into(), + role: ChatRole::Assistant, + content: "To set up a RAG pipeline with Ollama, you'll need to: \ + 1) Install Ollama and pull your preferred model, \ + 2) Set up a vector database (e.g. ChromaDB), \ + 3) Create an embedding pipeline for your documents, \ + 4) Wire the retrieval step into your prompt chain." + .into(), + attachments: vec![], + timestamp: "10:31".into(), + }, + ], + created_at: "2026-02-18".into(), + }, + ChatSession { + id: "session-2".into(), + title: "GDPR Compliance Check".into(), + messages: vec![ + ChatMessage { + id: "msg-3".into(), + role: ChatRole::User, + content: "What data does CERTifAI store about users?".into(), + attachments: vec![], + timestamp: "09:15".into(), + }, + ChatMessage { + id: "msg-4".into(), + role: ChatRole::Assistant, + content: "CERTifAI stores only the minimum data required: \ + email address, session tokens, and usage metrics. \ + All data stays on your infrastructure." + .into(), + attachments: vec![], + timestamp: "09:16".into(), + }, + ], + created_at: "2026-02-17".into(), + }, + ChatSession { + id: "session-3".into(), + title: "MCP Server Configuration".into(), + messages: vec![ChatMessage { + id: "msg-5".into(), + role: ChatRole::User, + content: "How do I add a new MCP server?".into(), + attachments: vec![], + timestamp: "14:00".into(), + }], + created_at: "2026-02-16".into(), + }, + ] +} diff --git a/src/pages/dashboard.rs b/src/pages/dashboard.rs new file mode 100644 index 0000000..605a7bc --- /dev/null +++ b/src/pages/dashboard.rs @@ -0,0 +1,71 @@ +use dioxus::prelude::*; + +use crate::components::{NewsCardView, PageHeader}; +use crate::models::NewsCategory; + +/// Dashboard page displaying an AI news feed grid with category filters. +/// +/// Replaces the previous `OverviewPage`. Shows mock news items +/// that will eventually be sourced from the SearXNG instance. +#[component] +pub fn DashboardPage() -> Element { + let news = use_signal(crate::components::news_card::mock_news); + let mut active_filter = use_signal(|| Option::::None); + + // Collect filtered news items based on active category filter + let filtered: Vec<_> = { + let items = news.read(); + let filter = active_filter.read(); + match &*filter { + Some(cat) => items + .iter() + .filter(|n| n.category == *cat) + .cloned() + .collect(), + None => items.clone(), + } + }; + + // All available filter categories + let categories = [ + ("All", None), + ("LLM", Some(NewsCategory::Llm)), + ("Agents", Some(NewsCategory::Agents)), + ("Privacy", Some(NewsCategory::Privacy)), + ("Infrastructure", Some(NewsCategory::Infrastructure)), + ("Open Source", Some(NewsCategory::OpenSource)), + ]; + + rsx! { + section { class: "dashboard-page", + PageHeader { + title: "Dashboard".to_string(), + subtitle: "AI news and updates".to_string(), + } + div { class: "dashboard-filters", + for (label, cat) in categories { + { + let is_active = *active_filter.read() == cat; + let class = if is_active { + "filter-tab filter-tab--active" + } else { + "filter-tab" + }; + rsx! { + button { + class: "{class}", + onclick: move |_| active_filter.set(cat.clone()), + "{label}" + } + } + } + } + } + div { class: "news-grid", + for card in filtered { + NewsCardView { key: "{card.title}", card } + } + } + } + } +} diff --git a/src/pages/developer/agents.rs b/src/pages/developer/agents.rs new file mode 100644 index 0000000..efcc141 --- /dev/null +++ b/src/pages/developer/agents.rs @@ -0,0 +1,24 @@ +use dioxus::prelude::*; + +/// Agents page placeholder for the LangGraph agent builder. +/// +/// Shows a "Coming Soon" card with a disabled launch button. +/// Will eventually integrate with the LangGraph framework. +#[component] +pub fn AgentsPage() -> Element { + rsx! { + section { class: "placeholder-page", + div { class: "placeholder-card", + div { class: "placeholder-icon", "A" } + h2 { "Agent Builder" } + p { class: "placeholder-desc", + "Build and manage AI agents with LangGraph. \ + Create multi-step reasoning pipelines, tool-using agents, \ + and autonomous workflows." + } + button { class: "btn-primary", disabled: true, "Launch Agent Builder" } + span { class: "placeholder-badge", "Coming Soon" } + } + } + } +} diff --git a/src/pages/developer/analytics.rs b/src/pages/developer/analytics.rs new file mode 100644 index 0000000..9e26717 --- /dev/null +++ b/src/pages/developer/analytics.rs @@ -0,0 +1,70 @@ +use dioxus::prelude::*; + +use crate::models::AnalyticsMetric; + +/// Analytics page placeholder for LangFuse integration. +/// +/// Shows a "Coming Soon" card with a disabled launch button, +/// plus a mock stats bar showing sample metrics. +#[component] +pub fn AnalyticsPage() -> Element { + let metrics = mock_metrics(); + + rsx! { + section { class: "placeholder-page", + div { class: "analytics-stats-bar", + for metric in &metrics { + div { class: "analytics-stat", + span { class: "analytics-stat-value", "{metric.value}" } + span { class: "analytics-stat-label", "{metric.label}" } + span { + class: if metric.change_pct >= 0.0 { + "analytics-stat-change analytics-stat-change--up" + } else { + "analytics-stat-change analytics-stat-change--down" + }, + "{metric.change_pct:+.1}%" + } + } + } + } + div { class: "placeholder-card", + div { class: "placeholder-icon", "L" } + h2 { "Analytics & Observability" } + p { class: "placeholder-desc", + "Monitor and analyze your AI pipelines with LangFuse. \ + Track token usage, latency, costs, and quality metrics \ + across all your deployments." + } + button { class: "btn-primary", disabled: true, "Launch LangFuse" } + span { class: "placeholder-badge", "Coming Soon" } + } + } + } +} + +/// Returns mock analytics metrics for the stats bar. +fn mock_metrics() -> Vec { + vec![ + AnalyticsMetric { + label: "Total Requests".into(), + value: "12,847".into(), + change_pct: 14.2, + }, + AnalyticsMetric { + label: "Avg Latency".into(), + value: "245ms".into(), + change_pct: -8.5, + }, + AnalyticsMetric { + label: "Tokens Used".into(), + value: "2.4M".into(), + change_pct: 22.1, + }, + AnalyticsMetric { + label: "Error Rate".into(), + value: "0.3%".into(), + change_pct: -12.0, + }, + ] +} diff --git a/src/pages/developer/flow.rs b/src/pages/developer/flow.rs new file mode 100644 index 0000000..58365d3 --- /dev/null +++ b/src/pages/developer/flow.rs @@ -0,0 +1,24 @@ +use dioxus::prelude::*; + +/// Flow page placeholder for the LangFlow visual workflow builder. +/// +/// Shows a "Coming Soon" card with a disabled launch button. +/// Will eventually integrate with LangFlow for visual flow design. +#[component] +pub fn FlowPage() -> Element { + rsx! { + section { class: "placeholder-page", + div { class: "placeholder-card", + div { class: "placeholder-icon", "F" } + h2 { "Flow Builder" } + p { class: "placeholder-desc", + "Design visual AI workflows with LangFlow. \ + Drag-and-drop nodes to create data processing pipelines, \ + prompt chains, and integration flows." + } + button { class: "btn-primary", disabled: true, "Launch Flow Builder" } + span { class: "placeholder-badge", "Coming Soon" } + } + } + } +} diff --git a/src/pages/developer/mod.rs b/src/pages/developer/mod.rs new file mode 100644 index 0000000..79ef966 --- /dev/null +++ b/src/pages/developer/mod.rs @@ -0,0 +1,41 @@ +mod agents; +mod analytics; +mod flow; + +pub use agents::*; +pub use analytics::*; +pub use flow::*; + +use dioxus::prelude::*; + +use crate::app::Route; +use crate::components::sub_nav::{SubNav, SubNavItem}; + +/// Shell layout for the Developer section. +/// +/// Renders a horizontal tab bar (Agents, Flow, Analytics) above +/// the child route outlet. Sits inside the main `AppShell` layout. +#[component] +pub fn DeveloperShell() -> Element { + let tabs = vec![ + SubNavItem { + label: "Agents", + route: Route::AgentsPage {}, + }, + SubNavItem { + label: "Flow", + route: Route::FlowPage {}, + }, + SubNavItem { + label: "Analytics", + route: Route::AnalyticsPage {}, + }, + ]; + + rsx! { + div { class: "developer-shell", + SubNav { items: tabs } + div { class: "shell-content", Outlet:: {} } + } + } +} diff --git a/src/pages/knowledge.rs b/src/pages/knowledge.rs new file mode 100644 index 0000000..8f3877c --- /dev/null +++ b/src/pages/knowledge.rs @@ -0,0 +1,124 @@ +use dioxus::prelude::*; + +use crate::components::{FileRow, PageHeader}; +use crate::models::{FileKind, KnowledgeFile}; + +/// Knowledge Base page with file explorer table and upload controls. +/// +/// Displays uploaded documents used for RAG retrieval with their +/// metadata, chunk counts, and management actions. +#[component] +pub fn KnowledgePage() -> Element { + let mut files = use_signal(mock_files); + let mut search_query = use_signal(String::new); + + // Filter files by search query (case-insensitive name match) + let query = search_query.read().to_lowercase(); + let filtered: Vec<_> = files + .read() + .iter() + .filter(|f| query.is_empty() || f.name.to_lowercase().contains(&query)) + .cloned() + .collect(); + + // Remove a file by ID + let on_delete = move |id: String| { + files.write().retain(|f| f.id != id); + }; + + rsx! { + section { class: "knowledge-page", + PageHeader { + title: "Knowledge Base".to_string(), + subtitle: "Manage documents for RAG retrieval".to_string(), + actions: rsx! { + button { class: "btn-primary", "Upload File" } + }, + } + div { class: "knowledge-toolbar", + input { + class: "form-input knowledge-search", + r#type: "text", + placeholder: "Search files...", + value: "{search_query}", + oninput: move |evt: Event| { + search_query.set(evt.value()); + }, + } + } + div { class: "knowledge-table-wrapper", + table { class: "knowledge-table", + thead { + tr { + th { "Name" } + th { "Type" } + th { "Size" } + th { "Chunks" } + th { "Uploaded" } + th { "Actions" } + } + } + tbody { + for file in filtered { + FileRow { key: "{file.id}", file, on_delete } + } + } + } + } + } + } +} + +/// Returns mock knowledge base files. +fn mock_files() -> Vec { + vec![ + KnowledgeFile { + id: "f1".into(), + name: "company-handbook.pdf".into(), + kind: FileKind::Pdf, + size_bytes: 2_450_000, + uploaded_at: "2026-02-15".into(), + chunk_count: 142, + }, + KnowledgeFile { + id: "f2".into(), + name: "api-reference.md".into(), + kind: FileKind::Text, + size_bytes: 89_000, + uploaded_at: "2026-02-14".into(), + chunk_count: 34, + }, + KnowledgeFile { + id: "f3".into(), + name: "sales-data-q4.csv".into(), + kind: FileKind::Spreadsheet, + size_bytes: 1_200_000, + uploaded_at: "2026-02-12".into(), + chunk_count: 67, + }, + KnowledgeFile { + id: "f4".into(), + name: "deployment-guide.pdf".into(), + kind: FileKind::Pdf, + size_bytes: 540_000, + uploaded_at: "2026-02-10".into(), + chunk_count: 28, + }, + KnowledgeFile { + id: "f5".into(), + name: "onboarding-checklist.md".into(), + kind: FileKind::Text, + size_bytes: 12_000, + uploaded_at: "2026-02-08".into(), + chunk_count: 8, + }, + KnowledgeFile { + id: "f6".into(), + name: "architecture-diagram.png".into(), + kind: FileKind::Image, + size_bytes: 3_800_000, + uploaded_at: "2026-02-05".into(), + chunk_count: 1, + }, + ] +} diff --git a/src/pages/mod.rs b/src/pages/mod.rs index e0e9e8d..46575a5 100644 --- a/src/pages/mod.rs +++ b/src/pages/mod.rs @@ -1,9 +1,21 @@ +mod chat; +mod dashboard; +pub mod developer; mod impressum; +mod knowledge; mod landing; -mod overview; +pub mod organization; mod privacy; +mod providers; +mod tools; +pub use chat::*; +pub use dashboard::*; +pub use developer::*; pub use impressum::*; +pub use knowledge::*; pub use landing::*; -pub use overview::*; +pub use organization::*; pub use privacy::*; +pub use providers::*; +pub use tools::*; diff --git a/src/pages/organization/dashboard.rs b/src/pages/organization/dashboard.rs new file mode 100644 index 0000000..d2b7abb --- /dev/null +++ b/src/pages/organization/dashboard.rs @@ -0,0 +1,177 @@ +use dioxus::prelude::*; + +use crate::components::{MemberRow, PageHeader}; +use crate::models::{BillingUsage, MemberRole, OrgMember}; + +/// Organization dashboard with billing stats, member table, and invite modal. +/// +/// Shows current billing usage, a table of organization members +/// with role management, and a button to invite new members. +#[component] +pub fn OrgDashboardPage() -> Element { + let members = use_signal(mock_members); + let usage = mock_usage(); + let mut show_invite = use_signal(|| false); + let mut invite_email = use_signal(String::new); + + let members_list = members.read().clone(); + + // Format token counts for display + let tokens_display = format_tokens(usage.tokens_used); + let tokens_limit_display = format_tokens(usage.tokens_limit); + + rsx! { + section { class: "org-dashboard-page", + PageHeader { + title: "Organization".to_string(), + subtitle: "Manage members and billing".to_string(), + actions: rsx! { + button { + class: "btn-primary", + onclick: move |_| show_invite.set(true), + "Invite Member" + } + }, + } + + // Stats bar + div { class: "org-stats-bar", + div { class: "org-stat", + span { class: "org-stat-value", + "{usage.seats_used}/{usage.seats_total}" + } + span { class: "org-stat-label", "Seats Used" } + } + div { class: "org-stat", + span { class: "org-stat-value", "{tokens_display}" } + span { class: "org-stat-label", + "of {tokens_limit_display} tokens" + } + } + div { class: "org-stat", + span { class: "org-stat-value", "{usage.billing_cycle_end}" } + span { class: "org-stat-label", "Cycle Ends" } + } + } + + // Members table + div { class: "org-table-wrapper", + table { class: "org-table", + thead { + tr { + th { "Name" } + th { "Email" } + th { "Role" } + th { "Joined" } + } + } + tbody { + for member in members_list { + MemberRow { + key: "{member.id}", + member, + on_role_change: move |_| {}, + } + } + } + } + } + + // Invite modal + if *show_invite.read() { + div { class: "modal-overlay", + onclick: move |_| show_invite.set(false), + div { + class: "modal-content", + // Prevent clicks inside modal from closing it + onclick: move |evt: Event| evt.stop_propagation(), + h3 { "Invite New Member" } + div { class: "form-group", + label { "Email Address" } + input { + class: "form-input", + r#type: "email", + placeholder: "colleague@company.com", + value: "{invite_email}", + oninput: move |evt: Event| { + invite_email.set(evt.value()); + }, + } + } + div { class: "modal-actions", + button { + class: "btn-secondary", + onclick: move |_| show_invite.set(false), + "Cancel" + } + button { + class: "btn-primary", + onclick: move |_| show_invite.set(false), + "Send Invite" + } + } + } + } + } + } + } +} + +/// Formats a token count into a human-readable string (e.g. "1.2M"). +fn format_tokens(count: u64) -> String { + const M: u64 = 1_000_000; + const K: u64 = 1_000; + + if count >= M { + format!("{:.1}M", count as f64 / M as f64) + } else if count >= K { + format!("{:.0}K", count as f64 / K as f64) + } else { + count.to_string() + } +} + +/// Returns mock organization members. +fn mock_members() -> Vec { + vec![ + OrgMember { + id: "m1".into(), + name: "Max Mustermann".into(), + email: "max@example.com".into(), + role: MemberRole::Admin, + joined_at: "2026-01-10".into(), + }, + OrgMember { + id: "m2".into(), + name: "Erika Musterfrau".into(), + email: "erika@example.com".into(), + role: MemberRole::Member, + joined_at: "2026-01-15".into(), + }, + OrgMember { + id: "m3".into(), + name: "Johann Schmidt".into(), + email: "johann@example.com".into(), + role: MemberRole::Member, + joined_at: "2026-02-01".into(), + }, + OrgMember { + id: "m4".into(), + name: "Anna Weber".into(), + email: "anna@example.com".into(), + role: MemberRole::Viewer, + joined_at: "2026-02-10".into(), + }, + ] +} + +/// Returns mock billing usage data. +fn mock_usage() -> BillingUsage { + BillingUsage { + seats_used: 4, + seats_total: 25, + tokens_used: 847_000, + tokens_limit: 1_000_000, + billing_cycle_end: "2026-03-01".into(), + } +} diff --git a/src/pages/organization/mod.rs b/src/pages/organization/mod.rs new file mode 100644 index 0000000..be03149 --- /dev/null +++ b/src/pages/organization/mod.rs @@ -0,0 +1,35 @@ +mod dashboard; +mod pricing; + +pub use dashboard::*; +pub use pricing::*; + +use dioxus::prelude::*; + +use crate::app::Route; +use crate::components::sub_nav::{SubNav, SubNavItem}; + +/// Shell layout for the Organization section. +/// +/// Renders a horizontal tab bar (Pricing, Dashboard) above +/// the child route outlet. Sits inside the main `AppShell` layout. +#[component] +pub fn OrgShell() -> Element { + let tabs = vec![ + SubNavItem { + label: "Pricing", + route: Route::OrgPricingPage {}, + }, + SubNavItem { + label: "Dashboard", + route: Route::OrgDashboardPage {}, + }, + ]; + + rsx! { + div { class: "org-shell", + SubNav { items: tabs } + div { class: "shell-content", Outlet:: {} } + } + } +} diff --git a/src/pages/organization/pricing.rs b/src/pages/organization/pricing.rs new file mode 100644 index 0000000..c2e83ba --- /dev/null +++ b/src/pages/organization/pricing.rs @@ -0,0 +1,88 @@ +use dioxus::prelude::*; + +use crate::app::Route; +use crate::components::{PageHeader, PricingCard}; +use crate::models::PricingPlan; + +/// Organization pricing page displaying three plan tiers. +/// +/// Clicking "Get Started" on any plan navigates to the +/// organization dashboard. +#[component] +pub fn OrgPricingPage() -> Element { + let navigator = use_navigator(); + let plans = mock_plans(); + + rsx! { + section { class: "pricing-page", + PageHeader { + title: "Pricing".to_string(), + subtitle: "Choose the plan that fits your organization".to_string(), + } + div { class: "pricing-grid", + for plan in plans { + PricingCard { + key: "{plan.id}", + plan, + on_select: move |_| { + navigator.push(Route::OrgDashboardPage {}); + }, + } + } + } + } + } +} + +/// Returns mock pricing plans. +fn mock_plans() -> Vec { + vec![ + PricingPlan { + id: "starter".into(), + name: "Starter".into(), + price_eur: 49, + features: vec![ + "Up to 5 users".into(), + "1 LLM provider".into(), + "100K tokens/month".into(), + "Community support".into(), + "Basic analytics".into(), + ], + highlighted: false, + max_seats: Some(5), + }, + PricingPlan { + id: "team".into(), + name: "Team".into(), + price_eur: 199, + features: vec![ + "Up to 25 users".into(), + "All LLM providers".into(), + "1M tokens/month".into(), + "Priority support".into(), + "Advanced analytics".into(), + "Custom MCP tools".into(), + "SSO integration".into(), + ], + highlighted: true, + max_seats: Some(25), + }, + PricingPlan { + id: "enterprise".into(), + name: "Enterprise".into(), + price_eur: 499, + features: vec![ + "Unlimited users".into(), + "All LLM providers".into(), + "Unlimited tokens".into(), + "Dedicated support".into(), + "Full observability".into(), + "Custom integrations".into(), + "SLA guarantee".into(), + "On-premise deployment".into(), + ], + highlighted: false, + max_seats: None, + }, + ] +} diff --git a/src/pages/overview.rs b/src/pages/overview.rs deleted file mode 100644 index 0d42461..0000000 --- a/src/pages/overview.rs +++ /dev/null @@ -1,102 +0,0 @@ -use dioxus::prelude::*; -use dioxus_free_icons::icons::bs_icons::BsBook; -use dioxus_free_icons::icons::fa_solid_icons::{FaChartLine, FaCubes, FaGears}; -use dioxus_free_icons::Icon; - -use crate::components::DashboardCard; -use crate::Route; - -/// Overview dashboard page rendered inside the `AppShell` layout. -/// -/// Displays a welcome heading and a grid of quick-access cards -/// for the main GenAI platform tools. -#[component] -pub fn OverviewPage() -> Element { - // Check authentication status on mount via a server function. - let auth_check = use_resource(check_auth); - let navigator = use_navigator(); - - // Once the server responds, redirect unauthenticated users to /auth. - use_effect(move || { - if let Some(Ok(false)) = auth_check() { - navigator.push(NavigationTarget::::External( - "/auth?redirect_url=/dashboard".into(), - )); - } - }); - - match auth_check() { - // Still waiting for the server to respond. - None => rsx! {}, - // Not authenticated -- render nothing while the redirect fires. - Some(Ok(false)) => rsx! {}, - // Authenticated -- render the overview dashboard. - Some(Ok(true)) => rsx! { - section { class: "overview-page", - h1 { class: "overview-heading", "GenAI Dashboard" } - div { class: "dashboard-grid", - DashboardCard { - title: "Documentation".to_string(), - description: "Guides & API Reference".to_string(), - href: "#".to_string(), - icon: rsx! { - Icon { icon: BsBook, width: 28, height: 28 } - }, - } - DashboardCard { - title: "Langfuse".to_string(), - description: "Observability & Analytics".to_string(), - href: "#".to_string(), - icon: rsx! { - Icon { icon: FaChartLine, width: 28, height: 28 } - }, - } - DashboardCard { - title: "Langchain".to_string(), - description: "Agent Framework".to_string(), - href: "#".to_string(), - icon: rsx! { - Icon { icon: FaGears, width: 28, height: 28 } - }, - } - DashboardCard { - title: "Hugging Face".to_string(), - description: "Browse Models".to_string(), - href: "#".to_string(), - icon: rsx! { - Icon { icon: FaCubes, width: 28, height: 28 } - }, - } - } - } - }, - // Server error -- surface it so it is not silently swallowed. - Some(Err(err)) => rsx! { - p { "Error: {err}" } - }, - } -} - -/// Check whether the current request has an active logged-in session. -/// -/// # Returns -/// -/// `true` if the session contains a logged-in user, `false` otherwise. -/// -/// # Errors -/// -/// Returns `ServerFnError` if the session cannot be extracted from the request. -#[server] -async fn check_auth() -> Result { - use crate::infrastructure::{UserStateInner, LOGGED_IN_USER_SESS_KEY}; - use tower_sessions::Session; - - // Extract the tower_sessions::Session from the Axum request. - let session: Session = FullstackContext::extract().await?; - let user: Option = session - .get(LOGGED_IN_USER_SESS_KEY) - .await - .map_err(|e| ServerFnError::new(format!("session read failed: {e}")))?; - - Ok(user.is_some()) -} diff --git a/src/pages/providers.rs b/src/pages/providers.rs new file mode 100644 index 0000000..c8419f3 --- /dev/null +++ b/src/pages/providers.rs @@ -0,0 +1,227 @@ +use dioxus::prelude::*; + +use crate::components::PageHeader; +use crate::models::{EmbeddingEntry, LlmProvider, ModelEntry, ProviderConfig}; + +/// Providers page for configuring LLM and embedding model backends. +/// +/// Two-column layout: left side has a configuration form, right side +/// shows the currently active provider status. +#[component] +pub fn ProvidersPage() -> Element { + let mut selected_provider = use_signal(|| LlmProvider::Ollama); + let mut selected_model = use_signal(|| "llama3.1:8b".to_string()); + let mut selected_embedding = use_signal(|| "nomic-embed-text".to_string()); + let mut api_key = use_signal(String::new); + let mut saved = use_signal(|| false); + + let models = mock_models(); + let embeddings = mock_embeddings(); + + // Filter models/embeddings by selected provider + let provider_val = selected_provider.read().clone(); + let available_models: Vec<_> = models + .iter() + .filter(|m| m.provider == provider_val) + .collect(); + let available_embeddings: Vec<_> = embeddings + .iter() + .filter(|e| e.provider == provider_val) + .collect(); + + let active_config = ProviderConfig { + provider: provider_val.clone(), + selected_model: selected_model.read().clone(), + selected_embedding: selected_embedding.read().clone(), + api_key_set: !api_key.read().is_empty(), + }; + + rsx! { + section { class: "providers-page", + PageHeader { + title: "Providers".to_string(), + subtitle: "Configure your LLM and embedding backends".to_string(), + } + div { class: "providers-layout", + div { class: "providers-form", + div { class: "form-group", + label { "Provider" } + select { + class: "form-select", + value: "{provider_val.label()}", + onchange: move |evt: Event| { + let val = evt.value(); + let prov = match val.as_str() { + "Hugging Face" => LlmProvider::HuggingFace, + "OpenAI" => LlmProvider::OpenAi, + "Anthropic" => LlmProvider::Anthropic, + _ => LlmProvider::Ollama, + }; + selected_provider.set(prov); + saved.set(false); + }, + option { value: "Ollama", "Ollama" } + option { value: "Hugging Face", "Hugging Face" } + option { value: "OpenAI", "OpenAI" } + option { value: "Anthropic", "Anthropic" } + } + } + div { class: "form-group", + label { "Model" } + select { + class: "form-select", + value: "{selected_model}", + onchange: move |evt: Event| { + selected_model.set(evt.value()); + saved.set(false); + }, + for m in &available_models { + option { value: "{m.id}", + "{m.name} ({m.context_window}k ctx)" + } + } + } + } + div { class: "form-group", + label { "Embedding Model" } + select { + class: "form-select", + value: "{selected_embedding}", + onchange: move |evt: Event| { + selected_embedding.set(evt.value()); + saved.set(false); + }, + for e in &available_embeddings { + option { value: "{e.id}", + "{e.name} ({e.dimensions}d)" + } + } + } + } + div { class: "form-group", + label { "API Key" } + input { + class: "form-input", + r#type: "password", + placeholder: "Enter API key...", + value: "{api_key}", + oninput: move |evt: Event| { + api_key.set(evt.value()); + saved.set(false); + }, + } + } + button { + class: "btn-primary", + onclick: move |_| saved.set(true), + "Save Configuration" + } + if *saved.read() { + p { class: "form-success", "Configuration saved." } + } + } + div { class: "providers-status", + h3 { "Active Configuration" } + div { class: "status-card", + div { class: "status-row", + span { class: "status-label", "Provider" } + span { class: "status-value", + "{active_config.provider.label()}" + } + } + div { class: "status-row", + span { class: "status-label", "Model" } + span { class: "status-value", + "{active_config.selected_model}" + } + } + div { class: "status-row", + span { class: "status-label", "Embedding" } + span { class: "status-value", + "{active_config.selected_embedding}" + } + } + div { class: "status-row", + span { class: "status-label", "API Key" } + span { class: "status-value", + if active_config.api_key_set { "Set" } else { "Not set" } + } + } + } + } + } + } + } +} + +/// Returns mock model entries for all providers. +fn mock_models() -> Vec { + vec![ + ModelEntry { + id: "llama3.1:8b".into(), + name: "Llama 3.1 8B".into(), + provider: LlmProvider::Ollama, + context_window: 128, + }, + ModelEntry { + id: "llama3.1:70b".into(), + name: "Llama 3.1 70B".into(), + provider: LlmProvider::Ollama, + context_window: 128, + }, + ModelEntry { + id: "mistral:7b".into(), + name: "Mistral 7B".into(), + provider: LlmProvider::Ollama, + context_window: 32, + }, + ModelEntry { + id: "meta-llama/Llama-3.1-8B".into(), + name: "Llama 3.1 8B".into(), + provider: LlmProvider::HuggingFace, + context_window: 128, + }, + ModelEntry { + id: "gpt-4o".into(), + name: "GPT-4o".into(), + provider: LlmProvider::OpenAi, + context_window: 128, + }, + ModelEntry { + id: "claude-sonnet-4-6".into(), + name: "Claude Sonnet 4.6".into(), + provider: LlmProvider::Anthropic, + context_window: 200, + }, + ] +} + +/// Returns mock embedding entries for all providers. +fn mock_embeddings() -> Vec { + vec![ + EmbeddingEntry { + id: "nomic-embed-text".into(), + name: "Nomic Embed Text".into(), + provider: LlmProvider::Ollama, + dimensions: 768, + }, + EmbeddingEntry { + id: "sentence-transformers/all-MiniLM-L6-v2".into(), + name: "MiniLM-L6-v2".into(), + provider: LlmProvider::HuggingFace, + dimensions: 384, + }, + EmbeddingEntry { + id: "text-embedding-3-small".into(), + name: "Embedding 3 Small".into(), + provider: LlmProvider::OpenAi, + dimensions: 1536, + }, + EmbeddingEntry { + id: "voyage-3".into(), + name: "Voyage 3".into(), + provider: LlmProvider::Anthropic, + dimensions: 1024, + }, + ] +} diff --git a/src/pages/tools.rs b/src/pages/tools.rs new file mode 100644 index 0000000..bda66bb --- /dev/null +++ b/src/pages/tools.rs @@ -0,0 +1,120 @@ +use dioxus::prelude::*; + +use crate::components::{PageHeader, ToolCard}; +use crate::models::{McpTool, ToolCategory, ToolStatus}; + +/// Tools page displaying a grid of MCP tool cards with toggle switches. +/// +/// Shows all available MCP tools with their status and allows +/// enabling/disabling them via toggle buttons. +#[component] +pub fn ToolsPage() -> Element { + let mut tools = use_signal(mock_tools); + + // Toggle a tool's enabled state by its ID + let on_toggle = move |id: String| { + tools.write().iter_mut().for_each(|t| { + if t.id == id { + t.enabled = !t.enabled; + } + }); + }; + + let tool_list = tools.read().clone(); + + rsx! { + section { class: "tools-page", + PageHeader { + title: "Tools".to_string(), + subtitle: "Manage MCP servers and tool integrations".to_string(), + } + div { class: "tools-grid", + for tool in tool_list { + ToolCard { + key: "{tool.id}", + tool, + on_toggle, + } + } + } + } + } +} + +/// Returns mock MCP tools for the tools grid. +fn mock_tools() -> Vec { + vec![ + McpTool { + id: "calculator".into(), + name: "Calculator".into(), + description: "Mathematical computation and unit conversion".into(), + category: ToolCategory::Compute, + status: ToolStatus::Active, + enabled: true, + icon: "calculator".into(), + }, + McpTool { + id: "tavily".into(), + name: "Tavily Search".into(), + description: "AI-optimized web search API for real-time information".into(), + category: ToolCategory::Search, + status: ToolStatus::Active, + enabled: true, + icon: "search".into(), + }, + McpTool { + id: "searxng".into(), + name: "SearXNG".into(), + description: "Privacy-respecting metasearch engine".into(), + category: ToolCategory::Search, + status: ToolStatus::Active, + enabled: true, + icon: "globe".into(), + }, + McpTool { + id: "file-reader".into(), + name: "File Reader".into(), + description: "Read and parse local files in various formats".into(), + category: ToolCategory::FileSystem, + status: ToolStatus::Active, + enabled: true, + icon: "file".into(), + }, + McpTool { + id: "code-exec".into(), + name: "Code Executor".into(), + description: "Sandboxed code execution for Python and JavaScript".into(), + category: ToolCategory::Code, + status: ToolStatus::Inactive, + enabled: false, + icon: "terminal".into(), + }, + McpTool { + id: "web-scraper".into(), + name: "Web Scraper".into(), + description: "Extract structured data from web pages".into(), + category: ToolCategory::Search, + status: ToolStatus::Active, + enabled: true, + icon: "download".into(), + }, + McpTool { + id: "email".into(), + name: "Email Sender".into(), + description: "Send emails via configured SMTP server".into(), + category: ToolCategory::Communication, + status: ToolStatus::Inactive, + enabled: false, + icon: "mail".into(), + }, + McpTool { + id: "git".into(), + name: "Git Operations".into(), + description: "Interact with Git repositories for version control".into(), + category: ToolCategory::Code, + status: ToolStatus::Active, + enabled: true, + icon: "git".into(), + }, + ] +}