From e0a4d2d8889b169b4b969fc7363011da7934483f Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Wed, 18 Feb 2026 21:52:45 +0100 Subject: [PATCH 1/8] feat(ui): add public landing page with impressum and privacy pages Introduce a marketing landing page at `/` with hero section, feature grid, how-it-works steps, CTA banner, and footer. Move the authenticated dashboard to `/dashboard`. Add static Impressum and Privacy Policy pages for EU legal compliance. Update login redirect defaults accordingly. Co-Authored-By: Claude Opus 4.6 --- assets/main.css | 539 ++++++++++++++++++++++++++++++++++++++++ assets/tailwind.css | 486 ++++++++++++++++++++++++++++++++---- src/app.rs | 14 +- src/components/login.rs | 17 +- src/pages/impressum.rs | 74 ++++++ src/pages/landing.rs | 419 +++++++++++++++++++++++++++++++ src/pages/mod.rs | 7 + src/pages/overview.rs | 2 +- src/pages/privacy.rs | 110 ++++++++ 9 files changed, 1620 insertions(+), 48 deletions(-) create mode 100644 src/pages/impressum.rs create mode 100644 src/pages/landing.rs create mode 100644 src/pages/privacy.rs diff --git a/assets/main.css b/assets/main.css index 89995cd..f1c4e1a 100644 --- a/assets/main.css +++ b/assets/main.css @@ -211,3 +211,542 @@ h1, h2, h3, h4, h5, h6 { color: #8892a8; margin: 0; } + +/* ===== Landing Page ===== */ +.landing { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +/* -- Landing Nav -- */ +.landing-nav { + position: sticky; + top: 0; + z-index: 100; + background-color: rgba(15, 17, 22, 0.85); + backdrop-filter: blur(12px); + border-bottom: 1px solid #1e222d; +} + +.landing-nav-inner { + max-width: 1200px; + margin: 0 auto; + padding: 16px 32px; + display: flex; + align-items: center; + gap: 32px; +} + +.landing-logo { + display: flex; + align-items: center; + gap: 10px; + font-family: 'Space Grotesk', sans-serif; + font-size: 20px; + font-weight: 700; + color: #f1f5f9; + text-decoration: none; +} + +.landing-logo-icon { + color: #91a4d2; + display: flex; + align-items: center; +} + +.landing-nav-links { + display: flex; + gap: 28px; + flex: 1; +} + +.landing-nav-links a { + color: #8892a8; + text-decoration: none; + font-size: 14px; + font-weight: 500; + transition: color 0.15s ease; +} + +.landing-nav-links a:hover { + color: #e2e8f0; +} + +.landing-nav-actions { + display: flex; + gap: 12px; + align-items: center; +} + +/* -- Hero Section -- */ +.hero-section { + max-width: 1200px; + margin: 0 auto; + padding: 80px 32px 60px; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 64px; + align-items: center; +} + +.hero-badge { + font-size: 13px; + font-weight: 500; + color: #91a4d2; + border-color: rgba(145, 164, 210, 0.3); + margin-bottom: 24px; +} + +.hero-title { + font-size: 52px; + font-weight: 700; + line-height: 1.1; + color: #f1f5f9; + margin: 0 0 24px; +} + +.hero-title-accent { + background: linear-gradient(135deg, #91a4d2, #6d85c6); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.hero-subtitle { + font-size: 18px; + line-height: 1.7; + color: #8892a8; + margin: 0 0 36px; + max-width: 520px; +} + +.hero-actions { + display: flex; + gap: 16px; + align-items: center; +} + +.hero-graphic { + display: flex; + justify-content: center; + align-items: center; + max-width: 400px; + margin: 0 auto; +} + +/* -- Social Proof -- */ +.social-proof { + border-top: 1px solid #1e222d; + border-bottom: 1px solid #1e222d; + padding: 40px 32px; + text-align: center; +} + +.social-proof-text { + font-size: 16px; + color: #8892a8; + margin: 0 0 28px; +} + +.social-proof-highlight { + color: #91a4d2; + font-weight: 600; +} + +.social-proof-stats { + display: flex; + justify-content: center; + gap: 40px; + align-items: center; + flex-wrap: wrap; +} + +.proof-stat { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; +} + +.proof-stat-value { + font-family: 'Space Grotesk', sans-serif; + font-size: 24px; + font-weight: 700; + color: #f1f5f9; +} + +.proof-stat-label { + font-size: 13px; + color: #5a6478; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.proof-divider { + width: 1px; + height: 40px; + background-color: #1e222d; +} + +/* -- Section Titles -- */ +.section-title { + font-size: 36px; + font-weight: 700; + color: #f1f5f9; + text-align: center; + margin: 0 0 12px; +} + +.section-subtitle { + font-size: 18px; + color: #8892a8; + text-align: center; + margin: 0 0 48px; +} + +/* -- Features Section -- */ +.features-section { + max-width: 1200px; + margin: 0 auto; + padding: 80px 32px; +} + +.features-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 24px; +} + +.feature-card { + background-color: #1a1d26; + border: 1px solid #2a2f3d; + border-radius: 12px; + padding: 32px 28px; + transition: border-color 0.2s ease, transform 0.2s ease; +} + +.feature-card:hover { + border-color: #91a4d2; + transform: translateY(-2px); +} + +.feature-card-icon { + color: #91a4d2; + margin-bottom: 16px; +} + +.feature-card-title { + font-size: 18px; + font-weight: 600; + color: #f1f5f9; + margin: 0 0 8px; +} + +.feature-card-desc { + font-size: 14px; + line-height: 1.6; + color: #8892a8; + margin: 0; +} + +/* -- How It Works -- */ +.how-it-works-section { + max-width: 1200px; + margin: 0 auto; + padding: 80px 32px; +} + +.steps-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 32px; +} + +.step-card { + text-align: center; + padding: 40px 28px; +} + +.step-number { + font-family: 'Space Grotesk', sans-serif; + font-size: 48px; + font-weight: 700; + background: linear-gradient(135deg, #91a4d2, #6d85c6); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + display: block; + margin-bottom: 16px; +} + +.step-title { + font-size: 22px; + font-weight: 600; + color: #f1f5f9; + margin: 0 0 12px; +} + +.step-desc { + font-size: 15px; + line-height: 1.6; + color: #8892a8; + margin: 0; +} + +/* -- CTA Banner -- */ +.cta-banner { + max-width: 900px; + margin: 0 auto 80px; + padding: 64px 48px; + text-align: center; + background: linear-gradient( + 135deg, + rgba(145, 164, 210, 0.08), + rgba(109, 133, 198, 0.04) + ); + border: 1px solid rgba(145, 164, 210, 0.15); + border-radius: 20px; +} + +.cta-title { + font-size: 32px; + font-weight: 700; + color: #f1f5f9; + margin: 0 0 12px; +} + +.cta-subtitle { + font-size: 18px; + color: #8892a8; + margin: 0 0 32px; +} + +.cta-actions { + display: flex; + gap: 16px; + justify-content: center; +} + +/* -- Landing Footer -- */ +.landing-footer { + border-top: 1px solid #1e222d; + padding: 60px 32px 0; + margin-top: auto; +} + +.landing-footer-inner { + max-width: 1200px; + margin: 0 auto; + display: grid; + grid-template-columns: 2fr 1fr 1fr 1fr; + gap: 48px; +} + +.footer-brand { + display: flex; + flex-direction: column; + gap: 12px; +} + +.footer-tagline { + font-size: 14px; + color: #5a6478; + margin: 0; + max-width: 280px; +} + +.footer-links-group { + display: flex; + flex-direction: column; + gap: 10px; +} + +.footer-links-heading { + font-size: 13px; + font-weight: 600; + color: #8892a8; + text-transform: uppercase; + letter-spacing: 0.05em; + margin: 0 0 4px; +} + +.footer-links-group a { + font-size: 14px; + color: #5a6478; + text-decoration: none; + transition: color 0.15s ease; +} + +.footer-links-group a:hover { + color: #91a4d2; +} + +.footer-bottom { + max-width: 1200px; + margin: 48px auto 0; + padding: 20px 0; + border-top: 1px solid #1e222d; + text-align: center; +} + +.footer-bottom p { + font-size: 13px; + color: #3d4556; + margin: 0; +} + +/* ===== Legal Pages (Impressum, Privacy) ===== */ +.legal-page { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.legal-nav { + padding: 20px 32px; + border-bottom: 1px solid #1e222d; +} + +.legal-content { + max-width: 760px; + margin: 0 auto; + padding: 48px 32px 80px; + flex: 1; +} + +.legal-content h1 { + font-size: 36px; + font-weight: 700; + color: #f1f5f9; + margin: 0 0 32px; +} + +.legal-content h2 { + font-size: 22px; + font-weight: 600; + color: #f1f5f9; + margin: 40px 0 12px; +} + +.legal-content p { + font-size: 15px; + line-height: 1.7; + color: #8892a8; + margin: 0 0 16px; +} + +.legal-content ul { + padding-left: 24px; + margin: 0 0 16px; +} + +.legal-content li { + font-size: 15px; + line-height: 1.7; + color: #8892a8; + margin-bottom: 8px; +} + +.legal-updated { + font-size: 14px; + color: #5a6478; + font-style: italic; +} + +.legal-footer { + padding: 20px 32px; + border-top: 1px solid #1e222d; + display: flex; + gap: 24px; + justify-content: center; +} + +.legal-footer a { + font-size: 14px; + color: #5a6478; + text-decoration: none; + transition: color 0.15s ease; +} + +.legal-footer a:hover { + color: #91a4d2; +} + +/* ===== Responsive: Landing Page ===== */ +@media (max-width: 1024px) { + .hero-section { + grid-template-columns: 1fr; + padding: 60px 24px 40px; + gap: 40px; + } + + .hero-graphic { + max-width: 300px; + order: -1; + } + + .features-grid { + grid-template-columns: repeat(2, 1fr); + } + + .landing-footer-inner { + grid-template-columns: 1fr 1fr; + gap: 32px; + } +} + +@media (max-width: 768px) { + .landing-nav-links { + display: none; + } + + .hero-title { + font-size: 36px; + } + + .hero-subtitle { + font-size: 16px; + } + + .hero-actions { + flex-direction: column; + align-items: stretch; + } + + .features-grid, + .steps-grid { + grid-template-columns: 1fr; + } + + .social-proof-stats { + gap: 24px; + } + + .proof-divider { + display: none; + } + + .cta-banner { + margin: 0 16px 60px; + padding: 40px 24px; + } + + .cta-title { + font-size: 24px; + } + + .cta-actions { + flex-direction: column; + align-items: stretch; + } + + .landing-footer-inner { + grid-template-columns: 1fr; + gap: 24px; + } + + .section-title { + font-size: 28px; + } +} diff --git a/assets/tailwind.css b/assets/tailwind.css index e626241..30a5aff 100644 --- a/assets/tailwind.css +++ b/assets/tailwind.css @@ -1,4 +1,4 @@ -/*! tailwindcss v4.1.18 | MIT License | https://tailwindcss.com */ +/*! tailwindcss v4.2.0 | MIT License | https://tailwindcss.com */ @layer properties; @layer theme, base, components, utilities; @layer theme { @@ -162,55 +162,137 @@ } } @layer utilities { - .diff { + .btn { + :where(&) { + @layer daisyui.l1.l2.l3 { + width: unset; + } + } + .prose :where(a&:not(.btn-link)):not(:where([class~="not-prose"], [class~="not-prose"] *)) { + text-decoration-line: none; + } @layer daisyui.l1.l2.l3 { - position: relative; - display: grid; - width: 100%; - overflow: hidden; + display: inline-flex; + flex-shrink: 0; + cursor: pointer; + flex-wrap: nowrap; + align-items: center; + justify-content: center; + gap: calc(0.25rem * 1.5); + text-align: center; + vertical-align: middle; + outline-offset: 2px; webkit-user-select: none; user-select: none; - grid-template-rows: 1fr 1.8rem 1fr; - direction: ltr; - container-type: inline-size; - grid-template-columns: auto 1fr; - &:focus-visible, &:has(.diff-item-1:focus-visible) { - outline-style: var(--tw-outline-style); - outline-width: 2px; - outline-offset: 1px; - outline-color: var(--color-base-content); + padding-inline: var(--btn-p); + color: var(--btn-fg); + --tw-prose-links: var(--btn-fg); + height: var(--size); + font-size: var(--fontsize, 0.875rem); + font-weight: 600; + outline-color: var(--btn-color, var(--color-base-content)); + transition-property: color, background-color, border-color, box-shadow; + transition-timing-function: cubic-bezier(0, 0, 0.2, 1); + transition-duration: 0.2s; + border-start-start-radius: var(--join-ss, var(--radius-field)); + border-start-end-radius: var(--join-se, var(--radius-field)); + border-end-start-radius: var(--join-es, var(--radius-field)); + border-end-end-radius: var(--join-ee, var(--radius-field)); + background-color: var(--btn-bg); + background-size: auto, calc(var(--noise) * 100%); + background-image: none, var(--btn-noise); + border-width: var(--border); + border-style: solid; + border-color: var(--btn-border); + text-shadow: 0 0.5px oklch(100% 0 0 / calc(var(--depth) * 0.15)); + touch-action: manipulation; + box-shadow: 0 0.5px 0 0.5px oklch(100% 0 0 / calc(var(--depth) * 6%)) inset, var(--btn-shadow); + --size: calc(var(--size-field, 0.25rem) * 10); + --btn-bg: var(--btn-color, var(--color-base-200)); + --btn-fg: var(--color-base-content); + --btn-p: 1rem; + --btn-border: var(--btn-bg); + @supports (color: color-mix(in lab, red, red)) { + --btn-border: color-mix(in oklab, var(--btn-bg), #000 calc(var(--depth) * 5%)); } - &:focus-visible { - outline-style: var(--tw-outline-style); - outline-width: 2px; - outline-offset: 1px; - outline-color: var(--color-base-content); - .diff-resizer { - min-width: 95cqi; - max-width: 95cqi; - } + --btn-shadow: 0 3px 2px -2px var(--btn-bg), + 0 4px 3px -2px var(--btn-bg); + @supports (color: color-mix(in lab, red, red)) { + --btn-shadow: 0 3px 2px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000), + 0 4px 3px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000); } - &:has(.diff-item-1:focus-visible) { - outline-style: var(--tw-outline-style); - outline-width: 2px; - outline-offset: 1px; - .diff-resizer { - min-width: 5cqi; - max-width: 5cqi; - } - } - @supports (-webkit-overflow-scrolling: touch) and (overflow: -webkit-paged-x) { - &:focus { - .diff-resizer { - min-width: 5cqi; - max-width: 5cqi; + --btn-noise: var(--fx-noise); + @media (hover: hover) { + &:hover { + --btn-bg: var(--btn-color, var(--color-base-200)); + @supports (color: color-mix(in lab, red, red)) { + --btn-bg: color-mix(in oklab, var(--btn-color, var(--color-base-200)), #000 7%); } } - &:has(.diff-item-1:focus) { - .diff-resizer { - min-width: 95cqi; - max-width: 95cqi; + } + &:focus-visible, &:has(:focus-visible) { + outline-width: 2px; + outline-style: solid; + isolation: isolate; + } + &:active:not(.btn-active) { + translate: 0 0.5px; + --btn-bg: var(--btn-color, var(--color-base-200)); + @supports (color: color-mix(in lab, red, red)) { + --btn-bg: color-mix(in oklab, var(--btn-color, var(--color-base-200)), #000 5%); + } + --btn-border: var(--btn-color, var(--color-base-200)); + @supports (color: color-mix(in lab, red, red)) { + --btn-border: color-mix(in oklab, var(--btn-color, var(--color-base-200)), #000 7%); + } + --btn-shadow: 0 0 0 0 oklch(0% 0 0/0), 0 0 0 0 oklch(0% 0 0/0); + } + &:is(input[type="checkbox"], input[type="radio"]) { + appearance: none; + &[aria-label]::after { + --tw-content: attr(aria-label); + content: var(--tw-content); + } + } + &:where(input:checked:not(.filter .btn)) { + --btn-color: var(--color-primary); + --btn-fg: var(--color-primary-content); + isolation: isolate; + } + } + &:disabled { + @layer daisyui.l1.l2 { + &:not(.btn-link, .btn-ghost) { + background-color: var(--color-base-content); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-base-content) 10%, transparent); } + box-shadow: none; + } + pointer-events: none; + --btn-border: #0000; + --btn-noise: none; + --btn-fg: var(--color-base-content); + @supports (color: color-mix(in lab, red, red)) { + --btn-fg: color-mix(in oklch, var(--color-base-content) 20%, #0000); + } + } + } + &[disabled] { + @layer daisyui.l1.l2 { + &:not(.btn-link, .btn-ghost) { + background-color: var(--color-base-content); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-base-content) 10%, transparent); + } + box-shadow: none; + } + pointer-events: none; + --btn-border: #0000; + --btn-noise: none; + --btn-fg: var(--color-base-content); + @supports (color: color-mix(in lab, red, red)) { + --btn-fg: color-mix(in oklch, var(--color-base-content) 20%, #0000); } } } @@ -490,6 +572,23 @@ } } } + .indicator { + @layer daisyui.l1.l2.l3 { + position: relative; + display: inline-flex; + width: max-content; + :where(.indicator-item) { + z-index: 1; + position: absolute; + white-space: nowrap; + top: var(--indicator-t, 0); + bottom: var(--indicator-b, auto); + left: var(--indicator-s, auto); + right: var(--indicator-e, 0); + translate: var(--indicator-x, 50%) var(--indicator-y, -50%); + } + } + } .steps { @layer daisyui.l1.l2.l3 { display: inline-grid; @@ -600,6 +699,175 @@ } } } + .select { + @layer daisyui.l1.l2.l3 { + border: var(--border) solid #0000; + position: relative; + display: inline-flex; + flex-shrink: 1; + appearance: none; + align-items: center; + gap: calc(0.25rem * 1.5); + background-color: var(--color-base-100); + padding-inline-start: calc(0.25rem * 3); + padding-inline-end: calc(0.25rem * 7); + vertical-align: middle; + width: clamp(3rem, 20rem, 100%); + height: var(--size); + font-size: 0.875rem; + touch-action: manipulation; + border-start-start-radius: var(--join-ss, var(--radius-field)); + border-start-end-radius: var(--join-se, var(--radius-field)); + border-end-start-radius: var(--join-es, var(--radius-field)); + border-end-end-radius: var(--join-ee, var(--radius-field)); + background-image: linear-gradient(45deg, #0000 50%, currentColor 50%), linear-gradient(135deg, currentColor 50%, #0000 50%); + background-position: calc(100% - 20px) calc(1px + 50%), calc(100% - 16.1px) calc(1px + 50%); + background-size: 4px 4px, 4px 4px; + background-repeat: no-repeat; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + box-shadow: 0 1px var(--input-color) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset; + @supports (color: color-mix(in lab, red, red)) { + box-shadow: 0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset; + } + border-color: var(--input-color); + --input-color: var(--color-base-content); + @supports (color: color-mix(in lab, red, red)) { + --input-color: color-mix(in oklab, var(--color-base-content) 20%, #0000); + } + --size: calc(var(--size-field, 0.25rem) * 10); + [dir="rtl"] & { + background-position: calc(0% + 12px) calc(1px + 50%), calc(0% + 16px) calc(1px + 50%); + &::picker(select), select::picker(select) { + translate: 0.5rem 0; + } + } + &[multiple] { + height: auto; + overflow: auto; + padding-block: calc(0.25rem * 3); + padding-inline-end: calc(0.25rem * 3); + background-image: none; + } + select { + margin-inline-start: calc(0.25rem * -3); + margin-inline-end: calc(0.25rem * -7); + width: calc(100% + 2.75rem); + appearance: none; + padding-inline-start: calc(0.25rem * 3); + padding-inline-end: calc(0.25rem * 7); + height: calc(100% - calc(var(--border) * 2)); + align-items: center; + background: inherit; + border-radius: inherit; + border-style: none; + &:focus, &:focus-within { + --tw-outline-style: none; + outline-style: none; + @media (forced-colors: active) { + outline: 2px solid transparent; + outline-offset: 2px; + } + } + &:not(:last-child) { + margin-inline-end: calc(0.25rem * -5.5); + background-image: none; + } + } + &:focus, &:focus-within { + --input-color: var(--color-base-content); + box-shadow: 0 1px var(--input-color); + @supports (color: color-mix(in lab, red, red)) { + box-shadow: 0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000); + } + outline: 2px solid var(--input-color); + outline-offset: 2px; + isolation: isolate; + } + &:has(> select[disabled]), &:is(:disabled, [disabled]), fieldset:disabled & { + cursor: not-allowed; + border-color: var(--color-base-200); + background-color: var(--color-base-200); + color: var(--color-base-content); + @supports (color: color-mix(in lab, red, red)) { + color: color-mix(in oklab, var(--color-base-content) 40%, transparent); + } + &::placeholder { + color: var(--color-base-content); + @supports (color: color-mix(in lab, red, red)) { + color: color-mix(in oklab, var(--color-base-content) 20%, transparent); + } + } + } + &:has(> select[disabled]) > select[disabled] { + cursor: not-allowed; + } + &, & select { + @supports (appearance: base-select) { + appearance: base-select; + } + @supports (appearance: base-select) { + &::picker(select) { + appearance: base-select; + } + } + &::picker(select) { + color: inherit; + max-height: min(24rem, 70dvh); + margin-inline: 0.5rem; + translate: -0.5rem 0; + border: var(--border) solid var(--color-base-200); + margin-block: calc(0.25rem * 2); + border-radius: var(--radius-box); + padding: calc(0.25rem * 2); + background-color: inherit; + box-shadow: 0 2px calc(var(--depth) * 3px) -2px oklch(0% 0 0/0.2); + box-shadow: 0 20px 25px -5px rgb(0 0 0 / calc(var(--depth) * 0.1)), 0 8px 10px -6px rgb(0 0 0 / calc(var(--depth) * 0.1)); + } + &::picker-icon { + display: none; + } + optgroup { + padding-top: 0.5em; + option { + &:nth-child(1) { + margin-top: 0.5em; + } + } + } + option { + border-radius: var(--radius-field); + padding-inline: calc(0.25rem * 3); + padding-block: calc(0.25rem * 1.5); + transition-property: color, background-color; + transition-duration: 0.2s; + transition-timing-function: cubic-bezier(0, 0, 0.2, 1); + white-space: normal; + &:not(:disabled) { + &:hover, &:focus-visible { + cursor: pointer; + background-color: var(--color-base-content); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-base-content) 10%, transparent); + } + --tw-outline-style: none; + outline-style: none; + @media (forced-colors: active) { + outline: 2px solid transparent; + outline-offset: 2px; + } + } + &:active { + background-color: var(--color-neutral); + color: var(--color-neutral-content); + box-shadow: 0 2px calc(var(--depth) * 3px) -2px var(--color-neutral); + } + } + } + } + } + } .avatar { @layer daisyui.l1.l2.l3 { position: relative; @@ -725,6 +993,23 @@ .static { position: static; } + .start { + inset-inline-start: var(--spacing); + } + .end { + inset-inline-end: var(--spacing); + } + .hero-content { + @layer daisyui.l1.l2.l3 { + isolation: isolate; + display: flex; + max-width: 80rem; + align-items: center; + justify-content: center; + gap: calc(0.25rem * 4); + padding: calc(0.25rem * 4); + } + } .stack { @layer daisyui.l1.l2.l3 { display: inline-grid; @@ -806,6 +1091,19 @@ } } } + .hero { + @layer daisyui.l1.l2.l3 { + display: grid; + width: 100%; + place-items: center; + background-size: cover; + background-position: center; + & > * { + grid-column-start: 1; + grid-row-start: 1; + } + } + } .container { width: 100%; @media (width >= 40rem) { @@ -888,6 +1186,28 @@ } } } + .badge { + @layer daisyui.l1.l2.l3 { + display: inline-flex; + align-items: center; + justify-content: center; + gap: calc(0.25rem * 2); + border-radius: var(--radius-selector); + vertical-align: middle; + color: var(--badge-fg); + border: var(--border) solid var(--badge-color, var(--color-base-200)); + font-size: 0.875rem; + width: fit-content; + background-size: auto, calc(var(--noise) * 100%); + background-image: none, var(--fx-noise); + background-color: var(--badge-bg); + --badge-bg: var(--badge-color, var(--color-base-100)); + --badge-fg: var(--color-base-content); + --size: calc(var(--size-selector, 0.25rem) * 6); + height: var(--size); + padding-inline: calc(var(--size) / 2 - var(--border)); + } + } .footer { @layer daisyui.l1.l2.l3 { display: grid; @@ -949,12 +1269,88 @@ } } } + .badge-outline { + @layer daisyui.l1.l2 { + color: var(--badge-color); + --badge-bg: #0000; + background-image: none; + border-color: currentColor; + } + } .p-6 { padding: calc(var(--spacing) * 6); } .text-center { text-align: center; } + .outline { + outline-style: var(--tw-outline-style); + outline-width: 1px; + } + .btn-ghost { + @layer daisyui.l1 { + &:not(.btn-active, :hover, :active:focus, :focus-visible, input:checked:not(.filter .btn)) { + --btn-shadow: ""; + --btn-bg: #0000; + --btn-border: #0000; + --btn-noise: none; + &:not(:disabled, [disabled], .btn-disabled) { + outline-color: currentcolor; + --btn-fg: var(--btn-color, currentColor); + } + } + @media (hover: none) { + &:not(.btn-active, :active, :focus-visible, input:checked:not(.filter .btn)):hover { + outline-color: currentcolor; + --btn-shadow: ""; + --btn-bg: #0000; + --btn-fg: var(--btn-color, currentColor); + --btn-border: #0000; + --btn-noise: none; + } + } + } + } + .btn-outline { + @layer daisyui.l1 { + &:not( .btn-active, :hover, :active:focus, :focus-visible, input:checked:not(.filter .btn), :disabled, [disabled], .btn-disabled ) { + --btn-shadow: ""; + --btn-bg: #0000; + --btn-fg: var(--btn-color); + --btn-border: var(--btn-color); + --btn-noise: none; + } + @media (hover: none) { + &:not(.btn-active, :active, :focus-visible, input:checked:not(.filter .btn)):hover { + --btn-shadow: ""; + --btn-bg: #0000; + --btn-fg: var(--btn-color); + --btn-border: var(--btn-color); + --btn-noise: none; + } + } + } + } + .btn-lg { + @layer daisyui.l1.l2 { + --fontsize: 1.125rem; + --btn-p: 1.25rem; + --size: calc(var(--size-field, 0.25rem) * 12); + } + } + .btn-sm { + @layer daisyui.l1.l2 { + --fontsize: 0.75rem; + --btn-p: 0.75rem; + --size: calc(var(--size-field, 0.25rem) * 8); + } + } + .btn-primary { + @layer daisyui.l1.l2.l3 { + --btn-color: var(--color-primary); + --btn-fg: var(--color-primary-content); + } + } } @layer base { :where(:root),:root:has(input.theme-controller[value=light]:checked),[data-theme=light] { @@ -1323,6 +1719,11 @@ syntax: "*"; inherits: false; } +@property --tw-outline-style { + syntax: "*"; + inherits: false; + initial-value: solid; +} @layer properties { @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { *, ::before, ::after, ::backdrop { @@ -1331,6 +1732,7 @@ --tw-rotate-z: initial; --tw-skew-x: initial; --tw-skew-y: initial; + --tw-outline-style: solid; } } } diff --git a/src/app.rs b/src/app.rs index 8b64822..de04a9f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3,14 +3,20 @@ use dioxus::prelude::*; /// Application routes. /// -/// `OverviewPage` is wrapped in the `AppShell` layout so the sidebar -/// renders around every authenticated page. The `/login` route remains -/// outside the shell (unauthenticated). +/// Public pages (`LandingPage`, `ImpressumPage`, `PrivacyPage`) live +/// outside the `AppShell` layout. Authenticated pages like `OverviewPage` +/// are wrapped in `AppShell` which renders the sidebar. #[derive(Debug, Clone, Routable, PartialEq)] #[rustfmt::skip] pub enum Route { + #[route("/")] + LandingPage {}, + #[route("/impressum")] + ImpressumPage {}, + #[route("/privacy")] + PrivacyPage {}, #[layout(AppShell)] - #[route("/")] + #[route("/dashboard")] OverviewPage {}, #[end_layout] #[route("/login?:redirect_url")] diff --git a/src/components/login.rs b/src/components/login.rs index c1f5772..80b5bf9 100644 --- a/src/components/login.rs +++ b/src/components/login.rs @@ -1,11 +1,26 @@ use crate::Route; use dioxus::prelude::*; + +/// Login redirect component. +/// +/// Redirects the user to the external OAuth authentication endpoint. +/// If no `redirect_url` is provided, defaults to `/dashboard`. +/// +/// # Arguments +/// +/// * `redirect_url` - URL to redirect to after successful authentication #[component] pub fn Login(redirect_url: String) -> Element { let navigator = use_navigator(); use_effect(move || { - let target = format!("/auth?redirect_url={}", redirect_url); + // Default to /dashboard when redirect_url is empty. + let destination = if redirect_url.is_empty() { + "/dashboard".to_string() + } else { + redirect_url.clone() + }; + let target = format!("/auth?redirect_url={destination}"); navigator.push(NavigationTarget::::External(target)); }); diff --git a/src/pages/impressum.rs b/src/pages/impressum.rs new file mode 100644 index 0000000..c35a1c9 --- /dev/null +++ b/src/pages/impressum.rs @@ -0,0 +1,74 @@ +use dioxus::prelude::*; +use dioxus_free_icons::icons::bs_icons::BsShieldCheck; +use dioxus_free_icons::Icon; + +use crate::Route; + +/// Impressum (legal notice) page required by German/EU law. +/// +/// Displays placeholder company information. This page is publicly +/// accessible without authentication. +#[component] +pub fn ImpressumPage() -> Element { + rsx! { + div { class: "legal-page", + nav { class: "legal-nav", + Link { to: Route::LandingPage {}, class: "landing-logo", + span { class: "landing-logo-icon", + Icon { icon: BsShieldCheck, width: 20, height: 20 } + } + span { "CERTifAI" } + } + } + main { class: "legal-content", + h1 { "Impressum" } + + h2 { "Information according to 5 TMG" } + p { + "CERTifAI GmbH" + br {} + "Musterstrasse 1" + br {} + "10115 Berlin" + br {} + "Germany" + } + + h2 { "Represented by" } + p { "Managing Director: [Name]" } + + h2 { "Contact" } + p { + "Email: info@certifai.example" + br {} + "Phone: +49 (0) 30 1234567" + } + + h2 { "Commercial Register" } + p { + "Registered at: Amtsgericht Berlin-Charlottenburg" + br {} + "Registration number: HRB XXXXXX" + } + + h2 { "VAT ID" } + p { "VAT identification number according to 27a UStG: DE XXXXXXXXX" } + + h2 { "Responsible for content according to 55 Abs. 2 RStV" } + p { + "[Name]" + br {} + "CERTifAI GmbH" + br {} + "Musterstrasse 1" + br {} + "10115 Berlin" + } + } + footer { class: "legal-footer", + Link { to: Route::LandingPage {}, "Back to Home" } + Link { to: Route::PrivacyPage {}, "Privacy Policy" } + } + } + } +} diff --git a/src/pages/landing.rs b/src/pages/landing.rs new file mode 100644 index 0000000..b6c6c6a --- /dev/null +++ b/src/pages/landing.rs @@ -0,0 +1,419 @@ +use dioxus::prelude::*; +use dioxus_free_icons::icons::bs_icons::{ + BsArrowRight, BsGlobe2, BsKey, BsRobot, BsServer, BsShieldCheck, +}; +use dioxus_free_icons::icons::fa_solid_icons::FaCubes; +use dioxus_free_icons::Icon; + +use crate::Route; + +/// Public landing page for the CERTifAI platform. +/// +/// Displays a marketing-oriented page with hero section, feature grid, +/// how-it-works steps, and call-to-action banners. This page is accessible +/// without authentication. +#[component] +pub fn LandingPage() -> Element { + rsx! { + div { class: "landing", + LandingNav {} + HeroSection {} + SocialProof {} + FeaturesGrid {} + HowItWorks {} + CtaBanner {} + LandingFooter {} + } + } +} + +/// Sticky top navigation bar with logo, nav links, and CTA buttons. +#[component] +fn LandingNav() -> Element { + rsx! { + nav { class: "landing-nav", + div { class: "landing-nav-inner", + Link { to: Route::LandingPage {}, class: "landing-logo", + span { class: "landing-logo-icon", + Icon { icon: BsShieldCheck, width: 24, height: 24 } + } + span { "CERTifAI" } + } + div { class: "landing-nav-links", + a { href: "#features", "Features" } + a { href: "#how-it-works", "How It Works" } + a { href: "#pricing", "Pricing" } + } + div { class: "landing-nav-actions", + Link { + to: Route::Login { redirect_url: "/dashboard".into() }, + class: "btn btn-ghost btn-sm", + "Log In" + } + Link { + to: Route::Login { redirect_url: "/dashboard".into() }, + class: "btn btn-primary btn-sm", + "Get Started" + } + } + } + } + } +} + +/// Hero section with headline, subtitle, and CTA buttons. +#[component] +fn HeroSection() -> Element { + rsx! { + section { class: "hero-section", + div { class: "hero-content", + div { class: "hero-badge badge badge-outline", + "Privacy-First GenAI Infrastructure" + } + h1 { class: "hero-title", + "Your AI. Your Data." + br {} + span { class: "hero-title-accent", "Your Infrastructure." } + } + p { class: "hero-subtitle", + "Self-hosted, GDPR-compliant generative AI platform for " + "enterprises that refuse to compromise on data sovereignty. " + "Deploy LLMs, agents, and MCP servers on your own terms." + } + div { class: "hero-actions", + Link { + to: Route::Login { redirect_url: "/dashboard".into() }, + class: "btn btn-primary btn-lg", + "Get Started" + Icon { icon: BsArrowRight, width: 18, height: 18 } + } + a { + href: "#features", + class: "btn btn-outline btn-lg", + "Learn More" + } + } + } + div { class: "hero-graphic", + // Abstract shield/network SVG motif + svg { + view_box: "0 0 400 400", + fill: "none", + width: "100%", + height: "100%", + // Gradient definitions + defs { + linearGradient { + id: "grad1", + x1: "0%", y1: "0%", + x2: "100%", y2: "100%", + stop { offset: "0%", stop_color: "#91a4d2" } + stop { offset: "100%", stop_color: "#6d85c6" } + } + linearGradient { + id: "grad2", + x1: "0%", y1: "100%", + x2: "100%", y2: "0%", + stop { offset: "0%", stop_color: "#f97066" } + stop { offset: "100%", stop_color: "#f9a066" } + } + radialGradient { + id: "glow", + cx: "50%", cy: "50%", r: "50%", + stop { offset: "0%", stop_color: "rgba(145,164,210,0.3)" } + stop { offset: "100%", stop_color: "rgba(145,164,210,0)" } + } + } + // Background glow + circle { cx: "200", cy: "200", r: "180", fill: "url(#glow)" } + // Shield outline + path { + d: "M200 40 L340 110 L340 230 C340 300 270 360 200 380 \ + C130 360 60 300 60 230 L60 110 Z", + stroke: "url(#grad1)", + stroke_width: "2", + fill: "none", + opacity: "0.6", + } + // Inner shield + path { + d: "M200 80 L310 135 L310 225 C310 280 255 330 200 345 \ + C145 330 90 280 90 225 L90 135 Z", + stroke: "url(#grad1)", + stroke_width: "1.5", + fill: "rgba(145,164,210,0.05)", + opacity: "0.8", + } + // Network nodes + circle { cx: "200", cy: "180", r: "8", fill: "url(#grad1)" } + circle { cx: "150", cy: "230", r: "6", fill: "url(#grad2)" } + circle { cx: "250", cy: "230", r: "6", fill: "url(#grad2)" } + circle { cx: "200", cy: "280", r: "6", fill: "url(#grad1)" } + circle { cx: "130", cy: "170", r: "4", fill: "#91a4d2", opacity: "0.6" } + circle { cx: "270", cy: "170", r: "4", fill: "#91a4d2", opacity: "0.6" } + // Network connections + line { + x1: "200", y1: "180", x2: "150", y2: "230", + stroke: "#91a4d2", stroke_width: "1", opacity: "0.4", + } + line { + x1: "200", y1: "180", x2: "250", y2: "230", + stroke: "#91a4d2", stroke_width: "1", opacity: "0.4", + } + line { + x1: "150", y1: "230", x2: "200", y2: "280", + stroke: "#91a4d2", stroke_width: "1", opacity: "0.4", + } + line { + x1: "250", y1: "230", x2: "200", y2: "280", + stroke: "#91a4d2", stroke_width: "1", opacity: "0.4", + } + line { + x1: "200", y1: "180", x2: "130", y2: "170", + stroke: "#91a4d2", stroke_width: "1", opacity: "0.3", + } + line { + x1: "200", y1: "180", x2: "270", y2: "170", + stroke: "#91a4d2", stroke_width: "1", opacity: "0.3", + } + // Checkmark inside shield center + path { + d: "M180 200 L195 215 L225 185", + stroke: "url(#grad1)", + stroke_width: "3", + stroke_linecap: "round", + stroke_linejoin: "round", + fill: "none", + } + } + } + } + } +} + +/// Social proof / trust indicator strip. +#[component] +fn SocialProof() -> Element { + rsx! { + section { class: "social-proof", + p { class: "social-proof-text", + "Built for enterprises that value " + span { class: "social-proof-highlight", "data sovereignty" } + } + div { class: "social-proof-stats", + div { class: "proof-stat", + span { class: "proof-stat-value", "100%" } + span { class: "proof-stat-label", "On-Premise" } + } + div { class: "proof-divider" } + div { class: "proof-stat", + span { class: "proof-stat-value", "GDPR" } + span { class: "proof-stat-label", "Compliant" } + } + div { class: "proof-divider" } + div { class: "proof-stat", + span { class: "proof-stat-value", "EU" } + span { class: "proof-stat-label", "Data Residency" } + } + div { class: "proof-divider" } + div { class: "proof-stat", + span { class: "proof-stat-value", "Zero" } + span { class: "proof-stat-label", "Third-Party Sharing" } + } + } + } + } +} + +/// Feature cards grid section. +#[component] +fn FeaturesGrid() -> Element { + rsx! { + section { id: "features", class: "features-section", + h2 { class: "section-title", "Everything You Need" } + p { class: "section-subtitle", + "A complete, self-hosted GenAI stack under your full control." + } + div { class: "features-grid", + FeatureCard { + icon: rsx! { Icon { icon: BsServer, width: 28, height: 28 } }, + title: "Self-Hosted Infrastructure", + description: "Deploy on your own hardware or private cloud. \ + Full control over your AI stack with no external dependencies.", + } + FeatureCard { + icon: rsx! { Icon { icon: BsShieldCheck, width: 28, height: 28 } }, + title: "GDPR Compliant", + description: "EU data residency guaranteed. Your data never \ + leaves your infrastructure or gets shared with third parties.", + } + FeatureCard { + icon: rsx! { Icon { icon: FaCubes, width: 28, height: 28 } }, + title: "LLM Management", + description: "Deploy, monitor, and manage multiple language \ + models. Switch between models with zero downtime.", + } + FeatureCard { + icon: rsx! { Icon { icon: BsRobot, width: 28, height: 28 } }, + title: "Agent Builder", + description: "Create custom AI agents with integrated Langchain \ + and Langfuse for full observability and control.", + } + FeatureCard { + icon: rsx! { Icon { icon: BsGlobe2, width: 28, height: 28 } }, + title: "MCP Server Management", + description: "Manage Model Context Protocol servers to extend \ + your AI capabilities with external tool integrations.", + } + FeatureCard { + icon: rsx! { Icon { icon: BsKey, width: 28, height: 28 } }, + title: "API Key Management", + description: "Generate API keys, track usage per seat, and \ + set fine-grained permissions for every integration.", + } + } + } + } +} + +/// Individual feature card. +/// +/// # Arguments +/// +/// * `icon` - The icon element to display +/// * `title` - Feature title +/// * `description` - Feature description text +#[component] +fn FeatureCard(icon: Element, title: &'static str, description: &'static str) -> Element { + rsx! { + div { class: "card feature-card", + div { class: "feature-card-icon", {icon} } + h3 { class: "feature-card-title", "{title}" } + p { class: "feature-card-desc", "{description}" } + } + } +} + +/// Three-step "How It Works" section. +#[component] +fn HowItWorks() -> Element { + rsx! { + section { id: "how-it-works", class: "how-it-works-section", + h2 { class: "section-title", "Up and Running in Minutes" } + p { class: "section-subtitle", + "Three steps to sovereign AI infrastructure." + } + div { class: "steps-grid", + StepCard { + number: "01", + title: "Deploy", + description: "Install CERTifAI on your infrastructure \ + with a single command. Supports Docker, Kubernetes, \ + and bare metal.", + } + StepCard { + number: "02", + title: "Configure", + description: "Connect your identity provider, select \ + your models, and set up team permissions through \ + the admin dashboard.", + } + StepCard { + number: "03", + title: "Scale", + description: "Add users, deploy more models, and \ + integrate with your existing tools via API keys \ + and MCP servers.", + } + } + } + } +} + +/// Individual step card. +/// +/// # Arguments +/// +/// * `number` - Step number string (e.g. "01") +/// * `title` - Step title +/// * `description` - Step description text +#[component] +fn StepCard(number: &'static str, title: &'static str, description: &'static str) -> Element { + rsx! { + div { class: "step-card", + span { class: "step-number", "{number}" } + h3 { class: "step-title", "{title}" } + p { class: "step-desc", "{description}" } + } + } +} + +/// Call-to-action banner before the footer. +#[component] +fn CtaBanner() -> Element { + rsx! { + section { class: "cta-banner", + h2 { class: "cta-title", + "Ready to take control of your AI infrastructure?" + } + p { class: "cta-subtitle", + "Start deploying sovereign GenAI today. No credit card required." + } + div { class: "cta-actions", + Link { + to: Route::Login { redirect_url: "/dashboard".into() }, + class: "btn btn-primary btn-lg", + "Get Started Free" + Icon { icon: BsArrowRight, width: 18, height: 18 } + } + Link { + to: Route::Login { redirect_url: "/dashboard".into() }, + class: "btn btn-outline btn-lg", + "Log In" + } + } + } + } +} + +/// Landing page footer with links and copyright. +#[component] +fn LandingFooter() -> Element { + rsx! { + footer { class: "landing-footer", + div { class: "landing-footer-inner", + div { class: "footer-brand", + div { class: "landing-logo", + span { class: "landing-logo-icon", + Icon { icon: BsShieldCheck, width: 20, height: 20 } + } + span { "CERTifAI" } + } + p { class: "footer-tagline", + "Sovereign GenAI infrastructure for enterprises." + } + } + div { class: "footer-links-group", + h4 { class: "footer-links-heading", "Product" } + a { href: "#features", "Features" } + a { href: "#how-it-works", "How It Works" } + a { href: "#pricing", "Pricing" } + } + div { class: "footer-links-group", + h4 { class: "footer-links-heading", "Legal" } + Link { to: Route::ImpressumPage {}, "Impressum" } + Link { to: Route::PrivacyPage {}, "Privacy Policy" } + } + div { class: "footer-links-group", + h4 { class: "footer-links-heading", "Resources" } + a { href: "#", "Documentation" } + a { href: "#", "API Reference" } + a { href: "#", "Support" } + } + } + div { class: "footer-bottom", + p { "2026 CERTifAI. All rights reserved." } + } + } + } +} diff --git a/src/pages/mod.rs b/src/pages/mod.rs index 4f91989..e0e9e8d 100644 --- a/src/pages/mod.rs +++ b/src/pages/mod.rs @@ -1,2 +1,9 @@ +mod impressum; +mod landing; mod overview; +mod privacy; + +pub use impressum::*; +pub use landing::*; pub use overview::*; +pub use privacy::*; diff --git a/src/pages/overview.rs b/src/pages/overview.rs index 3f5c1b8..0d42461 100644 --- a/src/pages/overview.rs +++ b/src/pages/overview.rs @@ -20,7 +20,7 @@ pub fn OverviewPage() -> Element { use_effect(move || { if let Some(Ok(false)) = auth_check() { navigator.push(NavigationTarget::::External( - "/auth?redirect_url=/".into(), + "/auth?redirect_url=/dashboard".into(), )); } }); diff --git a/src/pages/privacy.rs b/src/pages/privacy.rs new file mode 100644 index 0000000..127e946 --- /dev/null +++ b/src/pages/privacy.rs @@ -0,0 +1,110 @@ +use dioxus::prelude::*; +use dioxus_free_icons::icons::bs_icons::BsShieldCheck; +use dioxus_free_icons::Icon; + +use crate::Route; + +/// Privacy Policy page. +/// +/// Displays the platform's privacy policy. Publicly accessible +/// without authentication. +#[component] +pub fn PrivacyPage() -> Element { + rsx! { + div { class: "legal-page", + nav { class: "legal-nav", + Link { to: Route::LandingPage {}, class: "landing-logo", + span { class: "landing-logo-icon", + Icon { icon: BsShieldCheck, width: 20, height: 20 } + } + span { "CERTifAI" } + } + } + main { class: "legal-content", + h1 { "Privacy Policy" } + p { class: "legal-updated", + "Last updated: February 2026" + } + + h2 { "1. Introduction" } + p { + "CERTifAI GmbH (\"we\", \"our\", \"us\") is committed to " + "protecting your personal data. This privacy policy explains " + "how we collect, use, and safeguard your information when you " + "use our platform." + } + + h2 { "2. Data Controller" } + p { + "CERTifAI GmbH" + br {} + "Musterstrasse 1, 10115 Berlin, Germany" + br {} + "Email: privacy@certifai.example" + } + + h2 { "3. Data We Collect" } + p { + "We collect only the minimum data necessary to provide " + "our services:" + } + ul { + li { + strong { "Account data: " } + "Name, email address, and organization details " + "provided during registration." + } + li { + strong { "Usage data: " } + "API call logs, token counts, and feature usage " + "metrics for billing and analytics." + } + li { + strong { "Technical data: " } + "IP addresses, browser type, and session identifiers " + "for security and platform stability." + } + } + + h2 { "4. How We Use Your Data" } + ul { + li { "To provide and maintain the CERTifAI platform" } + li { "To manage your account and subscription" } + li { "To communicate service updates and security notices" } + li { "To comply with legal obligations" } + } + + h2 { "5. Data Storage and Sovereignty" } + p { + "CERTifAI is a self-hosted platform. All AI workloads, " + "model data, and inference results remain entirely within " + "your own infrastructure. We do not access, store, or " + "process your AI data on our servers." + } + + h2 { "6. Your Rights (GDPR)" } + p { "Under the GDPR, you have the right to:" } + ul { + li { "Access your personal data" } + li { "Rectify inaccurate data" } + li { "Request erasure of your data" } + li { "Restrict or object to processing" } + li { "Data portability" } + li { + "Lodge a complaint with a supervisory authority" + } + } + + h2 { "7. Contact" } + p { + "For privacy-related inquiries, contact us at " + "privacy@certifai.example." + } + } + footer { class: "legal-footer", + Link { to: Route::LandingPage {}, "Back to Home" } + Link { to: Route::ImpressumPage {}, "Impressum" } + } + } + } +} -- 2.49.1 From 80faa4fa86d874396f9a4c0805106ebea132591e Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Wed, 18 Feb 2026 22:01:28 +0100 Subject: [PATCH 2/8] fix(ui): fix hero section layout with flex column and proper sizing Add explicit flex-column layout to .hero-content so child elements stack vertically instead of flowing inline. Set proper width and min-height on hero graphic container. Co-Authored-By: Claude Opus 4.6 --- assets/main.css | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/assets/main.css b/assets/main.css index f1c4e1a..bb48a7d 100644 --- a/assets/main.css +++ b/assets/main.css @@ -288,6 +288,13 @@ h1, h2, h3, h4, h5, h6 { grid-template-columns: 1fr 1fr; gap: 64px; align-items: center; + width: 100%; +} + +.hero-content { + display: flex; + flex-direction: column; + align-items: flex-start; } .hero-badge { @@ -304,6 +311,7 @@ h1, h2, h3, h4, h5, h6 { line-height: 1.1; color: #f1f5f9; margin: 0 0 24px; + width: 100%; } .hero-title-accent { @@ -319,6 +327,7 @@ h1, h2, h3, h4, h5, h6 { color: #8892a8; margin: 0 0 36px; max-width: 520px; + width: 100%; } .hero-actions { @@ -331,8 +340,8 @@ h1, h2, h3, h4, h5, h6 { display: flex; justify-content: center; align-items: center; - max-width: 400px; - margin: 0 auto; + width: 100%; + min-height: 350px; } /* -- Social Proof -- */ @@ -682,8 +691,10 @@ h1, h2, h3, h4, h5, h6 { } .hero-graphic { - max-width: 300px; + max-width: 320px; + margin: 0 auto; order: -1; + min-height: auto; } .features-grid { -- 2.49.1 From 46b2ee5dfa4e10c5c2d831ffb73898dc6d0c1fdd Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Thu, 19 Feb 2026 09:58:11 +0100 Subject: [PATCH 3/8] fix(ci): removed build and changelog --- .gitea/workflows/ci.yml | 74 ----------------------------------------- 1 file changed, 74 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index aa11871..5d3311f 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -95,77 +95,3 @@ jobs: - name: Run tests (web) run: cargo test --features web --no-default-features - # --------------------------------------------------------------------------- - # Stage 3: Build Docker image and push to registry - # Only on main and release/* branches - # --------------------------------------------------------------------------- - build-and-push: - name: Build & Push Image - runs-on: docker - needs: [test] - if: >- - github.event_name == 'push' && - (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/')) - steps: - - name: Checkout - run: | - git init - git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" - git fetch --depth=1 origin "${GITHUB_SHA}" - git checkout FETCH_HEAD - - - name: Determine image tag - id: tag - run: | - BRANCH="${GITHUB_REF#refs/heads/}" - # Replace / with - for valid Docker tags (e.g. release/1.0 -> release-1.0) - BRANCH_SAFE=$(echo "$BRANCH" | tr '/' '-') - SHA=$(echo "$GITHUB_SHA" | head -c 8) - echo "tag=${BRANCH_SAFE}-${SHA}" >> "$GITHUB_OUTPUT" - - - name: Log in to container registry - run: >- - echo "${{ secrets.REGISTRY_PASSWORD }}" - | docker login https://registry.meghsakha.com - -u "${{ secrets.REGISTRY_USERNAME }}" - --password-stdin - - - name: Build Docker image - run: >- - docker build - -t registry.meghsakha.com/certifai/dashboard:${{ steps.tag.outputs.tag }} - -t registry.meghsakha.com/certifai/dashboard:latest - . - - - name: Push Docker image - run: | - docker push registry.meghsakha.com/certifai/dashboard:${{ steps.tag.outputs.tag }} - docker push registry.meghsakha.com/certifai/dashboard:latest - - # --------------------------------------------------------------------------- - # Stage 3b: Generate changelog from conventional commits - # Only on main and release/* branches - # --------------------------------------------------------------------------- - changelog: - name: Changelog - runs-on: docker - needs: [test] - if: >- - github.event_name == 'push' && - (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/')) - container: - image: rust:1.89-bookworm - steps: - - name: Checkout (full history) - run: | - git clone "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" . - git checkout "${GITHUB_SHA}" - - name: Install git-cliff - run: cargo install git-cliff --locked - - name: Generate changelog - run: git cliff --output CHANGELOG.md - - name: Upload changelog artifact - uses: actions/upload-artifact@v4 - with: - name: changelog - path: CHANGELOG.md -- 2.49.1 From d473f7570b6cd29480f5d84ab2cd6f88de8dcd25 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Thu, 19 Feb 2026 10:17:34 +0100 Subject: [PATCH 4/8] ci: add deploy stage to trigger Coolify after CI passes Deploy job runs only on main branch after all quality checks and tests succeed, replacing the immediate push webhook with a gated deployment. Co-Authored-By: Claude Opus 4.6 --- .gitea/workflows/ci.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 5d3311f..d768d9f 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -95,3 +95,20 @@ jobs: - name: Run tests (web) run: cargo test --features web --no-default-features + # --------------------------------------------------------------------------- + # Stage 3: Deploy (only after tests pass, only on main) + # --------------------------------------------------------------------------- + deploy: + name: Deploy + runs-on: docker + needs: [test] + if: github.ref == 'refs/heads/main' + container: + image: alpine:latest + steps: + - name: Trigger Coolify deploy + run: | + apk add --no-cache curl + curl -sf "${{ secrets.COOLIFY_WEBHOOK }}" \ + -H "Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}" + -- 2.49.1 From 58420b4547477114353eb8b169abe7a7ffb00424 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Thu, 19 Feb 2026 10:32:36 +0100 Subject: [PATCH 5/8] docs: add project badges to README Co-Authored-By: Claude Opus 4.6 --- README.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 63160a7..3f0a60b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,11 @@ # CERTifAI +[![CI](https://gitea.meghsakha.com/sharang/certifai/actions/workflows/ci.yml/badge.svg?branch=main)](https://gitea.meghsakha.com/sharang/certifai/actions?workflow=ci.yml) +[![Rust](https://img.shields.io/badge/Rust-1.89-orange?logo=rust&logoColor=white)](https://www.rust-lang.org/) +[![Dioxus](https://img.shields.io/badge/Dioxus-0.7-blue?logo=webassembly&logoColor=white)](https://dioxuslabs.com/) +[![License](https://img.shields.io/badge/License-Proprietary-red)](LICENSE) +[![GDPR](https://img.shields.io/badge/GDPR-Compliant-green)](https://gdpr.eu/) + This project is a SaaS application dashboard for administation of self-hosted private GenAI (generative AI) toolbox for companies and individuals. The purpose of the dashboard is to manage LLMs, Agents, MCP Servers and other GenAI related features. The purpose of `CERTifAI`is to provide self-hosted or GDPR-Conform GenAI infrastructure to companies who do not wish to subscribe to non-EU cloud providers to protect their intellectual property from being used as training data. @@ -19,9 +25,6 @@ The SaaS application dashboard is the landing page for the company admin to view This project is written in dioxus with fullstack and router features. MongoDB is used as a database for maintaining user state. Keycloak is used as identity provider for user management. - ## Features management - - All features are detailed and described under the features folder in clear markdown instructions which are valid for both human and AI code developers. ## Code structure The following folder structure is maintained for separation of concerns: -- 2.49.1 From 8b16eba1ade0fdc63c9f700f83ea20e20086b4d2 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Thu, 19 Feb 2026 10:35:26 +0100 Subject: [PATCH 6/8] chore: remove completed feature specs and apply dx fmt Remove CAI-1 and CAI-2 feature files that have been implemented. Apply dx fmt formatting to landing and privacy pages. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 4 - features/CAI-1.md | 9 -- features/CAI-2.md | 3 - src/pages/landing.rs | 197 +++++++++++++++++++++++++++++++------------ src/pages/privacy.rs | 8 +- 5 files changed, 145 insertions(+), 76 deletions(-) delete mode 100644 features/CAI-1.md delete mode 100644 features/CAI-2.md diff --git a/CLAUDE.md b/CLAUDE.md index c0a8dca..311505e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -237,10 +237,6 @@ The SaaS application dashboard is the landing page for the company admin to view This project is written in dioxus with fullstack and router features. MongoDB is used as a database for maintaining user state. Keycloak is used as identity provider for user management. - ## Features management - - All features are detailed and described under the features folder in clear markdown instructions which are valid for both human and AI code developers. - ## Code structure The following folder structure is maintained for separation of concerns: - src/components/*.rs : All components that are required to be rendered are placed here. These are frontend only, reusable components that are specific for the application. diff --git a/features/CAI-1.md b/features/CAI-1.md deleted file mode 100644 index ebf6606..0000000 --- a/features/CAI-1.md +++ /dev/null @@ -1,9 +0,0 @@ -# CAI-1 - -This feature creates a new login/registration page for the GenAI admin dashboard. The user management is provided by Keycloak, which also serves the login/registration flow. The dioxus app should detect if a user is already logged-in or not, and if not, redirect the user to the keycloak landing page and after successful login, capture the user's access token in a state and save a session state. - -Steps to follow: -- Create a docker-compose file for hosting a local keycloak and create a realm for testing and a client for Oauth. -- Setup the environment variables using .env. Fill the environment with keycloak URL, realm, client ID and secret. -- Create a user state in Dioxus which manages the session and the access token. Add other user identifying information like email address to the state. -- Modify dioxus to check the state and load the correct URL based on the state. diff --git a/features/CAI-2.md b/features/CAI-2.md deleted file mode 100644 index 1b9d119..0000000 --- a/features/CAI-2.md +++ /dev/null @@ -1,3 +0,0 @@ -# CERTifAI 2 - -This feature defines the types for database as well as the API between the dashboard backend and frontend. diff --git a/src/pages/landing.rs b/src/pages/landing.rs index b6c6c6a..d6cbd34 100644 --- a/src/pages/landing.rs +++ b/src/pages/landing.rs @@ -46,12 +46,16 @@ fn LandingNav() -> Element { } div { class: "landing-nav-actions", Link { - to: Route::Login { redirect_url: "/dashboard".into() }, + to: Route::Login { + redirect_url: "/dashboard".into(), + }, class: "btn btn-ghost btn-sm", "Log In" } Link { - to: Route::Login { redirect_url: "/dashboard".into() }, + to: Route::Login { + redirect_url: "/dashboard".into(), + }, class: "btn btn-primary btn-sm", "Get Started" } @@ -67,9 +71,7 @@ fn HeroSection() -> Element { rsx! { section { class: "hero-section", div { class: "hero-content", - div { class: "hero-badge badge badge-outline", - "Privacy-First GenAI Infrastructure" - } + div { class: "hero-badge badge badge-outline", "Privacy-First GenAI Infrastructure" } h1 { class: "hero-title", "Your AI. Your Data." br {} @@ -82,16 +84,14 @@ fn HeroSection() -> Element { } div { class: "hero-actions", Link { - to: Route::Login { redirect_url: "/dashboard".into() }, + to: Route::Login { + redirect_url: "/dashboard".into(), + }, class: "btn btn-primary btn-lg", "Get Started" Icon { icon: BsArrowRight, width: 18, height: 18 } } - a { - href: "#features", - class: "btn btn-outline btn-lg", - "Learn More" - } + a { href: "#features", class: "btn btn-outline btn-lg", "Learn More" } } } div { class: "hero-graphic", @@ -105,27 +105,44 @@ fn HeroSection() -> Element { defs { linearGradient { id: "grad1", - x1: "0%", y1: "0%", - x2: "100%", y2: "100%", + x1: "0%", + y1: "0%", + x2: "100%", + y2: "100%", stop { offset: "0%", stop_color: "#91a4d2" } stop { offset: "100%", stop_color: "#6d85c6" } } linearGradient { id: "grad2", - x1: "0%", y1: "100%", - x2: "100%", y2: "0%", + x1: "0%", + y1: "100%", + x2: "100%", + y2: "0%", stop { offset: "0%", stop_color: "#f97066" } stop { offset: "100%", stop_color: "#f9a066" } } radialGradient { id: "glow", - cx: "50%", cy: "50%", r: "50%", - stop { offset: "0%", stop_color: "rgba(145,164,210,0.3)" } - stop { offset: "100%", stop_color: "rgba(145,164,210,0)" } + cx: "50%", + cy: "50%", + r: "50%", + stop { + offset: "0%", + stop_color: "rgba(145,164,210,0.3)", + } + stop { + offset: "100%", + stop_color: "rgba(145,164,210,0)", + } } } // Background glow - circle { cx: "200", cy: "200", r: "180", fill: "url(#glow)" } + circle { + cx: "200", + cy: "200", + r: "180", + fill: "url(#glow)", + } // Shield outline path { d: "M200 40 L340 110 L340 230 C340 300 270 360 200 380 \ @@ -145,36 +162,98 @@ fn HeroSection() -> Element { opacity: "0.8", } // Network nodes - circle { cx: "200", cy: "180", r: "8", fill: "url(#grad1)" } - circle { cx: "150", cy: "230", r: "6", fill: "url(#grad2)" } - circle { cx: "250", cy: "230", r: "6", fill: "url(#grad2)" } - circle { cx: "200", cy: "280", r: "6", fill: "url(#grad1)" } - circle { cx: "130", cy: "170", r: "4", fill: "#91a4d2", opacity: "0.6" } - circle { cx: "270", cy: "170", r: "4", fill: "#91a4d2", opacity: "0.6" } + circle { + cx: "200", + cy: "180", + r: "8", + fill: "url(#grad1)", + } + circle { + cx: "150", + cy: "230", + r: "6", + fill: "url(#grad2)", + } + circle { + cx: "250", + cy: "230", + r: "6", + fill: "url(#grad2)", + } + circle { + cx: "200", + cy: "280", + r: "6", + fill: "url(#grad1)", + } + circle { + cx: "130", + cy: "170", + r: "4", + fill: "#91a4d2", + opacity: "0.6", + } + circle { + cx: "270", + cy: "170", + r: "4", + fill: "#91a4d2", + opacity: "0.6", + } // Network connections line { - x1: "200", y1: "180", x2: "150", y2: "230", - stroke: "#91a4d2", stroke_width: "1", opacity: "0.4", + x1: "200", + y1: "180", + x2: "150", + y2: "230", + stroke: "#91a4d2", + stroke_width: "1", + opacity: "0.4", } line { - x1: "200", y1: "180", x2: "250", y2: "230", - stroke: "#91a4d2", stroke_width: "1", opacity: "0.4", + x1: "200", + y1: "180", + x2: "250", + y2: "230", + stroke: "#91a4d2", + stroke_width: "1", + opacity: "0.4", } line { - x1: "150", y1: "230", x2: "200", y2: "280", - stroke: "#91a4d2", stroke_width: "1", opacity: "0.4", + x1: "150", + y1: "230", + x2: "200", + y2: "280", + stroke: "#91a4d2", + stroke_width: "1", + opacity: "0.4", } line { - x1: "250", y1: "230", x2: "200", y2: "280", - stroke: "#91a4d2", stroke_width: "1", opacity: "0.4", + x1: "250", + y1: "230", + x2: "200", + y2: "280", + stroke: "#91a4d2", + stroke_width: "1", + opacity: "0.4", } line { - x1: "200", y1: "180", x2: "130", y2: "170", - stroke: "#91a4d2", stroke_width: "1", opacity: "0.3", + x1: "200", + y1: "180", + x2: "130", + y2: "170", + stroke: "#91a4d2", + stroke_width: "1", + opacity: "0.3", } line { - x1: "200", y1: "180", x2: "270", y2: "170", - stroke: "#91a4d2", stroke_width: "1", opacity: "0.3", + x1: "200", + y1: "180", + x2: "270", + y2: "170", + stroke: "#91a4d2", + stroke_width: "1", + opacity: "0.3", } // Checkmark inside shield center path { @@ -236,37 +315,49 @@ fn FeaturesGrid() -> Element { } div { class: "features-grid", FeatureCard { - icon: rsx! { Icon { icon: BsServer, width: 28, height: 28 } }, + icon: rsx! { + Icon { icon: BsServer, width: 28, height: 28 } + }, title: "Self-Hosted Infrastructure", description: "Deploy on your own hardware or private cloud. \ Full control over your AI stack with no external dependencies.", } FeatureCard { - icon: rsx! { Icon { icon: BsShieldCheck, width: 28, height: 28 } }, + icon: rsx! { + Icon { icon: BsShieldCheck, width: 28, height: 28 } + }, title: "GDPR Compliant", description: "EU data residency guaranteed. Your data never \ leaves your infrastructure or gets shared with third parties.", } FeatureCard { - icon: rsx! { Icon { icon: FaCubes, width: 28, height: 28 } }, + icon: rsx! { + Icon { icon: FaCubes, width: 28, height: 28 } + }, title: "LLM Management", description: "Deploy, monitor, and manage multiple language \ models. Switch between models with zero downtime.", } FeatureCard { - icon: rsx! { Icon { icon: BsRobot, width: 28, height: 28 } }, + icon: rsx! { + Icon { icon: BsRobot, width: 28, height: 28 } + }, title: "Agent Builder", description: "Create custom AI agents with integrated Langchain \ and Langfuse for full observability and control.", } FeatureCard { - icon: rsx! { Icon { icon: BsGlobe2, width: 28, height: 28 } }, + icon: rsx! { + Icon { icon: BsGlobe2, width: 28, height: 28 } + }, title: "MCP Server Management", description: "Manage Model Context Protocol servers to extend \ your AI capabilities with external tool integrations.", } FeatureCard { - icon: rsx! { Icon { icon: BsKey, width: 28, height: 28 } }, + icon: rsx! { + Icon { icon: BsKey, width: 28, height: 28 } + }, title: "API Key Management", description: "Generate API keys, track usage per seat, and \ set fine-grained permissions for every integration.", @@ -300,9 +391,7 @@ fn HowItWorks() -> Element { rsx! { section { id: "how-it-works", class: "how-it-works-section", h2 { class: "section-title", "Up and Running in Minutes" } - p { class: "section-subtitle", - "Three steps to sovereign AI infrastructure." - } + p { class: "section-subtitle", "Three steps to sovereign AI infrastructure." } div { class: "steps-grid", StepCard { number: "01", @@ -353,21 +442,23 @@ fn StepCard(number: &'static str, title: &'static str, description: &'static str fn CtaBanner() -> Element { rsx! { section { class: "cta-banner", - h2 { class: "cta-title", - "Ready to take control of your AI infrastructure?" - } + h2 { class: "cta-title", "Ready to take control of your AI infrastructure?" } p { class: "cta-subtitle", "Start deploying sovereign GenAI today. No credit card required." } div { class: "cta-actions", Link { - to: Route::Login { redirect_url: "/dashboard".into() }, + to: Route::Login { + redirect_url: "/dashboard".into(), + }, class: "btn btn-primary btn-lg", "Get Started Free" Icon { icon: BsArrowRight, width: 18, height: 18 } } Link { - to: Route::Login { redirect_url: "/dashboard".into() }, + to: Route::Login { + redirect_url: "/dashboard".into(), + }, class: "btn btn-outline btn-lg", "Log In" } @@ -389,9 +480,7 @@ fn LandingFooter() -> Element { } span { "CERTifAI" } } - p { class: "footer-tagline", - "Sovereign GenAI infrastructure for enterprises." - } + p { class: "footer-tagline", "Sovereign GenAI infrastructure for enterprises." } } div { class: "footer-links-group", h4 { class: "footer-links-heading", "Product" } diff --git a/src/pages/privacy.rs b/src/pages/privacy.rs index 127e946..f7bccd0 100644 --- a/src/pages/privacy.rs +++ b/src/pages/privacy.rs @@ -22,9 +22,7 @@ pub fn PrivacyPage() -> Element { } main { class: "legal-content", h1 { "Privacy Policy" } - p { class: "legal-updated", - "Last updated: February 2026" - } + p { class: "legal-updated", "Last updated: February 2026" } h2 { "1. Introduction" } p { @@ -90,9 +88,7 @@ pub fn PrivacyPage() -> Element { li { "Request erasure of your data" } li { "Restrict or object to processing" } li { "Data portability" } - li { - "Lodge a complaint with a supervisory authority" - } + li { "Lodge a complaint with a supervisory authority" } } h2 { "7. Contact" } -- 2.49.1 From 661be22e824acf2bbb2ee0ffa25c84ceead6a32b Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Thu, 19 Feb 2026 12:03:11 +0100 Subject: [PATCH 7/8] 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(), + }, + ] +} -- 2.49.1 From 83772cc256512d4d9eba877789b9066a8ba80219 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Thu, 19 Feb 2026 12:20:41 +0100 Subject: [PATCH 8/8] fix(fmt): ran dx fmt --- .gitignore | 2 ++ src/components/news_card.rs | 13 +++++++++++-- src/components/sub_nav.rs | 12 ++++++------ src/components/tool_card.rs | 6 +++++- src/pages/chat.rs | 4 +--- src/pages/dashboard.rs | 8 ++------ src/pages/developer/analytics.rs | 7 +------ src/pages/organization/dashboard.rs | 17 +++++------------ src/pages/providers.rs | 26 ++++++++++---------------- src/pages/tools.rs | 6 +----- 10 files changed, 44 insertions(+), 57 deletions(-) diff --git a/.gitignore b/.gitignore index 9d42943..3e7e175 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,5 @@ keycloak/* # Node modules node_modules/ + +searxng/ diff --git a/src/components/news_card.rs b/src/components/news_card.rs index 41ff41b..8642049 100644 --- a/src/components/news_card.rs +++ b/src/components/news_card.rs @@ -14,7 +14,11 @@ pub fn NewsCardView(card: NewsCardModel) -> Element { 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" } + img { + src: "{thumb}", + alt: "{card.title}", + loading: "lazy", + } } } div { class: "news-card-body", @@ -24,7 +28,12 @@ pub fn NewsCardView(card: NewsCardModel) -> Element { span { class: "news-card-date", "{card.published_at}" } } h3 { class: "news-card-title", - a { href: "{card.url}", target: "_blank", rel: "noopener", "{card.title}" } + a { + href: "{card.url}", + target: "_blank", + rel: "noopener", + "{card.title}" + } } p { class: "news-card-summary", "{card.summary}" } } diff --git a/src/components/sub_nav.rs b/src/components/sub_nav.rs index 37d09a3..2ddfc03 100644 --- a/src/components/sub_nav.rs +++ b/src/components/sub_nav.rs @@ -29,13 +29,13 @@ pub fn SubNav(items: Vec) -> Element { 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" }; + 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}" - } + Link { class: "{class}", to: item.route.clone(), "{item.label}" } } } } diff --git a/src/components/tool_card.rs b/src/components/tool_card.rs index 21e9ce3..0383083 100644 --- a/src/components/tool_card.rs +++ b/src/components/tool_card.rs @@ -32,7 +32,11 @@ pub fn ToolCard(tool: McpTool, on_toggle: EventHandler) -> Element { let id = tool.id.clone(); move |_| on_toggle.call(id.clone()) }, - if tool.enabled { "ON" } else { "OFF" } + if tool.enabled { + "ON" + } else { + "OFF" + } } } } diff --git a/src/pages/chat.rs b/src/pages/chat.rs index 1173cd3..d85bf25 100644 --- a/src/pages/chat.rs +++ b/src/pages/chat.rs @@ -37,9 +37,7 @@ pub fn ChatPage() -> Element { }; let id = session.id.clone(); rsx! { - button { - class: "{class}", - onclick: move |_| active_session_id.set(id.clone()), + 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}" } } diff --git a/src/pages/dashboard.rs b/src/pages/dashboard.rs index 605a7bc..9a25c91 100644 --- a/src/pages/dashboard.rs +++ b/src/pages/dashboard.rs @@ -43,7 +43,7 @@ pub fn DashboardPage() -> Element { subtitle: "AI news and updates".to_string(), } div { class: "dashboard-filters", - for (label, cat) in categories { + for (label , cat) in categories { { let is_active = *active_filter.read() == cat; let class = if is_active { @@ -52,11 +52,7 @@ pub fn DashboardPage() -> Element { "filter-tab" }; rsx! { - button { - class: "{class}", - onclick: move |_| active_filter.set(cat.clone()), - "{label}" - } + button { class: "{class}", onclick: move |_| active_filter.set(cat.clone()), "{label}" } } } } diff --git a/src/pages/developer/analytics.rs b/src/pages/developer/analytics.rs index 9e26717..39e9154 100644 --- a/src/pages/developer/analytics.rs +++ b/src/pages/developer/analytics.rs @@ -17,12 +17,7 @@ pub fn AnalyticsPage() -> Element { 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" - }, + 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}%" } } diff --git a/src/pages/organization/dashboard.rs b/src/pages/organization/dashboard.rs index d2b7abb..eadf6af 100644 --- a/src/pages/organization/dashboard.rs +++ b/src/pages/organization/dashboard.rs @@ -26,27 +26,19 @@ pub fn OrgDashboardPage() -> Element { 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" - } + 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-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" - } + span { class: "org-stat-label", "of {tokens_limit_display} tokens" } } div { class: "org-stat", span { class: "org-stat-value", "{usage.billing_cycle_end}" } @@ -79,7 +71,8 @@ pub fn OrgDashboardPage() -> Element { // Invite modal if *show_invite.read() { - div { class: "modal-overlay", + div { + class: "modal-overlay", onclick: move |_| show_invite.set(false), div { class: "modal-content", diff --git a/src/pages/providers.rs b/src/pages/providers.rs index c8419f3..9167e01 100644 --- a/src/pages/providers.rs +++ b/src/pages/providers.rs @@ -76,9 +76,7 @@ pub fn ProvidersPage() -> Element { saved.set(false); }, for m in &available_models { - option { value: "{m.id}", - "{m.name} ({m.context_window}k ctx)" - } + option { value: "{m.id}", "{m.name} ({m.context_window}k ctx)" } } } } @@ -92,9 +90,7 @@ pub fn ProvidersPage() -> Element { saved.set(false); }, for e in &available_embeddings { - option { value: "{e.id}", - "{e.name} ({e.dimensions}d)" - } + option { value: "{e.id}", "{e.name} ({e.dimensions}d)" } } } } @@ -125,26 +121,24 @@ pub fn ProvidersPage() -> Element { div { class: "status-card", div { class: "status-row", span { class: "status-label", "Provider" } - span { class: "status-value", - "{active_config.provider.label()}" - } + 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}" - } + 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}" - } + 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" } + if active_config.api_key_set { + "Set" + } else { + "Not set" + } } } } diff --git a/src/pages/tools.rs b/src/pages/tools.rs index bda66bb..56dc95b 100644 --- a/src/pages/tools.rs +++ b/src/pages/tools.rs @@ -30,11 +30,7 @@ pub fn ToolsPage() -> Element { } div { class: "tools-grid", for tool in tool_list { - ToolCard { - key: "{tool.id}", - tool, - on_toggle, - } + ToolCard { key: "{tool.id}", tool, on_toggle } } } } -- 2.49.1