import io import mimetypes import os import tempfile from html import escape from pathlib import Path from string import Template import PyPDF2 import requests import streamlit as st from dotenv import load_dotenv load_dotenv() # LlamaIndex imports for RAG retrieval try: from llama_index.core import Settings, StorageContext, load_index_from_storage from llama_index.embeddings.huggingface import HuggingFaceEmbedding LLAMA_INDEX_AVAILABLE = True except ImportError: LLAMA_INDEX_AVAILABLE = False # GitHub repo that hosts study materials via Releases + manifest.json # Format: "owner/repo" MATERIALS_REPO = os.getenv("MATERIALS_REPO", "KunalGupta25/plexi-materials") MANIFEST_BRANCH = "main" THEME_MODE_STATE_KEY = "plexi_theme_mode" THEME_MODE_WIDGET_KEY = "_plexi_theme_mode_widget" APP_ICON_PATH = str(Path(__file__).resolve().with_name("plexi-mcp-square-logo.svg")) LIGHT_PALETTE = { "ink": "#16312c", "muted": "#5b6c66", "bg": "#f5f0e8", "panel": "rgba(255, 252, 247, 0.88)", "panel_strong": "#fffaf1", "line": "rgba(22, 49, 44, 0.11)", "accent": "#1d7a63", "accent_soft": "#d7efe4", "highlight": "#f4b860", "shadow": "0 18px 60px rgba(30, 48, 43, 0.08)", "app_background": """ radial-gradient(circle at top left, rgba(244, 184, 96, 0.18), transparent 28%), radial-gradient(circle at top right, rgba(29, 122, 99, 0.14), transparent 30%), linear-gradient(180deg, #fbf7ef 0%, #f4ecde 100%) """, "hero_background": """ linear-gradient(135deg, rgba(29, 122, 99, 0.08), rgba(255, 250, 241, 0.92)), rgba(255, 252, 247, 0.88) """, "chip_background": "rgba(29, 122, 99, 0.08)", "chip_border": "rgba(29, 122, 99, 0.12)", "button_border": "rgba(29, 122, 99, 0.14)", "button_surface": "#f8fbfa", "button_hover": "#eef7f2", "primary_button": "linear-gradient(135deg, #1d7a63, #245e74)", "sidebar_background": """ linear-gradient(180deg, rgba(255, 251, 245, 0.98), rgba(246, 238, 224, 0.96)) """, "expander_background": "rgba(255, 251, 245, 0.72)", "meta_background": "rgba(255, 251, 245, 0.72)", "divider": "linear-gradient(90deg, rgba(29, 122, 99, 0.25), transparent)", "meta_row_border": "rgba(22, 49, 44, 0.08)", "bottom_background": "#fbf7ef", } DARK_PALETTE = { "ink": "#eef4ef", "muted": "#b8c6c0", "bg": "#0d1715", "panel": "rgba(20, 31, 29, 0.9)", "panel_strong": "#15211f", "line": "rgba(196, 223, 211, 0.14)", "accent": "#54c6a2", "accent_soft": "#17392f", "highlight": "#f0b564", "shadow": "0 22px 70px rgba(0, 0, 0, 0.32)", "app_background": """ radial-gradient(circle at top left, rgba(240, 181, 100, 0.12), transparent 28%), radial-gradient(circle at top right, rgba(84, 198, 162, 0.12), transparent 32%), linear-gradient(180deg, #0f1b19 0%, #09110f 100%) """, "hero_background": """ linear-gradient(135deg, rgba(84, 198, 162, 0.12), rgba(16, 28, 25, 0.92)), rgba(20, 31, 29, 0.9) """, "chip_background": "rgba(84, 198, 162, 0.12)", "chip_border": "rgba(84, 198, 162, 0.18)", "button_border": "rgba(84, 198, 162, 0.18)", "button_surface": "rgba(84, 198, 162, 0.14)", "button_hover": "rgba(84, 198, 162, 0.22)", "primary_button": "linear-gradient(135deg, #2ea483, #245e74)", "sidebar_background": """ linear-gradient(180deg, rgba(17, 28, 26, 0.98), rgba(12, 20, 18, 0.97)) """, "expander_background": "rgba(17, 28, 26, 0.84)", "meta_background": "rgba(19, 31, 28, 0.84)", "divider": "linear-gradient(90deg, rgba(84, 198, 162, 0.32), transparent)", "meta_row_border": "rgba(196, 223, 211, 0.1)", "bottom_background": "#09110f", } def get_theme_mode(): """Return the selected appearance mode.""" if THEME_MODE_STATE_KEY not in st.session_state: st.session_state[THEME_MODE_STATE_KEY] = "system" return st.session_state[THEME_MODE_STATE_KEY] def sync_theme_mode(): """Persist the appearance selector value across page switches.""" st.session_state[THEME_MODE_STATE_KEY] = st.session_state.get( THEME_MODE_WIDGET_KEY, "System" ).lower() def _css_vars_block(palette): """Return CSS custom property definitions for a palette.""" return "\n".join( [ f" --plexi-ink: {palette['ink']};", f" --plexi-muted: {palette['muted']};", f" --plexi-bg: {palette['bg']};", f" --plexi-panel: {palette['panel']};", f" --plexi-panel-strong: {palette['panel_strong']};", f" --plexi-line: {palette['line']};", f" --plexi-accent: {palette['accent']};", f" --plexi-accent-soft: {palette['accent_soft']};", f" --plexi-highlight: {palette['highlight']};", f" --plexi-shadow: {palette['shadow']};", f" --plexi-app-background: {palette['app_background']};", f" --plexi-hero-background: {palette['hero_background']};", f" --plexi-chip-background: {palette['chip_background']};", f" --plexi-chip-border: {palette['chip_border']};", f" --plexi-button-border: {palette['button_border']};", f" --plexi-button-surface: {palette['button_surface']};", f" --plexi-button-hover: {palette['button_hover']};", f" --plexi-primary-button: {palette['primary_button']};", f" --plexi-sidebar-background: {palette['sidebar_background']};", f" --plexi-expander-background: {palette['expander_background']};", f" --plexi-meta-background: {palette['meta_background']};", f" --plexi-divider: {palette['divider']};", f" --plexi-meta-row-border: {palette['meta_row_border']};", f" --plexi-bottom-background: {palette['bottom_background']};", ] ) def inject_theme(): """Inject the shared visual language for the Streamlit app.""" theme_mode = get_theme_mode() palette = DARK_PALETTE if theme_mode == "dark" else LIGHT_PALETTE system_css = "" color_scheme = "dark" if theme_mode == "dark" else "light" if theme_mode == "system": system_css = f""" @media (prefers-color-scheme: dark) {{ :root {{ {_css_vars_block(DARK_PALETTE)} }} html {{ color-scheme: dark; }} }} """ css = Template( """ """ ).substitute( { "palette_vars": _css_vars_block(palette), "color_scheme": color_scheme, "system_css": system_css, } ) st.markdown(css, unsafe_allow_html=True) def summarize_manifest(manifest): """Return top-level counts for the materials catalog.""" subject_total = sum(len(subjects) for subjects in manifest.values()) file_total = sum( len(files) for subjects in manifest.values() for types in subjects.values() for files in types.values() ) material_types = sorted( { material_type for subjects in manifest.values() for types in subjects.values() for material_type in types.keys() } ) return { "semester_count": len(manifest), "subject_count": subject_total, "file_count": file_total, "material_types": material_types, } def summarize_subject_catalog(subject_data): """Return counts for one selected subject catalog.""" return { "type_count": len(subject_data), "file_count": sum(len(files) for files in subject_data.values()), "types": sorted(subject_data.keys()), } def render_page_header(kicker, title, subtitle, badges=None): """Render a shared hero block for each page.""" badge_html = "" if badges: badge_html = "".join( f'{escape(str(badge))}' for badge in badges if badge ) badge_html = f'
{escape(subtitle)}
{badge_html}