Add Suite app documentation, templates, and Askama config

- Add askama.toml for template configuration (ui/ directory)
- Add Suite app documentation with flow diagrams (SVG)
  - App launcher, chat flow, drive flow, tasks flow
  - Individual app docs: chat, drive, tasks, mail, etc.
- Add HTML templates for Suite apps
  - Base template with header and app launcher
  - Auth login page
  - Chat, Drive, Mail, Meet, Tasks templates
  - Partial templates for messages, sessions, notifications
- Add Extensions type to AppState for type-erased storage
- Add mTLS module for service-to-service authentication
- Update web handlers to use new template paths (suite/)
- Fix auth module to avoid axum-extra TypedHeader dependency
This commit is contained in:
Rodrigo Rodriguez (Pragmatismo) 2025-11-30 21:00:48 -03:00
parent 36d5f3838c
commit e68a12176d
42 changed files with 8507 additions and 51 deletions

9
askama.toml Normal file
View file

@ -0,0 +1,9 @@
[general]
# Configure Askama to look for templates in ui/ directory
dirs = ["ui"]
# Enable syntax highlighting hints for editors
syntax = [{ name = "html", ext = ["html"] }]
# Escape HTML by default for security
escape = "html"

View file

@ -45,6 +45,19 @@
- [Player - Media Viewer](./chapter-04-gbui/player.md)
- [Monitoring Dashboard](./chapter-04-gbui/monitoring.md)
- [HTMX Architecture](./chapter-04-gbui/htmx-architecture.md)
- [Suite Applications](./chapter-04-gbui/apps/README.md)
- [Chat - AI Assistant](./chapter-04-gbui/apps/chat.md)
- [Drive - File Management](./chapter-04-gbui/apps/drive.md)
- [Tasks - To-Do Lists](./chapter-04-gbui/apps/tasks.md)
- [Mail - Email Client](./chapter-04-gbui/apps/mail.md)
- [Calendar - Scheduling](./chapter-04-gbui/apps/calendar.md)
- [Meet - Video Calls](./chapter-04-gbui/apps/meet.md)
- [Paper - AI Writing](./chapter-04-gbui/apps/paper.md)
- [Research - AI Search](./chapter-04-gbui/apps/research.md)
- [Analytics - Dashboards](./chapter-04-gbui/apps/analytics.md)
- [Designer - Visual Builder](./chapter-04-gbui/apps/designer.md)
- [Sources - Prompts & Templates](./chapter-04-gbui/apps/sources.md)
- [Compliance - Security Scanner](./chapter-04-gbui/apps/compliance.md)
# Part V - Themes and Styling

View file

@ -0,0 +1,214 @@
<svg width="1400" height="900" xmlns="http://www.w3.org/2000/svg">
<style>
/* Light theme defaults */
.neon-blue { stroke: #4A90E2; stroke-width: 2.6; }
.neon-orange { stroke: #F5A623; stroke-width: 2.6; }
.neon-purple { stroke: #BD10E0; stroke-width: 2.6; }
.neon-green { stroke: #7ED321; stroke-width: 2.6; }
.neon-cyan { stroke: #50E3C2; stroke-width: 2.6; }
.neon-red { stroke: #E74C3C; stroke-width: 2.6; }
.neon-pink { stroke: #FF6B9D; stroke-width: 2.6; }
.neon-yellow { stroke: #F1C40F; stroke-width: 2.6; }
.main-text { fill: #1a1a1a; }
.secondary-text { fill: #666; }
.icon-fill { fill: #666; }
.card-bg { fill: #ffffff; }
.launcher-bg { fill: #f8f9fa; }
@media (prefers-color-scheme: dark) {
.neon-blue {
stroke: #00D4FF;
stroke-width: 2.8;
filter: drop-shadow(0 0 4px #00D4FF) drop-shadow(0 0 8px #00A0FF);
}
.neon-orange {
stroke: #FF9500;
stroke-width: 2.8;
filter: drop-shadow(0 0 4px #FF9500) drop-shadow(0 0 8px #FF7700);
}
.neon-purple {
stroke: #E040FB;
stroke-width: 2.8;
filter: drop-shadow(0 0 4px #E040FB) drop-shadow(0 0 8px #D500F9);
}
.neon-green {
stroke: #00FF88;
stroke-width: 2.8;
filter: drop-shadow(0 0 4px #00FF88) drop-shadow(0 0 8px #00E676);
}
.neon-cyan {
stroke: #00E5EA;
stroke-width: 2.8;
filter: drop-shadow(0 0 4px #00E5EA) drop-shadow(0 0 8px #00BCD4);
}
.neon-red {
stroke: #FF6B6B;
stroke-width: 2.8;
filter: drop-shadow(0 0 4px #FF6B6B) drop-shadow(0 0 8px #FF5252);
}
.neon-pink {
stroke: #FF6B9D;
stroke-width: 2.8;
filter: drop-shadow(0 0 4px #FF6B9D) drop-shadow(0 0 8px #FF4081);
}
.neon-yellow {
stroke: #FFD93D;
stroke-width: 2.8;
filter: drop-shadow(0 0 4px #FFD93D) drop-shadow(0 0 8px #FFC107);
}
.main-text { fill: #FFFFFF; }
.secondary-text { fill: #B0B0B0; }
.icon-fill { fill: #B0B0B0; }
.card-bg { fill: #1e1e1e; }
.launcher-bg { fill: #121212; }
}
</style>
<defs>
<filter id="card-shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="2" stdDeviation="4" flood-opacity="0.1"/>
</filter>
</defs>
<!-- Title -->
<text x="700" y="50" text-anchor="middle" font-family="Arial, sans-serif" font-size="36" font-weight="600" class="main-text">General Bots Suite</text>
<text x="700" y="85" text-anchor="middle" font-family="Arial, sans-serif" font-size="20" class="secondary-text">Your AI-Powered Productivity Workspace</text>
<!-- App Launcher Grid - 3x3 layout -->
<g id="app-grid" transform="translate(250, 130)">
<!-- Row 1 -->
<!-- Chat -->
<g transform="translate(0, 0)">
<rect x="0" y="0" width="260" height="180" rx="16" class="card-bg" filter="url(#card-shadow)" stroke="none"/>
<rect x="0" y="0" width="260" height="180" rx="16" fill="none" class="neon-blue"/>
<circle cx="130" cy="70" r="35" fill="none" class="neon-blue"/>
<path d="M115 55 L145 55 C150 55 155 60 155 65 L155 80 C155 85 150 90 145 90 L125 90 L115 100 L115 90 L115 65 C115 60 120 55 125 55" fill="none" class="neon-blue" stroke-width="2.5"/>
<text x="130" y="135" text-anchor="middle" font-family="Arial, sans-serif" font-size="22" font-weight="600" class="main-text">Chat</text>
<text x="130" y="160" text-anchor="middle" font-family="Arial, sans-serif" font-size="14" class="secondary-text">AI Assistant</text>
</g>
<!-- Drive -->
<g transform="translate(300, 0)">
<rect x="0" y="0" width="260" height="180" rx="16" class="card-bg" filter="url(#card-shadow)" stroke="none"/>
<rect x="0" y="0" width="260" height="180" rx="16" fill="none" class="neon-orange"/>
<circle cx="130" cy="70" r="35" fill="none" class="neon-orange"/>
<path d="M110 55 L150 55 L150 50 L155 50 L155 90 L105 90 L105 60 L110 55 Z" fill="none" class="neon-orange" stroke-width="2.5"/>
<line x1="105" y1="70" x2="155" y2="70" class="neon-orange" stroke-width="2"/>
<text x="130" y="135" text-anchor="middle" font-family="Arial, sans-serif" font-size="22" font-weight="600" class="main-text">Drive</text>
<text x="130" y="160" text-anchor="middle" font-family="Arial, sans-serif" font-size="14" class="secondary-text">File Storage</text>
</g>
<!-- Tasks -->
<g transform="translate(600, 0)">
<rect x="0" y="0" width="260" height="180" rx="16" class="card-bg" filter="url(#card-shadow)" stroke="none"/>
<rect x="0" y="0" width="260" height="180" rx="16" fill="none" class="neon-green"/>
<circle cx="130" cy="70" r="35" fill="none" class="neon-green"/>
<path d="M115 60 L125 70 L145 50" fill="none" class="neon-green" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="115" y1="80" x2="145" y2="80" class="neon-green" stroke-width="2.5"/>
<line x1="115" y1="90" x2="140" y2="90" class="neon-green" stroke-width="2.5"/>
<text x="130" y="135" text-anchor="middle" font-family="Arial, sans-serif" font-size="22" font-weight="600" class="main-text">Tasks</text>
<text x="130" y="160" text-anchor="middle" font-family="Arial, sans-serif" font-size="14" class="secondary-text">To-Do Lists</text>
</g>
<!-- Row 2 -->
<!-- Mail -->
<g transform="translate(0, 220)">
<rect x="0" y="0" width="260" height="180" rx="16" class="card-bg" filter="url(#card-shadow)" stroke="none"/>
<rect x="0" y="0" width="260" height="180" rx="16" fill="none" class="neon-red"/>
<circle cx="130" cy="70" r="35" fill="none" class="neon-red"/>
<rect x="105" y="55" width="50" height="35" rx="3" fill="none" class="neon-red" stroke-width="2.5"/>
<polyline points="105,58 130,75 155,58" fill="none" class="neon-red" stroke-width="2.5"/>
<text x="130" y="135" text-anchor="middle" font-family="Arial, sans-serif" font-size="22" font-weight="600" class="main-text">Mail</text>
<text x="130" y="160" text-anchor="middle" font-family="Arial, sans-serif" font-size="14" class="secondary-text">Email Client</text>
</g>
<!-- Calendar -->
<g transform="translate(300, 220)">
<rect x="0" y="0" width="260" height="180" rx="16" class="card-bg" filter="url(#card-shadow)" stroke="none"/>
<rect x="0" y="0" width="260" height="180" rx="16" fill="none" class="neon-purple"/>
<circle cx="130" cy="70" r="35" fill="none" class="neon-purple"/>
<rect x="108" y="50" width="44" height="40" rx="4" fill="none" class="neon-purple" stroke-width="2.5"/>
<line x1="108" y1="62" x2="152" y2="62" class="neon-purple" stroke-width="2"/>
<line x1="118" y1="45" x2="118" y2="55" class="neon-purple" stroke-width="2.5"/>
<line x1="142" y1="45" x2="142" y2="55" class="neon-purple" stroke-width="2.5"/>
<text x="130" y="135" text-anchor="middle" font-family="Arial, sans-serif" font-size="22" font-weight="600" class="main-text">Calendar</text>
<text x="130" y="160" text-anchor="middle" font-family="Arial, sans-serif" font-size="14" class="secondary-text">Scheduling</text>
</g>
<!-- Meet -->
<g transform="translate(600, 220)">
<rect x="0" y="0" width="260" height="180" rx="16" class="card-bg" filter="url(#card-shadow)" stroke="none"/>
<rect x="0" y="0" width="260" height="180" rx="16" fill="none" class="neon-cyan"/>
<circle cx="130" cy="70" r="35" fill="none" class="neon-cyan"/>
<rect x="108" y="52" width="35" height="28" rx="4" fill="none" class="neon-cyan" stroke-width="2.5"/>
<polygon points="143,58 155,52 155,88 143,82" fill="none" class="neon-cyan" stroke-width="2.5"/>
<text x="130" y="135" text-anchor="middle" font-family="Arial, sans-serif" font-size="22" font-weight="600" class="main-text">Meet</text>
<text x="130" y="160" text-anchor="middle" font-family="Arial, sans-serif" font-size="14" class="secondary-text">Video Calls</text>
</g>
<!-- Row 3 -->
<!-- Paper -->
<g transform="translate(0, 440)">
<rect x="0" y="0" width="260" height="180" rx="16" class="card-bg" filter="url(#card-shadow)" stroke="none"/>
<rect x="0" y="0" width="260" height="180" rx="16" fill="none" class="neon-yellow"/>
<circle cx="130" cy="70" r="35" fill="none" class="neon-yellow"/>
<rect x="112" y="48" width="36" height="44" rx="2" fill="none" class="neon-yellow" stroke-width="2.5"/>
<line x1="118" y1="60" x2="142" y2="60" class="neon-yellow" stroke-width="2"/>
<line x1="118" y1="70" x2="142" y2="70" class="neon-yellow" stroke-width="2"/>
<line x1="118" y1="80" x2="135" y2="80" class="neon-yellow" stroke-width="2"/>
<text x="130" y="135" text-anchor="middle" font-family="Arial, sans-serif" font-size="22" font-weight="600" class="main-text">Paper</text>
<text x="130" y="160" text-anchor="middle" font-family="Arial, sans-serif" font-size="14" class="secondary-text">AI Writing</text>
</g>
<!-- Research -->
<g transform="translate(300, 440)">
<rect x="0" y="0" width="260" height="180" rx="16" class="card-bg" filter="url(#card-shadow)" stroke="none"/>
<rect x="0" y="0" width="260" height="180" rx="16" fill="none" class="neon-pink"/>
<circle cx="130" cy="70" r="35" fill="none" class="neon-pink"/>
<circle cx="125" cy="65" r="18" fill="none" class="neon-pink" stroke-width="2.5"/>
<line x1="138" y1="78" x2="150" y2="90" class="neon-pink" stroke-width="3" stroke-linecap="round"/>
<text x="130" y="135" text-anchor="middle" font-family="Arial, sans-serif" font-size="22" font-weight="600" class="main-text">Research</text>
<text x="130" y="160" text-anchor="middle" font-family="Arial, sans-serif" font-size="14" class="secondary-text">AI Search</text>
</g>
<!-- Analytics -->
<g transform="translate(600, 440)">
<rect x="0" y="0" width="260" height="180" rx="16" class="card-bg" filter="url(#card-shadow)" stroke="none"/>
<rect x="0" y="0" width="260" height="180" rx="16" fill="none" class="neon-blue"/>
<circle cx="130" cy="70" r="35" fill="none" class="neon-blue"/>
<rect x="110" y="75" width="10" height="15" fill="none" class="neon-blue" stroke-width="2"/>
<rect x="125" y="60" width="10" height="30" fill="none" class="neon-blue" stroke-width="2"/>
<rect x="140" y="50" width="10" height="40" fill="none" class="neon-blue" stroke-width="2"/>
<text x="130" y="135" text-anchor="middle" font-family="Arial, sans-serif" font-size="22" font-weight="600" class="main-text">Analytics</text>
<text x="130" y="160" text-anchor="middle" font-family="Arial, sans-serif" font-size="14" class="secondary-text">Reports</text>
</g>
</g>
<!-- Bottom section - Additional Tools -->
<g transform="translate(0, 780)">
<text x="700" y="30" text-anchor="middle" font-family="Arial, sans-serif" font-size="16" class="secondary-text">Additional Tools</text>
<g transform="translate(350, 50)">
<rect x="0" y="0" width="140" height="50" rx="8" fill="none" class="neon-purple"/>
<text x="70" y="32" text-anchor="middle" font-family="Arial, sans-serif" font-size="16" font-weight="500" class="main-text">Designer</text>
</g>
<g transform="translate(520, 50)">
<rect x="0" y="0" width="140" height="50" rx="8" fill="none" class="neon-orange"/>
<text x="70" y="32" text-anchor="middle" font-family="Arial, sans-serif" font-size="16" font-weight="500" class="main-text">Sources</text>
</g>
<g transform="translate(690, 50)">
<rect x="0" y="0" width="140" height="50" rx="8" fill="none" class="neon-green"/>
<text x="70" y="32" text-anchor="middle" font-family="Arial, sans-serif" font-size="16" font-weight="500" class="main-text">Compliance</text>
</g>
<g transform="translate(860, 50)">
<rect x="0" y="0" width="140" height="50" rx="8" fill="none" class="neon-cyan"/>
<text x="70" y="32" text-anchor="middle" font-family="Arial, sans-serif" font-size="16" font-weight="500" class="main-text">Settings</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 12 KiB

View file

@ -0,0 +1,239 @@
<svg width="1400" height="900" xmlns="http://www.w3.org/2000/svg">
<style>
/* Light theme defaults */
.neon-blue { stroke: #4A90E2; stroke-width: 2.6; }
.neon-orange { stroke: #F5A623; stroke-width: 2.6; }
.neon-purple { stroke: #BD10E0; stroke-width: 2.6; }
.neon-green { stroke: #7ED321; stroke-width: 2.6; }
.neon-cyan { stroke: #50E3C2; stroke-width: 2.6; }
.main-text { fill: #1a1a1a; }
.secondary-text { fill: #666; }
.arrow-color { stroke: #666; fill: #666; }
@media (prefers-color-scheme: dark) {
.neon-blue {
stroke: #00D4FF;
stroke-width: 2.8;
filter: drop-shadow(0 0 4px #00D4FF) drop-shadow(0 0 8px #00A0FF);
}
.neon-orange {
stroke: #FF9500;
stroke-width: 2.8;
filter: drop-shadow(0 0 4px #FF9500) drop-shadow(0 0 8px #FF7700);
}
.neon-purple {
stroke: #E040FB;
stroke-width: 2.8;
filter: drop-shadow(0 0 4px #E040FB) drop-shadow(0 0 8px #D500F9);
}
.neon-green {
stroke: #00FF88;
stroke-width: 2.8;
filter: drop-shadow(0 0 4px #00FF88) drop-shadow(0 0 8px #00E676);
}
.neon-cyan {
stroke: #00E5EA;
stroke-width: 2.8;
filter: drop-shadow(0 0 4px #00E5EA) drop-shadow(0 0 8px #00BCD4);
}
.main-text { fill: #FFFFFF; }
.secondary-text { fill: #B0B0B0; }
.arrow-color { stroke: #B0B0B0; fill: #B0B0B0; }
}
</style>
<defs>
<marker id="arrow" markerWidth="13" markerHeight="13" refX="11.7" refY="3.9" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L0,7.8 L11.7,3.9 z" class="arrow-color"/>
</marker>
<linearGradient id="flowGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#4A90E2;stop-opacity:0.3" />
<stop offset="33%" style="stop-color:#F5A623;stop-opacity:0.3" />
<stop offset="66%" style="stop-color:#BD10E0;stop-opacity:0.3" />
<stop offset="100%" style="stop-color:#7ED321;stop-opacity:0.3" />
</linearGradient>
</defs>
<!-- Title -->
<text x="700" y="45" text-anchor="middle" font-family="Arial, sans-serif" font-size="32" font-weight="600" class="main-text">Chat - AI Assistant Flow</text>
<text x="700" y="80" text-anchor="middle" font-family="Arial, sans-serif" font-size="18" class="secondary-text">WebSocket-powered real-time conversation with your AI assistant</text>
<!-- Phase Labels -->
<text x="180" y="130" text-anchor="middle" font-family="Arial, sans-serif" font-size="18" font-weight="500" class="secondary-text">Input</text>
<text x="480" y="130" text-anchor="middle" font-family="Arial, sans-serif" font-size="18" font-weight="500" class="secondary-text">Transport</text>
<text x="780" y="130" text-anchor="middle" font-family="Arial, sans-serif" font-size="18" font-weight="500" class="secondary-text">Processing</text>
<text x="1100" y="130" text-anchor="middle" font-family="Arial, sans-serif" font-size="18" font-weight="500" class="secondary-text">Response</text>
<!-- MAIN FLOW DIAGRAM -->
<g id="main-flow">
<!-- User Input Section -->
<g transform="translate(80, 160)">
<rect x="0" y="0" width="200" height="70" rx="6.5" fill="none" class="neon-blue"/>
<text x="100" y="30" text-anchor="middle" font-family="Arial, sans-serif" font-size="20" font-weight="500" class="main-text">User Message</text>
<text x="100" y="52" text-anchor="middle" font-family="Arial, sans-serif" font-size="14" class="secondary-text">Text or Voice Input</text>
</g>
<!-- Voice Input (parallel) -->
<g transform="translate(80, 260)">
<rect x="0" y="0" width="200" height="60" rx="6.5" fill="none" class="neon-blue" stroke-dasharray="3.9,3.9"/>
<text x="100" y="25" text-anchor="middle" font-family="Arial, sans-serif" font-size="18" font-weight="500" class="main-text">Voice Input</text>
<text x="100" y="45" text-anchor="middle" font-family="Arial, sans-serif" font-size="13" class="secondary-text">Speech-to-Text</text>
</g>
<!-- Suggestions (parallel) -->
<g transform="translate(80, 350)">
<rect x="0" y="0" width="200" height="60" rx="6.5" fill="none" class="neon-blue" stroke-dasharray="3.9,3.9"/>
<text x="100" y="25" text-anchor="middle" font-family="Arial, sans-serif" font-size="18" font-weight="500" class="main-text">Quick Suggestions</text>
<text x="100" y="45" text-anchor="middle" font-family="Arial, sans-serif" font-size="13" class="secondary-text">One-click actions</text>
</g>
<!-- WebSocket -->
<g transform="translate(380, 160)">
<rect x="0" y="0" width="200" height="70" rx="6.5" fill="none" class="neon-orange"/>
<text x="100" y="30" text-anchor="middle" font-family="Arial, sans-serif" font-size="20" font-weight="500" class="main-text">WebSocket</text>
<text x="100" y="52" text-anchor="middle" font-family="Arial, sans-serif" font-size="14" class="secondary-text">Real-time Connection</text>
</g>
<!-- HTMX POST (alternative) -->
<g transform="translate(380, 260)">
<rect x="0" y="0" width="200" height="60" rx="6.5" fill="none" class="neon-orange" stroke-dasharray="3.9,3.9"/>
<text x="100" y="25" text-anchor="middle" font-family="Arial, sans-serif" font-size="18" font-weight="500" class="main-text">HTMX POST</text>
<text x="100" y="45" text-anchor="middle" font-family="Arial, sans-serif" font-size="13" class="secondary-text">Fallback Transport</text>
</g>
<!-- AI Processing -->
<g transform="translate(680, 160)">
<rect x="0" y="0" width="200" height="70" rx="6.5" fill="none" class="neon-purple"/>
<text x="100" y="30" text-anchor="middle" font-family="Arial, sans-serif" font-size="20" font-weight="500" class="main-text">AI Processing</text>
<text x="100" y="52" text-anchor="middle" font-family="Arial, sans-serif" font-size="14" class="secondary-text">LLM + Context</text>
</g>
<!-- Context/Memory -->
<g transform="translate(680, 260)">
<rect x="0" y="0" width="95" height="60" rx="6.5" fill="none" class="neon-purple"/>
<text x="47" y="25" text-anchor="middle" font-family="Arial, sans-serif" font-size="15" font-weight="500" class="main-text">Context</text>
<text x="47" y="45" text-anchor="middle" font-family="Arial, sans-serif" font-size="12" class="secondary-text">History</text>
</g>
<!-- Tools/KB -->
<g transform="translate(785, 260)">
<rect x="0" y="0" width="95" height="60" rx="6.5" fill="none" class="neon-purple"/>
<text x="47" y="25" text-anchor="middle" font-family="Arial, sans-serif" font-size="15" font-weight="500" class="main-text">Tools</text>
<text x="47" y="45" text-anchor="middle" font-family="Arial, sans-serif" font-size="12" class="secondary-text">KB + APIs</text>
</g>
<!-- Response -->
<g transform="translate(980, 160)">
<rect x="0" y="0" width="200" height="70" rx="6.5" fill="none" class="neon-green"/>
<text x="100" y="30" text-anchor="middle" font-family="Arial, sans-serif" font-size="20" font-weight="500" class="main-text">Bot Response</text>
<text x="100" y="52" text-anchor="middle" font-family="Arial, sans-serif" font-size="14" class="secondary-text">Streamed to UI</text>
</g>
<!-- Message Display -->
<g transform="translate(980, 260)">
<rect x="0" y="0" width="200" height="60" rx="6.5" fill="none" class="neon-cyan"/>
<text x="100" y="25" text-anchor="middle" font-family="Arial, sans-serif" font-size="18" font-weight="500" class="main-text">Message Display</text>
<text x="100" y="45" text-anchor="middle" font-family="Arial, sans-serif" font-size="13" class="secondary-text">Markdown Rendered</text>
</g>
<!-- Scroll/History -->
<g transform="translate(980, 350)">
<rect x="0" y="0" width="200" height="60" rx="6.5" fill="none" class="neon-cyan"/>
<text x="100" y="25" text-anchor="middle" font-family="Arial, sans-serif" font-size="18" font-weight="500" class="main-text">Chat History</text>
<text x="100" y="45" text-anchor="middle" font-family="Arial, sans-serif" font-size="13" class="secondary-text">Scroll + Load More</text>
</g>
<!-- Arrows - Main Flow -->
<line x1="280" y1="195" x2="375" y2="195" class="arrow-color" stroke-width="2.6" marker-end="url(#arrow)" opacity="0.7"/>
<line x1="580" y1="195" x2="675" y2="195" class="arrow-color" stroke-width="2.6" marker-end="url(#arrow)" opacity="0.7"/>
<line x1="880" y1="195" x2="975" y2="195" class="arrow-color" stroke-width="2.6" marker-end="url(#arrow)" opacity="0.7"/>
<!-- Arrow from Voice to main -->
<path d="M280 290 Q320 290 320 210 L375 195" fill="none" class="arrow-color" stroke-width="2" stroke-dasharray="3.9,3.9" marker-end="url(#arrow)" opacity="0.5"/>
<!-- Arrow from Suggestions to main -->
<path d="M280 380 Q340 380 340 220 L375 195" fill="none" class="arrow-color" stroke-width="2" stroke-dasharray="3.9,3.9" marker-end="url(#arrow)" opacity="0.5"/>
<!-- Arrow from Context/Tools to AI -->
<line x1="728" y1="260" x2="728" y2="235" class="arrow-color" stroke-width="1.5" opacity="0.5"/>
<line x1="833" y1="260" x2="833" y2="235" class="arrow-color" stroke-width="1.5" opacity="0.5"/>
<!-- Arrow to Message Display -->
<line x1="1080" y1="230" x2="1080" y2="255" class="arrow-color" stroke-width="2" marker-end="url(#arrow)" opacity="0.5"/>
<!-- Arrow to History -->
<line x1="1080" y1="320" x2="1080" y2="345" class="arrow-color" stroke-width="2" marker-end="url(#arrow)" opacity="0.5"/>
<!-- Feedback loop - user continues conversation -->
<path d="M980 380 Q50 450 50 195 L75 195" fill="none" class="arrow-color" stroke-width="2" stroke-dasharray="5,5" marker-end="url(#arrow)" opacity="0.4"/>
<text x="500" y="475" text-anchor="middle" font-family="Arial, sans-serif" font-size="14" class="secondary-text" opacity="0.6">Conversation continues...</text>
</g>
<!-- PROGRESS INDICATOR -->
<g id="progress-legend" transform="translate(0, 520)">
<rect x="100" y="30" width="1200" height="80" fill="url(#flowGradient)" rx="10" opacity="0.2"/>
<!-- Stage markers -->
<circle cx="200" cy="70" r="12" class="neon-blue" fill="none" stroke-width="3"/>
<text x="200" y="75" text-anchor="middle" font-family="Arial, sans-serif" font-size="14" font-weight="bold" class="main-text">1</text>
<circle cx="500" cy="70" r="12" class="neon-orange" fill="none" stroke-width="3"/>
<text x="500" y="75" text-anchor="middle" font-family="Arial, sans-serif" font-size="14" font-weight="bold" class="main-text">2</text>
<circle cx="800" cy="70" r="12" class="neon-purple" fill="none" stroke-width="3"/>
<text x="800" y="75" text-anchor="middle" font-family="Arial, sans-serif" font-size="14" font-weight="bold" class="main-text">3</text>
<circle cx="1100" cy="70" r="12" class="neon-green" fill="none" stroke-width="3"/>
<text x="1100" y="75" text-anchor="middle" font-family="Arial, sans-serif" font-size="14" font-weight="bold" class="main-text">4</text>
<!-- Connecting lines -->
<line x1="212" y1="70" x2="488" y2="70" class="arrow-color" stroke-width="2" opacity="0.3"/>
<line x1="512" y1="70" x2="788" y2="70" class="arrow-color" stroke-width="2" opacity="0.3"/>
<line x1="812" y1="70" x2="1088" y2="70" class="arrow-color" stroke-width="2" opacity="0.3"/>
<!-- Stage labels -->
<text x="200" y="130" text-anchor="middle" font-family="Arial, sans-serif" font-size="16" font-weight="500" class="main-text">Type or Speak</text>
<text x="200" y="150" text-anchor="middle" font-family="Arial, sans-serif" font-size="13" class="secondary-text">Enter your message</text>
<text x="500" y="130" text-anchor="middle" font-family="Arial, sans-serif" font-size="16" font-weight="500" class="main-text">Send</text>
<text x="500" y="150" text-anchor="middle" font-family="Arial, sans-serif" font-size="13" class="secondary-text">WebSocket transmit</text>
<text x="800" y="130" text-anchor="middle" font-family="Arial, sans-serif" font-size="16" font-weight="500" class="main-text">Process</text>
<text x="800" y="150" text-anchor="middle" font-family="Arial, sans-serif" font-size="13" class="secondary-text">AI generates response</text>
<text x="1100" y="130" text-anchor="middle" font-family="Arial, sans-serif" font-size="16" font-weight="500" class="main-text">Display</text>
<text x="1100" y="150" text-anchor="middle" font-family="Arial, sans-serif" font-size="13" class="secondary-text">See bot reply</text>
</g>
<!-- Features Legend -->
<g transform="translate(100, 720)">
<text x="0" y="0" font-family="Arial, sans-serif" font-size="16" font-weight="600" class="main-text">Key Features:</text>
<rect x="0" y="20" width="16" height="16" rx="3" fill="none" class="neon-blue" stroke-width="2"/>
<text x="25" y="33" font-family="Arial, sans-serif" font-size="14" class="secondary-text">Voice input with speech-to-text</text>
<rect x="280" y="20" width="16" height="16" rx="3" fill="none" class="neon-orange" stroke-width="2"/>
<text x="305" y="33" font-family="Arial, sans-serif" font-size="14" class="secondary-text">Real-time WebSocket connection</text>
<rect x="580" y="20" width="16" height="16" rx="3" fill="none" class="neon-purple" stroke-width="2"/>
<text x="605" y="33" font-family="Arial, sans-serif" font-size="14" class="secondary-text">Context-aware AI responses</text>
<rect x="880" y="20" width="16" height="16" rx="3" fill="none" class="neon-green" stroke-width="2"/>
<text x="905" y="33" font-family="Arial, sans-serif" font-size="14" class="secondary-text">Markdown rendering</text>
</g>
<!-- Keyboard shortcuts -->
<g transform="translate(100, 780)">
<text x="0" y="0" font-family="Arial, sans-serif" font-size="16" font-weight="600" class="main-text">Shortcuts:</text>
<text x="100" y="0" font-family="Arial, sans-serif" font-size="14" class="secondary-text">Enter = Send | Shift+Enter = New line | ↑ = Edit last | / = Commands</text>
</g>
<!-- API Endpoints -->
<g transform="translate(100, 820)">
<text x="0" y="0" font-family="Arial, sans-serif" font-size="16" font-weight="600" class="main-text">Endpoints:</text>
<text x="100" y="0" font-family="monospace, sans-serif" font-size="13" class="secondary-text">/ws (WebSocket) | POST /api/sessions/current/message | GET /api/sessions/current/history | POST /api/voice/start</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -0,0 +1,269 @@
<svg width="1400" height="900" xmlns="http://www.w3.org/2000/svg">
<style>
/* Light theme defaults */
.neon-blue { stroke: #4A90E2; stroke-width: 2.6; }
.neon-orange { stroke: #F5A623; stroke-width: 2.6; }
.neon-purple { stroke: #BD10E0; stroke-width: 2.6; }
.neon-green { stroke: #7ED321; stroke-width: 2.6; }
.neon-cyan { stroke: #50E3C2; stroke-width: 2.6; }
.main-text { fill: #1a1a1a; }
.secondary-text { fill: #666; }
.arrow-color { stroke: #666; fill: #666; }
@media (prefers-color-scheme: dark) {
.neon-blue {
stroke: #00D4FF;
stroke-width: 2.8;
filter: drop-shadow(0 0 4px #00D4FF) drop-shadow(0 0 8px #00A0FF);
}
.neon-orange {
stroke: #FF9500;
stroke-width: 2.8;
filter: drop-shadow(0 0 4px #FF9500) drop-shadow(0 0 8px #FF7700);
}
.neon-purple {
stroke: #E040FB;
stroke-width: 2.8;
filter: drop-shadow(0 0 4px #E040FB) drop-shadow(0 0 8px #D500F9);
}
.neon-green {
stroke: #00FF88;
stroke-width: 2.8;
filter: drop-shadow(0 0 4px #00FF88) drop-shadow(0 0 8px #00E676);
}
.neon-cyan {
stroke: #00E5EA;
stroke-width: 2.8;
filter: drop-shadow(0 0 4px #00E5EA) drop-shadow(0 0 8px #00BCD4);
}
.main-text { fill: #FFFFFF; }
.secondary-text { fill: #B0B0B0; }
.arrow-color { stroke: #B0B0B0; fill: #B0B0B0; }
}
</style>
<defs>
<marker id="arrow" markerWidth="13" markerHeight="13" refX="11.7" refY="3.9" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L0,7.8 L11.7,3.9 z" class="arrow-color"/>
</marker>
<linearGradient id="flowGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#F5A623;stop-opacity:0.3" />
<stop offset="50%" style="stop-color:#BD10E0;stop-opacity:0.3" />
<stop offset="100%" style="stop-color:#7ED321;stop-opacity:0.3" />
</linearGradient>
</defs>
<!-- Title -->
<text x="700" y="45" text-anchor="middle" font-family="Arial, sans-serif" font-size="32" font-weight="600" class="main-text">Drive - File Management Flow</text>
<text x="700" y="80" text-anchor="middle" font-family="Arial, sans-serif" font-size="18" class="secondary-text">Cloud storage with drag-and-drop, sharing, and organization</text>
<!-- Phase Labels -->
<text x="180" y="130" text-anchor="middle" font-family="Arial, sans-serif" font-size="18" font-weight="500" class="secondary-text">Navigation</text>
<text x="500" y="130" text-anchor="middle" font-family="Arial, sans-serif" font-size="18" font-weight="500" class="secondary-text">Actions</text>
<text x="900" y="130" text-anchor="middle" font-family="Arial, sans-serif" font-size="18" font-weight="500" class="secondary-text">Storage</text>
<text x="1200" y="130" text-anchor="middle" font-family="Arial, sans-serif" font-size="18" font-weight="500" class="secondary-text">Display</text>
<!-- MAIN FLOW DIAGRAM -->
<g id="main-flow">
<!-- Sidebar Navigation -->
<g transform="translate(80, 160)">
<rect x="0" y="0" width="200" height="200" rx="6.5" fill="none" class="neon-orange"/>
<text x="100" y="30" text-anchor="middle" font-family="Arial, sans-serif" font-size="18" font-weight="600" class="main-text">Sidebar</text>
<line x1="20" y1="45" x2="180" y2="45" class="neon-orange" stroke-width="1" opacity="0.5"/>
<!-- Nav items -->
<rect x="20" y="60" width="160" height="28" rx="4" fill="none" class="neon-blue" stroke-width="1.5"/>
<text x="100" y="80" text-anchor="middle" font-family="Arial, sans-serif" font-size="14" class="main-text">My Drive</text>
<rect x="20" y="95" width="160" height="28" rx="4" fill="none" class="neon-orange" stroke-width="1.5" opacity="0.6"/>
<text x="100" y="115" text-anchor="middle" font-family="Arial, sans-serif" font-size="14" class="secondary-text">Starred</text>
<rect x="20" y="130" width="160" height="28" rx="4" fill="none" class="neon-orange" stroke-width="1.5" opacity="0.6"/>
<text x="100" y="150" text-anchor="middle" font-family="Arial, sans-serif" font-size="14" class="secondary-text">Recent</text>
<rect x="20" y="165" width="160" height="28" rx="4" fill="none" class="neon-orange" stroke-width="1.5" opacity="0.6"/>
<text x="100" y="185" text-anchor="middle" font-family="Arial, sans-serif" font-size="14" class="secondary-text">Trash</text>
</g>
<!-- Action Buttons -->
<g transform="translate(350, 160)">
<rect x="0" y="0" width="300" height="70" rx="6.5" fill="none" class="neon-purple"/>
<text x="150" y="30" text-anchor="middle" font-family="Arial, sans-serif" font-size="18" font-weight="500" class="main-text">+ New</text>
<text x="150" y="52" text-anchor="middle" font-family="Arial, sans-serif" font-size="14" class="secondary-text">Upload Files | New Folder</text>
</g>
<!-- File Operations Grid -->
<g transform="translate(350, 260)">
<!-- Upload -->
<rect x="0" y="0" width="140" height="55" rx="6.5" fill="none" class="neon-blue"/>
<text x="70" y="25" text-anchor="middle" font-family="Arial, sans-serif" font-size="15" font-weight="500" class="main-text">Upload</text>
<text x="70" y="43" text-anchor="middle" font-family="Arial, sans-serif" font-size="12" class="secondary-text">Drag & Drop</text>
<!-- Download -->
<rect x="155" y="0" width="140" height="55" rx="6.5" fill="none" class="neon-green"/>
<text x="225" y="25" text-anchor="middle" font-family="Arial, sans-serif" font-size="15" font-weight="500" class="main-text">Download</text>
<text x="225" y="43" text-anchor="middle" font-family="Arial, sans-serif" font-size="12" class="secondary-text">Single/Batch</text>
<!-- Rename -->
<rect x="0" y="70" width="140" height="55" rx="6.5" fill="none" class="neon-cyan"/>
<text x="70" y="95" text-anchor="middle" font-family="Arial, sans-serif" font-size="15" font-weight="500" class="main-text">Rename</text>
<text x="70" y="113" text-anchor="middle" font-family="Arial, sans-serif" font-size="12" class="secondary-text">Edit name</text>
<!-- Move/Copy -->
<rect x="155" y="70" width="140" height="55" rx="6.5" fill="none" class="neon-orange"/>
<text x="225" y="95" text-anchor="middle" font-family="Arial, sans-serif" font-size="15" font-weight="500" class="main-text">Move / Copy</text>
<text x="225" y="113" text-anchor="middle" font-family="Arial, sans-serif" font-size="12" class="secondary-text">Organize</text>
<!-- Share -->
<rect x="0" y="140" width="140" height="55" rx="6.5" fill="none" class="neon-purple"/>
<text x="70" y="165" text-anchor="middle" font-family="Arial, sans-serif" font-size="15" font-weight="500" class="main-text">Share</text>
<text x="70" y="183" text-anchor="middle" font-family="Arial, sans-serif" font-size="12" class="secondary-text">Get link</text>
<!-- Delete -->
<rect x="155" y="140" width="140" height="55" rx="6.5" fill="none" class="neon-blue" stroke-dasharray="3.9,3.9"/>
<text x="225" y="165" text-anchor="middle" font-family="Arial, sans-serif" font-size="15" font-weight="500" class="main-text">Delete</text>
<text x="225" y="183" text-anchor="middle" font-family="Arial, sans-serif" font-size="12" class="secondary-text">Move to Trash</text>
</g>
<!-- Storage Backend -->
<g transform="translate(750, 160)">
<rect x="0" y="0" width="200" height="70" rx="6.5" fill="none" class="neon-green"/>
<text x="100" y="30" text-anchor="middle" font-family="Arial, sans-serif" font-size="18" font-weight="500" class="main-text">SeaweedFS</text>
<text x="100" y="52" text-anchor="middle" font-family="Arial, sans-serif" font-size="14" class="secondary-text">Object Storage</text>
</g>
<!-- API Layer -->
<g transform="translate(750, 260)">
<rect x="0" y="0" width="200" height="60" rx="6.5" fill="none" class="neon-cyan"/>
<text x="100" y="25" text-anchor="middle" font-family="Arial, sans-serif" font-size="16" font-weight="500" class="main-text">REST API</text>
<text x="100" y="45" text-anchor="middle" font-family="Arial, sans-serif" font-size="13" class="secondary-text">/api/v1/drive/*</text>
</g>
<!-- Metadata -->
<g transform="translate(750, 345)">
<rect x="0" y="0" width="200" height="60" rx="6.5" fill="none" class="neon-purple"/>
<text x="100" y="25" text-anchor="middle" font-family="Arial, sans-serif" font-size="16" font-weight="500" class="main-text">PostgreSQL</text>
<text x="100" y="45" text-anchor="middle" font-family="Arial, sans-serif" font-size="13" class="secondary-text">File Metadata</text>
</g>
<!-- File Display -->
<g transform="translate(1050, 160)">
<rect x="0" y="0" width="250" height="250" rx="6.5" fill="none" class="neon-cyan"/>
<text x="125" y="30" text-anchor="middle" font-family="Arial, sans-serif" font-size="18" font-weight="600" class="main-text">File View</text>
<line x1="20" y1="45" x2="230" y2="45" class="neon-cyan" stroke-width="1" opacity="0.5"/>
<!-- View toggle -->
<rect x="20" y="55" width="60" height="30" rx="4" fill="none" class="neon-blue" stroke-width="1.5"/>
<text x="50" y="75" text-anchor="middle" font-family="Arial, sans-serif" font-size="13" class="main-text">Grid</text>
<rect x="90" y="55" width="60" height="30" rx="4" fill="none" class="neon-cyan" stroke-width="1.5" opacity="0.6"/>
<text x="120" y="75" text-anchor="middle" font-family="Arial, sans-serif" font-size="13" class="secondary-text">List</text>
<!-- Breadcrumb -->
<text x="20" y="110" font-family="Arial, sans-serif" font-size="12" class="secondary-text">My Drive / Projects / 2024</text>
<!-- File grid preview -->
<rect x="20" y="125" width="65" height="55" rx="4" fill="none" class="neon-orange" stroke-width="1.5"/>
<text x="52" y="160" text-anchor="middle" font-family="Arial, sans-serif" font-size="11" class="secondary-text">📁 Docs</text>
<rect x="95" y="125" width="65" height="55" rx="4" fill="none" class="neon-blue" stroke-width="1.5"/>
<text x="127" y="160" text-anchor="middle" font-family="Arial, sans-serif" font-size="11" class="secondary-text">📄 Report</text>
<rect x="170" y="125" width="65" height="55" rx="4" fill="none" class="neon-green" stroke-width="1.5"/>
<text x="202" y="160" text-anchor="middle" font-family="Arial, sans-serif" font-size="11" class="secondary-text">🖼 Image</text>
<!-- Storage bar -->
<text x="20" y="210" font-family="Arial, sans-serif" font-size="12" class="secondary-text">Storage Used</text>
<rect x="20" y="220" width="210" height="10" rx="5" fill="none" class="neon-cyan" stroke-width="1"/>
<rect x="22" y="222" width="84" height="6" rx="3" fill="#7ED321" opacity="0.7"/>
<text x="130" y="235" font-family="Arial, sans-serif" font-size="11" class="secondary-text">4.2 GB of 10 GB</text>
</g>
<!-- Arrows -->
<line x1="280" y1="260" x2="345" y2="260" class="arrow-color" stroke-width="2.6" marker-end="url(#arrow)" opacity="0.7"/>
<line x1="650" y1="290" x2="745" y2="250" class="arrow-color" stroke-width="2.6" marker-end="url(#arrow)" opacity="0.7"/>
<line x1="950" y1="195" x2="1045" y2="195" class="arrow-color" stroke-width="2.6" marker-end="url(#arrow)" opacity="0.7"/>
<!-- API to Storage -->
<line x1="850" y1="260" x2="850" y2="235" class="arrow-color" stroke-width="1.5" opacity="0.5"/>
<!-- API to Metadata -->
<line x1="850" y1="320" x2="850" y2="340" class="arrow-color" stroke-width="1.5" opacity="0.5"/>
</g>
<!-- PROGRESS INDICATOR -->
<g id="progress-legend" transform="translate(0, 470)">
<rect x="100" y="30" width="1200" height="80" fill="url(#flowGradient)" rx="10" opacity="0.2"/>
<!-- Stage markers -->
<circle cx="200" cy="70" r="12" class="neon-orange" fill="none" stroke-width="3"/>
<text x="200" y="75" text-anchor="middle" font-family="Arial, sans-serif" font-size="14" font-weight="bold" class="main-text">1</text>
<circle cx="500" cy="70" r="12" class="neon-purple" fill="none" stroke-width="3"/>
<text x="500" y="75" text-anchor="middle" font-family="Arial, sans-serif" font-size="14" font-weight="bold" class="main-text">2</text>
<circle cx="850" cy="70" r="12" class="neon-green" fill="none" stroke-width="3"/>
<text x="850" y="75" text-anchor="middle" font-family="Arial, sans-serif" font-size="14" font-weight="bold" class="main-text">3</text>
<circle cx="1150" cy="70" r="12" class="neon-cyan" fill="none" stroke-width="3"/>
<text x="1150" y="75" text-anchor="middle" font-family="Arial, sans-serif" font-size="14" font-weight="bold" class="main-text">4</text>
<!-- Connecting lines -->
<line x1="212" y1="70" x2="488" y2="70" class="arrow-color" stroke-width="2" opacity="0.3"/>
<line x1="512" y1="70" x2="838" y2="70" class="arrow-color" stroke-width="2" opacity="0.3"/>
<line x1="862" y1="70" x2="1138" y2="70" class="arrow-color" stroke-width="2" opacity="0.3"/>
<!-- Stage labels -->
<text x="200" y="130" text-anchor="middle" font-family="Arial, sans-serif" font-size="16" font-weight="500" class="main-text">Navigate</text>
<text x="200" y="150" text-anchor="middle" font-family="Arial, sans-serif" font-size="13" class="secondary-text">Browse folders</text>
<text x="500" y="130" text-anchor="middle" font-family="Arial, sans-serif" font-size="16" font-weight="500" class="main-text">Action</text>
<text x="500" y="150" text-anchor="middle" font-family="Arial, sans-serif" font-size="13" class="secondary-text">Upload, move, share</text>
<text x="850" y="130" text-anchor="middle" font-family="Arial, sans-serif" font-size="16" font-weight="500" class="main-text">Store</text>
<text x="850" y="150" text-anchor="middle" font-family="Arial, sans-serif" font-size="13" class="secondary-text">Save to cloud</text>
<text x="1150" y="130" text-anchor="middle" font-family="Arial, sans-serif" font-size="16" font-weight="500" class="main-text">View</text>
<text x="1150" y="150" text-anchor="middle" font-family="Arial, sans-serif" font-size="13" class="secondary-text">Grid or list display</text>
</g>
<!-- Features Legend -->
<g transform="translate(100, 670)">
<text x="0" y="0" font-family="Arial, sans-serif" font-size="16" font-weight="600" class="main-text">Key Features:</text>
<rect x="0" y="20" width="16" height="16" rx="3" fill="none" class="neon-orange" stroke-width="2"/>
<text x="25" y="33" font-family="Arial, sans-serif" font-size="14" class="secondary-text">Drag-and-drop upload</text>
<rect x="250" y="20" width="16" height="16" rx="3" fill="none" class="neon-purple" stroke-width="2"/>
<text x="275" y="33" font-family="Arial, sans-serif" font-size="14" class="secondary-text">Context menu actions</text>
<rect x="500" y="20" width="16" height="16" rx="3" fill="none" class="neon-green" stroke-width="2"/>
<text x="525" y="33" font-family="Arial, sans-serif" font-size="14" class="secondary-text">SeaweedFS object storage</text>
<rect x="780" y="20" width="16" height="16" rx="3" fill="none" class="neon-cyan" stroke-width="2"/>
<text x="805" y="33" font-family="Arial, sans-serif" font-size="14" class="secondary-text">Grid/List view toggle</text>
<rect x="1020" y="20" width="16" height="16" rx="3" fill="none" class="neon-blue" stroke-width="2"/>
<text x="1045" y="33" font-family="Arial, sans-serif" font-size="14" class="secondary-text">Star & label files</text>
</g>
<!-- Keyboard shortcuts -->
<g transform="translate(100, 730)">
<text x="0" y="0" font-family="Arial, sans-serif" font-size="16" font-weight="600" class="main-text">Shortcuts:</text>
<text x="100" y="0" font-family="Arial, sans-serif" font-size="14" class="secondary-text">Ctrl+U = Upload | Ctrl+N = New Folder | Delete = Trash | Ctrl+C/V = Copy/Paste | Enter = Open</text>
</g>
<!-- API Endpoints -->
<g transform="translate(100, 770)">
<text x="0" y="0" font-family="Arial, sans-serif" font-size="16" font-weight="600" class="main-text">Endpoints:</text>
<text x="100" y="0" font-family="monospace, sans-serif" font-size="13" class="secondary-text">GET /api/v1/drive/list | POST /api/v1/drive/upload | DELETE /api/v1/drive/file | PUT /api/v1/drive/move</text>
</g>
<!-- HTMX Attributes -->
<g transform="translate(100, 810)">
<text x="0" y="0" font-family="Arial, sans-serif" font-size="16" font-weight="600" class="main-text">HTMX:</text>
<text x="100" y="0" font-family="monospace, sans-serif" font-size="13" class="secondary-text">hx-get="/api/v1/drive/list" hx-target="#file-list" hx-trigger="load, click" hx-swap="innerHTML"</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 17 KiB

View file

@ -0,0 +1,280 @@
<svg width="1400" height="900" xmlns="http://www.w3.org/2000/svg">
<style>
/* Light theme defaults */
.neon-blue { stroke: #4A90E2; stroke-width: 2.6; }
.neon-orange { stroke: #F5A623; stroke-width: 2.6; }
.neon-purple { stroke: #BD10E0; stroke-width: 2.6; }
.neon-green { stroke: #7ED321; stroke-width: 2.6; }
.neon-cyan { stroke: #50E3C2; stroke-width: 2.6; }
.neon-red { stroke: #E74C3C; stroke-width: 2.6; }
.main-text { fill: #1a1a1a; }
.secondary-text { fill: #666; }
.arrow-color { stroke: #666; fill: #666; }
@media (prefers-color-scheme: dark) {
.neon-blue {
stroke: #00D4FF;
stroke-width: 2.8;
filter: drop-shadow(0 0 4px #00D4FF) drop-shadow(0 0 8px #00A0FF);
}
.neon-orange {
stroke: #FF9500;
stroke-width: 2.8;
filter: drop-shadow(0 0 4px #FF9500) drop-shadow(0 0 8px #FF7700);
}
.neon-purple {
stroke: #E040FB;
stroke-width: 2.8;
filter: drop-shadow(0 0 4px #E040FB) drop-shadow(0 0 8px #D500F9);
}
.neon-green {
stroke: #00FF88;
stroke-width: 2.8;
filter: drop-shadow(0 0 4px #00FF88) drop-shadow(0 0 8px #00E676);
}
.neon-cyan {
stroke: #00E5EA;
stroke-width: 2.8;
filter: drop-shadow(0 0 4px #00E5EA) drop-shadow(0 0 8px #00BCD4);
}
.neon-red {
stroke: #FF6B6B;
stroke-width: 2.8;
filter: drop-shadow(0 0 4px #FF6B6B) drop-shadow(0 0 8px #FF5252);
}
.main-text { fill: #FFFFFF; }
.secondary-text { fill: #B0B0B0; }
.arrow-color { stroke: #B0B0B0; fill: #B0B0B0; }
}
</style>
<defs>
<marker id="arrow" markerWidth="13" markerHeight="13" refX="11.7" refY="3.9" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L0,7.8 L11.7,3.9 z" class="arrow-color"/>
</marker>
<linearGradient id="flowGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#7ED321;stop-opacity:0.3" />
<stop offset="50%" style="stop-color:#F5A623;stop-opacity:0.3" />
<stop offset="100%" style="stop-color:#4A90E2;stop-opacity:0.3" />
</linearGradient>
</defs>
<!-- Title -->
<text x="700" y="45" text-anchor="middle" font-family="Arial, sans-serif" font-size="32" font-weight="600" class="main-text">Tasks - To-Do Management Flow</text>
<text x="700" y="80" text-anchor="middle" font-family="Arial, sans-serif" font-size="18" class="secondary-text">Create, organize, and track your tasks with categories and priorities</text>
<!-- Phase Labels -->
<text x="200" y="130" text-anchor="middle" font-family="Arial, sans-serif" font-size="18" font-weight="500" class="secondary-text">Create</text>
<text x="550" y="130" text-anchor="middle" font-family="Arial, sans-serif" font-size="18" font-weight="500" class="secondary-text">Organize</text>
<text x="900" y="130" text-anchor="middle" font-family="Arial, sans-serif" font-size="18" font-weight="500" class="secondary-text">Track</text>
<text x="1200" y="130" text-anchor="middle" font-family="Arial, sans-serif" font-size="18" font-weight="500" class="secondary-text">Complete</text>
<!-- MAIN FLOW DIAGRAM -->
<g id="main-flow">
<!-- Add Task Form -->
<g transform="translate(80, 160)">
<rect x="0" y="0" width="240" height="150" rx="6.5" fill="none" class="neon-green"/>
<text x="120" y="30" text-anchor="middle" font-family="Arial, sans-serif" font-size="18" font-weight="600" class="main-text">Add Task</text>
<line x1="20" y1="45" x2="220" y2="45" class="neon-green" stroke-width="1" opacity="0.5"/>
<!-- Input field -->
<rect x="20" y="60" width="200" height="35" rx="4" fill="none" class="neon-cyan" stroke-width="1.5"/>
<text x="30" y="83" font-family="Arial, sans-serif" font-size="13" class="secondary-text">What needs to be done?</text>
<!-- Category & Date -->
<rect x="20" y="105" width="90" height="30" rx="4" fill="none" class="neon-orange" stroke-width="1.5"/>
<text x="65" y="125" text-anchor="middle" font-family="Arial, sans-serif" font-size="12" class="secondary-text">Category</text>
<rect x="120" y="105" width="100" height="30" rx="4" fill="none" class="neon-purple" stroke-width="1.5"/>
<text x="170" y="125" text-anchor="middle" font-family="Arial, sans-serif" font-size="12" class="secondary-text">Due Date</text>
</g>
<!-- Filter Tabs -->
<g transform="translate(400, 160)">
<rect x="0" y="0" width="300" height="70" rx="6.5" fill="none" class="neon-orange"/>
<text x="150" y="25" text-anchor="middle" font-family="Arial, sans-serif" font-size="16" font-weight="500" class="main-text">Filter Tabs</text>
<!-- Tab buttons -->
<rect x="15" y="38" width="55" height="24" rx="4" fill="none" class="neon-blue" stroke-width="2"/>
<text x="42" y="55" text-anchor="middle" font-family="Arial, sans-serif" font-size="11" class="main-text">All</text>
<rect x="80" y="38" width="55" height="24" rx="4" fill="none" class="neon-orange" stroke-width="1.5" opacity="0.7"/>
<text x="107" y="55" text-anchor="middle" font-family="Arial, sans-serif" font-size="11" class="secondary-text">Active</text>
<rect x="145" y="38" width="55" height="24" rx="4" fill="none" class="neon-green" stroke-width="1.5" opacity="0.7"/>
<text x="172" y="55" text-anchor="middle" font-family="Arial, sans-serif" font-size="11" class="secondary-text">Done</text>
<rect x="210" y="38" width="75" height="24" rx="4" fill="none" class="neon-red" stroke-width="1.5" opacity="0.7"/>
<text x="247" y="55" text-anchor="middle" font-family="Arial, sans-serif" font-size="11" class="secondary-text">Priority</text>
</g>
<!-- Categories -->
<g transform="translate(400, 260)">
<rect x="0" y="0" width="300" height="100" rx="6.5" fill="none" class="neon-purple"/>
<text x="150" y="25" text-anchor="middle" font-family="Arial, sans-serif" font-size="16" font-weight="500" class="main-text">Categories</text>
<circle cx="40" cy="55" r="10" fill="none" class="neon-blue" stroke-width="2"/>
<text x="60" y="60" font-family="Arial, sans-serif" font-size="12" class="main-text">Work</text>
<circle cx="120" cy="55" r="10" fill="none" class="neon-green" stroke-width="2"/>
<text x="140" y="60" font-family="Arial, sans-serif" font-size="12" class="main-text">Personal</text>
<circle cx="220" cy="55" r="10" fill="none" class="neon-orange" stroke-width="2"/>
<text x="240" y="60" font-family="Arial, sans-serif" font-size="12" class="main-text">Shopping</text>
<circle cx="80" cy="85" r="10" fill="none" class="neon-red" stroke-width="2"/>
<text x="100" y="90" font-family="Arial, sans-serif" font-size="12" class="main-text">Health</text>
<circle cx="180" cy="85" r="10" fill="none" class="neon-cyan" stroke-width="2"/>
<text x="200" y="90" font-family="Arial, sans-serif" font-size="12" class="main-text">Custom</text>
</g>
<!-- Task List -->
<g transform="translate(780, 160)">
<rect x="0" y="0" width="240" height="200" rx="6.5" fill="none" class="neon-cyan"/>
<text x="120" y="25" text-anchor="middle" font-family="Arial, sans-serif" font-size="16" font-weight="600" class="main-text">Task List</text>
<line x1="20" y1="40" x2="220" y2="40" class="neon-cyan" stroke-width="1" opacity="0.5"/>
<!-- Task items -->
<rect x="15" y="50" width="20" height="20" rx="4" fill="none" class="neon-green" stroke-width="2"/>
<text x="45" y="65" font-family="Arial, sans-serif" font-size="13" class="main-text">Review report</text>
<circle cx="210" cy="60" r="6" fill="none" class="neon-red" stroke-width="2"/>
<rect x="15" y="85" width="20" height="20" rx="4" fill="none" class="neon-orange" stroke-width="2"/>
<text x="45" y="100" font-family="Arial, sans-serif" font-size="13" class="main-text">Call client</text>
<circle cx="210" cy="95" r="6" fill="none" class="neon-orange" stroke-width="2"/>
<rect x="15" y="120" width="20" height="20" rx="4" fill="none" class="neon-blue" stroke-width="2"/>
<text x="45" y="135" font-family="Arial, sans-serif" font-size="13" class="main-text">Update docs</text>
<circle cx="210" cy="130" r="6" fill="none" class="neon-green" stroke-width="2"/>
<rect x="15" y="155" width="20" height="20" rx="4" fill="none" class="neon-green" stroke-width="2"/>
<path d="M20 165 L25 170 L32 160" fill="none" class="neon-green" stroke-width="2" stroke-linecap="round"/>
<text x="45" y="170" font-family="Arial, sans-serif" font-size="13" class="secondary-text" text-decoration="line-through">Send email</text>
</g>
<!-- Stats / Completion -->
<g transform="translate(1080, 160)">
<rect x="0" y="0" width="220" height="200" rx="6.5" fill="none" class="neon-blue"/>
<text x="110" y="25" text-anchor="middle" font-family="Arial, sans-serif" font-size="16" font-weight="600" class="main-text">Stats</text>
<line x1="20" y1="40" x2="200" y2="40" class="neon-blue" stroke-width="1" opacity="0.5"/>
<!-- Stats boxes -->
<rect x="20" y="55" width="80" height="55" rx="6" fill="none" class="neon-cyan" stroke-width="1.5"/>
<text x="60" y="80" text-anchor="middle" font-family="Arial, sans-serif" font-size="24" font-weight="600" class="main-text">12</text>
<text x="60" y="100" text-anchor="middle" font-family="Arial, sans-serif" font-size="12" class="secondary-text">Total</text>
<rect x="115" y="55" width="80" height="55" rx="6" fill="none" class="neon-orange" stroke-width="1.5"/>
<text x="155" y="80" text-anchor="middle" font-family="Arial, sans-serif" font-size="24" font-weight="600" class="main-text">5</text>
<text x="155" y="100" text-anchor="middle" font-family="Arial, sans-serif" font-size="12" class="secondary-text">Active</text>
<rect x="20" y="125" width="80" height="55" rx="6" fill="none" class="neon-green" stroke-width="1.5"/>
<text x="60" y="150" text-anchor="middle" font-family="Arial, sans-serif" font-size="24" font-weight="600" class="main-text">7</text>
<text x="60" y="170" text-anchor="middle" font-family="Arial, sans-serif" font-size="12" class="secondary-text">Completed</text>
<rect x="115" y="125" width="80" height="55" rx="6" fill="none" class="neon-red" stroke-width="1.5"/>
<text x="155" y="150" text-anchor="middle" font-family="Arial, sans-serif" font-size="24" font-weight="600" class="main-text">2</text>
<text x="155" y="170" text-anchor="middle" font-family="Arial, sans-serif" font-size="12" class="secondary-text">Overdue</text>
</g>
<!-- Arrows -->
<line x1="320" y1="235" x2="395" y2="195" class="arrow-color" stroke-width="2.6" marker-end="url(#arrow)" opacity="0.7"/>
<line x1="700" y1="195" x2="775" y2="195" class="arrow-color" stroke-width="2.6" marker-end="url(#arrow)" opacity="0.7"/>
<line x1="1020" y1="260" x2="1075" y2="260" class="arrow-color" stroke-width="2.6" marker-end="url(#arrow)" opacity="0.7"/>
<!-- Filter to List -->
<line x1="550" y1="230" x2="550" y2="255" class="arrow-color" stroke-width="1.5" opacity="0.5"/>
<line x1="700" y1="310" x2="775" y2="280" class="arrow-color" stroke-width="2" stroke-dasharray="3.9,3.9" marker-end="url(#arrow)" opacity="0.5"/>
</g>
<!-- PROGRESS INDICATOR -->
<g id="progress-legend" transform="translate(0, 420)">
<rect x="100" y="30" width="1200" height="80" fill="url(#flowGradient)" rx="10" opacity="0.2"/>
<!-- Stage markers -->
<circle cx="200" cy="70" r="12" class="neon-green" fill="none" stroke-width="3"/>
<text x="200" y="75" text-anchor="middle" font-family="Arial, sans-serif" font-size="14" font-weight="bold" class="main-text">1</text>
<circle cx="530" cy="70" r="12" class="neon-orange" fill="none" stroke-width="3"/>
<text x="530" y="75" text-anchor="middle" font-family="Arial, sans-serif" font-size="14" font-weight="bold" class="main-text">2</text>
<circle cx="870" cy="70" r="12" class="neon-cyan" fill="none" stroke-width="3"/>
<text x="870" y="75" text-anchor="middle" font-family="Arial, sans-serif" font-size="14" font-weight="bold" class="main-text">3</text>
<circle cx="1200" cy="70" r="12" class="neon-blue" fill="none" stroke-width="3"/>
<text x="1200" y="75" text-anchor="middle" font-family="Arial, sans-serif" font-size="14" font-weight="bold" class="main-text">4</text>
<!-- Connecting lines -->
<line x1="212" y1="70" x2="518" y2="70" class="arrow-color" stroke-width="2" opacity="0.3"/>
<line x1="542" y1="70" x2="858" y2="70" class="arrow-color" stroke-width="2" opacity="0.3"/>
<line x1="882" y1="70" x2="1188" y2="70" class="arrow-color" stroke-width="2" opacity="0.3"/>
<!-- Stage labels -->
<text x="200" y="130" text-anchor="middle" font-family="Arial, sans-serif" font-size="16" font-weight="500" class="main-text">Add Task</text>
<text x="200" y="150" text-anchor="middle" font-family="Arial, sans-serif" font-size="13" class="secondary-text">Enter description</text>
<text x="530" y="130" text-anchor="middle" font-family="Arial, sans-serif" font-size="16" font-weight="500" class="main-text">Categorize</text>
<text x="530" y="150" text-anchor="middle" font-family="Arial, sans-serif" font-size="13" class="secondary-text">Set priority & date</text>
<text x="870" y="130" text-anchor="middle" font-family="Arial, sans-serif" font-size="16" font-weight="500" class="main-text">Track</text>
<text x="870" y="150" text-anchor="middle" font-family="Arial, sans-serif" font-size="13" class="secondary-text">View & filter tasks</text>
<text x="1200" y="130" text-anchor="middle" font-family="Arial, sans-serif" font-size="16" font-weight="500" class="main-text">Complete</text>
<text x="1200" y="150" text-anchor="middle" font-family="Arial, sans-serif" font-size="13" class="secondary-text">Check off done</text>
</g>
<!-- Priority Legend -->
<g transform="translate(100, 600)">
<text x="0" y="0" font-family="Arial, sans-serif" font-size="16" font-weight="600" class="main-text">Priority Levels:</text>
<circle cx="20" cy="30" r="8" fill="none" class="neon-red" stroke-width="2.5"/>
<text x="35" y="35" font-family="Arial, sans-serif" font-size="14" class="secondary-text">High - Must do today</text>
<circle cx="220" cy="30" r="8" fill="none" class="neon-orange" stroke-width="2.5"/>
<text x="235" y="35" font-family="Arial, sans-serif" font-size="14" class="secondary-text">Medium - Important</text>
<circle cx="440" cy="30" r="8" fill="none" class="neon-green" stroke-width="2.5"/>
<text x="455" y="35" font-family="Arial, sans-serif" font-size="14" class="secondary-text">Low - Can wait</text>
<circle cx="640" cy="30" r="8" fill="none" class="neon-blue" stroke-width="2.5"/>
<text x="655" y="35" font-family="Arial, sans-serif" font-size="14" class="secondary-text">None - No deadline</text>
</g>
<!-- Features -->
<g transform="translate(100, 670)">
<text x="0" y="0" font-family="Arial, sans-serif" font-size="16" font-weight="600" class="main-text">Key Features:</text>
<rect x="0" y="20" width="16" height="16" rx="3" fill="none" class="neon-green" stroke-width="2"/>
<text x="25" y="33" font-family="Arial, sans-serif" font-size="14" class="secondary-text">Click checkbox to complete</text>
<rect x="280" y="20" width="16" height="16" rx="3" fill="none" class="neon-orange" stroke-width="2"/>
<text x="305" y="33" font-family="Arial, sans-serif" font-size="14" class="secondary-text">Filter by status or category</text>
<rect x="560" y="20" width="16" height="16" rx="3" fill="none" class="neon-purple" stroke-width="2"/>
<text x="585" y="33" font-family="Arial, sans-serif" font-size="14" class="secondary-text">Set due dates with calendar</text>
<rect x="840" y="20" width="16" height="16" rx="3" fill="none" class="neon-cyan" stroke-width="2"/>
<text x="865" y="33" font-family="Arial, sans-serif" font-size="14" class="secondary-text">Real-time stats dashboard</text>
</g>
<!-- Keyboard shortcuts -->
<g transform="translate(100, 730)">
<text x="0" y="0" font-family="Arial, sans-serif" font-size="16" font-weight="600" class="main-text">Shortcuts:</text>
<text x="100" y="0" font-family="Arial, sans-serif" font-size="14" class="secondary-text">Enter = Add task | Space = Toggle complete | Delete = Remove task | Tab = Next field</text>
</g>
<!-- API Endpoints -->
<g transform="translate(100, 770)">
<text x="0" y="0" font-family="Arial, sans-serif" font-size="16" font-weight="600" class="main-text">Endpoints:</text>
<text x="100" y="0" font-family="monospace, sans-serif" font-size="13" class="secondary-text">GET /api/tasks | POST /api/tasks | PATCH /api/tasks/:id | DELETE /api/tasks/:id</text>
</g>
<!-- HTMX Attributes -->
<g transform="translate(100, 810)">
<text x="0" y="0" font-family="Arial, sans-serif" font-size="16" font-weight="600" class="main-text">HTMX:</text>
<text x="100" y="0" font-family="monospace, sans-serif" font-size="13" class="secondary-text">hx-post="/api/tasks" hx-target="#task-list" hx-swap="afterbegin" hx-on::after-request="this.reset()"</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 17 KiB

View file

@ -0,0 +1,165 @@
# Suite Applications
> **Individual app documentation for General Bots Suite**
Each application in the Suite has its own dedicated documentation with:
- Flow diagrams (SVG with light/dark theme support)
- Interface layouts
- HTMX integration patterns
- API endpoints
- CSS classes
- JavaScript handlers
- Keyboard shortcuts
---
## Core Applications
| App | Description | Documentation |
|-----|-------------|---------------|
| 💬 **Chat** | AI-powered conversation assistant | [chat.md](./chat.md) |
| 📁 **Drive** | Cloud file storage and management | [drive.md](./drive.md) |
| ✓ **Tasks** | To-do lists with priorities | [tasks.md](./tasks.md) |
| ✉ **Mail** | Email client | [mail.md](./mail.md) |
| 📅 **Calendar** | Scheduling and events | [calendar.md](./calendar.md) |
| 🎥 **Meet** | Video conferencing | [meet.md](./meet.md) |
## Productivity Applications
| App | Description | Documentation |
|-----|-------------|---------------|
| 📝 **Paper** | AI-assisted document writing | [paper.md](./paper.md) |
| 🔍 **Research** | AI-powered search and discovery | [research.md](./research.md) |
| 📊 **Analytics** | Reports and dashboards | [analytics.md](./analytics.md) |
## Developer Tools
| App | Description | Documentation |
|-----|-------------|---------------|
| 🎨 **Designer** | Visual dialog builder (VB6-style) | [designer.md](./designer.md) |
| 📚 **Sources** | Prompts, templates, and models | [sources.md](./sources.md) |
| 🛡️ **Compliance** | Security scanner | [compliance.md](./compliance.md) |
---
## App Launcher
The Suite features a Google-style app launcher accessible from the header:
<img src="../../assets/suite/app-launcher.svg" alt="App Launcher" style="max-width: 100%; height: auto;">
### Accessing Apps
1. **Click the grid icon** (⋮⋮⋮) in the top-right corner
2. **Select an app** from the dropdown menu
3. App loads in the main content area
### Keyboard Shortcuts
| Shortcut | App |
|----------|-----|
| `Alt+1` | Chat |
| `Alt+2` | Drive |
| `Alt+3` | Tasks |
| `Alt+4` | Mail |
| `Alt+5` | Calendar |
| `Alt+6` | Meet |
---
## Architecture Overview
All Suite apps follow the same patterns:
### HTMX Loading
Apps are loaded lazily when selected:
```html
<a href="#chat"
data-section="chat"
hx-get="/ui/suite/chat/chat.html"
hx-target="#main-content"
hx-swap="innerHTML">
Chat
</a>
```
### Component Structure
Each app is a self-contained HTML fragment:
```
app-name/
├── app-name.html # Main component
├── app-name.css # Styles (optional)
└── app-name.js # JavaScript (optional)
```
### API Integration
Apps communicate with the backend via REST APIs:
```html
<div hx-get="/api/v1/app/data"
hx-trigger="load"
hx-swap="innerHTML">
Loading...
</div>
```
### Real-Time Updates
WebSocket support for live data:
```html
<div hx-ext="ws" ws-connect="/ws">
<!-- Real-time content -->
</div>
```
---
## Creating Custom Apps
To add a new app to the Suite:
1. **Create the component** in `ui/suite/your-app/`
2. **Add navigation entry** in `index.html`
3. **Define API endpoints** in your Rust backend
4. **Document the app** in this folder
### Template
```html
<!-- ui/suite/your-app/your-app.html -->
<div class="your-app-container" id="your-app">
<header class="your-app-header">
<h2>Your App</h2>
</header>
<main class="your-app-content"
hx-get="/api/v1/your-app/data"
hx-trigger="load"
hx-swap="innerHTML">
<div class="htmx-indicator">Loading...</div>
</main>
</div>
<style>
.your-app-container {
display: flex;
flex-direction: column;
height: 100%;
}
</style>
```
---
## See Also
- [Suite Manual](../suite-manual.md) - Complete user guide
- [HTMX Architecture](../htmx-architecture.md) - Technical details
- [UI Structure](../ui-structure.md) - File organization
- [Chapter 10: REST API](../../chapter-10-api/README.md) - API reference

View file

@ -0,0 +1 @@
# Analytics - Dashboards

View file

@ -0,0 +1 @@
# Calendar - Scheduling

View file

@ -0,0 +1,384 @@
# Chat - AI Assistant
> **Your intelligent conversation partner**
<img src="../../assets/suite/chat-flow.svg" alt="Chat Flow Diagram" style="max-width: 100%; height: auto;">
---
## Overview
Chat is the heart of General Bots Suite - your AI-powered assistant that understands context, remembers conversations, and helps you get things done. Built with WebSocket for real-time communication and HTMX for seamless updates.
## Interface Layout
```
┌─────────────────────────────────────────────────────────────────┐
│ Connection Status [●] │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ 🤖 Bot 10:30 AM │ │
│ │ Hello! How can I help you today? │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ You 10:31 AM│ │
│ │ What meetings do I have today? │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ 🤖 Bot 10:31 AM │ │
│ │ You have 2 meetings scheduled: │ │
│ │ • 2:00 PM - Team Standup (30 min) │ │
│ │ • 4:00 PM - Project Review (1 hour) │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
├─────────────────────────────────────────────────────────────────┤
│ [📊 Tasks] [📧 Check mail] [📅 Schedule] [❓ Help] │
├─────────────────────────────────────────────────────────────────┤
│ ┌────────────────────────────────────────────┐ [🎤] [↑] │
│ │ Type your message... │ │
│ └────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
## Features
### Real-Time Messaging
Messages are sent and received instantly via WebSocket connection:
```html
<div id="chat-app" hx-ext="ws" ws-connect="/ws">
<main id="messages"
hx-get="/api/sessions/current/history"
hx-trigger="load"
hx-swap="innerHTML">
</main>
<form ws-send>
<input name="content" type="text" placeholder="Message...">
<button type="submit"></button>
</form>
</div>
```
### Voice Input
Click the microphone button to speak your message:
1. Click **🎤** to start recording
2. Speak your message clearly
3. Click again to stop
4. Message converts to text automatically
```html
<button type="button" id="voiceBtn"
hx-post="/api/voice/start"
hx-swap="none">
🎤
</button>
```
### Quick Suggestions
Pre-built action chips for common requests:
| Chip | Action |
|------|--------|
| 📊 Tasks | Show your task list |
| 📧 Check mail | Display unread emails |
| 📅 Schedule | Today's calendar |
| ❓ Help | Available commands |
```html
<div class="suggestions-container"
hx-get="/api/suggestions"
hx-trigger="load"
hx-swap="innerHTML">
</div>
```
### Message History
- Auto-loads previous messages on page open
- Scroll up to load older messages
- Click "Scroll to bottom" button to return to latest
### Markdown Support
Bot responses support full Markdown rendering:
- **Bold** and *italic* text
- `code snippets` and code blocks
- Bullet and numbered lists
- Links and images
- Tables
## Keyboard Shortcuts
| Shortcut | Action |
|----------|--------|
| `Enter` | Send message |
| `Shift+Enter` | New line (without sending) |
| `↑` (Up arrow) | Edit last message |
| `/` | Open command menu |
| `Escape` | Cancel current action |
## API Endpoints
### WebSocket Connection
```
ws://your-server:8080/ws
```
**Message Types:**
- `TEXT (1)` - Regular text messages
- `VOICE (2)` - Voice messages
- `CONTINUE (3)` - Continue interrupted responses
- `CONTEXT (4)` - Context changes
- `SYSTEM (5)` - System messages
### REST Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/sessions/current/message` | POST | Send a message |
| `/api/sessions/current/history` | GET | Get chat history |
| `/api/voice/start` | POST | Start voice recording |
| `/api/voice/stop` | POST | Stop voice recording |
| `/api/suggestions` | GET | Get suggestion chips |
## HTMX Integration
### Message Submission
```html
<form hx-post="/api/sessions/current/message"
hx-target="#messages"
hx-swap="beforeend"
hx-on::after-request="this.reset()">
<input name="content" type="text" required>
<button type="submit">Send</button>
</form>
```
### History Loading
```html
<main id="messages"
hx-get="/api/sessions/current/history"
hx-trigger="load"
hx-swap="innerHTML">
<!-- Messages rendered here -->
</main>
```
### Connection Status
```html
<div id="connectionStatus" class="connection-status disconnected">
<!-- Updates via WebSocket events -->
</div>
```
## Example Conversations
### Getting Information
```
You: What's the weather like today?
Bot: Currently in your area:
🌤 Partly cloudy, 72°F (22°C)
Wind: 8 mph from the west
Humidity: 45%
```
### Creating Tasks
```
You: Remind me to call John tomorrow at 3pm
Bot: ✅ Task created:
📋 Call John
📅 Tomorrow at 3:00 PM
Would you like me to set a notification?
```
### Drafting Emails
```
You: Write an email declining the meeting tomorrow
Bot: Here's a draft:
Subject: Unable to Attend Tomorrow's Meeting
Hi [Name],
Thank you for the invitation. Unfortunately, I have
a scheduling conflict and won't be able to attend
tomorrow's meeting.
Would it be possible to reschedule, or could someone
share the meeting notes with me afterward?
Best regards,
[Your name]
[📧 Send] [✏️ Edit] [🗑 Discard]
```
## CSS Classes
```css
.chat-layout {
display: flex;
flex-direction: column;
height: 100%;
}
#messages {
flex: 1;
overflow-y: auto;
padding: 1rem;
}
.message {
margin-bottom: 1rem;
padding: 0.75rem 1rem;
border-radius: 12px;
max-width: 80%;
}
.message.bot {
background: var(--surface);
margin-right: auto;
}
.message.user {
background: var(--primary);
color: white;
margin-left: auto;
}
.suggestions-container {
display: flex;
gap: 0.5rem;
padding: 0.5rem 1rem;
overflow-x: auto;
}
.suggestion-chip {
padding: 0.5rem 1rem;
border-radius: 20px;
background: var(--surface);
cursor: pointer;
white-space: nowrap;
}
.input-container {
display: flex;
gap: 0.5rem;
padding: 1rem;
border-top: 1px solid var(--border);
}
.connection-status {
height: 4px;
transition: background 0.3s;
}
.connection-status.connected {
background: var(--success);
}
.connection-status.disconnected {
background: var(--error);
}
```
## JavaScript Events
```javascript
// Connection status handling
document.body.addEventListener('htmx:wsOpen', () => {
document.getElementById('connectionStatus')
.classList.replace('disconnected', 'connected');
});
document.body.addEventListener('htmx:wsClose', () => {
document.getElementById('connectionStatus')
.classList.replace('connected', 'disconnected');
});
// Auto-scroll to new messages
document.body.addEventListener('htmx:afterSwap', (e) => {
if (e.detail.target.id === 'messages') {
e.detail.target.scrollTop = e.detail.target.scrollHeight;
}
});
// Voice input handling
document.getElementById('voiceBtn').addEventListener('click', async () => {
const recognition = new webkitSpeechRecognition();
recognition.onresult = (event) => {
document.getElementById('messageInput').value =
event.results[0][0].transcript;
};
recognition.start();
});
```
## Accessibility
- Full keyboard navigation
- Screen reader announcements for new messages
- High contrast mode support
- Adjustable font sizes
- ARIA labels on all interactive elements
```html
<main id="messages"
role="log"
aria-live="polite"
aria-label="Chat messages">
</main>
<button type="submit"
aria-label="Send message"
title="Send">
</button>
```
## Troubleshooting
### Messages Not Sending
1. Check connection status indicator
2. Verify WebSocket is connected
3. Try refreshing the page
4. Check browser console for errors
### Voice Not Working
1. Allow microphone permissions in browser
2. Check device microphone settings
3. Try a different browser
4. Ensure HTTPS connection (required for voice)
### History Not Loading
1. Check network connection
2. Verify API endpoint is accessible
3. Clear browser cache
4. Check for JavaScript errors
## See Also
- [HTMX Architecture](../htmx-architecture.md) - How Chat uses HTMX
- [Suite Manual](../suite-manual.md) - Complete user guide
- [Tasks App](./tasks.md) - Create tasks from chat
- [Mail App](./mail.md) - Email integration

View file

@ -0,0 +1 @@
# Compliance - Security Scanner

View file

@ -0,0 +1 @@
# Designer - Visual Builder

View file

@ -0,0 +1,636 @@
# Drive - File Management
> **Your cloud storage workspace**
<img src="../../assets/suite/drive-flow.svg" alt="Drive Flow Diagram" style="max-width: 100%; height: auto;">
---
## Overview
Drive is your personal cloud storage within General Bots Suite. Upload, organize, and share files with a familiar interface inspired by Google Drive. Built with HTMX for smooth interactions and SeaweedFS for reliable object storage.
## Interface Layout
```
┌────────────────┬──────────────────────────────────────────────────┐
│ │ 🔍 Search files... [⊞] [≡] │
│ [+ New ▼] ├──────────────────────────────────────────────────┤
│ │ 📁 My Drive > Projects > 2024 │
│ ─────────────│ ─────────────────────────────────────────────────│
│ 🏠 My Drive │ [☐] Name Size Modified │
│ ⭐ Starred │ ─────────────────────────────────────────────── │
│ 🕐 Recent │ 📁 Reports - Today │
│ 🗑 Trash │ 📁 Presentations - Yesterday │
│ │ 📄 Budget.xlsx 245 KB Mar 15 │
│ ─────────────│ 📄 Notes.docx 12 KB Mar 14 │
│ Labels │ 🖼 Logo.png 89 KB Mar 10 │
│ 🔵 Work │ 📊 Sales.csv 156 KB Mar 8 │
│ 🟢 Personal │ │
│ 🟡 Projects │ │
│ ├──────────────────────────────────────────────────┤
│ ─────────────│ Storage: ████████░░ 4.2 GB of 10 GB │
│ 4.2 GB used │ │
└────────────────┴──────────────────────────────────────────────────┘
```
## Features
### Upload Files
**Drag and Drop:**
1. Drag files from your computer
2. Drop anywhere in the file area
3. Upload progress shows automatically
**Click to Upload:**
1. Click **+ New** button
2. Select **Upload Files** or **Upload Folder**
3. Choose files from file picker
```html
<button hx-get="/api/v1/drive/upload-modal"
hx-target="#modal-container"
hx-swap="innerHTML">
+ New
</button>
<div class="upload-zone"
ondrop="handleDrop(event)"
ondragover="handleDragOver(event)">
<input type="file"
multiple
hx-post="/api/v1/drive/upload"
hx-encoding="multipart/form-data"
hx-target="#file-list">
</div>
```
### File Operations
| Action | How to Access | HTMX Attribute |
|--------|---------------|----------------|
| **Open** | Double-click | `hx-get="/api/v1/drive/open"` |
| **Download** | Right-click > Download | `hx-get="/api/v1/drive/download"` |
| **Rename** | Right-click > Rename | `hx-patch="/api/v1/drive/rename"` |
| **Copy** | Right-click > Copy | `hx-post="/api/v1/drive/copy"` |
| **Move** | Right-click > Move to | `hx-post="/api/v1/drive/move"` |
| **Star** | Right-click > Star | `hx-post="/api/v1/drive/star"` |
| **Share** | Right-click > Share | `hx-get="/api/v1/drive/share-modal"` |
| **Delete** | Right-click > Delete | `hx-delete="/api/v1/drive/file"` |
### Context Menu
```html
<div class="context-menu" id="context-menu">
<div class="context-menu-item"
hx-get="/api/v1/drive/open"
hx-include="[name='selected-path']">
📂 Open
</div>
<div class="context-menu-item"
hx-get="/api/v1/drive/download"
hx-include="[name='selected-path']">
⬇️ Download
</div>
<div class="context-menu-separator"></div>
<div class="context-menu-item"
hx-get="/api/v1/drive/rename-modal"
hx-target="#modal-container">
✏️ Rename
</div>
<div class="context-menu-item"
hx-post="/api/v1/drive/copy"
hx-include="[name='selected-path']">
📋 Copy
</div>
<div class="context-menu-item"
hx-get="/api/v1/drive/move-modal"
hx-target="#modal-container">
📁 Move to...
</div>
<div class="context-menu-separator"></div>
<div class="context-menu-item"
hx-post="/api/v1/drive/star"
hx-include="[name='selected-path']">
⭐ Add to Starred
</div>
<div class="context-menu-item"
hx-get="/api/v1/drive/share-modal"
hx-target="#modal-container">
🔗 Share
</div>
<div class="context-menu-separator"></div>
<div class="context-menu-item danger"
hx-delete="/api/v1/drive/file"
hx-include="[name='selected-path']"
hx-confirm="Move to trash?">
🗑 Delete
</div>
</div>
```
### View Modes
**Grid View (⊞):**
- Large thumbnails for images
- Folder icons with previews
- Best for visual browsing
**List View (≡):**
- Detailed file information
- Sortable columns
- Best for managing many files
```html
<div class="view-toggle">
<button class="view-toggle-btn active"
onclick="setView('grid')">
</button>
<button class="view-toggle-btn"
onclick="setView('list')">
</button>
</div>
```
### Navigation
**Sidebar:**
- My Drive - All your files
- Starred - Favorite files
- Recent - Recently accessed
- Trash - Deleted files (30-day retention)
**Breadcrumb:**
```html
<div class="breadcrumb" id="breadcrumb">
<div class="breadcrumb-item"
hx-get="/api/v1/drive/list?path=/"
hx-target="#file-list">
My Drive
</div>
<span class="breadcrumb-separator">/</span>
<div class="breadcrumb-item"
hx-get="/api/v1/drive/list?path=/Projects"
hx-target="#file-list">
Projects
</div>
<span class="breadcrumb-separator">/</span>
<div class="breadcrumb-item current">
2024
</div>
</div>
```
### Labels & Organization
Create colored labels to organize files:
| Label | Color | Use Case |
|-------|-------|----------|
| 🔵 Work | Blue | Business files |
| 🟢 Personal | Green | Personal documents |
| 🟡 Projects | Yellow | Active projects |
| 🔴 Urgent | Red | Priority items |
## Keyboard Shortcuts
| Shortcut | Action |
|----------|--------|
| `Ctrl+U` | Upload files |
| `Ctrl+N` | New folder |
| `Delete` | Move to trash |
| `Ctrl+C` | Copy selected |
| `Ctrl+X` | Cut selected |
| `Ctrl+V` | Paste |
| `Enter` | Open selected |
| `F2` | Rename selected |
| `Ctrl+A` | Select all |
| `Escape` | Deselect all |
## API Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/v1/drive/list` | GET | List files in directory |
| `/api/v1/drive/upload` | POST | Upload files |
| `/api/v1/drive/download` | GET | Download file |
| `/api/v1/drive/file` | DELETE | Delete file |
| `/api/v1/drive/rename` | PATCH | Rename file |
| `/api/v1/drive/move` | POST | Move file |
| `/api/v1/drive/copy` | POST | Copy file |
| `/api/v1/drive/star` | POST | Toggle star |
| `/api/v1/drive/share` | POST | Create share link |
| `/api/v1/drive/folder` | POST | Create folder |
### Query Parameters
```
GET /api/v1/drive/list?path=/Projects&sort=name&order=asc&view=grid
```
| Parameter | Values | Default |
|-----------|--------|---------|
| `path` | Directory path | `/` |
| `sort` | `name`, `size`, `modified`, `type` | `name` |
| `order` | `asc`, `desc` | `asc` |
| `view` | `grid`, `list` | `grid` |
## HTMX Integration
### File Listing
```html
<div id="file-list"
hx-get="/api/v1/drive/list"
hx-trigger="load"
hx-vals='{"path": "/"}'
hx-swap="innerHTML">
<div class="htmx-indicator">
Loading files...
</div>
</div>
```
### File Upload with Progress
```html
<form hx-post="/api/v1/drive/upload"
hx-encoding="multipart/form-data"
hx-target="#file-list"
hx-swap="innerHTML"
hx-indicator="#upload-progress">
<input type="file" name="files" multiple>
<input type="hidden" name="path" id="current-path">
<button type="submit">Upload</button>
</form>
<div id="upload-progress" class="htmx-indicator">
<div class="progress-bar">
<div class="progress-fill"></div>
</div>
<span>Uploading...</span>
</div>
```
### Folder Navigation
```html
<div class="file-card"
data-type="folder"
data-path="/Projects"
hx-get="/api/v1/drive/list"
hx-vals='{"path": "/Projects"}'
hx-target="#file-list"
hx-trigger="dblclick">
<div class="file-card-preview folder">
📁
</div>
<div class="file-card-name">Projects</div>
</div>
```
## CSS Classes
```css
.drive-container {
display: grid;
grid-template-columns: 250px 1fr;
height: calc(100vh - 64px);
}
.drive-sidebar {
background: var(--surface);
border-right: 1px solid var(--border);
padding: 1rem;
}
.drive-main {
display: flex;
flex-direction: column;
overflow: hidden;
}
.file-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 1rem;
padding: 1rem;
}
.file-card {
border: 2px solid transparent;
border-radius: 8px;
padding: 1rem;
cursor: pointer;
transition: all 0.2s;
}
.file-card:hover {
background: var(--surface-hover);
border-color: var(--border);
}
.file-card.selected {
background: var(--primary-light);
border-color: var(--primary);
}
.file-card-preview {
height: 100px;
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
border-radius: 4px;
background: var(--surface);
}
.file-card-preview img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.file-card-name {
margin-top: 0.5rem;
font-size: 0.875rem;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-list {
display: flex;
flex-direction: column;
}
.file-row {
display: grid;
grid-template-columns: auto 1fr 100px 100px 150px auto;
gap: 1rem;
padding: 0.75rem 1rem;
align-items: center;
border-bottom: 1px solid var(--border);
}
.file-row:hover {
background: var(--surface-hover);
}
.upload-zone {
border: 2px dashed var(--border);
border-radius: 8px;
padding: 2rem;
text-align: center;
transition: all 0.2s;
}
.upload-zone.dragover {
border-color: var(--primary);
background: var(--primary-light);
}
.breadcrumb {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
}
.breadcrumb-item {
cursor: pointer;
color: var(--text-secondary);
}
.breadcrumb-item:hover {
color: var(--primary);
}
.breadcrumb-item.current {
color: var(--text-primary);
font-weight: 500;
}
.storage-bar {
height: 8px;
background: var(--surface);
border-radius: 4px;
overflow: hidden;
}
.storage-bar-fill {
height: 100%;
background: var(--primary);
border-radius: 4px;
transition: width 0.3s;
}
```
## JavaScript Handlers
```javascript
// Drag and drop handling
function initDragAndDrop() {
const uploadZone = document.querySelector('.upload-zone');
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(event => {
uploadZone.addEventListener(event, preventDefaults);
});
['dragenter', 'dragover'].forEach(event => {
uploadZone.addEventListener(event, () => {
uploadZone.classList.add('dragover');
});
});
['dragleave', 'drop'].forEach(event => {
uploadZone.addEventListener(event, () => {
uploadZone.classList.remove('dragover');
});
});
uploadZone.addEventListener('drop', handleDrop);
}
function preventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
function handleDrop(e) {
const files = e.dataTransfer.files;
uploadFiles(files);
}
function uploadFiles(files) {
const formData = new FormData();
const currentPath = document.getElementById('current-path').value;
formData.append('path', currentPath);
[...files].forEach(file => {
formData.append('files', file);
});
htmx.ajax('POST', '/api/v1/drive/upload', {
target: '#file-list',
swap: 'innerHTML',
values: formData
});
}
// Context menu
function initContextMenu() {
const contextMenu = document.getElementById('context-menu');
document.addEventListener('contextmenu', (e) => {
const fileCard = e.target.closest('.file-card, .file-row');
if (fileCard) {
e.preventDefault();
selectFile(fileCard);
contextMenu.style.left = e.clientX + 'px';
contextMenu.style.top = e.clientY + 'px';
contextMenu.classList.add('visible');
}
});
document.addEventListener('click', () => {
contextMenu.classList.remove('visible');
});
}
// File selection
let selectedFiles = new Set();
function selectFile(element) {
if (!event.ctrlKey && !event.metaKey) {
document.querySelectorAll('.file-card.selected, .file-row.selected')
.forEach(el => el.classList.remove('selected'));
selectedFiles.clear();
}
element.classList.toggle('selected');
const path = element.dataset.path;
if (element.classList.contains('selected')) {
selectedFiles.add(path);
} else {
selectedFiles.delete(path);
}
document.querySelector('[name="selected-path"]').value =
[...selectedFiles].join(',');
}
// View toggle
function setView(view) {
const fileList = document.getElementById('file-list');
fileList.classList.toggle('file-grid', view === 'grid');
fileList.classList.toggle('file-list-view', view === 'list');
document.querySelectorAll('.view-toggle-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.view === view);
});
localStorage.setItem('drive-view', view);
}
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (e.ctrlKey || e.metaKey) {
switch (e.key) {
case 'u':
e.preventDefault();
document.getElementById('upload-input').click();
break;
case 'n':
e.preventDefault();
showModal('new-folder-modal');
break;
case 'a':
e.preventDefault();
document.querySelectorAll('.file-card, .file-row')
.forEach(el => el.classList.add('selected'));
break;
}
}
if (e.key === 'Delete' && selectedFiles.size > 0) {
htmx.ajax('DELETE', '/api/v1/drive/file', {
target: '#file-list',
values: { paths: [...selectedFiles].join(',') }
});
}
if (e.key === 'Enter' && selectedFiles.size === 1) {
const path = [...selectedFiles][0];
htmx.ajax('GET', '/api/v1/drive/open', {
values: { path }
});
}
});
```
## File Type Icons
| Extension | Icon | Category |
|-----------|------|----------|
| `.pdf` | 📕 | Document |
| `.doc`, `.docx` | 📄 | Document |
| `.xls`, `.xlsx` | 📊 | Spreadsheet |
| `.ppt`, `.pptx` | 📽 | Presentation |
| `.jpg`, `.png`, `.gif` | 🖼 | Image |
| `.mp4`, `.mov` | 🎬 | Video |
| `.mp3`, `.wav` | 🎵 | Audio |
| `.zip`, `.rar` | 📦 | Archive |
| `.txt`, `.md` | 📝 | Text |
| Folder | 📁 | Directory |
## Storage Backend
Drive uses **SeaweedFS** for object storage:
- Distributed file system
- Automatic replication
- High availability
- Efficient for large files
- S3-compatible API
File metadata is stored in **PostgreSQL**:
- File names and paths
- Permissions and sharing
- Labels and stars
- Version history
## Troubleshooting
### Upload Fails
1. Check file size limit (default: 100MB)
2. Verify storage quota
3. Check network connection
4. Look for file type restrictions
### Files Not Displaying
1. Refresh the page
2. Check current path is valid
3. Verify permissions
4. Clear browser cache
### Context Menu Not Working
1. Enable JavaScript
2. Check for console errors
3. Try right-clicking on file directly
4. Refresh the page
## See Also
- [HTMX Architecture](../htmx-architecture.md) - How Drive uses HTMX
- [Suite Manual](../suite-manual.md) - Complete user guide
- [Chat App](./chat.md) - Share files in chat
- [Storage API](../../chapter-10-api/storage-api.md) - API reference

View file

@ -0,0 +1 @@
# Mail - Email Client

View file

@ -0,0 +1 @@
# Meet - Video Calls

View file

@ -0,0 +1 @@
# Paper - AI Writing

View file

@ -0,0 +1 @@
# Research - AI Search

View file

@ -0,0 +1 @@
# Sources - Prompts & Templates

View file

@ -0,0 +1,610 @@
# Tasks - To-Do Management
> **Track what needs to be done**
<img src="../../assets/suite/tasks-flow.svg" alt="Tasks Flow Diagram" style="max-width: 100%; height: auto;">
---
## Overview
Tasks is your to-do list manager within General Bots Suite. Create tasks, set priorities, organize by category, and track your progress. Built with HTMX for instant updates without page reloads.
## Interface Layout
```
┌─────────────────────────────────────────────────────────────────┐
│ ✓ Tasks Total: 12 Active: 5 Done: 7│
├─────────────────────────────────────────────────────────────────┤
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ What needs to be done? [Category ▼] [📅] [+ Add]│ │
│ └─────────────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────────────┤
│ [📋 All (12)] [⏳ Active (5)] [✓ Completed (7)] [⚡ Priority] │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ☐ Review quarterly report 📅 Today 🔴 │
│ ☐ Call client about proposal 📅 Today 🟡 │
│ ☐ Update project documentation 📅 Tomorrow 🟢 │
│ ☐ Schedule team meeting 📅 Mar 20 ⚪ │
│ ────────────────────────────────────────────────────────────── │
│ ☑ Send meeting notes ✓ Done │
│ ☑ Complete expense report ✓ Done │
│ ☑ Review pull request ✓ Done │
│ │
└─────────────────────────────────────────────────────────────────┘
```
## Features
### Adding Tasks
**Quick Add:**
1. Type task description in the input box
2. Press **Enter** or click **+ Add**
**With Details:**
1. Type task description
2. Select a category (optional)
3. Pick a due date (optional)
4. Click **+ Add**
```html
<form class="add-task-form"
hx-post="/api/tasks"
hx-target="#task-list"
hx-swap="afterbegin"
hx-on::after-request="this.reset()">
<input type="text"
name="text"
placeholder="What needs to be done?"
required>
<select name="category">
<option value="">No Category</option>
<option value="work">Work</option>
<option value="personal">Personal</option>
<option value="shopping">Shopping</option>
<option value="health">Health</option>
</select>
<input type="date" name="dueDate">
<button type="submit">+ Add Task</button>
</form>
```
### Completing Tasks
Click the checkbox to mark a task complete:
```html
<div class="task-item" id="task-123">
<input type="checkbox"
hx-patch="/api/tasks/123"
hx-vals='{"completed": true}'
hx-target="#task-123"
hx-swap="outerHTML">
<span class="task-text">Review quarterly report</span>
<span class="task-due">📅 Today</span>
<span class="task-priority high">🔴</span>
</div>
```
### Priority Levels
| Priority | Color | Icon | When to Use |
|----------|-------|------|-------------|
| **High** | Red | 🔴 | Must do today |
| **Medium** | Yellow | 🟡 | Important but not urgent |
| **Low** | Green | 🟢 | Can wait |
| **None** | Gray | ⚪ | No deadline |
### Categories
Organize tasks by category:
| Category | Color | Icon |
|----------|-------|------|
| Work | Blue | 💼 |
| Personal | Green | 🏠 |
| Shopping | Orange | 🛒 |
| Health | Red | ❤️ |
| Custom | Purple | 🏷️ |
### Filter Tabs
| Tab | Shows | HTMX Trigger |
|-----|-------|--------------|
| **All** | All tasks | `hx-get="/api/tasks?filter=all"` |
| **Active** | Uncompleted tasks | `hx-get="/api/tasks?filter=active"` |
| **Completed** | Done tasks | `hx-get="/api/tasks?filter=completed"` |
| **Priority** | High priority only | `hx-get="/api/tasks?filter=priority"` |
```html
<div class="filter-tabs">
<button class="filter-tab active"
hx-get="/api/tasks?filter=all"
hx-target="#task-list"
hx-swap="innerHTML">
📋 All
<span class="tab-count" id="count-all">12</span>
</button>
<button class="filter-tab"
hx-get="/api/tasks?filter=active"
hx-target="#task-list"
hx-swap="innerHTML">
⏳ Active
<span class="tab-count" id="count-active">5</span>
</button>
<button class="filter-tab"
hx-get="/api/tasks?filter=completed"
hx-target="#task-list"
hx-swap="innerHTML">
✓ Completed
<span class="tab-count" id="count-completed">7</span>
</button>
<button class="filter-tab"
hx-get="/api/tasks?filter=priority"
hx-target="#task-list"
hx-swap="innerHTML">
⚡ Priority
</button>
</div>
```
### Stats Dashboard
Real-time statistics shown in the header:
```html
<div class="header-stats" id="task-stats"
hx-get="/api/tasks/stats"
hx-trigger="load, taskUpdated from:body"
hx-swap="innerHTML">
<span class="stat-item">
<span class="stat-value">12</span>
<span class="stat-label">Total</span>
</span>
<span class="stat-item">
<span class="stat-value">5</span>
<span class="stat-label">Active</span>
</span>
<span class="stat-item">
<span class="stat-value">7</span>
<span class="stat-label">Completed</span>
</span>
</div>
```
## Keyboard Shortcuts
| Shortcut | Action |
|----------|--------|
| `Enter` | Add task (when in input) |
| `Space` | Toggle task completion |
| `Delete` | Remove selected task |
| `Tab` | Move to next field |
| `Escape` | Cancel editing |
| `↑` / `↓` | Navigate tasks |
## API Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/tasks` | GET | List all tasks |
| `/api/tasks` | POST | Create new task |
| `/api/tasks/:id` | GET | Get single task |
| `/api/tasks/:id` | PATCH | Update task |
| `/api/tasks/:id` | DELETE | Delete task |
| `/api/tasks/stats` | GET | Get task statistics |
### Query Parameters
```
GET /api/tasks?filter=active&category=work&sort=dueDate&order=asc
```
| Parameter | Values | Default |
|-----------|--------|---------|
| `filter` | `all`, `active`, `completed`, `priority` | `all` |
| `category` | `work`, `personal`, `shopping`, `health` | none |
| `sort` | `created`, `dueDate`, `priority`, `text` | `created` |
| `order` | `asc`, `desc` | `desc` |
### Request Body (Create/Update)
```json
{
"text": "Review quarterly report",
"category": "work",
"dueDate": "2024-03-20",
"priority": "high",
"completed": false
}
```
### Response Format
```json
{
"id": 123,
"text": "Review quarterly report",
"category": "work",
"dueDate": "2024-03-20",
"priority": "high",
"completed": false,
"createdAt": "2024-03-18T10:30:00Z",
"updatedAt": "2024-03-18T10:30:00Z"
}
```
## HTMX Integration
### Task Creation
```html
<form hx-post="/api/tasks"
hx-target="#task-list"
hx-swap="afterbegin"
hx-on::after-request="this.reset(); htmx.trigger('body', 'taskUpdated')">
<input type="text" name="text" required>
<button type="submit">Add</button>
</form>
```
### Task Toggle
```html
<input type="checkbox"
hx-patch="/api/tasks/123"
hx-vals='{"completed": true}'
hx-target="closest .task-item"
hx-swap="outerHTML"
hx-on::after-request="htmx.trigger('body', 'taskUpdated')">
```
### Task Deletion
```html
<button hx-delete="/api/tasks/123"
hx-target="closest .task-item"
hx-swap="outerHTML swap:0.3s"
hx-confirm="Delete this task?">
🗑
</button>
```
### Inline Editing
```html
<span class="task-text"
hx-get="/api/tasks/123/edit"
hx-trigger="dblclick"
hx-target="this"
hx-swap="outerHTML">
Review quarterly report
</span>
```
## CSS Classes
```css
.tasks-container {
display: flex;
flex-direction: column;
height: 100%;
padding: 1rem;
}
.tasks-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.header-stats {
display: flex;
gap: 1.5rem;
}
.stat-item {
text-align: center;
}
.stat-value {
display: block;
font-size: 1.5rem;
font-weight: 600;
color: var(--primary);
}
.stat-label {
font-size: 0.75rem;
color: var(--text-secondary);
}
.add-task-form {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.task-input {
flex: 1;
padding: 0.75rem 1rem;
border: 2px solid var(--border);
border-radius: 8px;
font-size: 1rem;
}
.task-input:focus {
border-color: var(--primary);
outline: none;
}
.filter-tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
border-bottom: 1px solid var(--border);
padding-bottom: 0.5rem;
}
.filter-tab {
padding: 0.5rem 1rem;
border: none;
background: transparent;
cursor: pointer;
border-radius: 6px;
display: flex;
align-items: center;
gap: 0.5rem;
}
.filter-tab:hover {
background: var(--surface-hover);
}
.filter-tab.active {
background: var(--primary);
color: white;
}
.tab-count {
background: rgba(255,255,255,0.2);
padding: 0.125rem 0.5rem;
border-radius: 10px;
font-size: 0.75rem;
}
.task-list {
flex: 1;
overflow-y: auto;
}
.task-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
border-bottom: 1px solid var(--border);
transition: all 0.2s;
}
.task-item:hover {
background: var(--surface-hover);
}
.task-item.completed {
opacity: 0.6;
}
.task-item.completed .task-text {
text-decoration: line-through;
color: var(--text-secondary);
}
.task-checkbox {
width: 20px;
height: 20px;
border: 2px solid var(--border);
border-radius: 4px;
cursor: pointer;
appearance: none;
}
.task-checkbox:checked {
background: var(--success);
border-color: var(--success);
}
.task-checkbox:checked::after {
content: '✓';
display: block;
text-align: center;
color: white;
font-size: 14px;
line-height: 16px;
}
.task-text {
flex: 1;
}
.task-due {
font-size: 0.875rem;
color: var(--text-secondary);
}
.task-due.overdue {
color: var(--error);
}
.task-priority {
font-size: 0.75rem;
}
.task-priority.high {
color: var(--error);
}
.task-priority.medium {
color: var(--warning);
}
.task-priority.low {
color: var(--success);
}
.task-delete {
opacity: 0;
padding: 0.25rem;
border: none;
background: transparent;
cursor: pointer;
color: var(--error);
}
.task-item:hover .task-delete {
opacity: 1;
}
/* Animation for task completion */
.task-item.completing {
animation: complete 0.3s ease-out;
}
@keyframes complete {
0% { transform: scale(1); }
50% { transform: scale(1.02); background: var(--success-light); }
100% { transform: scale(1); }
}
```
## JavaScript Handlers
```javascript
// Tab switching
function setActiveTab(button) {
document.querySelectorAll('.filter-tab').forEach(tab => {
tab.classList.remove('active');
});
button.classList.add('active');
}
// Task completion animation
document.body.addEventListener('htmx:beforeSwap', (e) => {
if (e.detail.target.classList.contains('task-item')) {
e.detail.target.classList.add('completing');
}
});
// Update counts after any task change
document.body.addEventListener('taskUpdated', () => {
htmx.ajax('GET', '/api/tasks/stats', {
target: '#task-stats',
swap: 'innerHTML'
});
});
// Keyboard navigation
document.addEventListener('keydown', (e) => {
const taskItems = document.querySelectorAll('.task-item');
const focused = document.activeElement.closest('.task-item');
if (e.key === 'ArrowDown' && focused) {
const index = [...taskItems].indexOf(focused);
if (index < taskItems.length - 1) {
taskItems[index + 1].querySelector('input').focus();
}
}
if (e.key === 'ArrowUp' && focused) {
const index = [...taskItems].indexOf(focused);
if (index > 0) {
taskItems[index - 1].querySelector('input').focus();
}
}
if (e.key === ' ' && focused) {
e.preventDefault();
focused.querySelector('.task-checkbox').click();
}
});
// Due date formatting
function formatDueDate(dateString) {
const date = new Date(dateString);
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
if (date.toDateString() === today.toDateString()) {
return 'Today';
} else if (date.toDateString() === tomorrow.toDateString()) {
return 'Tomorrow';
} else {
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric'
});
}
}
```
## Creating Tasks from Chat
In the Chat app, you can create tasks naturally:
```
You: Create a task to review the budget by Friday
Bot: ✅ Task created:
📋 Review the budget
📅 Due: Friday, March 22
🏷️ Category: Work
[View Tasks]
```
## Integration with Calendar
Tasks with due dates appear in your Calendar view:
```html
<div class="calendar-task"
hx-get="/api/tasks/123"
hx-target="#task-detail"
hx-trigger="click">
📋 Review quarterly report
</div>
```
## Troubleshooting
### Tasks Not Saving
1. Check network connection
2. Verify API endpoint is accessible
3. Check browser console for errors
4. Try refreshing the page
### Filters Not Working
1. Click the filter tab again
2. Check if tasks exist for that filter
3. Clear browser cache
4. Verify HTMX is loaded
### Stats Not Updating
1. Check for JavaScript errors
2. Verify `taskUpdated` event is firing
3. Inspect network requests
4. Reload the page
## See Also
- [HTMX Architecture](../htmx-architecture.md) - How Tasks uses HTMX
- [Suite Manual](../suite-manual.md) - Complete user guide
- [Chat App](./chat.md) - Create tasks from chat
- [Calendar App](./calendar.md) - View tasks in calendar
- [Tasks API](../../chapter-10-api/tasks-api.md) - API reference

View file

@ -14,10 +14,73 @@ use crate::tasks::{TaskEngine, TaskScheduler};
use aws_sdk_s3::Client as S3Client;
#[cfg(feature = "redis-cache")]
use redis::Client as RedisClient;
use std::any::{Any, TypeId};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::mpsc;
/// Type-erased extension storage for AppState
#[derive(Default)]
pub struct Extensions {
map: HashMap<TypeId, Box<dyn Any + Send + Sync>>,
}
impl Extensions {
pub fn new() -> Self {
Self {
map: HashMap::new(),
}
}
/// Insert a value into the extensions
pub fn insert<T: Send + Sync + 'static>(&mut self, value: T) {
self.map.insert(TypeId::of::<T>(), Box::new(value));
}
/// Get a reference to a value from the extensions
pub fn get<T: Send + Sync + 'static>(&self) -> Option<&T> {
self.map
.get(&TypeId::of::<T>())
.and_then(|boxed| boxed.downcast_ref::<T>())
}
/// Get a mutable reference to a value from the extensions
pub fn get_mut<T: Send + Sync + 'static>(&mut self) -> Option<&mut T> {
self.map
.get_mut(&TypeId::of::<T>())
.and_then(|boxed| boxed.downcast_mut::<T>())
}
/// Check if a value of type T exists
pub fn contains<T: Send + Sync + 'static>(&self) -> bool {
self.map.contains_key(&TypeId::of::<T>())
}
/// Remove a value from the extensions
pub fn remove<T: Send + Sync + 'static>(&mut self) -> Option<T> {
self.map
.remove(&TypeId::of::<T>())
.and_then(|boxed| boxed.downcast::<T>().ok())
.map(|boxed| *boxed)
}
}
impl Clone for Extensions {
fn clone(&self) -> Self {
// Extensions cannot be cloned deeply, so we create an empty one
// This is a limitation - extensions should be Arc-wrapped if sharing is needed
Self::new()
}
}
impl std::fmt::Debug for Extensions {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Extensions")
.field("count", &self.map.len())
.finish()
}
}
pub struct AppState {
#[cfg(feature = "drive")]
pub drive: Option<S3Client>,
@ -41,6 +104,8 @@ pub struct AppState {
pub voice_adapter: Arc<VoiceAdapter>,
pub kb_manager: Option<Arc<KnowledgeBaseManager>>,
pub task_engine: Arc<TaskEngine>,
/// Type-erased extension storage for web handlers and other components
pub extensions: Extensions,
}
impl Clone for AppState {
fn clone(&self) -> Self {
@ -67,6 +132,7 @@ impl Clone for AppState {
web_adapter: Arc::clone(&self.web_adapter),
voice_adapter: Arc::clone(&self.voice_adapter),
task_engine: Arc::clone(&self.task_engine),
extensions: self.extensions.clone(),
}
}
}
@ -103,6 +169,7 @@ impl std::fmt::Debug for AppState {
.field("response_channels", &"Arc<Mutex<HashMap>>")
.field("web_adapter", &self.web_adapter)
.field("voice_adapter", &self.voice_adapter)
.field("extensions", &self.extensions)
.finish()
}
}

View file

@ -649,6 +649,7 @@ async fn main() -> std::io::Result<()> {
voice_adapter: voice_adapter.clone(),
kb_manager: Some(kb_manager.clone()),
task_engine: task_engine,
extensions: botserver::core::shared::state::Extensions::new(),
});
// Initialize TaskScheduler with the AppState

401
src/security/mutual_tls.rs Normal file
View file

@ -0,0 +1,401 @@
//! Mutual TLS (mTLS) Module
//!
//! This module provides mutual TLS authentication for service-to-service communication.
//! It enables secure connections between BotServer and its dependent services like
//! PostgreSQL, Qdrant, LiveKit, Forgejo, and Directory services.
use std::path::Path;
use std::sync::Arc;
use tracing::{debug, error, info, warn};
/// Services module containing mTLS configuration functions for each service
pub mod services {
use super::*;
/// Configure mTLS for PostgreSQL connections
///
/// # Arguments
/// * `ca_cert_path` - Path to the CA certificate
/// * `client_cert_path` - Path to the client certificate
/// * `client_key_path` - Path to the client private key
///
/// # Returns
/// Result containing the SSL mode string or an error
pub fn configure_postgres_mtls(
ca_cert_path: Option<&Path>,
client_cert_path: Option<&Path>,
client_key_path: Option<&Path>,
) -> Result<String, MtlsError> {
match (ca_cert_path, client_cert_path, client_key_path) {
(Some(ca), Some(cert), Some(key)) => {
if !ca.exists() {
return Err(MtlsError::CertificateNotFound(
ca.to_string_lossy().to_string(),
));
}
if !cert.exists() {
return Err(MtlsError::CertificateNotFound(
cert.to_string_lossy().to_string(),
));
}
if !key.exists() {
return Err(MtlsError::KeyNotFound(key.to_string_lossy().to_string()));
}
info!("PostgreSQL mTLS configured with client certificates");
Ok("verify-full".to_string())
}
(Some(ca), None, None) => {
if !ca.exists() {
return Err(MtlsError::CertificateNotFound(
ca.to_string_lossy().to_string(),
));
}
info!("PostgreSQL TLS configured with CA verification only");
Ok("verify-ca".to_string())
}
_ => {
debug!("PostgreSQL mTLS not configured, using default connection");
Ok("prefer".to_string())
}
}
}
/// Configure mTLS for Qdrant vector database connections
///
/// # Arguments
/// * `ca_cert_path` - Path to the CA certificate
/// * `client_cert_path` - Path to the client certificate
/// * `client_key_path` - Path to the client private key
///
/// # Returns
/// Result containing the mTLS configuration or an error
pub fn configure_qdrant_mtls(
ca_cert_path: Option<&Path>,
client_cert_path: Option<&Path>,
client_key_path: Option<&Path>,
) -> Result<MtlsConfig, MtlsError> {
match (ca_cert_path, client_cert_path, client_key_path) {
(Some(ca), Some(cert), Some(key)) => {
let ca_pem = std::fs::read_to_string(ca).map_err(|e| {
MtlsError::IoError(format!("Failed to read CA cert: {}", e))
})?;
let cert_pem = std::fs::read_to_string(cert).map_err(|e| {
MtlsError::IoError(format!("Failed to read client cert: {}", e))
})?;
let key_pem = std::fs::read_to_string(key).map_err(|e| {
MtlsError::IoError(format!("Failed to read client key: {}", e))
})?;
info!("Qdrant mTLS configured successfully");
Ok(MtlsConfig {
enabled: true,
ca_cert: Some(ca_pem),
client_cert: Some(cert_pem),
client_key: Some(key_pem),
})
}
_ => {
debug!("Qdrant mTLS not configured");
Ok(MtlsConfig::default())
}
}
}
/// Configure mTLS for LiveKit media server connections
///
/// # Arguments
/// * `ca_cert_path` - Path to the CA certificate
/// * `client_cert_path` - Path to the client certificate
/// * `client_key_path` - Path to the client private key
///
/// # Returns
/// Result containing the mTLS configuration or an error
pub fn configure_livekit_mtls(
ca_cert_path: Option<&Path>,
client_cert_path: Option<&Path>,
client_key_path: Option<&Path>,
) -> Result<MtlsConfig, MtlsError> {
match (ca_cert_path, client_cert_path, client_key_path) {
(Some(ca), Some(cert), Some(key)) => {
let ca_pem = std::fs::read_to_string(ca).map_err(|e| {
MtlsError::IoError(format!("Failed to read CA cert: {}", e))
})?;
let cert_pem = std::fs::read_to_string(cert).map_err(|e| {
MtlsError::IoError(format!("Failed to read client cert: {}", e))
})?;
let key_pem = std::fs::read_to_string(key).map_err(|e| {
MtlsError::IoError(format!("Failed to read client key: {}", e))
})?;
info!("LiveKit mTLS configured successfully");
Ok(MtlsConfig {
enabled: true,
ca_cert: Some(ca_pem),
client_cert: Some(cert_pem),
client_key: Some(key_pem),
})
}
_ => {
debug!("LiveKit mTLS not configured");
Ok(MtlsConfig::default())
}
}
}
/// Configure mTLS for Forgejo git server connections
///
/// # Arguments
/// * `ca_cert_path` - Path to the CA certificate
/// * `client_cert_path` - Path to the client certificate
/// * `client_key_path` - Path to the client private key
///
/// # Returns
/// Result containing the mTLS configuration or an error
pub fn configure_forgejo_mtls(
ca_cert_path: Option<&Path>,
client_cert_path: Option<&Path>,
client_key_path: Option<&Path>,
) -> Result<MtlsConfig, MtlsError> {
match (ca_cert_path, client_cert_path, client_key_path) {
(Some(ca), Some(cert), Some(key)) => {
let ca_pem = std::fs::read_to_string(ca).map_err(|e| {
MtlsError::IoError(format!("Failed to read CA cert: {}", e))
})?;
let cert_pem = std::fs::read_to_string(cert).map_err(|e| {
MtlsError::IoError(format!("Failed to read client cert: {}", e))
})?;
let key_pem = std::fs::read_to_string(key).map_err(|e| {
MtlsError::IoError(format!("Failed to read client key: {}", e))
})?;
info!("Forgejo mTLS configured successfully");
Ok(MtlsConfig {
enabled: true,
ca_cert: Some(ca_pem),
client_cert: Some(cert_pem),
client_key: Some(key_pem),
})
}
_ => {
debug!("Forgejo mTLS not configured");
Ok(MtlsConfig::default())
}
}
}
/// Configure mTLS for Directory service (LDAP/AD) connections
///
/// # Arguments
/// * `ca_cert_path` - Path to the CA certificate
/// * `client_cert_path` - Path to the client certificate
/// * `client_key_path` - Path to the client private key
///
/// # Returns
/// Result containing the mTLS configuration or an error
pub fn configure_directory_mtls(
ca_cert_path: Option<&Path>,
client_cert_path: Option<&Path>,
client_key_path: Option<&Path>,
) -> Result<MtlsConfig, MtlsError> {
match (ca_cert_path, client_cert_path, client_key_path) {
(Some(ca), Some(cert), Some(key)) => {
let ca_pem = std::fs::read_to_string(ca).map_err(|e| {
MtlsError::IoError(format!("Failed to read CA cert: {}", e))
})?;
let cert_pem = std::fs::read_to_string(cert).map_err(|e| {
MtlsError::IoError(format!("Failed to read client cert: {}", e))
})?;
let key_pem = std::fs::read_to_string(key).map_err(|e| {
MtlsError::IoError(format!("Failed to read client key: {}", e))
})?;
info!("Directory service mTLS configured successfully");
Ok(MtlsConfig {
enabled: true,
ca_cert: Some(ca_pem),
client_cert: Some(cert_pem),
client_key: Some(key_pem),
})
}
_ => {
debug!("Directory service mTLS not configured");
Ok(MtlsConfig::default())
}
}
}
}
/// mTLS configuration structure
#[derive(Debug, Clone, Default)]
pub struct MtlsConfig {
/// Whether mTLS is enabled
pub enabled: bool,
/// CA certificate PEM content
pub ca_cert: Option<String>,
/// Client certificate PEM content
pub client_cert: Option<String>,
/// Client private key PEM content
pub client_key: Option<String>,
}
impl MtlsConfig {
/// Create a new mTLS configuration
pub fn new(
ca_cert: Option<String>,
client_cert: Option<String>,
client_key: Option<String>,
) -> Self {
let enabled = ca_cert.is_some() && client_cert.is_some() && client_key.is_some();
Self {
enabled,
ca_cert,
client_cert,
client_key,
}
}
/// Check if mTLS is properly configured
pub fn is_configured(&self) -> bool {
self.enabled
&& self.ca_cert.is_some()
&& self.client_cert.is_some()
&& self.client_key.is_some()
}
}
/// mTLS error types
#[derive(Debug, thiserror::Error)]
pub enum MtlsError {
#[error("Certificate not found: {0}")]
CertificateNotFound(String),
#[error("Private key not found: {0}")]
KeyNotFound(String),
#[error("Invalid certificate format: {0}")]
InvalidCertificate(String),
#[error("Invalid key format: {0}")]
InvalidKey(String),
#[error("IO error: {0}")]
IoError(String),
#[error("TLS configuration error: {0}")]
TlsConfigError(String),
}
/// mTLS Manager for handling mutual TLS connections
pub struct MtlsManager {
config: MtlsConfig,
}
impl MtlsManager {
/// Create a new mTLS manager
pub fn new(config: MtlsConfig) -> Self {
Self { config }
}
/// Get the current configuration
pub fn config(&self) -> &MtlsConfig {
&self.config
}
/// Check if mTLS is enabled
pub fn is_enabled(&self) -> bool {
self.config.enabled
}
/// Validate the mTLS configuration
pub fn validate(&self) -> Result<(), MtlsError> {
if !self.config.enabled {
return Ok(());
}
// Validate CA certificate
if let Some(ref ca) = self.config.ca_cert {
if !ca.contains("-----BEGIN CERTIFICATE-----") {
return Err(MtlsError::InvalidCertificate(
"CA certificate is not in PEM format".to_string(),
));
}
}
// Validate client certificate
if let Some(ref cert) = self.config.client_cert {
if !cert.contains("-----BEGIN CERTIFICATE-----") {
return Err(MtlsError::InvalidCertificate(
"Client certificate is not in PEM format".to_string(),
));
}
}
// Validate client key
if let Some(ref key) = self.config.client_key {
if !key.contains("-----BEGIN") || !key.contains("PRIVATE KEY-----") {
return Err(MtlsError::InvalidKey(
"Client key is not in PEM format".to_string(),
));
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_mtls_config_default() {
let config = MtlsConfig::default();
assert!(!config.enabled);
assert!(config.ca_cert.is_none());
assert!(config.client_cert.is_none());
assert!(config.client_key.is_none());
}
#[test]
fn test_mtls_config_new() {
let config = MtlsConfig::new(
Some("ca_cert".to_string()),
Some("client_cert".to_string()),
Some("client_key".to_string()),
);
assert!(config.enabled);
assert!(config.is_configured());
}
#[test]
fn test_mtls_config_partial() {
let config = MtlsConfig::new(Some("ca_cert".to_string()), None, None);
assert!(!config.enabled);
assert!(!config.is_configured());
}
#[test]
fn test_mtls_manager_validation() {
let config = MtlsConfig {
enabled: true,
ca_cert: Some("-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----".to_string()),
client_cert: Some("-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----".to_string()),
client_key: Some("-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----".to_string()),
};
let manager = MtlsManager::new(config);
assert!(manager.validate().is_ok());
}
#[test]
fn test_mtls_manager_invalid_cert() {
let config = MtlsConfig {
enabled: true,
ca_cert: Some("invalid".to_string()),
client_cert: Some("-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----".to_string()),
client_key: Some("-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----".to_string()),
};
let manager = MtlsManager::new(config);
assert!(manager.validate().is_err());
}
}

View file

@ -3,21 +3,34 @@
use axum::{
async_trait,
extract::{FromRef, FromRequestParts, Query, State},
headers::{authorization::Bearer, Authorization, Cookie},
http::{header, request::Parts, Request, StatusCode},
http::{header, request::Parts, HeaderMap, Request, StatusCode},
middleware::Next,
response::{IntoResponse, Redirect, Response},
Json, RequestPartsExt, TypedHeader,
Json,
};
use chrono::{Duration, Utc};
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tower_cookies::{Cookies, Key};
use tower_cookies::Cookies;
use uuid::Uuid;
use crate::shared::state::AppState;
/// Extract bearer token from Authorization header
fn extract_bearer_token(headers: &HeaderMap) -> Option<String> {
headers
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|auth| {
if auth.to_lowercase().starts_with("bearer ") {
Some(auth[7..].to_string())
} else {
None
}
})
}
/// JWT Claims structure
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Claims {
@ -45,6 +58,16 @@ pub struct UserSession {
pub created_at: i64,
}
/// Cookie key for signing (simple wrapper)
#[derive(Clone)]
pub struct CookieKey(Vec<u8>);
impl CookieKey {
pub fn from(bytes: &[u8]) -> Self {
Self(bytes.to_vec())
}
}
/// Authentication configuration
#[derive(Clone)]
pub struct AuthConfig {
@ -54,16 +77,18 @@ pub struct AuthConfig {
pub zitadel_url: String,
pub zitadel_client_id: String,
pub zitadel_client_secret: String,
pub cookie_key: Key,
pub cookie_key: CookieKey,
}
impl AuthConfig {
pub fn from_env() -> Self {
// Use Zitadel directory service for all configuration
// No environment variables should be read directly
use base64::Engine;
let jwt_secret = {
// Generate a secure random secret - should come from directory service
let secret = base64::encode(uuid::Uuid::new_v4().as_bytes());
let secret =
base64::engine::general_purpose::STANDARD.encode(uuid::Uuid::new_v4().as_bytes());
tracing::info!("Using generated JWT secret");
secret
};
@ -81,7 +106,7 @@ impl AuthConfig {
zitadel_url: crate::core::urls::InternalUrls::DIRECTORY_BASE.to_string(),
zitadel_client_id: "botserver-web".to_string(),
zitadel_client_secret: String::new(), // Retrieved from directory service
cookie_key: Key::from(cookie_secret.as_bytes()),
cookie_key: CookieKey::from(cookie_secret.as_bytes()),
}
}
@ -108,18 +133,13 @@ where
{
type Rejection = (StatusCode, &'static str);
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let app_state = AppState::from_ref(state);
let auth_config = app_state
.extensions
.get::<AuthConfig>()
.ok_or((StatusCode::INTERNAL_SERVER_ERROR, "Auth not configured"))?;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
// Get auth config from environment for now (simplified)
let auth_config = AuthConfig::from_env();
// Try to get token from Authorization header first
let token = if let Ok(TypedHeader(Authorization(bearer))) =
parts.extract::<TypedHeader<Authorization<Bearer>>>().await
{
bearer.token().to_string()
let token = if let Some(bearer_token) = extract_bearer_token(&parts.headers) {
bearer_token
} else if let Ok(cookies) = parts.extract::<Cookies>().await {
// Fall back to cookie
cookies

View file

@ -20,7 +20,7 @@ use super::auth::{
/// Login page template
#[derive(Template)]
#[template(path = "auth/login.html")]
#[template(path = "suite/auth/login.html")]
pub struct LoginTemplate {
pub error_message: Option<String>,
pub redirect_url: Option<String>,

View file

@ -18,35 +18,35 @@ use crate::shared::state::AppState;
/// Chat page template
#[derive(Template)]
#[template(path = "chat.html")]
#[template(path = "suite/chat.html")]
pub struct ChatTemplate {
pub session_id: String,
}
/// Session list template
#[derive(Template)]
#[template(path = "partials/sessions.html")]
#[template(path = "suite/partials/sessions.html")]
struct SessionsTemplate {
sessions: Vec<SessionItem>,
}
/// Message list template
#[derive(Template)]
#[template(path = "partials/messages.html")]
#[template(path = "suite/partials/messages.html")]
struct MessagesTemplate {
messages: Vec<Message>,
}
/// Suggestions template
#[derive(Template)]
#[template(path = "partials/suggestions.html")]
#[template(path = "suite/partials/suggestions.html")]
struct SuggestionsTemplate {
suggestions: Vec<String>,
}
/// Context selector template
#[derive(Template)]
#[template(path = "partials/contexts.html")]
#[template(path = "suite/partials/contexts.html")]
struct ContextsTemplate {
contexts: Vec<Context>,
current_context: Option<String>,
@ -94,15 +94,13 @@ impl ChatState {
pub fn new() -> Self {
let (tx, _) = broadcast::channel(1000);
Self {
sessions: Arc::new(RwLock::new(vec![
SessionItem {
sessions: Arc::new(RwLock::new(vec![SessionItem {
id: Uuid::new_v4().to_string(),
name: "Default Session".to_string(),
last_message: "Welcome to General Bots".to_string(),
timestamp: chrono::Utc::now().to_rfc3339(),
active: true,
},
])),
}])),
messages: Arc::new(RwLock::new(vec![])),
contexts: Arc::new(RwLock::new(vec![
Context {
@ -212,7 +210,9 @@ async fn send_message(
}
// Broadcast via WebSocket
let _ = chat_state.broadcast.send(WsMessage::Message(user_message.clone()));
let _ = chat_state
.broadcast
.send(WsMessage::Message(user_message.clone()));
// Simulate bot response (this would call actual LLM service)
let bot_message = Message {
@ -231,7 +231,9 @@ async fn send_message(
}
// Broadcast bot message
let _ = chat_state.broadcast.send(WsMessage::Message(bot_message.clone()));
let _ = chat_state
.broadcast
.send(WsMessage::Message(bot_message.clone()));
// Return rendered messages
MessagesTemplate {
@ -279,13 +281,13 @@ async fn create_session(
// Return single session HTML
format!(
r#"<div class="session-item active"
r##"<div class="session-item active"
hx-post="/api/chat/sessions/{}"
hx-target="#messages"
hx-swap="innerHTML">
<div class="session-name">{}</div>
<div class="session-time">{}</div>
</div>"#,
</div>"##,
new_session.id, new_session.name, new_session.timestamp
)
}
@ -312,11 +314,7 @@ async fn switch_session(
});
// Return messages for this session
get_messages(
Query(GetMessagesParams { session_id: id }),
State(state),
)
.await
get_messages(Query(GetMessagesParams { session_id: id }), State(state)).await
}
/// Get suggestions
@ -388,7 +386,11 @@ pub async fn websocket_handler(
ws.on_upgrade(move |socket| handle_chat_socket(socket, state, claims))
}
async fn handle_chat_socket(socket: axum::extract::ws::WebSocket, state: AppState, claims: crate::web::auth::Claims) {
async fn handle_chat_socket(
socket: axum::extract::ws::WebSocket,
state: AppState,
claims: crate::web::auth::Claims,
) {
let (mut sender, mut receiver) = socket.split();
let chat_state = state.extensions.get::<ChatState>().unwrap();
let mut rx = chat_state.broadcast.subscribe();

View file

@ -49,7 +49,7 @@ pub mod drive {
}
#[derive(Template)]
#[template(path = "drive.html")]
#[template(path = "suite/drive.html")]
struct DriveTemplate {
user_name: String,
user_email: String,
@ -158,7 +158,7 @@ pub mod mail {
}
#[derive(Template)]
#[template(path = "mail.html")]
#[template(path = "suite/mail.html")]
struct MailTemplate {
user_name: String,
user_email: String,
@ -277,7 +277,7 @@ pub mod meet {
}
#[derive(Template)]
#[template(path = "meet.html")]
#[template(path = "suite/meet.html")]
struct MeetTemplate {
user_name: String,
user_email: String,
@ -370,7 +370,7 @@ pub mod tasks {
}
#[derive(Template)]
#[template(path = "tasks.html")]
#[template(path = "suite/tasks.html")]
struct TasksTemplate {
user_name: String,
user_email: String,
@ -387,7 +387,7 @@ pub struct BaseContext {
/// Home page template
#[derive(Template)]
#[template(path = "home.html")]
#[template(path = "suite/home.html")]
struct HomeTemplate {
base: BaseContext,
apps: Vec<AppCard>,
@ -404,7 +404,7 @@ struct AppCard {
/// Apps menu template
#[derive(Template)]
#[template(path = "partials/apps_menu.html")]
#[template(path = "suite/partials/apps_menu.html")]
struct AppsMenuTemplate {
apps: Vec<AppMenuItem>,
}
@ -420,7 +420,7 @@ struct AppMenuItem {
/// User menu template
#[derive(Template)]
#[template(path = "partials/user_menu.html")]
#[template(path = "suite/partials/user_menu.html")]
struct UserMenuTemplate {
user_name: String,
user_email: String,
@ -704,7 +704,7 @@ pub struct HtmxResponse {
/// Notification for HTMX
#[derive(Serialize, Template)]
#[template(path = "partials/notification.html")]
#[template(path = "suite/partials/notification.html")]
pub struct NotificationTemplate {
pub message: String,
pub severity: String, // info, success, warning, error
@ -712,7 +712,7 @@ pub struct NotificationTemplate {
/// Message template for chat/notifications
#[derive(Serialize, Template)]
#[template(path = "partials/message.html")]
#[template(path = "suite/partials/message.html")]
pub struct MessageTemplate {
pub id: String,
pub sender: String,

351
ui/suite/auth/login.html Normal file
View file

@ -0,0 +1,351 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - General Bots</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<style>
:root {
--primary: #3b82f6;
--primary-hover: #2563eb;
--bg: #0f172a;
--surface: #1e293b;
--border: #334155;
--text: #f8fafc;
--text-secondary: #94a3b8;
--error: #ef4444;
--success: #22c55e;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.login-container {
width: 100%;
max-width: 400px;
padding: 2rem;
}
.login-header {
text-align: center;
margin-bottom: 2rem;
}
.login-logo {
width: 64px;
height: 64px;
margin: 0 auto 1rem;
background: var(--primary);
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
}
.login-title {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.login-subtitle {
color: var(--text-secondary);
font-size: 0.875rem;
}
.login-form {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.5rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.5rem;
color: var(--text-secondary);
}
.form-input {
width: 100%;
padding: 0.75rem 1rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
font-size: 1rem;
transition: border-color 0.2s, box-shadow 0.2s;
}
.form-input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
}
.form-input::placeholder {
color: var(--text-secondary);
}
.form-checkbox {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: var(--text-secondary);
}
.form-checkbox input {
width: 16px;
height: 16px;
accent-color: var(--primary);
}
.login-btn {
width: 100%;
padding: 0.75rem 1rem;
background: var(--primary);
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
margin-top: 1rem;
}
.login-btn:hover {
background: var(--primary-hover);
}
.login-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.login-footer {
text-align: center;
margin-top: 1.5rem;
font-size: 0.875rem;
color: var(--text-secondary);
}
.login-footer a {
color: var(--primary);
text-decoration: none;
}
.login-footer a:hover {
text-decoration: underline;
}
.error-message {
background: rgba(239, 68, 68, 0.1);
border: 1px solid var(--error);
color: var(--error);
padding: 0.75rem 1rem;
border-radius: 8px;
margin-bottom: 1rem;
font-size: 0.875rem;
display: none;
}
.error-message.visible {
display: block;
}
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator {
display: inline-block;
}
.htmx-request .btn-text {
display: none;
}
.spinner {
width: 20px;
height: 20px;
border: 2px solid transparent;
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.divider {
display: flex;
align-items: center;
margin: 1.5rem 0;
color: var(--text-secondary);
font-size: 0.75rem;
}
.divider::before,
.divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--border);
}
.divider span {
padding: 0 1rem;
}
.social-login {
display: flex;
gap: 0.75rem;
}
.social-btn {
flex: 1;
padding: 0.75rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
font-size: 0.875rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
transition: border-color 0.2s, background 0.2s;
}
.social-btn:hover {
border-color: var(--primary);
background: var(--surface);
}
</style>
</head>
<body>
<div class="login-container">
<div class="login-header">
<div class="login-logo">🤖</div>
<h1 class="login-title">Welcome Back</h1>
<p class="login-subtitle">Sign in to General Bots Suite</p>
</div>
<div class="login-form">
{% if let Some(error) = error %}
<div class="error-message visible">{{ error }}</div>
{% else %}
<div class="error-message" id="error-message"></div>
{% endif %}
<form hx-post="/api/auth/login"
hx-target="#error-message"
hx-swap="outerHTML"
hx-indicator=".login-btn">
<div class="form-group">
<label class="form-label" for="email">Email</label>
<input type="email"
id="email"
name="email"
class="form-input"
placeholder="you@example.com"
required
autocomplete="email">
</div>
<div class="form-group">
<label class="form-label" for="password">Password</label>
<input type="password"
id="password"
name="password"""
class="form-input"
placeholder="••••••••"
required
autocomplete="current-password">
</div>
<div class="form-group">
<label class="form-checkbox">
<input type="checkbox" name="remember" value="true">
<span>Remember me for 30 days</span>
</label>
</div>
<button type="submit" class="login-btn">
<span class="btn-text">Sign In</span>
<div class="spinner htmx-indicator"></div>
</button>
</form>
<div class="divider">
<span>or continue with</span>
</div>
<div class="social-login">
<button type="button" class="social-btn"
hx-get="/api/auth/oauth/google"
hx-swap="none">
<svg width="18" height="18" viewBox="0 0 24 24">
<path fill="currentColor" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="currentColor" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="currentColor" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="currentColor" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
Google
</button>
<button type="button" class="social-btn"
hx-get="/api/auth/oauth/microsoft"
hx-swap="none">
<svg width="18" height="18" viewBox="0 0 24 24">
<path fill="currentColor" d="M11.4 24H0V12.6h11.4V24zM24 24H12.6V12.6H24V24zM11.4 11.4H0V0h11.4v11.4zm12.6 0H12.6V0H24v11.4z"/>
</svg>
Microsoft
</button>
</div>
</div>
<div class="login-footer">
<p>Don't have an account? <a href="/auth/register">Sign up</a></p>
<p style="margin-top: 0.5rem;"><a href="/auth/forgot-password">Forgot password?</a></p>
</div>
</div>
<script>
// Handle successful login redirect
document.body.addEventListener('htmx:afterRequest', function(event) {
if (event.detail.successful && event.detail.xhr.status === 200) {
const response = event.detail.xhr.response;
if (response && response.includes('redirect')) {
window.location.href = '/';
}
}
});
</script>
</body>
</html>

502
ui/suite/base.html Normal file
View file

@ -0,0 +1,502 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}General Bots Suite{% endblock %}</title>
<!-- HTMX -->
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script src="https://unpkg.com/htmx.org@1.9.10/dist/ext/ws.js"></script>
<!-- Styles -->
<link rel="stylesheet" href="/css/app.css">
<style>
:root {
--primary: #3b82f6;
--primary-hover: #2563eb;
--primary-light: rgba(59, 130, 246, 0.1);
--bg: #0f172a;
--surface: #1e293b;
--surface-hover: #334155;
--border: #334155;
--text: #f8fafc;
--text-secondary: #94a3b8;
--success: #22c55e;
--warning: #f59e0b;
--error: #ef4444;
--info: #3b82f6;
}
@media (prefers-color-scheme: light) {
:root {
--bg: #f8fafc;
--surface: #ffffff;
--surface-hover: #f1f5f9;
--border: #e2e8f0;
--text: #1e293b;
--text-secondary: #64748b;
}
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* Header */
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 1rem;
height: 64px;
background: var(--surface);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 1000;
}
.header-left {
display: flex;
align-items: center;
gap: 1rem;
}
.logo {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
font-size: 1.125rem;
color: var(--text);
text-decoration: none;
}
.logo-icon {
width: 32px;
height: 32px;
background: linear-gradient(135deg, var(--primary), #8b5cf6);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
}
.header-right {
display: flex;
align-items: center;
gap: 0.5rem;
}
.header-btn {
padding: 0.5rem;
background: transparent;
border: none;
color: var(--text-secondary);
border-radius: 8px;
cursor: pointer;
transition: background 0.2s, color 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.header-btn:hover {
background: var(--surface-hover);
color: var(--text);
}
.user-avatar {
width: 32px;
height: 32px;
background: var(--primary);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 500;
font-size: 0.875rem;
cursor: pointer;
}
/* Apps Menu */
.apps-menu {
position: relative;
}
.apps-dropdown {
position: absolute;
top: 100%;
right: 0;
margin-top: 0.5rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1rem;
min-width: 320px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
display: none;
z-index: 1001;
}
.apps-dropdown.show {
display: block;
}
.apps-dropdown-title {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.75rem;
}
.apps-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
}
.app-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 0.75rem;
border-radius: 8px;
text-decoration: none;
color: var(--text);
transition: background 0.2s;
}
.app-item:hover {
background: var(--surface-hover);
}
.app-item.active {
background: var(--primary-light);
color: var(--primary);
}
.app-item-icon {
width: 40px;
height: 40px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
.app-item span {
font-size: 0.75rem;
font-weight: 500;
}
/* Main Content */
.app-main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
#main-content {
flex: 1;
overflow: auto;
}
/* HTMX Indicators */
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator {
display: inline-flex;
}
.htmx-request.htmx-indicator {
display: inline-flex;
}
/* Spinner */
.spinner {
width: 20px;
height: 20px;
border: 2px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Notifications */
.notifications-container {
position: fixed;
bottom: 1rem;
right: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
z-index: 2000;
max-width: 400px;
}
.notification {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 1rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
animation: slideIn 0.3s ease-out;
}
.notification.success { border-left: 4px solid var(--success); }
.notification.error { border-left: 4px solid var(--error); }
.notification.warning { border-left: 4px solid var(--warning); }
.notification.info { border-left: 4px solid var(--info); }
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* Utility Classes */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Responsive */
@media (max-width: 768px) {
.logo span {
display: none;
}
.apps-dropdown {
right: -1rem;
min-width: 280px;
}
}
</style>
{% block head %}{% endblock %}
</head>
<body>
<!-- Header -->
<header class="app-header">
<div class="header-left">
<a href="/" class="logo">
<div class="logo-icon">🤖</div>
<span>General Bots</span>
</a>
</div>
<div class="header-right">
<!-- Apps Menu -->
<div class="apps-menu">
<button class="header-btn" id="apps-btn" aria-label="Applications" aria-expanded="false">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<circle cx="5" cy="5" r="2"></circle>
<circle cx="12" cy="5" r="2"></circle>
<circle cx="19" cy="5" r="2"></circle>
<circle cx="5" cy="12" r="2"></circle>
<circle cx="12" cy="12" r="2"></circle>
<circle cx="19" cy="12" r="2"></circle>
<circle cx="5" cy="19" r="2"></circle>
<circle cx="12" cy="19" r="2"></circle>
<circle cx="19" cy="19" r="2"></circle>
</svg>
</button>
<nav class="apps-dropdown" id="apps-dropdown" role="menu">
<div class="apps-dropdown-title">Applications</div>
<div class="apps-grid">
<a href="#chat" class="app-item" role="menuitem" hx-get="/chat/chat.html" hx-target="#main-content" hx-push-url="true">
<div class="app-item-icon" style="background: linear-gradient(135deg, #3b82f6, #1d4ed8);">💬</div>
<span>Chat</span>
</a>
<a href="#drive" class="app-item" role="menuitem" hx-get="/drive/index.html" hx-target="#main-content" hx-push-url="true">
<div class="app-item-icon" style="background: linear-gradient(135deg, #f59e0b, #d97706);">📁</div>
<span>Drive</span>
</a>
<a href="#tasks" class="app-item" role="menuitem" hx-get="/tasks/tasks.html" hx-target="#main-content" hx-push-url="true">
<div class="app-item-icon" style="background: linear-gradient(135deg, #22c55e, #16a34a);"></div>
<span>Tasks</span>
</a>
<a href="#mail" class="app-item" role="menuitem" hx-get="/mail/mail.html" hx-target="#main-content" hx-push-url="true">
<div class="app-item-icon" style="background: linear-gradient(135deg, #ef4444, #dc2626);">✉️</div>
<span>Mail</span>
</a>
<a href="#calendar" class="app-item" role="menuitem" hx-get="/calendar/calendar.html" hx-target="#main-content" hx-push-url="true">
<div class="app-item-icon" style="background: linear-gradient(135deg, #a855f7, #7c3aed);">📅</div>
<span>Calendar</span>
</a>
<a href="#meet" class="app-item" role="menuitem" hx-get="/meet/meet.html" hx-target="#main-content" hx-push-url="true">
<div class="app-item-icon" style="background: linear-gradient(135deg, #06b6d4, #0891b2);">🎥</div>
<span>Meet</span>
</a>
<a href="#paper" class="app-item" role="menuitem" hx-get="/paper/paper.html" hx-target="#main-content" hx-push-url="true">
<div class="app-item-icon" style="background: linear-gradient(135deg, #eab308, #ca8a04);">📝</div>
<span>Paper</span>
</a>
<a href="#research" class="app-item" role="menuitem" hx-get="/research/research.html" hx-target="#main-content" hx-push-url="true">
<div class="app-item-icon" style="background: linear-gradient(135deg, #ec4899, #db2777);">🔍</div>
<span>Research</span>
</a>
<a href="#analytics" class="app-item" role="menuitem" hx-get="/analytics/analytics.html" hx-target="#main-content" hx-push-url="true">
<div class="app-item-icon" style="background: linear-gradient(135deg, #6366f1, #4f46e5);">📊</div>
<span>Analytics</span>
</a>
</div>
</nav>
</div>
<!-- Theme Toggle -->
<button class="header-btn" id="theme-btn" aria-label="Toggle theme">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
</button>
<!-- User Avatar -->
<button class="user-avatar" aria-label="User menu">
{{ user_initial|default("U") }}
</button>
</div>
</header>
<!-- Main Content -->
<main class="app-main">
<div id="main-content">
{% block content %}{% endblock %}
</div>
</main>
<!-- Notifications Container -->
<div class="notifications-container" id="notifications"></div>
<script>
// Apps menu toggle
const appsBtn = document.getElementById('apps-btn');
const appsDropdown = document.getElementById('apps-dropdown');
appsBtn.addEventListener('click', (e) => {
e.stopPropagation();
const isOpen = appsDropdown.classList.toggle('show');
appsBtn.setAttribute('aria-expanded', isOpen);
});
document.addEventListener('click', (e) => {
if (!appsDropdown.contains(e.target) && !appsBtn.contains(e.target)) {
appsDropdown.classList.remove('show');
appsBtn.setAttribute('aria-expanded', 'false');
}
});
// Close on escape
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
appsDropdown.classList.remove('show');
appsBtn.setAttribute('aria-expanded', 'false');
}
});
// Keyboard shortcuts for apps
document.addEventListener('keydown', (e) => {
if (e.altKey && !e.ctrlKey && !e.shiftKey) {
const shortcuts = {
'1': 'chat',
'2': 'drive',
'3': 'tasks',
'4': 'mail',
'5': 'calendar',
'6': 'meet'
};
if (shortcuts[e.key]) {
e.preventDefault();
const link = document.querySelector(`a[href="#${shortcuts[e.key]}"]`);
if (link) link.click();
appsDropdown.classList.remove('show');
}
}
});
// Update active app in menu
document.body.addEventListener('htmx:afterSwap', (e) => {
if (e.detail.target.id === 'main-content') {
const hash = window.location.hash || '#chat';
document.querySelectorAll('.app-item').forEach(item => {
item.classList.toggle('active', item.getAttribute('href') === hash);
});
}
});
// Theme toggle
const themeBtn = document.getElementById('theme-btn');
themeBtn.addEventListener('click', () => {
document.body.classList.toggle('light-theme');
localStorage.setItem('theme', document.body.classList.contains('light-theme') ? 'light' : 'dark');
});
// Restore theme
if (localStorage.getItem('theme') === 'light') {
document.body.classList.add('light-theme');
}
// Notification helper
window.showNotification = function(message, type = 'info', duration = 5000) {
const container = document.getElementById('notifications');
const notification = document.createElement('div');
notification.className = `notification ${type}`;
notification.innerHTML = `
<div class="notification-content">
<div class="notification-message">${message}</div>
</div>
<button class="notification-close" onclick="this.parentElement.remove()">×</button>
`;
container.appendChild(notification);
if (duration > 0) {
setTimeout(() => notification.remove(), duration);
}
};
</script>
{% block scripts %}{% endblock %}
</body>
</html>

607
ui/suite/chat.html Normal file
View file

@ -0,0 +1,607 @@
{% extends "suite/base.html" %} {% block title %}Chat - General Bots Suite{%
endblock %} {% block content %}
<div class="chat-container">
<!-- Sidebar with sessions -->
<aside class="chat-sidebar">
<div class="sidebar-header">
<h2>Conversations</h2>
<button
class="btn-icon"
hx-post="/api/chat/sessions/new"
hx-target="#session-list"
hx-swap="afterbegin"
title="New conversation"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>
</div>
<div
class="session-list"
id="session-list"
hx-get="/api/chat/sessions"
hx-trigger="load"
hx-swap="innerHTML"
>
<!-- Sessions loaded via HTMX -->
</div>
<div class="sidebar-footer">
<div
class="context-selector"
id="context-selector"
hx-get="/api/chat/contexts"
hx-trigger="load"
hx-swap="innerHTML"
>
<!-- Contexts loaded via HTMX -->
</div>
</div>
</aside>
<!-- Main chat area -->
<main class="chat-main" id="chat-app" hx-ext="ws" ws-connect="/ws/chat">
<div id="connection-status" class="connection-status"></div>
<!-- Messages container -->
<div class="messages-container" id="messages-container">
<div
class="messages"
id="messages"
hx-get="/api/chat/sessions/{{ session_id }}/messages"
hx-trigger="load"
hx-swap="innerHTML"
>
<!-- Messages loaded via HTMX -->
</div>
</div>
<!-- Suggestions -->
<div
class="suggestions-container"
id="suggestions"
hx-get="/api/chat/suggestions"
hx-trigger="load"
hx-swap="innerHTML"
>
<!-- Suggestions loaded via HTMX -->
</div>
<!-- Input area -->
<footer class="chat-footer">
<form
class="chat-input-form"
hx-post="/api/chat/sessions/{{ session_id }}/message"
hx-target="#messages"
hx-swap="beforeend scroll:#messages-container:bottom"
hx-on::after-request="this.reset(); this.querySelector('textarea').focus();"
>
<div class="input-wrapper">
<textarea
name="content"
id="message-input"
placeholder="Type a message..."
rows="1"
autofocus
required
onkeydown="if(event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); this.form.requestSubmit(); }"
></textarea>
<div class="input-actions">
<button
type="button"
class="btn-icon"
id="voice-btn"
hx-post="/api/chat/voice/start"
hx-swap="none"
title="Voice input"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"
></path>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
<line x1="12" y1="19" x2="12" y2="23"></line>
<line x1="8" y1="23" x2="16" y2="23"></line>
</svg>
</button>
<button
type="button"
class="btn-icon"
id="attach-btn"
title="Attach file"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"
></path>
</svg>
</button>
<button
type="submit"
class="btn-primary btn-send"
id="send-btn"
title="Send message"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="22" y1="2" x2="11" y2="13"></line>
<polygon
points="22 2 15 22 11 13 2 9 22 2"
></polygon>
</svg>
</button>
</div>
</div>
</form>
</footer>
<!-- Scroll to bottom button -->
<button
class="scroll-to-bottom"
id="scroll-to-bottom"
onclick="document.getElementById('messages-container').scrollTo({top: document.getElementById('messages-container').scrollHeight, behavior: 'smooth'})"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</button>
</main>
</div>
<style>
.chat-container {
display: grid;
grid-template-columns: 280px 1fr;
height: calc(100vh - 56px);
overflow: hidden;
}
/* Sidebar */
.chat-sidebar {
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid var(--border);
}
.sidebar-header h2 {
font-size: 16px;
font-weight: 600;
}
.session-list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.session-item {
padding: 12px;
border-radius: 8px;
cursor: pointer;
transition: background 0.15s;
margin-bottom: 4px;
}
.session-item:hover {
background: var(--surface-hover);
}
.session-item.active {
background: var(--primary-light);
border-left: 3px solid var(--primary);
}
.session-name {
font-weight: 500;
font-size: 14px;
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.session-time {
font-size: 12px;
color: var(--text-secondary);
}
.sidebar-footer {
padding: 12px;
border-top: 1px solid var(--border);
}
/* Main chat area */
.chat-main {
display: flex;
flex-direction: column;
height: 100%;
position: relative;
background: var(--bg);
}
.connection-status {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
z-index: 10;
}
.connection-status.connected {
background: var(--success);
}
.connection-status.disconnected {
background: var(--error);
}
.connection-status.connecting {
background: var(--warning);
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
/* Messages */
.messages-container {
flex: 1;
overflow-y: auto;
padding: 16px;
scroll-behavior: smooth;
}
.messages {
display: flex;
flex-direction: column;
gap: 16px;
max-width: 800px;
margin: 0 auto;
}
.message {
display: flex;
gap: 12px;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.message.user {
flex-direction: row-reverse;
}
.message-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--primary);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 14px;
flex-shrink: 0;
}
.message.user .message-avatar {
background: var(--surface);
}
.message-content {
max-width: 70%;
padding: 12px 16px;
border-radius: 16px;
background: var(--surface);
}
.message.user .message-content {
background: var(--primary);
color: white;
}
.message-text {
font-size: 14px;
line-height: 1.5;
}
.message-time {
font-size: 11px;
color: var(--text-secondary);
margin-top: 4px;
}
.message.user .message-time {
color: rgba(255, 255, 255, 0.7);
}
/* Suggestions */
.suggestions-container {
padding: 8px 16px;
max-width: 800px;
margin: 0 auto;
width: 100%;
}
.suggestions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.suggestion {
padding: 8px 16px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 20px;
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
}
.suggestion:hover {
background: var(--surface-hover);
border-color: var(--primary);
}
/* Chat footer */
.chat-footer {
padding: 16px;
background: var(--bg);
border-top: 1px solid var(--border);
}
.chat-input-form {
max-width: 800px;
margin: 0 auto;
}
.input-wrapper {
display: flex;
align-items: flex-end;
gap: 8px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 24px;
padding: 8px 8px 8px 16px;
transition: border-color 0.15s;
}
.input-wrapper:focus-within {
border-color: var(--primary);
}
.input-wrapper textarea {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--text);
font-size: 14px;
line-height: 1.5;
resize: none;
max-height: 150px;
padding: 8px 0;
}
.input-wrapper textarea::placeholder {
color: var(--text-secondary);
}
.input-actions {
display: flex;
align-items: center;
gap: 4px;
}
.btn-icon {
width: 36px;
height: 36px;
border-radius: 50%;
border: none;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.btn-icon:hover {
background: var(--surface-hover);
color: var(--text);
}
.btn-send {
background: var(--primary);
color: white;
}
.btn-send:hover {
background: var(--primary-hover);
color: white;
}
/* Scroll to bottom */
.scroll-to-bottom {
position: absolute;
bottom: 100px;
right: 24px;
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--surface);
border: 1px solid var(--border);
color: var(--text-secondary);
cursor: pointer;
display: none;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
transition: all 0.15s;
}
.scroll-to-bottom:hover {
background: var(--surface-hover);
color: var(--text);
}
.scroll-to-bottom.visible {
display: flex;
}
/* Context selector */
.context-selector select {
width: 100%;
padding: 10px 12px;
background: var(--surface-hover);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
font-size: 13px;
cursor: pointer;
}
/* Responsive */
@media (max-width: 768px) {
.chat-container {
grid-template-columns: 1fr;
}
.chat-sidebar {
display: none;
}
.chat-sidebar.open {
display: flex;
position: fixed;
top: 56px;
left: 0;
bottom: 0;
width: 280px;
z-index: 100;
}
.message-content {
max-width: 85%;
}
}
</style>
<script>
// Auto-resize textarea
const textarea = document.getElementById("message-input");
if (textarea) {
textarea.addEventListener("input", function () {
this.style.height = "auto";
this.style.height = Math.min(this.scrollHeight, 150) + "px";
});
}
// Scroll to bottom visibility
const messagesContainer = document.getElementById("messages-container");
const scrollBtn = document.getElementById("scroll-to-bottom");
if (messagesContainer && scrollBtn) {
messagesContainer.addEventListener("scroll", function () {
const isNearBottom =
this.scrollHeight - this.scrollTop - this.clientHeight < 100;
scrollBtn.classList.toggle("visible", !isNearBottom);
});
}
// WebSocket connection status
document.body.addEventListener("htmx:wsConnecting", function () {
document.getElementById("connection-status").className =
"connection-status connecting";
});
document.body.addEventListener("htmx:wsOpen", function () {
document.getElementById("connection-status").className =
"connection-status connected";
});
document.body.addEventListener("htmx:wsClose", function () {
document.getElementById("connection-status").className =
"connection-status disconnected";
});
// Auto-scroll on new messages
document.body.addEventListener("htmx:afterSwap", function (evt) {
if (evt.detail.target.id === "messages") {
messagesContainer.scrollTo({
top: messagesContainer.scrollHeight,
behavior: "smooth",
});
}
});
</script>
{% endblock %}

600
ui/suite/drive.html Normal file
View file

@ -0,0 +1,600 @@
{% extends "suite/base.html" %}
{% block title %}Drive - General Bots Suite{% endblock %}
{% block content %}
<div class="drive-container">
<!-- Sidebar -->
<aside class="drive-sidebar">
<button class="new-btn"
hx-get="/api/drive/upload-modal"
hx-target="#modal-container"
hx-swap="innerHTML">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
New
</button>
<nav class="nav-section">
<div class="nav-item active"
hx-get="/api/drive/files?path=/"
hx-target="#file-list"
hx-swap="innerHTML">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path>
<polyline points="9 22 9 12 15 12 15 22"></polyline>
</svg>
<span class="nav-item-label">My Drive</span>
</div>
<div class="nav-item"
hx-get="/api/drive/files?filter=shared"
hx-target="#file-list"
hx-swap="innerHTML">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
<circle cx="9" cy="7" r="4"></circle>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
</svg>
<span class="nav-item-label">Shared with me</span>
</div>
<div class="nav-item"
hx-get="/api/drive/files?filter=recent"
hx-target="#file-list"
hx-swap="innerHTML">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
<span class="nav-item-label">Recent</span>
</div>
<div class="nav-item"
hx-get="/api/drive/files?filter=starred"
hx-target="#file-list"
hx-swap="innerHTML">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>
</svg>
<span class="nav-item-label">Starred</span>
</div>
<div class="nav-item"
hx-get="/api/drive/files?filter=trash"
hx-target="#file-list"
hx-swap="innerHTML">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
<span class="nav-item-label">Trash</span>
</div>
</nav>
<div class="storage-info">
<div class="storage-header">
<span class="storage-label">Storage</span>
<span class="storage-value"
hx-get="/api/drive/storage"
hx-trigger="load"
hx-swap="innerHTML">
Loading...
</span>
</div>
<div class="storage-bar">
<div class="storage-bar-fill" style="width: 35%"></div>
</div>
</div>
</aside>
<!-- Main content -->
<main class="drive-main">
<!-- Toolbar -->
<div class="drive-toolbar">
<div class="breadcrumb" id="breadcrumb">
<div class="breadcrumb-item current">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path>
</svg>
My Drive
</div>
</div>
<div class="toolbar-actions">
<div class="view-toggle">
<button class="view-toggle-btn active" id="grid-view-btn" title="Grid view">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
</svg>
</button>
<button class="view-toggle-btn" id="list-view-btn" title="List view">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="8" y1="6" x2="21" y2="6"></line>
<line x1="8" y1="12" x2="21" y2="12"></line>
<line x1="8" y1="18" x2="21" y2="18"></line>
<line x1="3" y1="6" x2="3.01" y2="6"></line>
<line x1="3" y1="12" x2="3.01" y2="12"></line>
<line x1="3" y1="18" x2="3.01" y2="18"></line>
</svg>
</button>
</div>
<select class="sort-dropdown" id="sort-dropdown"
hx-get="/api/drive/files"
hx-target="#file-list"
hx-swap="innerHTML"
hx-include="[name='path']">
<option value="name">Name</option>
<option value="modified">Last modified</option>
<option value="size">File size</option>
<option value="type">Type</option>
</select>
</div>
</div>
<!-- File list -->
<div class="file-content">
<div class="file-grid" id="file-list"
hx-get="/api/drive/files?path=/"
hx-trigger="load"
hx-swap="innerHTML">
<!-- Files loaded via HTMX -->
</div>
</div>
</main>
</div>
<!-- Modal container -->
<div id="modal-container"></div>
<!-- Upload zone overlay -->
<div class="upload-overlay" id="upload-overlay">
<div class="upload-zone-large">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
<p>Drop files here to upload</p>
</div>
</div>
<style>
.drive-container {
display: grid;
grid-template-columns: 240px 1fr;
height: calc(100vh - 56px);
overflow: hidden;
}
/* Sidebar */
.drive-sidebar {
background: var(--surface);
border-right: 1px solid var(--border);
padding: 16px 12px;
display: flex;
flex-direction: column;
overflow-y: auto;
}
.new-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
padding: 12px 16px;
background: var(--primary);
color: white;
border: none;
border-radius: 24px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
margin-bottom: 20px;
transition: all 0.2s;
}
.new-btn:hover {
background: var(--primary-hover);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
.nav-section {
margin-bottom: 24px;
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: 8px;
color: var(--text-secondary);
text-decoration: none;
cursor: pointer;
transition: all 0.15s;
}
.nav-item:hover {
background: var(--surface-hover);
color: var(--text);
}
.nav-item.active {
background: var(--primary-light);
color: var(--primary);
}
.nav-item-label {
flex: 1;
font-size: 14px;
}
.storage-info {
margin-top: auto;
padding: 16px;
background: var(--surface-hover);
border-radius: 12px;
}
.storage-header {
display: flex;
justify-content: space-between;
margin-bottom: 12px;
}
.storage-label {
font-size: 13px;
color: var(--text-secondary);
}
.storage-value {
font-size: 13px;
font-weight: 500;
}
.storage-bar {
height: 6px;
background: var(--border);
border-radius: 3px;
overflow: hidden;
}
.storage-bar-fill {
height: 100%;
background: linear-gradient(90deg, var(--primary), #a855f7);
border-radius: 3px;
transition: width 0.3s;
}
/* Main content */
.drive-main {
display: flex;
flex-direction: column;
overflow: hidden;
}
.drive-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 24px;
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.breadcrumb {
display: flex;
align-items: center;
gap: 4px;
}
.breadcrumb-item {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 6px;
font-size: 14px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s;
}
.breadcrumb-item:hover {
background: var(--surface-hover);
color: var(--text);
}
.breadcrumb-item.current {
color: var(--text);
font-weight: 500;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 12px;
}
.view-toggle {
display: flex;
background: var(--surface-hover);
border-radius: 6px;
padding: 2px;
}
.view-toggle-btn {
background: transparent;
border: none;
color: var(--text-secondary);
width: 32px;
height: 32px;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.view-toggle-btn:hover {
color: var(--text);
}
.view-toggle-btn.active {
background: var(--surface);
color: var(--text);
}
.sort-dropdown {
background: var(--surface-hover);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 12px;
color: var(--text);
font-size: 13px;
cursor: pointer;
}
/* File content */
.file-content {
flex: 1;
overflow-y: auto;
padding: 24px;
}
.file-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 16px;
}
.file-list {
display: flex;
flex-direction: column;
gap: 2px;
}
/* File card */
.file-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px;
cursor: pointer;
transition: all 0.2s;
position: relative;
}
.file-card:hover {
background: var(--surface-hover);
border-color: var(--primary);
transform: translateY(-2px);
}
.file-card.selected {
border-color: var(--primary);
background: var(--primary-light);
}
.file-card-preview {
width: 100%;
aspect-ratio: 4/3;
background: var(--surface-hover);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 12px;
overflow: hidden;
}
.file-card-preview svg {
width: 48px;
height: 48px;
color: var(--text-secondary);
}
.file-card-preview.folder svg {
color: #eab308;
}
.file-card-preview.document svg {
color: var(--primary);
}
.file-card-preview.image svg {
color: #a855f7;
}
.file-card-name {
font-size: 14px;
font-weight: 500;
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-card-meta {
font-size: 12px;
color: var(--text-secondary);
}
/* Upload overlay */
.upload-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.8);
display: none;
align-items: center;
justify-content: center;
z-index: 1000;
}
.upload-overlay.visible {
display: flex;
}
.upload-zone-large {
border: 3px dashed var(--primary);
border-radius: 24px;
padding: 64px;
text-align: center;
color: var(--text);
}
.upload-zone-large svg {
margin-bottom: 16px;
color: var(--primary);
}
.upload-zone-large p {
font-size: 18px;
}
/* Responsive */
@media (max-width: 768px) {
.drive-container {
grid-template-columns: 1fr;
}
.drive-sidebar {
display: none;
}
.file-grid {
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
}
}
</style>
<script>
// View toggle
const gridBtn = document.getElementById('grid-view-btn');
const listBtn = document.getElementById('list-view-btn');
const fileList = document.getElementById('file-list');
if (gridBtn && listBtn) {
gridBtn.addEventListener('click', function() {
gridBtn.classList.add('active');
listBtn.classList.remove('active');
fileList.className = 'file-grid';
});
listBtn.addEventListener('click', function() {
listBtn.classList.add('active');
gridBtn.classList.remove('active');
fileList.className = 'file-list';
});
}
// Drag and drop upload
const uploadOverlay = document.getElementById('upload-overlay');
let dragCounter = 0;
document.addEventListener('dragenter', function(e) {
e.preventDefault();
dragCounter++;
if (dragCounter === 1) {
uploadOverlay.classList.add('visible');
}
});
document.addEventListener('dragleave', function(e) {
e.preventDefault();
dragCounter--;
if (dragCounter === 0) {
uploadOverlay.classList.remove('visible');
}
});
document.addEventListener('dragover', function(e) {
e.preventDefault();
});
document.addEventListener('drop', function(e) {
e.preventDefault();
dragCounter = 0;
uploadOverlay.classList.remove('visible');
const files = e.dataTransfer.files;
if (files.length > 0) {
// Trigger file upload via HTMX
const formData = new FormData();
for (let file of files) {
formData.append('files', file);
}
htmx.ajax('POST', '/api/drive/upload', {
target: '#file-list',
swap: 'innerHTML',
values: formData
});
}
});
// File selection
document.addEventListener('click', function(e) {
const card = e.target.closest('.file-card');
if (card) {
if (e.ctrlKey || e.metaKey) {
card.classList.toggle('selected');
} else {
document.querySelectorAll('.file-card.selected').forEach(c => c.classList.remove('selected'));
card.classList.add('selected');
}
}
});
// Double-click to open folder/file
document.addEventListener('dblclick', function(e) {
const card = e.target.closest('.file-card');
if (card) {
const path = card.dataset.path;
const isFolder = card.dataset.type === 'folder';
if (isFolder) {
htmx.ajax('GET', `/api/drive/files?path=${encodeURIComponent(path)}`, {
target: '#file-list',
swap: 'innerHTML'
});
} else {
htmx.ajax('GET', `/api/drive/preview?path=${encodeURIComponent(path)}`, {
target: '#modal-container',
swap: 'innerHTML'
});
}
}
});
</script>
{% endblock %}

372
ui/suite/home.html Normal file
View file

@ -0,0 +1,372 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>General Bots Suite</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<link rel="stylesheet" href="/css/app.css">
<style>
:root {
--primary: #3b82f6;
--primary-hover: #2563eb;
--bg: #0f172a;
--surface: #1e293b;
--border: #334155;
--text: #f8fafc;
--text-secondary: #94a3b8;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
.home-container {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
}
.home-header {
text-align: center;
margin-bottom: 3rem;
}
.home-logo {
width: 80px;
height: 80px;
margin: 0 auto 1.5rem;
background: linear-gradient(135deg, var(--primary), #8b5cf6);
border-radius: 20px;
display: flex;
align-items: center;
justify-content: center;
font-size: 2.5rem;
}
.home-title {
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 0.75rem;
background: linear-gradient(135deg, var(--text), var(--primary));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.home-subtitle {
color: var(--text-secondary);
font-size: 1.125rem;
max-width: 500px;
margin: 0 auto;
}
.apps-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
margin-bottom: 3rem;
}
.app-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 16px;
padding: 1.5rem;
text-decoration: none;
color: inherit;
transition: transform 0.2s, border-color 0.2s, box-shadow 0.2s;
display: block;
}
.app-card:hover {
transform: translateY(-4px);
border-color: var(--primary);
box-shadow: 0 8px 32px rgba(59, 130, 246, 0.15);
}
.app-icon {
width: 56px;
height: 56px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.75rem;
margin-bottom: 1rem;
}
.app-icon.chat { background: linear-gradient(135deg, #3b82f6, #1d4ed8); }
.app-icon.drive { background: linear-gradient(135deg, #f59e0b, #d97706); }
.app-icon.tasks { background: linear-gradient(135deg, #22c55e, #16a34a); }
.app-icon.mail { background: linear-gradient(135deg, #ef4444, #dc2626); }
.app-icon.calendar { background: linear-gradient(135deg, #a855f7, #7c3aed); }
.app-icon.meet { background: linear-gradient(135deg, #06b6d4, #0891b2); }
.app-icon.paper { background: linear-gradient(135deg, #eab308, #ca8a04); }
.app-icon.research { background: linear-gradient(135deg, #ec4899, #db2777); }
.app-icon.analytics { background: linear-gradient(135deg, #6366f1, #4f46e5); }
.app-name {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.app-description {
color: var(--text-secondary);
font-size: 0.875rem;
line-height: 1.5;
}
.section-title {
font-size: 1rem;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 1rem;
}
.quick-actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-bottom: 3rem;
}
.quick-action-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
color: var(--text);
font-size: 0.875rem;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
text-decoration: none;
}
.quick-action-btn:hover {
border-color: var(--primary);
background: rgba(59, 130, 246, 0.1);
}
.recent-section {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 16px;
padding: 1.5rem;
}
.recent-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.recent-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem;
border-radius: 8px;
cursor: pointer;
transition: background 0.2s;
}
.recent-item:hover {
background: var(--bg);
}
.recent-icon {
width: 40px;
height: 40px;
border-radius: 8px;
background: var(--bg);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
}
.recent-info {
flex: 1;
}
.recent-name {
font-weight: 500;
margin-bottom: 0.25rem;
}
.recent-meta {
font-size: 0.75rem;
color: var(--text-secondary);
}
.recent-time {
font-size: 0.75rem;
color: var(--text-secondary);
}
@media (max-width: 768px) {
.home-container {
padding: 1rem;
}
.home-title {
font-size: 1.75rem;
}
.apps-grid {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="home-container">
<header class="home-header">
<div class="home-logo">🤖</div>
<h1 class="home-title">General Bots Suite</h1>
<p class="home-subtitle">Your AI-powered productivity workspace. Chat, collaborate, and create.</p>
</header>
<section>
<h2 class="section-title">Quick Actions</h2>
<div class="quick-actions">
<a href="#chat" class="quick-action-btn" hx-get="/chat/chat.html"</section> hx-target="#main-content" hx-push-url="true">
💬 Start Chat
</a>
<a href="#drive" class="quick-action-btn" hx-get="/drive/index.html" hx-target="#main-content" hx-push-url="true">
📁 Upload Files
</a>
<a href="#tasks" class="quick-action-btn" hx-get="/tasks/tasks.html" hx-target="#main-content" hx-push-url="true">
✓ New Task
</a>
<a href="#mail" class="quick-action-btn" hx-get="/mail/mail.html" hx-target="#main-content" hx-push-url="true">
✉️ Compose Email
</a>
<a href="#meet" class="quick-action-btn" hx-get="/meet/meet.html" hx-target="#main-content" hx-push-url="true">
🎥 Start Meeting
</a>
</div>
</section>
<section>
<h2 class="section-title">Applications</h2>
<div class="apps-grid">
<a href="#chat" class="app-card" hx-get="/chat/chat.html" hx-target="#main-content" hx-push-url="true">
<div class="app-icon chat">💬</div>
<div class="app-name">Chat</div>
<div class="app-description">AI-powered conversations. Ask questions, get help, and automate tasks.</div>
</a>
<a href="#drive" class="app-card" hx-get="/drive/index.html" hx-target="#main-content" hx-push-url="true">
<div class="app-icon drive">📁</div>
<div class="app-name">Drive</div>
<div class="app-description">Cloud storage for all your files. Upload, organize, and share.</div>
</a>
<a href="#tasks" class="app-card" hx-get="/tasks/tasks.html" hx-target="#main-content" hx-push-url="true">
<div class="app-icon tasks"></div>
<div class="app-name">Tasks</div>
<div class="app-description">Stay organized with to-do lists, priorities, and due dates.</div>
</a>
<a href="#mail" class="app-card" hx-get="/mail/mail.html" hx-target="#main-content" hx-push-url="true">
<div class="app-icon mail">✉️</div>
<div class="app-name">Mail</div>
<div class="app-description">Email client with AI-assisted writing and smart organization.</div>
</a>
<a href="#calendar" class="app-card" hx-get="/calendar/calendar.html" hx-target="#main-content" hx-push-url="true">
<div class="app-icon calendar">📅</div>
<div class="app-name">Calendar</div>
<div class="app-description">Schedule meetings, events, and manage your time effectively.</div>
</a>
<a href="#meet" class="app-card" hx-get="/meet/meet.html" hx-target="#main-content" hx-push-url="true">
<div class="app-icon meet">🎥</div>
<div class="app-name">Meet</div>
<div class="app-description">Video conferencing with screen sharing and live transcription.</div>
</a>
<a href="#paper" class="app-card" hx-get="/paper/paper.html" hx-target="#main-content" hx-push-url="true">
<div class="app-icon paper">📝</div>
<div class="app-name">Paper</div>
<div class="app-description">Write documents with AI assistance. Notes, reports, and more.</div>
</a>
<a href="#research" class="app-card" hx-get="/research/research.html" hx-target="#main-content" hx-push-url="true">
<div class="app-icon research">🔍</div>
<div class="app-name">Research</div>
<div class="app-description">AI-powered search and discovery across all your sources.</div>
</a>
<a href="#analytics" class="app-card" hx-get="/analytics/analytics.html" hx-target="#main-content" hx-push-url="true">
<div class="app-icon analytics">📊</div>
<div class="app-name">Analytics</div>
<div class="app-description">Dashboards and reports to track usage and insights.</div>
</a>
</div>
</section>
<section class="recent-section">
<h2 class="section-title">Recent Activity</h2>
<div class="recent-list"
hx-get="/api/activity/recent"
hx-trigger="load"
hx-swap="innerHTML">
{% for item in recent_items %}
<div class="recent-item" hx-get="{{ item.url }}" hx-target="#main-content">
<div class="recent-icon">{{ item.icon }}</div>
<div class="recent-info">
<div class="recent-name">{{ item.name }}</div>
<div class="recent-meta">{{ item.app }} • {{ item.description }}</div>
</div>
<div class="recent-time">{{ item.time }}</div>
</div>
{% endfor %}
{% if recent_items.is_empty() %}
<div style="text-align: center; padding: 2rem; color: var(--text-secondary);">
<div style="font-size: 2rem; margin-bottom: 0.5rem;">🚀</div>
<p>No recent activity yet. Start exploring!</p>
</div>
{% endif %}
</div>
</section>
</div>
<script>
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (e.altKey && !e.ctrlKey && !e.shiftKey) {
const shortcuts = {
'1': '#chat',
'2': '#drive',
'3': '#tasks',
'4': '#mail',
'5': '#calendar',
'6': '#meet'
};
if (shortcuts[e.key]) {
e.preventDefault();
const link = document.querySelector(`a[href="${shortcuts[e.key]}"]`);
if (link) link.click();
}
}
});
</script>
</body>
</html>

512
ui/suite/mail.html Normal file
View file

@ -0,0 +1,512 @@
{% extends "base.html" %}
{% block title %}Mail - General Bots{% endblock %}
{% block content %}
<div class="mail-layout" id="mail-app">
<!-- Sidebar -->
<aside class="mail-sidebar">
<div class="sidebar-header">
<button class="compose-btn"
hx-get="/api/email/compose"
hx-target="#mail-content"
hx-swap="innerHTML">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
Compose
</button>
</div>
<!-- Folder List -->
<nav class="mail-folders">
<a href="#inbox" class="folder-item{% if current_folder == "inbox" %} active{% endif %}"
hx-get="/api/email/list?folder=inbox"
hx-target="#mail-list"
hx-swap="innerHTML">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="22 12 16 12 14 15 10 15 8 12 2 12"></polyline>
<path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"></path>
</svg>
<span>Inbox</span>
{% if unread_count > 0 %}
<span class="folder-badge">{{ unread_count }}</span>
{% endif %}
</a>
<a href="#sent" class="folder-item{% if current_folder == "sent" %} active{% endif %}"
hx-get="/api/email/list?folder=sent"
hx-target="#mail-list"
hx-swap="innerHTML">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="22" y1="2" x2="11" y2="13"></line>
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
</svg>
<span>Sent</span>
</a>
<a href="#drafts" class="folder-item{% if current_folder == "drafts" %} active{% endif %}"
hx-get="/api/email/list?folder=drafts"
hx-target="#mail-list"
hx-swap="innerHTML">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
</svg>
<span>Drafts</span>
{% if drafts_count > 0 %}
<span class="folder-badge secondary">{{ drafts_count }}</span>
{% endif %}
</a>
<a href="#starred" class="folder-item{% if current_folder == "starred" %} active{% endif %}"
hx-get="/api/email/list?folder=starred"
hx-target="#mail-list"
hx-swap="innerHTML">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>
</svg>
<span>Starred</span>
</a>
<a href="#archive" class="folder-item{% if current_folder == "archive" %} active{% endif %}"
hx-get="/api/email/list?folder=archive"
hx-target="#mail-list"
hx-swap="innerHTML">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="21 8 21 21 3 21 3 8"></polyline>
<rect x="1" y="3" width="22" height="5"></rect>
<line x1="10" y1="12" x2="14" y2="12"></line>
</svg>
<span>Archive</span>
</a>
<a href="#trash" class="folder-item{% if current_folder == "trash" %} active{% endif %}"
hx-get="/api/email/list?folder=trash"
hx-target="#mail-list"
hx-swap="innerHTML">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
<span>Trash</span>
</a>
</nav>
<!-- Labels -->
<div class="mail-labels">
<div class="labels-header">
<span>Labels</span>
<button class="btn-icon-sm" title="Create label">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>
</div>
{% for label in labels %}
<a href="#label-{{ label.id }}" class="label-item"
hx-get="/api/email/list?label={{ label.id }}"
hx-target="#mail-list"
hx-swap="innerHTML">
<span class="label-dot" style="background: {{ label.color }}"></span>
<span>{{ label.name }}</span>
</a>
{% endfor %}
</div>
</aside>
<!-- Email List -->
<section class="mail-list-panel">
<div class="mail-list-header">
<div class="mail-list-actions">
<input type="checkbox" class="select-all" title="Select all">
<button class="btn-icon" title="Refresh"
hx-get="/api/email/list?folder={{ current_folder }}"
hx-target="#mail-list"
hx-swap="innerHTML">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23 4 23 10 17 10"></polyline>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
</svg>
</button>
</div>
<div class="mail-search">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<input type="text"
placeholder="Search mail..."
name="q"
hx-get="/api/email/search"
hx-trigger="keyup changed delay:300ms"
hx-target="#mail-list"
hx-swap="innerHTML">
</div>
</div>
<div id="mail-list" class="mail-list"
hx-get="/api/email/list?folder={{ current_folder }}"
hx-trigger="load"
hx-swap="innerHTML">
<div class="mail-loading">
<div class="spinner"></div>
<span>Loading emails...</span>
</div>
</div>
</section>
<!-- Email Content -->
<section class="mail-content-panel">
<div id="mail-content" class="mail-content">
<div class="mail-empty-state">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path>
<polyline points="22,6 12,13 2,6"></polyline>
</svg>
<h3>Select an email to read</h3>
<p>Choose from your inbox on the left</p>
</div>
</div>
</section>
</div>
<style>
.mail-layout {
display: grid;
grid-template-columns: 250px 350px 1fr;
height: calc(100vh - 64px);
background: var(--bg);
}
.mail-sidebar {
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow-y: auto;
}
.sidebar-header {
padding: 1rem;
border-bottom: 1px solid var(--border);
}
.compose-btn {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: var(--primary);
color: white;
border: none;
border-radius: 8px;
font-size: 0.9375rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.compose-btn:hover {
background: var(--primary-hover);
}
.mail-folders {
padding: 0.5rem;
}
.folder-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 0.75rem;
border-radius: 6px;
color: var(--text-secondary);
text-decoration: none;
font-size: 0.875rem;
transition: background 0.2s, color 0.2s;
}
.folder-item:hover {
background: var(--bg);
color: var(--text);
}
.folder-item.active {
background: rgba(59, 130, 246, 0.1);
color: var(--primary);
}
.folder-badge {
margin-left: auto;
padding: 0.125rem 0.5rem;
background: var(--primary);
color: white;
border-radius: 10px;
font-size: 0.75rem;
font-weight: 500;
}
.folder-badge.secondary {
background: var(--text-secondary);
}
.mail-labels {
padding: 0.5rem;
border-top: 1px solid var(--border);
margin-top: auto;
}
.labels-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.75rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
}
.label-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-radius: 6px;
color: var(--text-secondary);
text-decoration: none;
font-size: 0.8125rem;
transition: background 0.2s;
}
.label-item:hover {
background: var(--bg);
}
.label-dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.mail-list-panel {
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
}
.mail-list-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
border-bottom: 1px solid var(--border);
}
.mail-list-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.mail-search {
flex: 1;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--bg);
border-radius: 6px;
}
.mail-search input {
flex: 1;
background: transparent;
border: none;
color: var(--text);
font-size: 0.875rem;
}
.mail-search input:focus {
outline: none;
}
.mail-search input::placeholder {
color: var(--text-secondary);
}
.mail-list {
flex: 1;
overflow-y: auto;
}
.mail-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
color: var(--text-secondary);
gap: 1rem;
}
.mail-content-panel {
display: flex;
flex-direction: column;
overflow: hidden;
}
.mail-content {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
}
.mail-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-secondary);
text-align: center;
}
.mail-empty-state svg {
margin-bottom: 1rem;
opacity: 0.5;
}
.mail-empty-state h3 {
font-size: 1.125rem;
font-weight: 500;
margin-bottom: 0.5rem;
color: var(--text);
}
.mail-empty-state p {
font-size: 0.875rem;
}
.btn-icon {
padding: 0.375rem;
background: transparent;
border: none;
color: var(--text-secondary);
border-radius: 4px;
cursor: pointer;
transition: background 0.2s, color 0.2s;
}
.btn-icon:hover {
background: var(--bg);
color: var(--text);
}
.btn-icon-sm {
padding: 0.25rem;
background: transparent;
border: none;
color: var(--text-secondary);
border-radius: 4px;
cursor: pointer;
}
.spinner {
width: 24px;
height: 24px;
border: 2px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@media (max-width: 1024px) {
.mail-layout {
grid-template-columns: 60px 280px 1fr;
}
.mail-sidebar {
padding: 0.5rem;
}
.compose-btn span,
.folder-item span,
.labels-header,
.label-item span {
display: none;
}
.folder-item {
justify-content: center;
padding: 0.75rem;
}
.folder-badge {
position: absolute;
top: 0;
right: 0;
transform: translate(25%, -25%);
}
}
@media (max-width: 768px) {
.mail-layout {
grid-template-columns: 1fr;
}
.mail-sidebar,
.mail-content-panel {
display: none;
}
.mail-sidebar.active,
.mail-content-panel.active {
display: flex;
position: fixed;
inset: 64px 0 0 0;
z-index: 100;
}
}
</style>
<script>
// Email selection
document.addEventListener('click', (e) => {
const emailItem = e.target.closest('.email-item');
if (emailItem) {
document.querySelectorAll('.email-item.selected').forEach(el => el.classList.remove('selected'));
emailItem.classList.add('selected');
}
});
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (e.key === 'c' && !e.ctrlKey && !e.metaKey) {
const activeElement = document.activeElement;
if (activeElement.tagName !== 'INPUT' && activeElement.tagName !== 'TEXTAREA') {
e.preventDefault();
document.querySelector('.compose-btn').click();
}
}
if (e.key === 'r' && !e.ctrlKey && !e.metaKey) {
const activeElement = document.activeElement;
if (activeElement.tagName !== 'INPUT' && activeElement.tagName !== 'TEXTAREA') {
e.preventDefault();
htmx.trigger('.mail-list', 'load');
}
}
});
</script>
{% endblock %}

1071
ui/suite/meet.html Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,101 @@
<div class="apps-menu" id="apps-menu">
<div class="apps-menu-header">
<h3>Apps</h3>
</div>
<div class="apps-grid">
{% for app in apps %}
<a href="{{ app.url }}" class="app-item{% if app.active %} active{% endif %}">
<div class="app-icon">
{{ app.icon|safe }}
</div>
<span class="app-name">{{ app.name }}</span>
</a>
{% endfor %}
</div>
</div>
<style>
.apps-menu {
position: absolute;
top: 100%;
right: 0;
margin-top: 8px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.2);
padding: 16px;
min-width: 280px;
z-index: 1000;
animation: fadeIn 0.15s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
.apps-menu-header {
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
}
.apps-menu-header h3 {
font-size: 14px;
font-weight: 600;
color: var(--text-secondary);
}
.apps-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.app-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 12px 8px;
border-radius: 8px;
text-decoration: none;
color: var(--text);
transition: all 0.15s;
}
.app-item:hover {
background: var(--surface-hover);
}
.app-item.active {
background: var(--primary-light);
}
.app-icon {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: var(--surface-hover);
}
.app-icon svg {
width: 24px;
height: 24px;
color: var(--primary);
}
.app-name {
font-size: 12px;
font-weight: 500;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
</style>

View file

@ -0,0 +1,117 @@
<div class="contexts-selector">
<label class="contexts-label">Context:</label>
<select
class="contexts-dropdown"
name="context"
hx-post="/api/chat/context"
hx-trigger="change"
hx-swap="none"
>
<option value="">Select context...</option>
{% for context in contexts %}
<option value="{{ context.id }}">{{ context.name }}</option>
{% endfor %}
</select>
</div>
{% if contexts.len() > 0 %}
<div class="contexts-list">
{% for context in contexts %}
<div
class="context-item"
hx-post="/api/chat/context"
hx-vals='{"context_id": "{{ context.id }}"}'
hx-swap="none"
>
<div class="context-name">{{ context.name }}</div>
<div class="context-description">{{ context.description }}</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="contexts-empty">
<p>No contexts configured</p>
</div>
{% endif %}
<style>
.contexts-selector {
margin-bottom: 12px;
}
.contexts-label {
display: block;
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 6px;
}
.contexts-dropdown {
width: 100%;
padding: 10px 12px;
background: var(--surface-hover);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
}
.contexts-dropdown:hover {
border-color: var(--primary);
}
.contexts-dropdown:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 2px var(--primary-light);
}
.contexts-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.context-item {
padding: 10px 12px;
background: var(--surface-hover);
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
transition: all 0.15s;
}
.context-item:hover {
background: var(--surface);
border-color: var(--primary);
}
.context-item.active {
background: var(--primary-light);
border-color: var(--primary);
}
.context-name {
font-size: 13px;
font-weight: 500;
margin-bottom: 2px;
}
.context-description {
font-size: 11px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.contexts-empty {
padding: 16px;
text-align: center;
color: var(--text-secondary);
font-size: 13px;
}
</style>

View file

@ -0,0 +1,16 @@
<div class="message {% if is_user %}user{% else %}bot{% endif %}" id="msg-{{ id }}">
<div class="message-avatar">
{% if is_user %}
<span class="avatar-user">U</span>
{% else %}
<span class="avatar-bot">🤖</span>
{% endif %}
</div>
<div class="message-content">
<div class="message-header">
<span class="message-sender">{{ sender }}</span>
<span class="message-time">{{ timestamp }}</span>
</div>
<div class="message-body">{{ content }}</div>
</div>
</div>

View file

@ -0,0 +1,25 @@
{% for message in messages %}
<div class="message {% if message.is_user %}user{% else %}bot{% endif %}" id="msg-{{ message.id }}">
<div class="message-avatar">
{% if message.is_user %}
<span class="avatar-user">U</span>
{% else %}
<span class="avatar-bot">🤖</span>
{% endif %}
</div>
<div class="message-content">
<div class="message-header">
<span class="message-sender">{{ message.sender }}</span>
<span class="message-time">{{ message.timestamp }}</span>
</div>
<div class="message-body">{{ message.content }}</div>
</div>
</div>
{% endfor %}
{% if messages.is_empty() %}
<div class="empty-messages">
<div class="empty-icon">💬</div>
<p>Start a conversation</p>
<p class="empty-hint">Type a message below or use voice input</p>
</div>
{% endif %}

View file

@ -0,0 +1,47 @@
<div class="notification {{ notification_type }}" id="notification-{{ id }}" role="alert">
<div class="notification-icon">
{% match notification_type.as_str() %}
{% when "success" %}
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
{% when "error" %}
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="15" y1="9" x2="9" y2="15"/>
<line x1="9" y1="9" x2="15" y2="15"/>
</svg>
{% when "warning" %}
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
{% when "info" %}
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="16" x2="12" y2="12"/>
<line x1="12" y1="8" x2="12.01" y2="8"/>
</svg>
{% else %}
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
</svg>
{% endmatch %}
</div>
<div class="notification-content">
{% if let Some(title) = title %}
<div class="notification-title">{{ title }}</div>
{% endif %}
<div class="notification-message">{{ message }}</div>
</div>
<button class="notification-close"
onclick="this.parentElement.remove()"
aria-label="Close notification">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>

View file

@ -0,0 +1,25 @@
{% for session in sessions %}
<div class="session-item{% if session.active %} active{% endif %}"
id="session-{{ session.id }}"
hx-post="/api/chat/sessions/{{ session.id }}"
hx-target="#messages"
hx-swap="innerHTML">
<div class="session-info">
<div class="session-name">{{ session.name }}</div>
<div class="session-preview">{{ session.last_message }}</div>
</div>
<div class="session-meta">
<span class="session-time">{{ session.timestamp }}</span>
</div>
</div>
{% endfor %}
{% if sessions.is_empty() %}
<div class="empty-state">
<p>No conversations yet</p>
<button hx-post="/api/chat/sessions/new"
hx-target="#session-list"
hx-swap="afterbegin">
Start a conversation
</button>
</div>
{% endif %}

View file

@ -0,0 +1,17 @@
<div class="suggestions-list">
{% for suggestion in suggestions %}
<button class="suggestion-chip"
hx-post="/api/chat/message"
hx-vals='{"content": "{{ suggestion }}"}'
hx-target="#messages"
hx-swap="beforeend"
hx-on::after-request="document.getElementById('messages').scrollTop = document.getElementById('messages').scrollHeight">
{{ suggestion }}
</button>
{% endfor %}
</div>
{% if suggestions.is_empty() %}
<div class="suggestions-empty">
<span class="suggestion-chip disabled">No suggestions available</span>
</div>
{% endif %}

View file

@ -0,0 +1,166 @@
<div class="user-menu" id="user-menu">
<div class="user-menu-header">
<div class="user-avatar-large">{{ user_initial }}</div>
<div class="user-info">
<div class="user-name">{{ user_name }}</div>
<div class="user-email">{{ user_email }}</div>
</div>
</div>
<div class="user-menu-divider"></div>
<nav class="user-menu-nav">
<a href="/settings/profile" class="user-menu-item">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>
<span>Profile</span>
</a>
<a href="/settings" class="user-menu-item">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg>
<span>Settings</span>
</a>
<a href="/help" class="user-menu-item">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>
<span>Help & Support</span>
</a>
</nav>
<div class="user-menu-divider"></div>
<a href="/auth/logout"
class="user-menu-item logout"
hx-post="/auth/logout"
hx-swap="none"
hx-on::after-request="window.location.href='/login'">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<polyline points="16 17 21 12 16 7"></polyline>
<line x1="21" y1="12" x2="9" y2="12"></line>
</svg>
<span>Sign out</span>
</a>
</div>
<style>
.user-menu {
position: absolute;
top: 100%;
right: 0;
margin-top: 8px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.2);
min-width: 260px;
z-index: 1000;
overflow: hidden;
animation: fadeIn 0.15s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
.user-menu-header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
}
.user-avatar-large {
width: 48px;
height: 48px;
border-radius: 50%;
background: var(--primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 600;
flex-shrink: 0;
}
.user-info {
flex: 1;
min-width: 0;
}
.user-name {
font-size: 15px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-email {
font-size: 13px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-menu-divider {
height: 1px;
background: var(--border);
margin: 0;
}
.user-menu-nav {
padding: 8px;
}
.user-menu-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: 8px;
text-decoration: none;
color: var(--text);
font-size: 14px;
transition: all 0.15s;
cursor: pointer;
}
.user-menu-item:hover {
background: var(--surface-hover);
}
.user-menu-item svg {
color: var(--text-secondary);
flex-shrink: 0;
}
.user-menu-item:hover svg {
color: var(--text);
}
.user-menu-item.logout {
margin: 8px;
color: var(--error);
}
.user-menu-item.logout:hover {
background: rgba(239, 68, 68, 0.1);
}
.user-menu-item.logout svg {
color: var(--error);
}
</style>

608
ui/suite/tasks.html Normal file
View file

@ -0,0 +1,608 @@
{% extends "base.html" %}
{% block title %}Tasks - General Bots{% endblock %}
{% block content %}
<div class="tasks-container" id="tasks-app">
<!-- Header -->
<div class="tasks-header">
<div class="header-content">
<h1 class="tasks-title">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 11l3 3L22 4"/>
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>
</svg>
Tasks
</h1>
<div class="header-stats" id="task-stats"
hx-get="/api/tasks/stats"
hx-trigger="load, taskUpdated from:body"
hx-swap="innerHTML">
<span class="stat-item">
<span class="stat-value">{{ total_count }}</span>
<span class="stat-label">Total</span>
</span>
<span class="stat-item">
<span class="stat-value">{{ active_count }}</span>
<span class="stat-label">Active</span>
</span>
<span class="stat-item">
<span class="stat-value">{{ completed_count }}</span>
<span class="stat-label">Completed</span>
</span>
</div>
</div>
</div>
<!-- Add Task Form -->
<div class="add-task-section">
<form class="add-task-form"
hx-post="/api/tasks"
hx-target="#task-list"
hx-swap="afterbegin"
hx-on::after-request="this.reset(); htmx.trigger('body', 'taskUpdated')">
<input type="text"
name="text"
class="task-input"
placeholder="What needs to be done?"
required
autofocus>
<select name="category" class="task-category-select">
<option value="">No Category</option>
<option value="work">💼 Work</option>
<option value="personal">🏠 Personal</option>
<option value="shopping">🛒 Shopping</option>
<option value="health">❤️ Health</option>
</select>
<input type="date"
name="dueDate"
class="task-date-input"
placeholder="Due date">
<select name="priority" class="task-priority-select">
<option value="">Priority</option>
<option value="high">🔴 High</option>
<option value="medium">🟡 Medium</option>
<option value="low">🟢 Low</option>
</select>
<button type="submit" class="add-task-btn">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
Add Task
</button>
</form>
</div>
<!-- Filter Tabs -->
<div class="filter-tabs">
<button class="filter-tab active"
hx-get="/api/tasks?filter=all"
hx-target="#task-list"
hx-swap="innerHTML"
onclick="setActiveTab(this)">
<span class="tab-icon">📋</span>
<span>All</span>
<span class="tab-count" id="count-all">{{ total_count }}</span>
</button>
<button class="filter-tab"
hx-get="/api/tasks?filter=active"
hx-target="#task-list"
hx-swap="innerHTML"
onclick="setActiveTab(this)">
<span class="tab-icon"></span>
<span>Active</span>
<span class="tab-count" id="count-active">{{ active_count }}</span>
</button>
<button class="filter-tab"
hx-get="/api/tasks?filter=completed"
hx-target="#task-list"
hx-swap="innerHTML"
onclick="setActiveTab(this)">
<span class="tab-icon"></span>
<span>Completed</span>
<span class="tab-count" id="count-completed">{{ completed_count }}</span>
</button>
<button class="filter-tab priority-tab"
hx-get="/api/tasks?filter=priority"
hx-target="#task-list"
hx-swap="innerHTML"
onclick="setActiveTab(this)">
<span class="tab-icon"></span>
<span>Priority</span>
</button>
</div>
<!-- Task List -->
<div class="task-list-container">
<div id="task-list" class="task-list"
hx-get="/api/tasks?filter=all"
hx-trigger="load"
hx-swap="innerHTML">
<div class="task-loading">
<div class="spinner"></div>
<span>Loading tasks...</span>
</div>
</div>
</div>
<!-- Bulk Actions (shown when tasks selected) -->
<div class="bulk-actions" id="bulk-actions" style="display: none;">
<span class="selected-count">0 selected</span>
<button class="bulk-btn"
hx-post="/api/tasks/bulk/complete"
hx-include="[name='selected-tasks']"
hx-target="#task-list">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
Complete
</button>
<button class="bulk-btn danger"
hx-delete="/api/tasks/bulk"
hx-include="[name='selected-tasks']"
hx-target="#task-list"
hx-confirm="Delete selected tasks?">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
Delete
</button>
</div>
</div>
<style>
.tasks-container {
max-width: 900px;
margin: 0 auto;
padding: 2rem;
}
.tasks-header {
margin-bottom: 2rem;
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 1rem;
}
.tasks-title {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 1.5rem;
font-weight: 600;
color: var(--text);
}
.header-stats {
display: flex;
gap: 1.5rem;
}
.stat-item {
text-align: center;
}
.stat-value {
display: block;
font-size: 1.5rem;
font-weight: 600;
color: var(--primary);
}
.stat-label {
font-size: 0.75rem;
color: var(--text-secondary);
text-transform: uppercase;
}
.add-task-section {
margin-bottom: 1.5rem;
}
.add-task-form {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.task-input {
flex: 1;
min-width: 200px;
padding: 0.75rem 1rem;
background: var(--surface);
border: 2px solid var(--border);
border-radius: 8px;
color: var(--text);
font-size: 1rem;
transition: border-color 0.2s;
}
.task-input:focus {
outline: none;
border-color: var(--primary);
}
.task-input::placeholder {
color: var(--text-secondary);
}
.task-category-select,
.task-date-input,
.task-priority-select {
padding: 0.75rem;
background: var(--surface);
border: 2px solid var(--border);
border-radius: 8px;
color: var(--text);
font-size: 0.875rem;
cursor: pointer;
}
.task-date-input {
width: 140px;
}
.add-task-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
background: var(--primary);
color: white;
border: none;
border-radius: 8px;
font-size: 0.9375rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.add-task-btn:hover {
background: var(--primary-hover);
}
.filter-tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
border-bottom: 1px solid var(--border);
padding-bottom: 0.5rem;
overflow-x: auto;
}
.filter-tab {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
background: transparent;
border: none;
border-radius: 6px;
color: var(--text-secondary);
font-size: 0.875rem;
cursor: pointer;
transition: background 0.2s, color 0.2s;
white-space: nowrap;
}
.filter-tab:hover {
background: var(--surface-hover);
color: var(--text);
}
.filter-tab.active {
background: var(--primary);
color: white;
}
.tab-count {
padding: 0.125rem 0.5rem;
background: rgba(255, 255, 255, 0.2);
border-radius: 10px;
font-size: 0.75rem;
}
.filter-tab:not(.active) .tab-count {
background: var(--surface);
}
.task-list-container {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
}
.task-list {
min-height: 200px;
}
.task-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
color: var(--text-secondary);
gap: 1rem;
}
.task-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
border-bottom: 1px solid var(--border);
transition: background 0.2s;
}
.task-item:last-child {
border-bottom: none;
}
.task-item:hover {
background: var(--surface-hover);
}
.task-item.completed {
opacity: 0.6;
}
.task-item.completed .task-text {
text-decoration: line-through;
color: var(--text-secondary);
}
.task-checkbox {
width: 22px;
height: 22px;
border: 2px solid var(--border);
border-radius: 6px;
cursor: pointer;
appearance: none;
background: transparent;
transition: background 0.2s, border-color 0.2s;
flex-shrink: 0;
}
.task-checkbox:checked {
background: var(--success);
border-color: var(--success);
}
.task-checkbox:checked::after {
content: '✓';
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 14px;
font-weight: bold;
}
.task-content {
flex: 1;
min-width: 0;
}
.task-text {
font-size: 0.9375rem;
margin-bottom: 0.25rem;
}
.task-meta {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.75rem;
color: var(--text-secondary);
}
.task-category {
display: flex;
align-items: center;
gap: 0.25rem;
}
.task-due {
display: flex;
align-items: center;
gap: 0.25rem;
}
.task-due.overdue {
color: var(--error);
}
.task-due.today {
color: var(--warning);
}
.task-priority {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.task-priority.high { background: var(--error); }
.task-priority.medium { background: var(--warning); }
.task-priority.low { background: var(--success); }
.task-actions {
display: flex;
gap: 0.25rem;
opacity: 0;
transition: opacity 0.2s;
}
.task-item:hover .task-actions {
opacity: 1;
}
.task-action-btn {
padding: 0.375rem;
background: transparent;
border: none;
color: var(--text-secondary);
border-radius: 4px;
cursor: pointer;
transition: background 0.2s, color 0.2s;
}
.task-action-btn:hover {
background: var(--bg);
color: var(--text);
}
.task-action-btn.delete:hover {
color: var(--error);
}
.bulk-actions {
position: fixed;
bottom: 2rem;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 1.5rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
.selected-count {
font-size: 0.875rem;
color: var(--text-secondary);
}
.bulk-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--primary);
color: white;
border: none;
border-radius: 6px;
font-size: 0.875rem;
cursor: pointer;
}
.bulk-btn.danger {
background: var(--error);
}
.empty-tasks {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem 2rem;
text-align: center;
}
.empty-tasks svg {
width: 64px;
height: 64px;
color: var(--text-secondary);
opacity: 0.5;
margin-bottom: 1rem;
}
.empty-tasks h3 {
font-size: 1.125rem;
margin-bottom: 0.5rem;
}
.empty-tasks p {
color: var(--text-secondary);
font-size: 0.875rem;
}
.spinner {
width: 24px;
height: 24px;
border: 2px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@media (max-width: 768px) {
.tasks-container {
padding: 1rem;
}
.add-task-form {
flex-direction: column;
}
.task-input {
width: 100%;
}
.header-content {
flex-direction: column;
align-items: flex-start;
}
.filter-tabs {
flex-wrap: nowrap;
}
}
</style>
<script>
function setActiveTab(btn) {
document.querySelectorAll('.filter-tab').forEach(tab => {
tab.classList.remove('active');
});
btn.classList.add('active');
}
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
// Focus input on 'n' key
if (e.key === 'n' && !e.ctrlKey && !e.metaKey) {
const activeElement = document.activeElement;
if (activeElement.tagName !== 'INPUT' && activeElement.tagName !== 'TEXTAREA' && activeElement.tagName !== 'SELECT') {
e.preventDefault();
document.querySelector('.task-input').focus();
}
}
});
// Task item interactions
document.addEventListener('click', (e) => {
const checkbox = e.target.closest('.task-checkbox');
if (checkbox) {
const taskItem = checkbox.closest('.task-item');
if (taskItem) {
taskItem.classList.toggle('completed', checkbox.checked);
}
}
});
// Update stats after task changes
document.body.addEventListener('htmx:afterSwap', (e) => {
if (e.detail.target.id === 'task-list') {
htmx.trigger('body', 'taskUpdated');
}
});
</script>
{% endblock %}