datapass / components /subscriptions.py
waroca's picture
Upload folder using huggingface_hub
c918ce6 verified
from datetime import datetime
import json
import os
import html
import base64
# Get MCP server URL from environment
MCP_SERVER_URL = os.getenv("MCP_SERVER_URL", "http://localhost:8000")
def create_subscriptions_html(subscriptions):
"""Creates HTML string for user's subscriptions list."""
if not subscriptions:
return """
<div class="empty-state">
<div class="empty-state-icon">🎫</div>
<div class="empty-state-title">No DataPass yet</div>
<div class="empty-state-text">Browse the catalog and start a 24-hour free trial to get your first DataPass.</div>
</div>
"""
cards_html = ""
drawers_html = ""
# Store data for copy functionality
copy_data = {}
for idx, sub in enumerate(subscriptions):
# Determine subscription status
end_date_str = sub.get('subscription_end', '')
is_active = sub.get('is_active', False)
status_class = "active" if is_active else "expired"
status_text = "Active" if is_active else "Expired"
expires_text = "Unknown expiry"
try:
if end_date_str:
# Handle Z suffix
if end_date_str.endswith("Z"):
end_date_str = end_date_str[:-1]
end_date = datetime.fromisoformat(end_date_str)
if is_active:
days_left = (end_date - datetime.utcnow()).days
hours_left = int((end_date - datetime.utcnow()).total_seconds() / 3600)
if days_left > 0:
expires_text = f"Expires in {days_left} day{'s' if days_left != 1 else ''}"
elif hours_left > 0:
expires_text = f"Expires in {hours_left} hour{'s' if hours_left != 1 else ''}"
else:
expires_text = "Expires soon"
else:
expires_text = "DataPass expired — Upgrade for continued access"
except Exception:
pass
dataset_id = sub.get('dataset_id', '')
access_token = sub.get('access_token', '')
drawer_id = f"drawer-{idx}"
# Connection details button for active subscriptions
connection_btn_html = ""
if is_active and access_token:
connection_btn_html = f'''
<button class="btn-connect" onclick="document.getElementById('{drawer_id}').classList.add('show'); document.body.style.overflow='hidden';">
Connect
</button>
'''
# Create MCP config JSON
dataset_name = dataset_id.split('/')[-1] if '/' in dataset_id else dataset_id
server_name = f"{dataset_name}-dataset"
mcp_config = {
"mcpServers": {
server_name: {
"url": f"{MCP_SERVER_URL}/sse",
"headers": {
"Authorization": f"Bearer {access_token}"
}
}
}
}
mcp_config_json = json.dumps(mcp_config, indent=2)
mcp_config_escaped = html.escape(mcp_config_json)
# Example prompt
example_prompt = f"Query the dataset {dataset_id} and show me a summary of the data. What columns are available and how many rows are there?"
example_prompt_escaped = html.escape(example_prompt)
# Store data for copy buttons using base64 encoding to avoid JS escaping issues
copy_data[f"{drawer_id}-mcp"] = base64.b64encode(mcp_config_json.encode()).decode()
copy_data[f"{drawer_id}-prompt"] = base64.b64encode(example_prompt.encode()).decode()
copy_data[f"{drawer_id}-token"] = base64.b64encode(access_token.encode()).decode()
# Drawer HTML (slides in from right)
drawers_html += f'''
<div id="{drawer_id}" class="drawer-overlay" onclick="if(event.target===this){{this.classList.remove('show'); document.body.style.overflow='';}}">
<div class="drawer-panel">
<div class="drawer-header">
<div class="drawer-header-info">
<h3 class="drawer-title">{html.escape(dataset_id)}</h3>
<span class="drawer-status {status_class}">{status_text}</span>
</div>
<button class="drawer-close" onclick="document.getElementById('{drawer_id}').classList.remove('show'); document.body.style.overflow='';">&times;</button>
</div>
<div class="drawer-body">
<!-- MCP Config - expanded by default -->
<details class="config-accordion" open>
<summary class="config-summary">
<span class="config-icon">🔌</span>
<span class="config-label">MCP Configuration</span>
<span class="chevron"></span>
</summary>
<div class="config-content">
<p class="config-desc">Add to your Claude Desktop config:</p>
<div class="code-wrapper">
<pre class="code-block">{mcp_config_escaped}</pre>
<button class="btn-copy" onclick="(function(b,v){{var t=atob(v);navigator.clipboard.writeText(t).then(function(){{b.textContent='Copied!';b.classList.add('copied');setTimeout(function(){{b.textContent='Copy';b.classList.remove('copied')}},2000)}}).catch(function(){{var a=document.createElement('textarea');a.value=t;a.style.cssText='position:fixed;opacity:0';document.body.appendChild(a);a.select();document.execCommand('copy');document.body.removeChild(a);b.textContent='Copied!';b.classList.add('copied');setTimeout(function(){{b.textContent='Copy';b.classList.remove('copied')}},2000)}})}})( this,'{copy_data[f"{drawer_id}-mcp"]}')">Copy</button>
</div>
</div>
</details>
<!-- Example Prompt - collapsed -->
<details class="config-accordion">
<summary class="config-summary">
<span class="config-icon">💬</span>
<span class="config-label">Example Prompt</span>
<span class="chevron"></span>
</summary>
<div class="config-content">
<div class="code-wrapper">
<pre class="code-block prompt-block">{example_prompt_escaped}</pre>
<button class="btn-copy" onclick="(function(b,v){{var t=atob(v);navigator.clipboard.writeText(t).then(function(){{b.textContent='Copied!';b.classList.add('copied');setTimeout(function(){{b.textContent='Copy';b.classList.remove('copied')}},2000)}}).catch(function(){{var a=document.createElement('textarea');a.value=t;a.style.cssText='position:fixed;opacity:0';document.body.appendChild(a);a.select();document.execCommand('copy');document.body.removeChild(a);b.textContent='Copied!';b.classList.add('copied');setTimeout(function(){{b.textContent='Copy';b.classList.remove('copied')}},2000)}})}})( this,'{copy_data[f"{drawer_id}-prompt"]}')">Copy</button>
</div>
</div>
</details>
<!-- Access Token - collapsed -->
<details class="config-accordion">
<summary class="config-summary">
<span class="config-icon">🔑</span>
<span class="config-label">Access Token</span>
<span class="chevron"></span>
</summary>
<div class="config-content">
<div class="code-wrapper">
<code class="token-block">{html.escape(access_token)}</code>
<button class="btn-copy" onclick="(function(b,v){{var t=atob(v);navigator.clipboard.writeText(t).then(function(){{b.textContent='Copied!';b.classList.add('copied');setTimeout(function(){{b.textContent='Copy';b.classList.remove('copied')}},2000)}}).catch(function(){{var a=document.createElement('textarea');a.value=t;a.style.cssText='position:fixed;opacity:0';document.body.appendChild(a);a.select();document.execCommand('copy');document.body.removeChild(a);b.textContent='Copied!';b.classList.add('copied');setTimeout(function(){{b.textContent='Copy';b.classList.remove('copied')}},2000)}})}})( this,'{copy_data[f"{drawer_id}-token"]}')">Copy</button>
</div>
</div>
</details>
<div class="config-tip">
<span>Use <code>query_dataset</code> or <code>query_dataset_natural_language</code> to analyze your data.</span>
</div>
</div>
</div>
</div>
'''
# View on HF button removed - access only through MCP
view_btn_html = ""
# Determine plan display name
plan_id = sub.get('plan_id', 'trial')
if plan_id.lower() in ['free', 'trial', 'free_tier']:
plan_display = "Free Trial"
else:
plan_display = f"{plan_id} plan"
cards_html += f"""
<div class="subscription-card">
<div class="subscription-info">
<h4 class="subscription-dataset">{html.escape(dataset_id)}</h4>
<div class="subscription-meta">
<span class="plan-badge">{html.escape(plan_display)}</span>
<span class="separator">·</span>
<span class="expiry-text">{expires_text}</span>
</div>
</div>
<div class="subscription-actions">
<span class="status-badge {status_class}">{status_text}</span>
{connection_btn_html}
{view_btn_html}
</div>
</div>
"""
# Convert copy_data to JSON for embedding in script
copy_data_json = json.dumps(copy_data)
# Build inline handlers that don't rely on global functions
result_html = f"""
<div class="subscriptions-container" id="datapass-subs-container" data-copy='{copy_data_json}'>
<div class="subscriptions-list">
{cards_html}
</div>
{drawers_html}
</div>
<style>
.subscriptions-container {{
position: relative;
}}
.subscriptions-list {{
display: flex;
flex-direction: column;
gap: 0.75rem;
}}
.subscription-card {{
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.25rem;
background: var(--bg-secondary, #1c1c1e);
border: 1px solid var(--border-color, rgba(255,255,255,0.08));
border-radius: 12px;
transition: all 0.15s ease;
gap: 1rem;
}}
.subscription-card:hover {{
border-color: var(--border-color-strong, rgba(255,255,255,0.12));
}}
.subscription-info {{
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
flex: 1;
}}
.subscription-dataset {{
margin: 0;
font-size: 0.9375rem;
font-weight: 600;
color: var(--text-primary, #f5f5f7);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}}
.subscription-meta {{
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
color: var(--text-tertiary, #6e6e73);
}}
.separator {{
opacity: 0.5;
}}
.subscription-actions {{
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}}
.status-badge {{
padding: 0.1875rem 0.5rem;
border-radius: 4px;
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}}
.status-badge.active {{
background: rgba(52, 199, 89, 0.15);
color: #34c759;
}}
.status-badge.expired {{
background: rgba(142, 142, 147, 0.15);
color: #8e8e93;
}}
/* Compact Buttons */
.btn-connect {{
padding: 0.375rem 0.75rem;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
border: none;
background: #007AFF;
color: white;
transition: all 0.15s ease;
}}
.btn-connect:hover {{
background: #0056b3;
}}
/* Drawer - slides from right */
.drawer-overlay {{
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
z-index: 99999;
margin: 0;
}}
.drawer-overlay.show {{
display: block;
}}
.drawer-panel {{
position: fixed;
top: 0;
right: 0;
width: 100%;
max-width: 420px;
height: 100vh;
background: var(--bg-primary, #000);
border-left: 1px solid var(--border-color, rgba(255,255,255,0.08));
box-shadow: -10px 0 40px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
transform: translateX(100%);
animation: slideIn 0.25s ease forwards;
}}
@keyframes slideIn {{
to {{
transform: translateX(0);
}}
}}
.drawer-overlay:not(.show) .drawer-panel {{
animation: slideOut 0.2s ease forwards;
}}
@keyframes slideOut {{
from {{
transform: translateX(0);
}}
to {{
transform: translateX(100%);
}}
}}
.drawer-header {{
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--border-color, rgba(255,255,255,0.08));
gap: 1rem;
flex-shrink: 0;
}}
.drawer-header-info {{
display: flex;
align-items: center;
gap: 0.625rem;
min-width: 0;
flex: 1;
}}
.drawer-title {{
margin: 0;
font-size: 0.9375rem;
font-weight: 600;
color: var(--text-primary, #f5f5f7);
font-family: 'SF Mono', Monaco, monospace;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}}
.drawer-status {{
padding: 0.125rem 0.375rem;
border-radius: 4px;
font-size: 0.5625rem;
font-weight: 600;
text-transform: uppercase;
flex-shrink: 0;
}}
.drawer-status.active {{
background: rgba(52, 199, 89, 0.15);
color: #34c759;
}}
.drawer-close {{
background: none;
border: none;
font-size: 1.5rem;
color: var(--text-tertiary, #6e6e73);
cursor: pointer;
padding: 0;
line-height: 1;
transition: color 0.15s;
flex-shrink: 0;
}}
.drawer-close:hover {{
color: var(--text-primary, #f5f5f7);
}}
.drawer-body {{
padding: 1.25rem 1.5rem;
overflow-y: auto;
flex: 1;
}}
/* Accordion sections */
.config-accordion {{
border: 1px solid var(--border-color, rgba(255,255,255,0.08));
border-radius: 8px;
margin-bottom: 0.75rem;
overflow: hidden;
}}
.config-summary {{
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.875rem 1rem;
cursor: pointer;
user-select: none;
background: var(--bg-secondary, #1c1c1e);
transition: background 0.15s;
list-style: none;
}}
.config-summary::-webkit-details-marker {{
display: none;
}}
.config-summary:hover {{
background: var(--bg-tertiary, #2c2c2e);
}}
.config-icon {{
font-size: 1rem;
}}
.config-label {{
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary, #f5f5f7);
flex: 1;
}}
.chevron {{
width: 0;
height: 0;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 5px solid var(--text-tertiary, #6e6e73);
transition: transform 0.2s;
}}
details[open] .chevron {{
transform: rotate(180deg);
}}
.config-content {{
padding: 0.875rem 1rem;
border-top: 1px solid var(--border-color, rgba(255,255,255,0.08));
background: var(--bg-primary, #000);
}}
.config-desc {{
font-size: 0.8125rem;
color: var(--text-tertiary, #6e6e73);
margin: 0 0 0.625rem 0;
}}
.code-wrapper {{
position: relative;
}}
.code-block {{
padding: 0.875rem;
padding-right: 4rem;
background: var(--bg-secondary, #1c1c1e);
border: 1px solid var(--border-color, rgba(255,255,255,0.08));
border-radius: 6px;
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.75rem;
color: var(--text-primary, #f5f5f7);
overflow-x: auto;
white-space: pre;
margin: 0;
line-height: 1.5;
}}
.prompt-block {{
white-space: pre-wrap;
word-break: break-word;
}}
.token-block {{
display: block;
padding: 0.625rem 0.875rem;
padding-right: 4rem;
background: var(--bg-secondary, #1c1c1e);
border: 1px solid var(--border-color, rgba(255,255,255,0.08));
border-radius: 6px;
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.75rem;
color: var(--text-primary, #f5f5f7);
word-break: break-all;
}}
.btn-copy {{
position: absolute;
top: 0.5rem;
right: 0.5rem;
padding: 0.375rem 0.625rem;
background: var(--bg-tertiary, #2c2c2e);
color: var(--text-secondary, #a1a1a6);
border: 1px solid var(--border-color, rgba(255,255,255,0.08));
border-radius: 4px;
font-size: 0.6875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}}
.btn-copy:hover {{
background: var(--bg-hover, #3a3a3c);
color: var(--text-primary, #f5f5f7);
}}
.btn-copy.copied {{
background: #34c759;
border-color: #34c759;
color: white;
}}
.config-tip {{
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.75rem 1rem;
background: rgba(0, 122, 255, 0.08);
border-radius: 6px;
font-size: 0.75rem;
color: var(--text-secondary, #a1a1a6);
margin-top: 0.5rem;
}}
.config-tip code {{
background: var(--bg-secondary, #1c1c1e);
padding: 0.125rem 0.375rem;
border-radius: 3px;
font-size: 0.6875rem;
color: var(--text-primary, #f5f5f7);
}}
@media (max-width: 640px) {{
.subscription-card {{
flex-direction: column;
align-items: stretch;
}}
.subscription-actions {{
justify-content: flex-start;
}}
.drawer-panel {{
max-width: 100%;
}}
}}
</style>
"""
return result_html