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:
parent
36d5f3838c
commit
e68a12176d
42 changed files with 8507 additions and 51 deletions
9
askama.toml
Normal file
9
askama.toml
Normal 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"
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
214
docs/src/assets/suite/app-launcher.svg
Normal file
214
docs/src/assets/suite/app-launcher.svg
Normal 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 |
239
docs/src/assets/suite/chat-flow.svg
Normal file
239
docs/src/assets/suite/chat-flow.svg
Normal 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 |
269
docs/src/assets/suite/drive-flow.svg
Normal file
269
docs/src/assets/suite/drive-flow.svg
Normal 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 |
280
docs/src/assets/suite/tasks-flow.svg
Normal file
280
docs/src/assets/suite/tasks-flow.svg
Normal 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 |
165
docs/src/chapter-04-gbui/apps/README.md
Normal file
165
docs/src/chapter-04-gbui/apps/README.md
Normal 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
|
||||
1
docs/src/chapter-04-gbui/apps/analytics.md
Normal file
1
docs/src/chapter-04-gbui/apps/analytics.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Analytics - Dashboards
|
||||
1
docs/src/chapter-04-gbui/apps/calendar.md
Normal file
1
docs/src/chapter-04-gbui/apps/calendar.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Calendar - Scheduling
|
||||
384
docs/src/chapter-04-gbui/apps/chat.md
Normal file
384
docs/src/chapter-04-gbui/apps/chat.md
Normal 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
|
||||
1
docs/src/chapter-04-gbui/apps/compliance.md
Normal file
1
docs/src/chapter-04-gbui/apps/compliance.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Compliance - Security Scanner
|
||||
1
docs/src/chapter-04-gbui/apps/designer.md
Normal file
1
docs/src/chapter-04-gbui/apps/designer.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Designer - Visual Builder
|
||||
636
docs/src/chapter-04-gbui/apps/drive.md
Normal file
636
docs/src/chapter-04-gbui/apps/drive.md
Normal 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
|
||||
1
docs/src/chapter-04-gbui/apps/mail.md
Normal file
1
docs/src/chapter-04-gbui/apps/mail.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Mail - Email Client
|
||||
1
docs/src/chapter-04-gbui/apps/meet.md
Normal file
1
docs/src/chapter-04-gbui/apps/meet.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Meet - Video Calls
|
||||
1
docs/src/chapter-04-gbui/apps/paper.md
Normal file
1
docs/src/chapter-04-gbui/apps/paper.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Paper - AI Writing
|
||||
1
docs/src/chapter-04-gbui/apps/research.md
Normal file
1
docs/src/chapter-04-gbui/apps/research.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Research - AI Search
|
||||
1
docs/src/chapter-04-gbui/apps/sources.md
Normal file
1
docs/src/chapter-04-gbui/apps/sources.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Sources - Prompts & Templates
|
||||
610
docs/src/chapter-04-gbui/apps/tasks.md
Normal file
610
docs/src/chapter-04-gbui/apps/tasks.md
Normal 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
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
401
src/security/mutual_tls.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
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,
|
||||
},
|
||||
])),
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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
351
ui/suite/auth/login.html
Normal 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
502
ui/suite/base.html
Normal 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
607
ui/suite/chat.html
Normal 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
600
ui/suite/drive.html
Normal 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
372
ui/suite/home.html
Normal 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
512
ui/suite/mail.html
Normal 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
1071
ui/suite/meet.html
Normal file
File diff suppressed because it is too large
Load diff
101
ui/suite/partials/apps_menu.html
Normal file
101
ui/suite/partials/apps_menu.html
Normal 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>
|
||||
117
ui/suite/partials/contexts.html
Normal file
117
ui/suite/partials/contexts.html
Normal 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>
|
||||
16
ui/suite/partials/message.html
Normal file
16
ui/suite/partials/message.html
Normal 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>
|
||||
25
ui/suite/partials/messages.html
Normal file
25
ui/suite/partials/messages.html
Normal 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 %}
|
||||
47
ui/suite/partials/notification.html
Normal file
47
ui/suite/partials/notification.html
Normal 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>
|
||||
25
ui/suite/partials/sessions.html
Normal file
25
ui/suite/partials/sessions.html
Normal 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 %}
|
||||
17
ui/suite/partials/suggestions.html
Normal file
17
ui/suite/partials/suggestions.html
Normal 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 %}
|
||||
166
ui/suite/partials/user_menu.html
Normal file
166
ui/suite/partials/user_menu.html
Normal 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
608
ui/suite/tasks.html
Normal 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 %}
|
||||
Loading…
Add table
Reference in a new issue