feat: Redesign UI to match Nextcloud ecosystem aesthetic

This commit updates the web interface to better align with Nextcloud's
design system and improve the Vector Viz layout.

Changes:
- Replace emoji icons with Material Design SVG icons for better
  consistency with Nextcloud apps
- Simplify navigation styling with minimal padding and subtle active
  states (250px width)
- Update CSS variables to match Nextcloud design system
- Restructure Vector Viz from two-column to single-column vertical
  layout for better plot visibility
- Move search controls to compact horizontal grid at top
- Make navigation toggle always visible (not just on mobile)
- Fix plot container sizing with overflow:visible to prevent colorbar
  clipping
- Remove heavy shadows and custom card styling for cleaner aesthetic
- Add error and success page templates with consistent styling

Technical details:
- Preserve Alpine.js for reactive functionality
- Use CSS Grid for responsive horizontal controls layout
- Add smooth transitions for navigation collapse/expand
- Maintain HTMX for dynamic content loading

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2025-11-19 00:45:19 +01:00
co-authored by Claude
parent 73e8012707
commit 9db20a4d01
7 changed files with 1085 additions and 688 deletions
+10
View File
@@ -1,3 +1,7 @@
<p align="center">
<img src="astrolabe.svg" alt="Nextcloud MCP Server" width="128" height="128">
</p>
# Nextcloud MCP Server # Nextcloud MCP Server
[![Docker Image](https://img.shields.io/badge/docker-ghcr.io/cbcoutinho/nextcloud--mcp--server-blue)](https://github.com/cbcoutinho/nextcloud-mcp-server/pkgs/container/nextcloud-mcp-server) [![Docker Image](https://img.shields.io/badge/docker-ghcr.io/cbcoutinho/nextcloud--mcp--server-blue)](https://github.com/cbcoutinho/nextcloud-mcp-server/pkgs/container/nextcloud-mcp-server)
@@ -29,6 +33,12 @@ docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
# 3. Test the connection # 3. Test the connection
curl http://127.0.0.1:8000/health/ready curl http://127.0.0.1:8000/health/ready
# 4. Connect to the endpoint
http://127.0.0.1:8000/sse
# 4. Or with --transport streamable-http
http://127.0.0.1:8000/mcp
``` ```
**Next Steps:** **Next Steps:**
@@ -0,0 +1,524 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#0082c9">
<title>{% block title %}Nextcloud MCP Server{% endblock %}</title>
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='32' height='32' viewBox='0 0 512 512'><rect width='512' height='512' rx='80' ry='80' fill='%230082C9'/><path d='M255.9 21.04c-11.8 0-22.2 4.08-28.6 10.01-5.6 4.98-8.6 11.41-8.6 18.11 0 5.55 2.2 11.01 5.9 15.48-16.4 4.97-30.1 13.64-39 24.53 22.1-7.67 45.7-11.86 70.3-11.86 24.6 0 48.3 4.19 70.3 11.86-8.9-10.89-22.6-19.56-39-24.53 3.9-4.47 5.9-9.93 5.9-15.48 0-6.7-3-13.13-8.5-18.11-6.4-5.93-16.9-10.01-28.7-10.01zm0 20.34c5.3 0 10.1 1.27 13.6 3.52 1.7 1.16 3.4 2.43 3.4 4.27 0 1.76-1.7 3.03-3.4 4.19-3.5 2.33-8.3 3.61-13.6 3.61-5.3 0-10.1-1.28-13.6-3.61-1.6-1.16-3.3-2.43-3.3-4.19 0-1.84 1.7-3.11 3.3-4.27 3.5-2.25 8.3-3.52 13.6-3.52zm.1 48.1c-110.8 0-200.72 90.02-200.72 200.82S145.2 491 256 491s200.7-89.9 200.7-200.7c0-110.8-89.9-200.82-200.7-200.82zm0 32.62c92.9 0 168.2 75.3 168.2 168.2 0 92.8-75.3 168.2-168.2 168.2-92.9 0-168.26-75.4-168.26-168.2 0-92.9 75.36-168.2 168.26-168.2zm-8.2 6.3c-9.6.5-19 1.9-28.3 4.1l2.3 7.8c8.4-2 17.1-3.3 26-3.8v-8.1zm16.2 0v8.1c9 .5 17.7 1.8 26 3.8l2.2-7.8c-9.1-2.2-18.6-3.6-28.2-4.1zm-60 8.5c-9 3.2-17.6 7-25.8 11.6l4.1 7.1c7.7-4.3 15.6-7.9 23.9-10.8l-2.2-7.9zm103.7 0-2 7.9c8.4 2.9 16.2 6.5 23.8 10.8l4.2-7.1c-8.2-4.6-16.9-8.4-26-11.6zm-143.3 20.3c-7.5 5.4-14.6 11.4-21.1 17.9l5.8 5.8c5.9-6.1 12.5-11.7 19.5-16.6l-4.2-7.1zm182.9 0-4 7.1c6.9 4.9 13.5 10.5 19.5 16.6l5.7-5.8c-6.5-6.5-13.7-12.5-21.2-17.9zm-91.4 11.5c-37 0-67.4 28.6-70.3 64.9l15.9 4.7c.7-29.6 24.7-53.4 54.4-53.4 30.1 0 54.4 24.4 54.4 54.3 0 15-6.2 28.7-16 38.5l.1.1c1.7 2.7 3 5.6 4.1 8.6.9 3 1.7 5.7 2.3 8.6v.4c33.8-16.7 57.2-51.5 57.2-91.7 0-3.8-.2-7.3-.6-10.9-3.2-3.3-6.3-6.4-9.8-9.5 1.5 6.5 2.3 13.4 2.3 20.4 0 28.7-13 54.7-33.5 71.8 6.3-10.6 10.1-23 10.1-36.3 0-38.9-31.7-70.5-70.6-70.5zm-91.8 14.6c-3.3 3.1-6.5 6.2-9.7 9.5-.3 3.6-.5 7.1-.5 10.9 0 7.3.7 14.2 2.1 20.9l9.1 2.7c-2.1-7.5-3.1-15.4-3.1-23.6 0-7 .7-13.9 2.1-20.4zm-31.6 4c-5.8 7.1-10.9 14.6-15.4 22.6l7.1 4c4.1-7.4 8.8-14.3 14-20.8l-5.7-5.8zm246.8 0-5.7 5.8c5.3 6.5 10 13.4 13.9 20.8l7.1-4c-4.4-8-9.5-15.5-15.3-22.6zm-269.2 37.1c-2.5 5.7-4.6 11.4-6.4 17.6l.1-.3c3.4-5 7.9-9.3 12.9-12.5l.3-.6-6.9-4.2zm291.8 0-7.2 4.2c3.2 7.3 5.7 15.1 7.6 23.1l7.9-2.1c-2.1-8.8-4.9-17.3-8.3-25.2zm-261.2 11.5c-13.4.1-25.7 9-29.7 22.5l114.8 34.2c-4.9 16.7 4.6 34.2 21.2 39.2L361.7 366c16.6 5 34.1-4.4 39.1-21l-114.6-34.4c4.9-16.5-4.7-34.1-21.3-39.1 0 0-72.4-21.5-114.8-34.3-3.1-.9-6.3-1.4-9.4-1.3zm-42.09 29.7c-.9 6.9-1.4 14-1.4 21.3 0 1.3.1 2.9.1 4.2h8.09v-4.2c0-6.5.4-12.9 1.2-19.2l-7.99-2.1zm314.59 0-7.9 2.1c.7 6.3 1.3 12.7 1.3 19.2 0 1.3 0 2.9-.2 4.2h8.2v-4.2c0-7.3-.5-14.4-1.4-21.3zm-157.3 24.7c6.3 0 11.5 5 11.5 11.3 0 6.4-5.2 11.6-11.5 11.6s-11.5-5.2-11.5-11.6c0-6.3 5.2-11.3 11.5-11.3zM98.51 307.4c1 8.2 2.89 16.4 5.09 24.3l7.9-2.1c-2.1-7.2-3.8-14.6-4.8-22.2h-8.19zm306.69 0c-1.1 7.6-2.7 15-4.8 22.2l7.8 2.1c2.2-7.9 4.1-16.1 5.2-24.3h-8.2zm-191.3 10.9c-19 13.3-31.4 35.3-31.4 60.1 0 10.4 2.3 20.4 6.2 29.7 8.8 4.9 17.9 8.8 27.6 11.7-10.8-10.7-17.5-25.2-17.5-41.4 0-19 9.3-36 23.7-46.3-3.8-4.1-6.7-8.7-8.6-13.8zM116.8 345l-7.9 2c3.1 7.6 6.8 14.7 11 21.6l6.9-4.2c-3.8-6.2-7-12.8-10-19.4zm194.8 20.5c.9 4.1 1.4 8.5 1.4 12.9 0 16.2-6.7 30.7-17.4 41.4 9.6-2.9 18.8-6.8 27.5-11.7 4-9.3 6.2-19.3 6.2-29.7 0-2.7-.2-5.2-.4-7.7l-17.3-5.2zM136 377.9l-7.1 4.1c4.7 6.2 9.7 12.1 15.3 17.3l5.7-5.5c-5.1-5-9.7-10.3-13.9-15.9zm243.9 2.3-.2.1c-2.1.3-4 .6-6.2.7h-.1c-3.6 4.5-7.3 8.8-11.5 12.8l5.8 5.5c5.5-5.2 10.5-11.1 15.2-17.3l-3-1.8zm-217.8 24-5.9 5.9c6 4.8 12.2 9.7 18.8 13.6l3.8-7.8c-5.7-2.9-11.4-6.8-16.7-11.7zm187.7 0c-5.4 4.9-11.1 8.8-16.8 11.7l3.9 7.8c6.5-3.9 12.8-8.8 18.7-13.6l-5.8-5.9zm-156.4 19.5-4.1 6.8c6.6 4 13.7 5.8 20.7 8.8l2.2-7.9c-6.5-1.9-12.7-4.8-18.8-7.7zm125.2 0c-6.2 2.9-12.5 5.8-19.1 7.7l2.3 7.9c7.2-3 14-4.8 20.7-8.8l-3.9-6.8zm-90.7 11.7-2 7.8c7.1 1 14.5 1.9 21.9 1.9v-7.7c-6.8 0-13.5-1.1-19.9-2zm55.9 0c-6.3.9-13 2-19.8 2v7.7c7.5 0 14.8-.9 22.1-1.9l-2.3-7.8z' fill='%23fff'/></svg>">
<!-- Open Sans font -->
<style>
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: normal;
src: local('Open Sans'), local('OpenSans');
}
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: bold;
src: local('Open Sans Semibold'), local('OpenSans-Semibold');
}
</style>
{% block extra_head %}{% endblock %}
<style>
/* Nextcloud App Design System */
/* CSS Variables */
:root {
/* Primary Colors */
--color-primary: #00679e;
--color-primary-element: #00679e;
--color-primary-light: #e5eff5;
--color-primary-element-light: #e5eff5;
/* Background Colors */
--color-main-background: #ffffff;
--color-background-dark: #ededed;
--color-background-hover: #f5f5f5;
/* Text Colors */
--color-main-text: #222222;
--color-text-maxcontrast: #6b6b6b;
--color-text-light: #767676;
/* Border Colors */
--color-border: #ededed;
--color-border-dark: #dbdbdb;
/* Borders & Radius */
--border-radius: 3px;
--border-radius-large: 10px;
--border-radius-pill: 100px;
/* Spacing */
--default-grid-baseline: 4px;
--default-clickable-area: 44px;
}
/* SVG Icon Styles */
.nav-icon {
width: 20px;
height: 20px;
display: inline-block;
fill: var(--color-main-text);
opacity: 0.7;
}
.app-navigation-entry.active .nav-icon {
fill: var(--color-primary-element);
opacity: 1;
}
/* General */
* {
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
color: var(--color-main-text);
background: var(--color-main-background);
margin: 0;
padding: 0;
}
h1, h2, h3 {
font-weight: 300;
line-height: 1.2;
}
h1 {
font-size: 32px;
margin: 0 0 20px 0;
color: var(--color-main-text);
}
h2 {
font-size: 20px;
margin: 20px 0 12px 0;
color: var(--color-main-text);
border-bottom: 1px solid var(--color-border);
padding-bottom: 8px;
}
h3 {
font-size: 16px;
margin: 16px 0 8px 0;
color: var(--color-main-text);
font-weight: 500;
}
img {
max-width: 100%;
}
/* App Header (simplified, no full menu) */
.app-header {
height: 50px;
background: var(--color-primary-element);
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
position: sticky;
top: 0;
z-index: 100;
display: flex;
align-items: center;
padding: 0 20px;
}
.app-header__brand {
color: white;
font-size: 18px;
font-weight: 600;
text-decoration: none;
display: flex;
align-items: center;
gap: 12px;
}
.app-header__brand:hover {
opacity: 0.9;
}
.app-header__logo {
height: 32px;
width: 32px;
fill: white;
}
/* App Layout */
.app-content-wrapper {
display: flex;
height: calc(100vh - 50px);
overflow: hidden;
}
/* Side Navigation */
#app-navigation {
width: 250px;
background: var(--color-main-background);
border-right: 1px solid var(--color-border);
display: flex;
flex-direction: column;
flex-shrink: 0;
transition: margin-left 0.3s ease;
}
#app-navigation.app-navigation--closed {
margin-left: -250px;
}
.app-navigation__content {
flex: 1;
overflow-y: auto;
padding: 8px;
display: flex;
flex-direction: column;
}
.app-navigation-list {
list-style: none;
padding: 0;
margin: 0;
flex: 1;
}
.app-navigation-entry {
position: relative;
margin-bottom: 2px;
}
.app-navigation-entry__wrapper {
display: flex;
align-items: center;
position: relative;
}
.app-navigation-entry-link {
display: flex;
align-items: center;
padding: 0 8px;
min-height: var(--default-clickable-area);
border-radius: var(--border-radius);
transition: background-color 100ms ease-in-out;
text-decoration: none;
color: var(--color-main-text);
flex: 1;
font-size: 14px;
}
.app-navigation-entry-link:hover {
background-color: var(--color-background-hover);
}
.app-navigation-entry.active .app-navigation-entry-link {
background-color: var(--color-primary-element-light);
font-weight: 500;
}
.app-navigation-entry-icon {
width: var(--default-clickable-area);
height: var(--default-clickable-area);
display: flex;
align-items: center;
justify-content: center;
margin-right: 0;
}
.app-navigation-entry__name {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.app-navigation-entry__counter {
margin-left: auto;
padding: 2px 6px;
border-radius: var(--border-radius-pill);
background-color: var(--color-background-dark);
font-size: 11px;
color: var(--color-text-maxcontrast);
min-width: 20px;
text-align: center;
}
.app-navigation__settings {
list-style: none;
padding: 8px 0 0 0;
margin: 8px 0 0 0;
border-top: 1px solid var(--color-border);
flex-shrink: 0;
}
.app-navigation-toggle {
display: flex;
align-items: center;
justify-content: center;
position: fixed;
top: 60px;
left: 10px;
z-index: 110;
background: var(--color-main-background);
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
padding: 8px 12px;
cursor: pointer;
box-shadow: 0 0 5px rgba(0,0,0,0.1);
transition: left 0.3s ease;
}
.app-navigation-toggle:hover {
background: var(--color-background-hover);
}
#app-navigation:not(.app-navigation--closed) ~ * .app-navigation-toggle {
left: 260px;
}
/* Main Content Area */
#app-content {
flex: 1;
overflow-y: auto;
background: var(--color-main-background);
}
.page-content {
max-width: 1000px;
margin: 0 auto;
padding: 24px;
}
.content-section {
background: var(--color-main-background);
border-radius: 0;
padding: 0;
box-shadow: none;
}
.content-section h1 {
font-size: 24px;
font-weight: 600;
margin-bottom: 24px;
}
.content-section h2 {
font-size: 18px;
font-weight: 500;
margin: 24px 0 12px 0;
border-bottom: none;
padding-bottom: 0;
}
.content-section h3 {
font-size: 16px;
font-weight: 500;
}
/* Responsive */
@media (max-width: 768px) {
#app-navigation {
position: fixed;
height: calc(100vh - 50px);
z-index: 105;
box-shadow: 2px 0 8px rgba(0,0,0,0.1);
}
.page-content {
padding: 16px;
}
}
/* Footer */
footer.page-footer {
background-color: #0F0833;
color: #ffffff;
padding: 40px 0;
margin-top: 60px;
}
footer.page-footer .bootstrap-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
footer.page-footer h1 {
font-size: 15px;
font-weight: bold;
line-height: 1.8;
color: #ffffff;
margin-top: 20px;
}
footer.page-footer ul {
list-style-type: none;
padding-left: 0;
}
footer.page-footer li {
font-size: 13px;
line-height: 1.8;
color: #ffffff;
margin-top: 0;
}
footer.page-footer li a {
color: #ffffff;
text-decoration: none;
display: block;
padding: 4px 0;
}
footer.page-footer li a:hover {
text-decoration: underline;
}
footer.page-footer p {
font-size: 15px;
line-height: 1.8;
color: #ffffff;
}
footer.page-footer p.copyright {
color: rgba(255, 255, 255, 0.5);
font-size: 13px;
text-align: center;
margin-top: 30px;
}
/* Buttons */
.btn {
border-radius: 50px;
padding: 10px 20px;
text-decoration: none;
display: inline-block;
cursor: pointer;
border: none;
font-size: 14px;
transition: all 0.3s;
}
.btn-primary {
background: #0082C9;
border: 1px solid #0062C9;
color: #fff;
}
.btn-primary:hover {
background: #006ba3;
}
/* Tables */
table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
}
td {
padding: 12px 8px;
border-bottom: 1px solid var(--color-border);
font-size: 14px;
}
td:first-child {
width: 180px;
color: var(--color-text-maxcontrast);
font-weight: 500;
}
code {
background-color: var(--color-background-dark);
padding: 2px 6px;
border-radius: var(--border-radius);
font-family: 'SFMono-Regular', 'Consolas', 'Liberation Mono', 'Menlo', monospace;
font-size: 90%;
color: var(--color-main-text);
}
/* Badges */
.badge {
display: inline-block;
padding: 3px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: bold;
text-transform: uppercase;
}
.badge-oauth {
background-color: #4caf50;
color: white;
}
.badge-basic {
background-color: #2196f3;
color: white;
}
/* Messages */
.warning {
background-color: #fff3cd;
border-left: 4px solid #ffc107;
padding: 15px;
margin: 15px 0;
color: #856404;
}
.info-message {
background-color: #e3f2fd;
border-left: 4px solid #2196f3;
padding: 15px;
margin: 15px 0;
color: #1565c0;
}
.error {
background-color: #ffebee;
border-left: 4px solid #d32f2f;
padding: 15px;
margin: 15px 0;
color: #c62828;
}
.success {
background-color: #e8f5e9;
border: 2px solid #4caf50;
padding: 30px;
border-radius: 8px;
text-align: center;
}
.success h1 {
color: #4caf50;
}
{% block extra_styles %}{% endblock %}
</style>
</head>
<body>
<!-- App Header -->
<header class="app-header">
<a href="/app" class="app-header__brand">
<svg class="app-header__logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<path d="M255.9 21.04c-11.8 0-22.2 4.08-28.6 10.01-5.6 4.98-8.6 11.41-8.6 18.11 0 5.55 2.2 11.01 5.9 15.48-16.4 4.97-30.1 13.64-39 24.53 22.1-7.67 45.7-11.86 70.3-11.86 24.6 0 48.3 4.19 70.3 11.86-8.9-10.89-22.6-19.56-39-24.53 3.9-4.47 5.9-9.93 5.9-15.48 0-6.7-3-13.13-8.5-18.11-6.4-5.93-16.9-10.01-28.7-10.01zm0 20.34c5.3 0 10.1 1.27 13.6 3.52 1.7 1.16 3.4 2.43 3.4 4.27 0 1.76-1.7 3.03-3.4 4.19-3.5 2.33-8.3 3.61-13.6 3.61-5.3 0-10.1-1.28-13.6-3.61-1.6-1.16-3.3-2.43-3.3-4.19 0-1.84 1.7-3.11 3.3-4.27 3.5-2.25 8.3-3.52 13.6-3.52zm.1 48.1c-110.8 0-200.72 90.02-200.72 200.82S145.2 491 256 491s200.7-89.9 200.7-200.7c0-110.8-89.9-200.82-200.7-200.82zm0 32.62c92.9 0 168.2 75.3 168.2 168.2 0 92.8-75.3 168.2-168.2 168.2-92.9 0-168.26-75.4-168.26-168.2 0-92.9 75.36-168.2 168.26-168.2zm-8.2 6.3c-9.6.5-19 1.9-28.3 4.1l2.3 7.8c8.4-2 17.1-3.3 26-3.8v-8.1zm16.2 0v8.1c9 .5 17.7 1.8 26 3.8l2.2-7.8c-9.1-2.2-18.6-3.6-28.2-4.1zm-60 8.5c-9 3.2-17.6 7-25.8 11.6l4.1 7.1c7.7-4.3 15.6-7.9 23.9-10.8l-2.2-7.9zm103.7 0-2 7.9c8.4 2.9 16.2 6.5 23.8 10.8l4.2-7.1c-8.2-4.6-16.9-8.4-26-11.6zm-143.3 20.3c-7.5 5.4-14.6 11.4-21.1 17.9l5.8 5.8c5.9-6.1 12.5-11.7 19.5-16.6l-4.2-7.1zm182.9 0-4 7.1c6.9 4.9 13.5 10.5 19.5 16.6l5.7-5.8c-6.5-6.5-13.7-12.5-21.2-17.9zm-91.4 11.5c-37 0-67.4 28.6-70.3 64.9l15.9 4.7c.7-29.6 24.7-53.4 54.4-53.4 30.1 0 54.4 24.4 54.4 54.3 0 15-6.2 28.7-16 38.5l.1.1c1.7 2.7 3 5.6 4.1 8.6.9 3 1.7 5.7 2.3 8.6v.4c33.8-16.7 57.2-51.5 57.2-91.7 0-3.8-.2-7.3-.6-10.9-3.2-3.3-6.3-6.4-9.8-9.5 1.5 6.5 2.3 13.4 2.3 20.4 0 28.7-13 54.7-33.5 71.8 6.3-10.6 10.1-23 10.1-36.3 0-38.9-31.7-70.5-70.6-70.5zm-91.8 14.6c-3.3 3.1-6.5 6.2-9.7 9.5-.3 3.6-.5 7.1-.5 10.9 0 7.3.7 14.2 2.1 20.9l9.1 2.7c-2.1-7.5-3.1-15.4-3.1-23.6 0-7 .7-13.9 2.1-20.4zm-31.6 4c-5.8 7.1-10.9 14.6-15.4 22.6l7.1 4c4.1-7.4 8.8-14.3 14-20.8l-5.7-5.8zm246.8 0-5.7 5.8c5.3 6.5 10 13.4 13.9 20.8l7.1-4c-4.4-8-9.5-15.5-15.3-22.6zm-269.2 37.1c-2.5 5.7-4.6 11.4-6.4 17.6l.1-.3c3.4-5 7.9-9.3 12.9-12.5l.3-.6-6.9-4.2zm291.8 0-7.2 4.2c3.2 7.3 5.7 15.1 7.6 23.1l7.9-2.1c-2.1-8.8-4.9-17.3-8.3-25.2zm-261.2 11.5c-13.4.1-25.7 9-29.7 22.5l114.8 34.2c-4.9 16.7 4.6 34.2 21.2 39.2L361.7 366c16.6 5 34.1-4.4 39.1-21l-114.6-34.4c4.9-16.5-4.7-34.1-21.3-39.1 0 0-72.4-21.5-114.8-34.3-3.1-.9-6.3-1.4-9.4-1.3zm-42.09 29.7c-.9 6.9-1.4 14-1.4 21.3 0 1.3.1 2.9.1 4.2h8.09v-4.2c0-6.5.4-12.9 1.2-19.2l-7.99-2.1zm314.59 0-7.9 2.1c.7 6.3 1.3 12.7 1.3 19.2 0 1.3 0 2.9-.2 4.2h8.2v-4.2c0-7.3-.5-14.4-1.4-21.3zm-157.3 24.7c6.3 0 11.5 5 11.5 11.3 0 6.4-5.2 11.6-11.5 11.6s-11.5-5.2-11.5-11.6c0-6.3 5.2-11.3 11.5-11.3zM98.51 307.4c1 8.2 2.89 16.4 5.09 24.3l7.9-2.1c-2.1-7.2-3.8-14.6-4.8-22.2h-8.19zm306.69 0c-1.1 7.6-2.7 15-4.8 22.2l7.8 2.1c2.2-7.9 4.1-16.1 5.2-24.3h-8.2zm-191.3 10.9c-19 13.3-31.4 35.3-31.4 60.1 0 10.4 2.3 20.4 6.2 29.7 8.8 4.9 17.9 8.8 27.6 11.7-10.8-10.7-17.5-25.2-17.5-41.4 0-19 9.3-36 23.7-46.3-3.8-4.1-6.7-8.7-8.6-13.8zM116.8 345l-7.9 2c3.1 7.6 6.8 14.7 11 21.6l6.9-4.2c-3.8-6.2-7-12.8-10-19.4zm194.8 20.5c.9 4.1 1.4 8.5 1.4 12.9 0 16.2-6.7 30.7-17.4 41.4 9.6-2.9 18.8-6.8 27.5-11.7 4-9.3 6.2-19.3 6.2-29.7 0-2.7-.2-5.2-.4-7.7l-17.3-5.2zM136 377.9l-7.1 4.1c4.7 6.2 9.7 12.1 15.3 17.3l5.7-5.5c-5.1-5-9.7-10.3-13.9-15.9zm243.9 2.3-.2.1c-2.1.3-4 .6-6.2.7h-.1c-3.6 4.5-7.3 8.8-11.5 12.8l5.8 5.5c5.5-5.2 10.5-11.1 15.2-17.3l-3-1.8zm-217.8 24-5.9 5.9c6 4.8 12.2 9.7 18.8 13.6l3.8-7.8c-5.7-2.9-11.4-6.8-16.7-11.7zm187.7 0c-5.4 4.9-11.1 8.8-16.8 11.7l3.9 7.8c6.5-3.9 12.8-8.8 18.7-13.6l-5.8-5.9zm-156.4 19.5-4.1 6.8c6.6 4 13.7 5.8 20.7 8.8l2.2-7.9c-6.5-1.9-12.7-4.8-18.8-7.7zm125.2 0c-6.2 2.9-12.5 5.8-19.1 7.7l2.3 7.9c7.2-3 14-4.8 20.7-8.8l-3.9-6.8zm-90.7 11.7-2 7.8c7.1 1 14.5 1.9 21.9 1.9v-7.7c-6.8 0-13.5-1.1-19.9-2zm55.9 0c-6.3.9-13 2-19.8 2v7.7c7.5 0 14.8-.9 22.1-1.9l-2.3-7.8z" fill="#fff"/>
</svg>
<span>Nextcloud MCP Server</span>
</a>
</header>
<!-- App Content Wrapper (Sidebar + Main Content) -->
{% block content %}{% endblock %}
{% block scripts %}{% endblock %}
</body>
</html>
@@ -0,0 +1,19 @@
{% extends "base.html" %}
{% block title %}{{ error_title|default('Error') }} - Nextcloud MCP Server{% endblock %}
{% block content %}
<h1>{{ error_title|default('Error') }}</h1>
<div class="error">
<strong>Error:</strong> {{ error_message }}
</div>
{% if login_url %}
<p><a href="{{ login_url }}" class="btn btn-primary">Login again</a></p>
{% endif %}
{% if back_url %}
<p><a href="{{ back_url }}" class="btn">Go Back</a></p>
{% endif %}
{% endblock %}
@@ -0,0 +1,21 @@
{% extends "base.html" %}
{% block title %}{{ success_title|default('Success') }} - Nextcloud MCP Server{% endblock %}
{% block extra_head %}
{% if redirect_url and redirect_delay %}
<meta http-equiv="refresh" content="{{ redirect_delay }};url={{ redirect_url }}">
{% endif %}
{% endblock %}
{% block content %}
<div class="success">
<h1>{{ success_title|default('✓ Success') }}</h1>
{% for message in success_messages %}
<p>{{ message }}</p>
{% endfor %}
{% if redirect_url %}
<p>Redirecting...</p>
{% endif %}
</div>
{% endblock %}
@@ -0,0 +1,325 @@
{% extends "base.html" %}
{% block title %}Nextcloud MCP Server{% endblock %}
{% block extra_head %}
<!-- htmx for dynamic loading -->
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<!-- Alpine.js for state management -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<!-- Plotly.js for vector visualization -->
<script src="https://cdn.plot.ly/plotly-3.3.0.min.js"></script>
{% endblock %}
{% block extra_styles %}
/* Smooth htmx transitions */
.htmx-swapping {
opacity: 0;
transition: opacity 200ms ease-out;
}
.htmx-settling {
opacity: 1;
transition: opacity 200ms ease-in;
}
/* Logout button styling */
.logout-section {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid var(--color-border);
}
{% endblock %}
{% block content %}
<div class="app-content-wrapper" x-data="{ activeSection: 'user-info', navOpen: true }">
<!-- Side Navigation -->
<nav id="app-navigation" :class="{ 'app-navigation--closed': !navOpen }">
<div class="app-navigation__content">
<!-- Navigation List -->
<ul class="app-navigation-list">
<li class="app-navigation-entry" :class="{ 'active': activeSection === 'user-info' }">
<div class="app-navigation-entry__wrapper">
<a href="#"
@click.prevent="activeSection = 'user-info'"
class="app-navigation-entry-link">
<span class="app-navigation-entry-icon">
<svg class="nav-icon" viewBox="0 0 24 24">
<path d="M12,4A4,4 0 0,1 16,8A4,4 0 0,1 12,12A4,4 0 0,1 8,8A4,4 0 0,1 12,4M12,14C16.42,14 20,15.79 20,18V20H4V18C4,15.79 7.58,14 12,14Z" />
</svg>
</span>
<span class="app-navigation-entry__name">User Info</span>
</a>
</div>
</li>
{% if show_vector_sync_tab %}
<li class="app-navigation-entry" :class="{ 'active': activeSection === 'vector-sync' }">
<div class="app-navigation-entry__wrapper">
<a href="#"
@click.prevent="activeSection = 'vector-sync'"
class="app-navigation-entry-link">
<span class="app-navigation-entry-icon">
<svg class="nav-icon" viewBox="0 0 24 24">
<path d="M12,18A6,6 0 0,1 6,12C6,11 6.25,10.03 6.7,9.2L5.24,7.74C4.46,8.97 4,10.43 4,12A8,8 0 0,0 12,20V23L16,19L12,15M12,4V1L8,5L12,9V6A6,6 0 0,1 18,12C18,13 17.75,13.97 17.3,14.8L18.76,16.26C19.54,15.03 20,13.57 20,12A8,8 0 0,0 12,4Z" />
</svg>
</span>
<span class="app-navigation-entry__name">Vector Sync</span>
</a>
</div>
</li>
<li class="app-navigation-entry" :class="{ 'active': activeSection === 'vector-viz' }">
<div class="app-navigation-entry__wrapper">
<a href="#"
@click.prevent="activeSection = 'vector-viz'"
class="app-navigation-entry-link">
<span class="app-navigation-entry-icon">
<svg class="nav-icon" viewBox="0 0 24 24">
<path d="M22,21H2V3H4V19H6V10H10V19H12V6H16V19H18V14H22V21Z" />
</svg>
</span>
<span class="app-navigation-entry__name">Vector Viz</span>
</a>
</div>
</li>
{% endif %}
{% if show_webhooks_tab %}
<li class="app-navigation-entry" :class="{ 'active': activeSection === 'webhooks' }">
<div class="app-navigation-entry__wrapper">
<a href="#"
@click.prevent="activeSection = 'webhooks'"
class="app-navigation-entry-link">
<span class="app-navigation-entry-icon">
<svg class="nav-icon" viewBox="0 0 24 24">
<path d="M10.59,13.41C11,13.8 11,14.44 10.59,14.83C10.2,15.22 9.56,15.22 9.17,14.83C7.22,12.88 7.22,9.71 9.17,7.76V7.76L12.71,4.22C14.66,2.27 17.83,2.27 19.78,4.22C21.73,6.17 21.73,9.34 19.78,11.29L18.29,12.78C18.3,11.96 18.17,11.14 17.89,10.36L18.36,9.88C19.54,8.71 19.54,6.81 18.36,5.64C17.19,4.46 15.29,4.46 14.12,5.64L10.59,9.17C9.41,10.34 9.41,12.24 10.59,13.41M13.41,9.17C13.8,8.78 14.44,8.78 14.83,9.17C16.78,11.12 16.78,14.29 14.83,16.24V16.24L11.29,19.78C9.34,21.73 6.17,21.73 4.22,19.78C2.27,17.83 2.27,14.66 4.22,12.71L5.71,11.22C5.7,12.04 5.83,12.86 6.11,13.65L5.64,14.12C4.46,15.29 4.46,17.19 5.64,18.36C6.81,19.54 8.71,19.54 9.88,18.36L13.41,14.83C14.59,13.66 14.59,11.76 13.41,10.59C13,10.2 13,9.56 13.41,9.17Z" />
</svg>
</span>
<span class="app-navigation-entry__name">Webhooks</span>
</a>
</div>
</li>
{% endif %}
</ul>
<!-- Settings/Logout at bottom -->
{% if logout_url %}
<ul class="app-navigation__settings">
<li class="app-navigation-entry">
<div class="app-navigation-entry__wrapper">
<a href="{{ logout_url }}" class="app-navigation-entry-link">
<span class="app-navigation-entry-icon">
<svg class="nav-icon" viewBox="0 0 24 24">
<path d="M16,17V14H9V10H16V7L21,12L16,17M14,2A2,2 0 0,1 16,4V6H14V4H5V20H14V18H16V20A2,2 0 0,1 14,22H5A2,2 0 0,1 3,20V4A2,2 0 0,1 5,2H14Z" />
</svg>
</span>
<span class="app-navigation-entry__name">Logout</span>
</a>
</div>
</li>
</ul>
{% endif %}
</div>
<!-- Toggle Button (mobile) -->
<button @click="navOpen = !navOpen"
class="app-navigation-toggle"
:aria-expanded="navOpen.toString()">
</button>
</nav>
<!-- Main Content Area -->
<main id="app-content">
<div class="page-content">
<!-- User Info Section -->
<div x-show="activeSection === 'user-info'">
<div class="content-section">
<h1>User Information</h1>
{{ user_info_tab_html|safe }}
</div>
</div>
{% if show_vector_sync_tab %}
<!-- Vector Sync Section -->
<div x-show="activeSection === 'vector-sync'">
<div class="content-section">
<h1>Vector Sync Status</h1>
{{ vector_sync_tab_html|safe }}
</div>
</div>
<!-- Vector Viz Section -->
<div x-show="activeSection === 'vector-viz'">
<div class="content-section">
<h1>Vector Visualization</h1>
<div hx-get="/app/vector-viz" hx-trigger="load" hx-swap="outerHTML">
<p style="color: #999;">Loading vector visualization...</p>
</div>
</div>
</div>
{% endif %}
{% if show_webhooks_tab %}
<!-- Webhooks Section -->
<div x-show="activeSection === 'webhooks'">
<div class="content-section">
<h1>Webhook Management</h1>
{{ webhooks_tab_html|safe }}
</div>
</div>
{% endif %}
</div>
</main>
</div>
<script>
// Initialize vizApp for vector visualization (if needed)
function vizApp() {
return {
query: '',
algorithm: 'bm25_hybrid',
fusion: 'rrf',
showAdvanced: false,
docTypes: [''],
limit: 50,
scoreThreshold: 0.0,
loading: false,
results: [],
expandedChunks: {},
chunkLoading: {},
async executeSearch() {
this.loading = true;
this.results = [];
try {
const params = new URLSearchParams({
query: this.query,
algorithm: this.algorithm,
limit: this.limit,
score_threshold: this.scoreThreshold,
});
if (this.algorithm === 'bm25_hybrid') {
params.append('fusion', this.fusion);
}
const selectedTypes = this.docTypes.filter(t => t !== '');
if (selectedTypes.length > 0) {
params.append('doc_types', selectedTypes.join(','));
}
const response = await fetch(`/app/vector-viz/search?${params}`);
const data = await response.json();
if (data.success) {
this.results = data.results;
this.renderPlot(data.coordinates_2d, data.results);
} else {
alert('Search failed: ' + data.error);
}
} catch (error) {
alert('Error: ' + error.message);
} finally {
this.loading = false;
}
},
renderPlot(coordinates, results) {
const scores = results.map(r => r.score);
const trace = {
x: coordinates.map(c => c[0]),
y: coordinates.map(c => c[1]),
mode: 'markers',
type: 'scatter',
text: results.map(r => `${r.title}<br>Raw Score: ${r.original_score.toFixed(3)} (${(r.score * 100).toFixed(0)}% relative)`),
marker: {
size: results.map(r => 6 + (Math.pow(r.score, 2) * 14)),
opacity: results.map(r => 0.2 + (r.score * 0.8)),
color: scores,
colorscale: 'Viridis',
showscale: true,
colorbar: { title: 'Relative Score' },
cmin: 0,
cmax: 1
}
};
const layout = {
title: `Vector Space (PCA 2D) - ${results.length} results`,
xaxis: { title: 'PC1' },
yaxis: { title: 'PC2' },
hovermode: 'closest',
height: 600
};
Plotly.newPlot('viz-plot', [trace], layout);
},
getNextcloudUrl(result) {
const baseUrl = '{{ nextcloud_host_for_links }}';
switch (result.doc_type) {
case 'note':
return `${baseUrl}/apps/notes/note/${result.id}`;
case 'file':
return `${baseUrl}/apps/files/?fileId=${result.id}`;
case 'calendar':
return `${baseUrl}/apps/calendar`;
case 'contact':
return `${baseUrl}/apps/contacts`;
case 'deck':
return `${baseUrl}/apps/deck`;
default:
return `${baseUrl}`;
}
},
hasChunkPosition(result) {
return result.chunk_start_offset != null && result.chunk_end_offset != null;
},
isChunkExpanded(resultKey) {
return this.expandedChunks[resultKey] !== undefined;
},
async toggleChunk(result) {
const resultKey = `${result.doc_type}_${result.id}`;
if (this.isChunkExpanded(resultKey)) {
delete this.expandedChunks[resultKey];
return;
}
this.chunkLoading[resultKey] = true;
try {
const params = new URLSearchParams({
doc_type: result.doc_type,
doc_id: result.id,
start: result.chunk_start_offset,
end: result.chunk_end_offset,
context: 500
});
const response = await fetch(`/app/chunk-context?${params}`);
const data = await response.json();
if (data.success) {
this.expandedChunks[resultKey] = data;
} else {
alert('Failed to load chunk: ' + data.error);
}
} catch (error) {
alert('Error loading chunk: ' + error.message);
} finally {
delete this.chunkLoading[resultKey];
}
}
};
}
</script>
{% endblock %}
@@ -1,38 +1,59 @@
<style> <style>
.viz-layout {
display: flex;
flex-direction: column;
gap: 16px;
height: 100%;
min-height: 0;
overflow-y: auto;
}
.viz-card { .viz-card {
background: white; background: var(--color-main-background);
border-radius: 8px; border-radius: 0;
padding: 20px; padding: 16px;
margin-bottom: 20px; box-shadow: none;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
} }
.viz-controls { .viz-controls-card {
margin-bottom: 20px; flex: 0 0 auto;
border-bottom: 1px solid var(--color-border);
padding-bottom: 16px;
} }
.viz-control-row { .viz-controls-grid {
display: grid; display: grid;
grid-template-columns: 2fr 1fr auto; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px; gap: 12px;
margin-bottom: 12px;
align-items: end; align-items: end;
} }
@media (min-width: 768px) {
.viz-controls-grid {
grid-template-columns: 2fr 1.5fr 1.5fr auto auto;
}
}
.viz-control-group { .viz-control-group {
margin-bottom: 15px; display: flex;
flex-direction: column;
gap: 4px;
} }
.viz-control-group label { .viz-control-group label {
display: block;
margin-bottom: 5px;
font-weight: 500; font-weight: 500;
color: #333; color: var(--color-main-text);
font-size: 13px;
} }
.viz-control-group input[type="text"], .viz-control-group input[type="text"],
.viz-control-group input[type="number"], .viz-control-group input[type="number"],
.viz-control-group select { .viz-control-group select {
width: 100%; width: 100%;
padding: 8px 12px; padding: 7px 10px;
border: 1px solid #ddd; border: 1px solid var(--color-border-dark);
border-radius: 4px; border-radius: var(--border-radius);
font-size: 14px; font-size: 14px;
background: var(--color-main-background);
color: var(--color-main-text);
}
.viz-control-group input:focus,
.viz-control-group select:focus {
outline: none;
border-color: var(--color-primary-element);
} }
.viz-control-group input[type="range"] { .viz-control-group input[type="range"] {
width: 100%; width: 100%;
@@ -47,14 +68,15 @@
color: #666; color: #666;
} }
.viz-btn { .viz-btn {
background: #0066cc; background: var(--color-primary-element);
color: white; color: white;
border: none; border: none;
padding: 10px 20px; padding: 7px 16px;
border-radius: 4px; border-radius: var(--border-radius);
cursor: pointer; cursor: pointer;
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
white-space: nowrap;
} }
.viz-btn:hover { .viz-btn:hover {
background: #0052a3; background: #0052a3;
@@ -63,19 +85,27 @@
background: #6c757d; background: #6c757d;
color: white; color: white;
border: none; border: none;
padding: 6px 12px; padding: 7px 16px;
border-radius: 4px; border-radius: var(--border-radius);
cursor: pointer; cursor: pointer;
font-size: 13px; font-size: 14px;
margin-bottom: 12px; white-space: nowrap;
} }
.viz-btn-secondary:hover { .viz-btn-secondary:hover {
background: #5a6268; background: #5a6268;
} }
.viz-card-plot {
flex: 0 0 auto;
display: flex;
flex-direction: column;
min-height: 500px;
height: 600px;
}
#viz-plot-container { #viz-plot-container {
width: 100%; width: 100%;
height: 600px; height: 100%;
position: relative; position: relative;
overflow: visible;
} }
#viz-plot { #viz-plot {
width: 100%; width: 100%;
@@ -102,23 +132,19 @@
font-style: italic; font-style: italic;
} }
.viz-advanced-section { .viz-advanced-section {
margin-top: 16px; margin-top: 12px;
padding: 16px; padding: 12px;
background: #f8f9fa; background: var(--color-background-hover);
border-radius: 4px; border-radius: var(--border-radius);
border: 1px solid #dee2e6; border: 1px solid var(--color-border);
}
.viz-advanced-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
} }
.viz-info-box { .viz-info-box {
background: #e3f2fd; background: var(--color-primary-element-light);
border-left: 4px solid #2196f3; border-left: 3px solid var(--color-primary-element);
padding: 12px; padding: 10px 12px;
margin-bottom: 20px; margin-bottom: 16px;
font-size: 14px; font-size: 13px;
color: var(--color-main-text);
} }
.chunk-toggle-btn { .chunk-toggle-btn {
background: #6c757d; background: #6c757d;
@@ -134,115 +160,109 @@
background: #5a6268; background: #5a6268;
} }
.chunk-context { .chunk-context {
background: #f8f9fa; background: var(--color-background-hover);
border: 1px solid #dee2e6; border: 1px solid var(--color-border);
border-radius: 4px; border-radius: var(--border-radius);
padding: 12px; padding: 12px;
margin-top: 8px; margin-top: 8px;
font-family: monospace; font-family: 'SFMono-Regular', 'Consolas', 'Liberation Mono', 'Menlo', monospace;
font-size: 13px; font-size: 13px;
line-height: 1.6; line-height: 1.6;
white-space: pre-wrap; white-space: pre-wrap;
word-wrap: break-word; word-wrap: break-word;
} }
.chunk-text { .chunk-text {
color: #666; color: var(--color-text-maxcontrast);
} }
.chunk-matched { .chunk-matched {
background: #fff3cd; background: #fff3cd;
border: 1px solid #ffc107; border: 1px solid #ffc107;
padding: 2px 4px; padding: 2px 4px;
border-radius: 2px; border-radius: var(--border-radius);
font-weight: 500; font-weight: 500;
color: #333; color: var(--color-main-text);
} }
.chunk-ellipsis { .chunk-ellipsis {
color: #999; color: var(--color-text-maxcontrast);
font-style: italic; font-style: italic;
} }
</style> </style>
<div x-data="vizApp()"> <div x-data="vizApp()">
<div class="viz-card"> <div class="viz-layout">
<h2>Vector Visualization</h2> <!-- Top: Search Controls -->
<div class="viz-info-box"> <div class="viz-card viz-controls-card">
Testing search algorithms on your indexed documents. User: <strong>{{ username }}</strong>
</div>
<form @submit.prevent="executeSearch"> <form @submit.prevent="executeSearch">
<div class="viz-controls"> <div class="viz-controls-grid">
<!-- Main Controls -->
<div class="viz-control-group"> <div class="viz-control-group">
<label>Search Query</label> <label>Search Query</label>
<input type="text" x-model="query" placeholder="Enter search query..." required /> <input type="text" x-model="query" placeholder="Enter search query..." required />
</div> </div>
<div class="viz-control-row"> <div class="viz-control-group">
<div class="viz-control-group" style="margin-bottom: 0;">
<label>Algorithm</label> <label>Algorithm</label>
<select x-model="algorithm"> <select x-model="algorithm">
<option value="semantic">Semantic (Dense Vectors)</option> <option value="semantic">Semantic (Dense)</option>
<option value="bm25_hybrid" selected>BM25 Hybrid (Dense + Sparse)</option> <option value="bm25_hybrid" selected>BM25 Hybrid</option>
</select> </select>
</div> </div>
<div class="viz-control-group" style="margin-bottom: 0;"> <div class="viz-control-group">
<label>Fusion Method</label> <label>Fusion</label>
<select x-model="fusion" :disabled="algorithm !== 'bm25_hybrid'" :style="algorithm !== 'bm25_hybrid' ? 'opacity: 0.5; cursor: not-allowed;' : ''"> <select x-model="fusion" :disabled="algorithm !== 'bm25_hybrid'" :style="algorithm !== 'bm25_hybrid' ? 'opacity: 0.5; cursor: not-allowed;' : ''">
<option value="rrf" selected>RRF (Reciprocal Rank Fusion)</option> <option value="rrf" selected>RRF</option>
<option value="dbsf">DBSF (Distribution-Based Score Fusion)</option> <option value="dbsf">DBSF</option>
</select> </select>
</div> </div>
<div style="display: flex; align-items: flex-end;"> <div class="viz-control-group">
<button type="submit" class="viz-btn" style="width: 100%;">Search & Visualize</button> <label>&nbsp;</label>
<button type="submit" class="viz-btn">Search</button>
</div> </div>
<div style="display: flex; align-items: flex-end;"> <div class="viz-control-group">
<button type="button" class="viz-btn-secondary" @click="showAdvanced = !showAdvanced" style="white-space: nowrap;"> <label>&nbsp;</label>
<span x-text="showAdvanced ? 'Hide Advanced' : 'Advanced'"></span> <button type="button" class="viz-btn-secondary" @click="showAdvanced = !showAdvanced">
<span x-text="showAdvanced ? 'Hide' : 'Advanced'"></span>
</button> </button>
</div> </div>
</div> </div>
<!-- Advanced Options (Collapsible) --> <!-- Advanced Options (Collapsible) -->
<div class="viz-advanced-section" x-show="showAdvanced" x-transition.opacity.duration.200ms> <div x-show="showAdvanced" style="margin-top: 16px;">
<h3 style="margin-top: 0; margin-bottom: 16px; font-size: 16px;">Advanced Options</h3> <div class="viz-controls-grid" style="grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));">
<div class="viz-advanced-grid">
<div class="viz-control-group"> <div class="viz-control-group">
<label style="display: block; margin-bottom: 8px;">Document Types</label> <label>Document Types</label>
<div style="display: grid; grid-template-columns: 1fr; gap: 6px;"> <div style="display: flex; flex-wrap: wrap; gap: 8px; font-size: 13px;">
<label style="display: flex; align-items: center; cursor: pointer; font-weight: normal;"> <label style="display: flex; align-items: center; cursor: pointer; font-weight: normal;">
<input type="checkbox" x-model="docTypes" value="" style="margin-right: 8px;"> <input type="checkbox" x-model="docTypes" value="" style="margin-right: 4px;">
<span>All Types</span> <span>All</span>
</label> </label>
<label style="display: flex; align-items: center; cursor: pointer; font-weight: normal;"> <label style="display: flex; align-items: center; cursor: pointer; font-weight: normal;">
<input type="checkbox" x-model="docTypes" value="note" style="margin-right: 8px;"> <input type="checkbox" x-model="docTypes" value="note" style="margin-right: 4px;">
<span>Notes</span> <span>Notes</span>
</label> </label>
<label style="display: flex; align-items: center; cursor: pointer; font-weight: normal;"> <label style="display: flex; align-items: center; cursor: pointer; font-weight: normal;">
<input type="checkbox" x-model="docTypes" value="file" style="margin-right: 8px;"> <input type="checkbox" x-model="docTypes" value="file" style="margin-right: 4px;">
<span>Files</span> <span>Files</span>
</label> </label>
<label style="display: flex; align-items: center; cursor: pointer; font-weight: normal;"> <label style="display: flex; align-items: center; cursor: pointer; font-weight: normal;">
<input type="checkbox" x-model="docTypes" value="calendar" style="margin-right: 8px;"> <input type="checkbox" x-model="docTypes" value="calendar" style="margin-right: 4px;">
<span>Calendar Events</span> <span>Calendar</span>
</label> </label>
<label style="display: flex; align-items: center; cursor: pointer; font-weight: normal;"> <label style="display: flex; align-items: center; cursor: pointer; font-weight: normal;">
<input type="checkbox" x-model="docTypes" value="contact" style="margin-right: 8px;"> <input type="checkbox" x-model="docTypes" value="contact" style="margin-right: 4px;">
<span>Contacts</span> <span>Contacts</span>
</label> </label>
<label style="display: flex; align-items: center; cursor: pointer; font-weight: normal;"> <label style="display: flex; align-items: center; cursor: pointer; font-weight: normal;">
<input type="checkbox" x-model="docTypes" value="deck" style="margin-right: 8px;"> <input type="checkbox" x-model="docTypes" value="deck" style="margin-right: 4px;">
<span>Deck Cards</span> <span>Deck</span>
</label> </label>
</div> </div>
</div> </div>
<div>
<div class="viz-control-group"> <div class="viz-control-group">
<label>Score Threshold (Semantic/Hybrid)</label> <label>Score Threshold</label>
<input type="number" x-model.number="scoreThreshold" min="0" max="1" step="any" /> <input type="number" x-model.number="scoreThreshold" min="0" max="1" step="any" />
</div> </div>
@@ -252,25 +272,11 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Info: BM25 Hybrid fusion methods -->
<div x-show="algorithm === 'bm25_hybrid'" style="margin-top: 16px; padding: 12px; background: #e9ecef; border-radius: 4px;">
<p style="margin: 0; font-size: 14px; color: #666;">
<strong>BM25 Hybrid Search:</strong> Combines dense semantic vectors with sparse BM25 keyword vectors.
</p>
<p style="margin: 8px 0 0 0; font-size: 13px; color: #666;">
<strong>RRF:</strong> Reciprocal Rank Fusion - Rank-based fusion producing scores in [0.0, 1.0]
</p>
<p style="margin: 4px 0 0 0; font-size: 13px; color: #666;">
<strong>DBSF:</strong> Distribution-Based Score Fusion - Sums normalized scores (can exceed 1.0)
</p>
</div>
</div>
</div>
</form> </form>
</div> </div>
<div class="viz-card"> <!-- Plot -->
<div class="viz-card viz-card-plot">
<div id="viz-plot-container"> <div id="viz-plot-container">
<div x-show="loading" class="viz-loading-overlay" x-transition.opacity.duration.200ms> <div x-show="loading" class="viz-loading-overlay" x-transition.opacity.duration.200ms>
Executing search and computing PCA projection... Executing search and computing PCA projection...
@@ -279,8 +285,9 @@
</div> </div>
</div> </div>
<div class="viz-card"> <!-- Results -->
<h3>Search Results (<span x-text="loading ? '...' : results.length"></span>)</h3> <div class="viz-card" style="flex: 0 0 auto;">
<h3 style="margin-top: 0;">Search Results (<span x-text="loading ? '...' : results.length"></span>)</h3>
<div x-show="loading" class="viz-loading" x-transition.opacity.duration.200ms> <div x-show="loading" class="viz-loading" x-transition.opacity.duration.200ms>
Loading results... Loading results...
@@ -335,5 +342,6 @@
</template> </template>
</div> </div>
</template> </template>
</div> </div><!-- Search Results -->
</div> </div><!-- .viz-layout -->
</div><!-- x-data="vizApp()" -->
+52 -562
View File
@@ -9,15 +9,21 @@ For OAuth mode: Requires browser-based OAuth login to establish session.
import logging import logging
import os import os
from pathlib import Path
from typing import Any from typing import Any
import httpx import httpx
from jinja2 import Environment, FileSystemLoader
from starlette.authentication import requires from starlette.authentication import requires
from starlette.requests import Request from starlette.requests import Request
from starlette.responses import HTMLResponse, JSONResponse from starlette.responses import HTMLResponse, JSONResponse
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Setup Jinja2 environment for templates
_template_dir = Path(__file__).parent / "templates"
_jinja_env = Environment(loader=FileSystemLoader(_template_dir))
async def _get_authenticated_client_for_userinfo(request: Request) -> httpx.AsyncClient: async def _get_authenticated_client_for_userinfo(request: Request) -> httpx.AsyncClient:
"""Get an authenticated HTTP client for user info page operations. """Get an authenticated HTTP client for user info page operations.
@@ -431,51 +437,14 @@ async def user_info_html(request: Request) -> HTMLResponse:
oauth_ctx = getattr(request.app.state, "oauth_context", None) oauth_ctx = getattr(request.app.state, "oauth_context", None)
login_url = str(request.url_for("oauth_login")) if oauth_ctx else "/oauth/login" login_url = str(request.url_for("oauth_login")) if oauth_ctx else "/oauth/login"
error_html = f""" template = _jinja_env.get_template("error.html")
<!DOCTYPE html> return HTMLResponse(
<html lang="en"> content=template.render(
<head> error_title="Error Retrieving User Info",
<meta charset="UTF-8"> error_message=user_context["error"],
<meta name="viewport" content="width=device-width, initial-scale=1.0"> login_url=login_url,
<title>Error - Nextcloud MCP Server</title> )
<style> )
body {{
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
max-width: 800px;
margin: 50px auto;
padding: 20px;
background-color: #f5f5f5;
}}
.container {{
background: white;
border-radius: 8px;
padding: 30px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}}
h1 {{
color: #d32f2f;
margin-top: 0;
}}
.error {{
background-color: #ffebee;
border-left: 4px solid #d32f2f;
padding: 15px;
margin: 20px 0;
}}
</style>
</head>
<body>
<div class="container">
<h1>Error Retrieving User Info</h1>
<div class="error">
<strong>Error:</strong> {user_context["error"]}
</div>
<p><a href="{login_url}">Login again</a></p>
</div>
</body>
</html>
"""
return HTMLResponse(content=error_html)
# Build HTML response # Build HTML response
auth_mode = user_context.get("auth_mode", "unknown") auth_mode = user_context.get("auth_mode", "unknown")
@@ -654,457 +623,19 @@ async def user_info_html(request: Request) -> HTMLResponse:
</div> </div>
""" """
html_content = f""" # Render template
<!DOCTYPE html> template = _jinja_env.get_template("user_info.html")
<html lang="en"> return HTMLResponse(
<head> content=template.render(
<meta charset="UTF-8"> user_info_tab_html=user_info_tab_html,
<meta name="viewport" content="width=device-width, initial-scale=1.0"> vector_sync_tab_html=vector_sync_tab_html,
<title>Nextcloud MCP Server</title> webhooks_tab_html=webhooks_tab_html,
show_vector_sync_tab=show_vector_sync_tab,
<!-- htmx for dynamic loading --> show_webhooks_tab=show_webhooks_tab,
<script src="https://unpkg.com/htmx.org@1.9.10"></script> logout_url=logout_url if auth_mode == "oauth" else None,
nextcloud_host_for_links=nextcloud_host_for_links,
<!-- Alpine.js for tab state management --> )
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script> )
<!-- Plotly.js for vector visualization -->
<script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>
<!-- Vector visualization app (Alpine.js component) -->
<script>
function vizApp() {{
return {{
query: '',
algorithm: 'bm25_hybrid',
fusion: 'rrf', // Default fusion method for BM25 Hybrid
showAdvanced: false,
docTypes: [''], // Default to "All Types"
limit: 50,
scoreThreshold: 0.0,
loading: false,
results: [],
expandedChunks: {{}}, // Track which chunks are expanded (result_id -> chunk data)
chunkLoading: {{}}, // Track loading state per result
async executeSearch() {{
this.loading = true;
this.results = [];
try {{
const params = new URLSearchParams({{
query: this.query,
algorithm: this.algorithm,
limit: this.limit,
score_threshold: this.scoreThreshold,
}});
// Add fusion parameter for BM25 Hybrid
if (this.algorithm === 'bm25_hybrid') {{
params.append('fusion', this.fusion);
}}
// Add doc_types parameter (filter out empty string for "All Types")
const selectedTypes = this.docTypes.filter(t => t !== '');
if (selectedTypes.length > 0) {{
params.append('doc_types', selectedTypes.join(','));
}}
const response = await fetch(`/app/vector-viz/search?${{params}}`);
const data = await response.json();
if (data.success) {{
this.results = data.results;
this.renderPlot(data.coordinates_2d, data.results);
}} else {{
alert('Search failed: ' + data.error);
}}
}} catch (error) {{
alert('Error: ' + error.message);
}} finally {{
this.loading = false;
}}
}},
renderPlot(coordinates, results) {{
// Calculate score range for auto-scaling
const scores = results.map(r => r.score);
const minScore = Math.min(...scores);
const maxScore = Math.max(...scores);
const trace = {{
x: coordinates.map(c => c[0]),
y: coordinates.map(c => c[1]),
mode: 'markers',
type: 'scatter',
text: results.map(r => `${{r.title}}<br>Raw Score: ${{r.original_score.toFixed(3)}} (${{(r.score * 100).toFixed(0)}}% relative)`),
marker: {{
// Multi-channel encoding: size + opacity + color for visual hierarchy
// Power scaling (score^2) amplifies visual differences dramatically
// score=0.0 → 6px, score=0.5 → 9.5px, score=1.0 → 20px
size: results.map(r => 6 + (Math.pow(r.score, 2) * 14)),
// Linear opacity scaling (0.2-1.0 range keeps all points visible)
opacity: results.map(r => 0.2 + (r.score * 0.8)),
// Color gradient shows score
color: scores,
colorscale: 'Viridis',
showscale: true,
colorbar: {{ title: 'Relative Score' }},
// Scores are normalized 0-1 within result set
cmin: 0,
cmax: 1
}}
}};
const layout = {{
title: `Vector Space (PCA 2D) - ${{results.length}} results`,
xaxis: {{ title: 'PC1' }},
yaxis: {{ title: 'PC2' }},
hovermode: 'closest',
height: 600
}};
Plotly.newPlot('viz-plot', [trace], layout);
}},
getNextcloudUrl(result) {{
// Generate Nextcloud URL based on document type
// Use the actual Nextcloud host (port 8080), not the MCP server
const baseUrl = '{nextcloud_host_for_links}';
switch (result.doc_type) {{
case 'note':
return `${{baseUrl}}/apps/notes/note/${{result.id}}`;
case 'file':
return `${{baseUrl}}/apps/files/?fileId=${{result.id}}`;
case 'calendar':
return `${{baseUrl}}/apps/calendar`;
case 'contact':
return `${{baseUrl}}/apps/contacts`;
case 'deck':
return `${{baseUrl}}/apps/deck`;
default:
return `${{baseUrl}}`;
}}
}},
hasChunkPosition(result) {{
// Check if result has position metadata
return result.chunk_start_offset != null && result.chunk_end_offset != null;
}},
isChunkExpanded(resultKey) {{
return this.expandedChunks[resultKey] !== undefined;
}},
async toggleChunk(result) {{
const resultKey = `${{result.doc_type}}_${{result.id}}`;
// If already expanded, collapse
if (this.isChunkExpanded(resultKey)) {{
delete this.expandedChunks[resultKey];
return;
}}
// Otherwise, fetch and expand
this.chunkLoading[resultKey] = true;
try {{
const params = new URLSearchParams({{
doc_type: result.doc_type,
doc_id: result.id,
start: result.chunk_start_offset,
end: result.chunk_end_offset,
context: 500 // 500 chars before/after
}});
const response = await fetch(`/app/chunk-context?${{params}}`);
const data = await response.json();
if (data.success) {{
this.expandedChunks[resultKey] = data;
}} else {{
alert('Failed to load chunk: ' + data.error);
}}
}} catch (error) {{
alert('Error loading chunk: ' + error.message);
}} finally {{
delete this.chunkLoading[resultKey];
}}
}}
}}
}}
</script>
<style>
body {{
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
max-width: 900px;
margin: 50px auto;
padding: 20px;
background-color: #f5f5f5;
}}
.container {{
background: white;
border-radius: 8px;
padding: 30px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
min-height: calc(100vh - 200px);
}}
h1 {{
color: #0082c9;
margin-top: 0;
border-bottom: 2px solid #0082c9;
padding-bottom: 10px;
}}
h2 {{
color: #333;
margin-top: 20px;
border-bottom: 1px solid #e0e0e0;
padding-bottom: 5px;
}}
/* Tab navigation */
.tabs {{
display: flex;
gap: 0;
margin: 20px 0 0 0;
border-bottom: 2px solid #e0e0e0;
}}
.tab {{
padding: 12px 24px;
cursor: pointer;
background: transparent;
border: none;
font-size: 14px;
font-weight: 500;
color: #666;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: all 0.2s;
}}
.tab:hover {{
color: #0082c9;
background-color: #f5f5f5;
}}
.tab.active {{
color: #0082c9;
border-bottom-color: #0082c9;
}}
/* Tab content - use grid to overlay panes */
.tab-content {{
padding: 20px 0;
display: grid;
}}
/* Tab panes - all occupy the same grid cell to overlay */
.tab-pane {{
grid-area: 1 / 1;
}}
/* Tables */
table {{
width: 100%;
border-collapse: collapse;
margin: 15px 0;
}}
td {{
padding: 10px;
border-bottom: 1px solid #e0e0e0;
}}
td:first-child {{
width: 200px;
color: #666;
}}
code {{
background-color: #f5f5f5;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Courier New', monospace;
}}
/* Badges */
.badge {{
display: inline-block;
padding: 3px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: bold;
text-transform: uppercase;
}}
.badge-oauth {{
background-color: #4caf50;
color: white;
}}
.badge-basic {{
background-color: #2196f3;
color: white;
}}
/* Messages */
.warning {{
background-color: #fff3cd;
border-left: 4px solid #ffc107;
padding: 15px;
margin: 15px 0;
color: #856404;
}}
.info-message {{
background-color: #e3f2fd;
border-left: 4px solid #2196f3;
padding: 15px;
margin: 15px 0;
color: #1565c0;
}}
/* Buttons */
.button {{
display: inline-block;
padding: 10px 20px;
background-color: #d32f2f;
color: white;
text-decoration: none;
border-radius: 4px;
transition: background-color 0.3s;
border: none;
cursor: pointer;
font-size: 14px;
}}
.button:hover {{
background-color: #b71c1c;
}}
.button-primary {{
background-color: #0082c9;
}}
.button-primary:hover {{
background-color: #006ba3;
}}
/* Logout section */
.logout {{
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #e0e0e0;
}}
/* Smooth htmx content swaps */
.htmx-swapping {{
opacity: 0;
transition: opacity 200ms ease-out;
}}
/* Smooth htmx content settling */
.htmx-settling {{
opacity: 1;
transition: opacity 200ms ease-in;
}}
</style>
</head>
<body>
<div class="container" x-data="{{ activeTab: 'user-info' }}">
<h1>Nextcloud MCP Server</h1>
<!-- Tab Navigation -->
<div class="tabs">
<button
class="tab"
:class="activeTab === 'user-info' ? 'active' : ''"
@click="activeTab = 'user-info'">
User Info
</button>
{
""
if not show_vector_sync_tab
else '''
<button
class="tab"
:class="activeTab === 'vector-sync' ? 'active' : ''"
@click="activeTab = 'vector-sync'">
Vector Sync
</button>
'''
}
{
""
if not show_vector_sync_tab
else '''
<button
class="tab"
:class="activeTab === 'vector-viz' ? 'active' : ''"
@click="activeTab = 'vector-viz'">
Vector Viz
</button>
'''
}
{
""
if not show_webhooks_tab
else '''
<button
class="tab"
:class="activeTab === 'webhooks' ? 'active' : ''"
@click="activeTab = 'webhooks'">
Webhooks
</button>
'''
}
</div>
<!-- Tab Content -->
<div class="tab-content">
<!-- User Info Tab -->
<div class="tab-pane" x-show="activeTab === 'user-info'" x-transition.opacity.duration.150ms>
{user_info_tab_html}
</div>
{
""
if not show_vector_sync_tab
else f'''
<!-- Vector Sync Tab -->
<div class="tab-pane" x-show="activeTab === 'vector-sync'" x-transition.opacity.duration.150ms>
{vector_sync_tab_html}
</div>
'''
}
{
""
if not show_vector_sync_tab
else '''
<!-- Vector Viz Tab -->
<div class="tab-pane" x-show="activeTab === 'vector-viz'" x-transition.opacity.duration.150ms>
<div hx-get="/app/vector-viz" hx-trigger="load" hx-swap="outerHTML">
<p style="color: #999;">Loading vector visualization...</p>
</div>
</div>
'''
}
{
""
if not show_webhooks_tab
else f'''
<!-- Webhooks Tab (admin-only, loaded dynamically) -->
<div class="tab-pane" x-show="activeTab === 'webhooks'" x-transition.opacity.duration.150ms>
{webhooks_tab_html}
</div>
'''
}
</div>
{
f'<div class="logout"><a href="{logout_url}" class="button">Logout</a></div>'
if auth_mode == "oauth"
else ""
}
</div>
</body>
</html>
"""
return HTMLResponse(content=html_content)
@requires("authenticated", redirect="oauth_login") @requires("authenticated", redirect="oauth_login")
@@ -1124,17 +655,12 @@ async def revoke_session(request: Request) -> HTMLResponse:
oauth_ctx = getattr(request.app.state, "oauth_context", None) oauth_ctx = getattr(request.app.state, "oauth_context", None)
if not oauth_ctx: if not oauth_ctx:
template = _jinja_env.get_template("error.html")
return HTMLResponse( return HTMLResponse(
""" content=template.render(
<!DOCTYPE html> error_title="Error",
<html> error_message="OAuth mode not enabled",
<head><title>Error</title></head> ),
<body>
<h1>Error</h1>
<p>OAuth mode not enabled</p>
</body>
</html>
""",
status_code=400, status_code=400,
) )
@@ -1142,17 +668,12 @@ async def revoke_session(request: Request) -> HTMLResponse:
session_id = request.cookies.get("mcp_session") session_id = request.cookies.get("mcp_session")
if not storage or not session_id: if not storage or not session_id:
template = _jinja_env.get_template("error.html")
return HTMLResponse( return HTMLResponse(
""" content=template.render(
<!DOCTYPE html> error_title="Error",
<html> error_message="Session not found",
<head><title>Error</title></head> ),
<body>
<h1>Error</h1>
<p>Session not found</p>
</body>
</html>
""",
status_code=400, status_code=400,
) )
@@ -1165,57 +686,26 @@ async def revoke_session(request: Request) -> HTMLResponse:
# Redirect back to user page # Redirect back to user page
user_page_url = str(request.url_for("user_info_html")) user_page_url = str(request.url_for("user_info_html"))
template = _jinja_env.get_template("success.html")
return HTMLResponse( return HTMLResponse(
f""" content=template.render(
<!DOCTYPE html> success_title="✓ Background Access Revoked",
<html lang="en"> success_messages=[
<head> "Your refresh token has been deleted successfully.",
<meta charset="UTF-8"> "Browser session remains active.",
<meta http-equiv="refresh" content="2;url={user_page_url}"> ],
<title>Background Access Revoked</title> redirect_url=user_page_url,
<style> redirect_delay=2,
body {{ )
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
max-width: 600px;
margin: 50px auto;
padding: 20px;
text-align: center;
}}
.success {{
background-color: #e8f5e9;
border: 2px solid #4caf50;
padding: 30px;
border-radius: 8px;
}}
h1 {{
color: #4caf50;
}}
</style>
</head>
<body>
<div class="success">
<h1>✓ Background Access Revoked</h1>
<p>Your refresh token has been deleted successfully.</p>
<p>Browser session remains active.</p>
<p>Redirecting back to user page...</p>
</div>
</body>
</html>
"""
) )
except Exception as e: except Exception as e:
logger.error(f"Failed to revoke background access: {e}") logger.error(f"Failed to revoke background access: {e}")
template = _jinja_env.get_template("error.html")
return HTMLResponse( return HTMLResponse(
f""" content=template.render(
<!DOCTYPE html> error_title="Error",
<html> error_message=f"Failed to revoke background access: {e}",
<head><title>Error</title></head> ),
<body>
<h1>Error</h1>
<p>Failed to revoke background access: {e}</p>
</body>
</html>
""",
status_code=500, status_code=500,
) )