Phase 2: Multiple storage configs per type with new UI structure

- Database: Add description field, remove unique constraint to allow
  multiple configs of same provider type
- UI: Main page shows provider types as cards with counts
- UI: Per-type page (/storage/type/{type}) for managing configs
- API: Add get_user_storage_by_type() for filtered queries
- Form: Add description field for distinguishing configs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
gilesb
2026-01-10 00:53:28 +00:00
parent de7ca82862
commit c1cbf0b4ad
2 changed files with 254 additions and 186 deletions

50
db.py
View File

@@ -118,17 +118,18 @@ CREATE TABLE IF NOT EXISTS revoked_tokens (
);
-- User storage providers (IPFS pinning services, local storage, etc.)
-- Users can have multiple configs of the same provider type
CREATE TABLE IF NOT EXISTS user_storage (
id SERIAL PRIMARY KEY,
username VARCHAR(255) NOT NULL REFERENCES users(username),
provider_type VARCHAR(50) NOT NULL, -- 'pinata', 'web3storage', 'filebase', 'local'
provider_type VARCHAR(50) NOT NULL, -- 'pinata', 'web3storage', 'nftstorage', 'infura', 'filebase', 'storj', 'local'
provider_name VARCHAR(255), -- User-friendly name
description TEXT, -- User description to distinguish configs
config JSONB NOT NULL DEFAULT '{}', -- API keys, endpoints, paths
capacity_gb INTEGER NOT NULL, -- Total capacity user is contributing
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(username, provider_type, provider_name)
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Track what's stored where
@@ -818,20 +819,33 @@ async def get_user_storage(username: str) -> list[dict]:
"""Get all storage providers for a user."""
async with get_connection() as conn:
rows = await conn.fetch(
"""SELECT id, username, provider_type, provider_name, config,
"""SELECT id, username, provider_type, provider_name, description, config,
capacity_gb, is_active, created_at, updated_at
FROM user_storage WHERE username = $1
ORDER BY created_at""",
ORDER BY provider_type, created_at""",
username
)
return [dict(row) for row in rows]
async def get_user_storage_by_type(username: str, provider_type: str) -> list[dict]:
"""Get storage providers of a specific type for a user."""
async with get_connection() as conn:
rows = await conn.fetch(
"""SELECT id, username, provider_type, provider_name, description, config,
capacity_gb, is_active, created_at, updated_at
FROM user_storage WHERE username = $1 AND provider_type = $2
ORDER BY created_at""",
username, provider_type
)
return [dict(row) for row in rows]
async def get_storage_by_id(storage_id: int) -> Optional[dict]:
"""Get a storage provider by ID."""
async with get_connection() as conn:
row = await conn.fetchrow(
"""SELECT id, username, provider_type, provider_name, config,
"""SELECT id, username, provider_type, provider_name, description, config,
capacity_gb, is_active, created_at, updated_at
FROM user_storage WHERE id = $1""",
storage_id
@@ -844,16 +858,17 @@ async def add_user_storage(
provider_type: str,
provider_name: str,
config: dict,
capacity_gb: int
capacity_gb: int,
description: Optional[str] = None
) -> Optional[int]:
"""Add a storage provider for a user. Returns storage ID."""
async with get_connection() as conn:
try:
row = await conn.fetchrow(
"""INSERT INTO user_storage (username, provider_type, provider_name, config, capacity_gb)
VALUES ($1, $2, $3, $4, $5)
"""INSERT INTO user_storage (username, provider_type, provider_name, description, config, capacity_gb)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id""",
username, provider_type, provider_name, json.dumps(config), capacity_gb
username, provider_type, provider_name, description, json.dumps(config), capacity_gb
)
return row["id"] if row else None
except Exception:
@@ -862,6 +877,8 @@ async def add_user_storage(
async def update_user_storage(
storage_id: int,
provider_name: Optional[str] = None,
description: Optional[str] = None,
config: Optional[dict] = None,
capacity_gb: Optional[int] = None,
is_active: Optional[bool] = None
@@ -871,6 +888,14 @@ async def update_user_storage(
params = []
param_num = 1
if provider_name is not None:
updates.append(f"provider_name = ${param_num}")
params.append(provider_name)
param_num += 1
if description is not None:
updates.append(f"description = ${param_num}")
params.append(description)
param_num += 1
if config is not None:
updates.append(f"config = ${param_num}")
params.append(json.dumps(config))
@@ -974,14 +999,15 @@ async def get_all_active_storage() -> list[dict]:
"""Get all active storage providers (for distributed pinning)."""
async with get_connection() as conn:
rows = await conn.fetch(
"""SELECT us.*,
"""SELECT us.id, us.username, us.provider_type, us.provider_name, us.description,
us.config, us.capacity_gb, us.is_active, us.created_at, us.updated_at,
COALESCE(SUM(sp.size_bytes), 0) as used_bytes,
COUNT(sp.id) as pin_count
FROM user_storage us
LEFT JOIN storage_pins sp ON us.id = sp.storage_id
WHERE us.is_active = true
GROUP BY us.id
ORDER BY us.created_at"""
ORDER BY us.provider_type, us.created_at"""
)
return [dict(row) for row in rows]

390
server.py
View File

@@ -3126,6 +3126,7 @@ async def add_storage_form(
request: Request,
provider_type: str = Form(...),
provider_name: Optional[str] = Form(None),
description: Optional[str] = Form(None),
capacity_gb: int = Form(5),
api_key: Optional[str] = Form(None),
secret_key: Optional[str] = Form(None),
@@ -3190,13 +3191,14 @@ async def add_storage_form(
return HTMLResponse(f'<div class="text-red-400">Provider connection failed: {message}</div>')
# Save to database
name = provider_name or f"{provider_type}-{username}"
name = provider_name or f"{provider_type}-{username}-{len(await db.get_user_storage_by_type(username, provider_type)) + 1}"
storage_id = await db.add_user_storage(
username=username,
provider_type=provider_type,
provider_name=name,
config=config,
capacity_gb=capacity_gb
capacity_gb=capacity_gb,
description=description
)
if not storage_id:
@@ -3204,7 +3206,7 @@ async def add_storage_form(
return HTMLResponse(f'''
<div class="text-green-400 mb-2">Storage provider "{name}" added successfully!</div>
<script>setTimeout(() => window.location.reload(), 1500);</script>
<script>setTimeout(() => window.location.href = '/storage/type/{provider_type}', 1500);</script>
''')
@@ -3326,8 +3328,122 @@ async def test_storage(storage_id: int, request: Request, user: User = Depends(g
return {"success": success, "message": message}
STORAGE_PROVIDERS_INFO = {
"pinata": {"name": "Pinata", "desc": "1GB free, IPFS pinning", "color": "blue"},
"web3storage": {"name": "web3.storage", "desc": "IPFS + Filecoin", "color": "green"},
"nftstorage": {"name": "NFT.Storage", "desc": "Free for NFTs", "color": "pink"},
"infura": {"name": "Infura IPFS", "desc": "5GB free", "color": "orange"},
"filebase": {"name": "Filebase", "desc": "5GB free, S3+IPFS", "color": "cyan"},
"storj": {"name": "Storj", "desc": "25GB free", "color": "indigo"},
"local": {"name": "Local Storage", "desc": "Your own disk", "color": "purple"},
}
async def ui_storage_page(username: str, storages: list, request: Request) -> HTMLResponse:
"""Render storage settings page."""
"""Render main storage settings page showing provider types."""
# Count configs per type
type_counts = {}
for s in storages:
pt = s["provider_type"]
type_counts[pt] = type_counts.get(pt, 0) + 1
# Build provider type cards
cards = ""
for ptype, info in STORAGE_PROVIDERS_INFO.items():
count = type_counts.get(ptype, 0)
count_badge = f'<span class="ml-2 px-2 py-0.5 bg-{info["color"]}-600 text-white text-xs rounded-full">{count}</span>' if count > 0 else ""
cards += f'''
<a href="/storage/type/{ptype}"
class="p-4 bg-dark-800 hover:bg-dark-600 border-2 border-{info["color"]}-600 hover:border-{info["color"]}-400 rounded-lg text-left transition-colors block">
<div class="flex items-center">
<span class="font-semibold text-white">{info["name"]}</span>
{count_badge}
</div>
<div class="text-sm text-gray-400 mt-1">{info["desc"]}</div>
</a>
'''
# Total stats
total_capacity = sum(s["capacity_gb"] for s in storages)
total_used = sum(s.get("used_bytes", 0) for s in storages)
total_pins = sum(s.get("pin_count", 0) for s in storages)
content = f'''
<div class="max-w-4xl mx-auto">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-white">Storage Providers</h1>
</div>
<div class="bg-dark-700 rounded-lg p-6 mb-6">
<p class="text-gray-400 mb-4">
Attach your own storage to help power the network. 50% of your capacity is donated to store
shared content, making popular assets more resilient.
</p>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6 text-center">
<div class="bg-dark-600 rounded-lg p-3">
<div class="text-2xl font-bold text-white">{len(storages)}</div>
<div class="text-sm text-gray-400">Total Configs</div>
</div>
<div class="bg-dark-600 rounded-lg p-3">
<div class="text-2xl font-bold text-white">{total_capacity} GB</div>
<div class="text-sm text-gray-400">Total Capacity</div>
</div>
<div class="bg-dark-600 rounded-lg p-3">
<div class="text-2xl font-bold text-white">{total_used / (1024**3):.1f} GB</div>
<div class="text-sm text-gray-400">Used</div>
</div>
<div class="bg-dark-600 rounded-lg p-3">
<div class="text-2xl font-bold text-white">{total_pins}</div>
<div class="text-sm text-gray-400">Total Pins</div>
</div>
</div>
<h2 class="text-lg font-semibold text-white mb-4">Select Provider Type</h2>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
{cards}
</div>
</div>
</div>
'''
return HTMLResponse(base_html("Storage", content, username))
@app.get("/storage/type/{provider_type}")
async def storage_type_page(provider_type: str, request: Request, user: User = Depends(get_optional_user)):
"""Page for managing storage configs of a specific type."""
username = user.username if user else get_user_from_cookie(request)
if not username:
return RedirectResponse(url="/login", status_code=302)
if provider_type not in STORAGE_PROVIDERS_INFO:
raise HTTPException(404, "Invalid provider type")
storages = await db.get_user_storage_by_type(username, provider_type)
# Add usage stats
for storage in storages:
usage = await db.get_storage_usage(storage["id"])
storage["used_bytes"] = usage["used_bytes"]
storage["pin_count"] = usage["pin_count"]
# Mask sensitive config keys
if storage.get("config"):
config = storage["config"] if isinstance(storage["config"], dict) else json.loads(storage["config"])
masked = {}
for k, v in config.items():
if "key" in k.lower() or "token" in k.lower() or "secret" in k.lower():
masked[k] = v[:4] + "..." + v[-4:] if len(str(v)) > 8 else "****"
else:
masked[k] = v
storage["config_display"] = masked
info = STORAGE_PROVIDERS_INFO[provider_type]
return await ui_storage_type_page(username, provider_type, info, storages, request)
async def ui_storage_type_page(username: str, provider_type: str, info: dict, storages: list, request: Request) -> HTMLResponse:
"""Render per-type storage management page."""
def format_bytes(b):
if b > 1024**3:
@@ -3338,26 +3454,29 @@ async def ui_storage_page(username: str, storages: list, request: Request) -> HT
return f"{b / 1024:.1f} KB"
return f"{b} bytes"
# Build storage rows
storage_rows = ""
for s in storages:
status_class = "bg-green-600" if s["is_active"] else "bg-gray-600"
status_text = "Active" if s["is_active"] else "Inactive"
config_display = s.get("config_display", {})
config_html = ", ".join(f"{k}: {v}" for k, v in config_display.items() if k != "path")
desc = s.get("description") or ""
desc_html = f'<div class="text-sm text-gray-300 mt-1">{desc}</div>' if desc else ""
storage_rows += f'''
<div class="p-4 bg-dark-600 rounded-lg mb-4">
<div class="p-4 bg-dark-600 rounded-lg mb-4" id="storage-{s["id"]}">
<div class="flex justify-between items-start mb-3">
<div>
<h3 class="font-semibold text-white">{s["provider_name"] or s["provider_type"]}</h3>
<span class="text-sm text-gray-400">{s["provider_type"]}</span>
<h3 class="font-semibold text-white">{s["provider_name"] or provider_type}</h3>
{desc_html}
</div>
<div class="flex items-center gap-2">
<span class="px-2 py-1 {status_class} text-white text-xs rounded-full">{status_text}</span>
<button hx-post="/storage/{s["id"]}/test" hx-target="#test-result-{s["id"]}" hx-swap="innerHTML"
class="px-2 py-1 bg-blue-600 hover:bg-blue-700 text-white text-xs rounded">Test</button>
<button hx-delete="/storage/{s["id"]}" hx-confirm="Remove this storage provider?"
hx-target="closest div.p-4" hx-swap="outerHTML"
<button hx-delete="/storage/{s["id"]}" hx-confirm="Remove this storage config?"
hx-target="#storage-{s["id"]}" hx-swap="outerHTML"
class="px-2 py-1 bg-red-600 hover:bg-red-700 text-white text-xs rounded">Remove</button>
</div>
</div>
@@ -3368,7 +3487,7 @@ async def ui_storage_page(username: str, storages: list, request: Request) -> HT
</div>
<div>
<div class="text-gray-400">Donated</div>
<div class="text-white">{s["donated_gb"]} GB</div>
<div class="text-white">{s["capacity_gb"] // 2} GB</div>
</div>
<div>
<div class="text-gray-400">Used</div>
@@ -3385,183 +3504,106 @@ async def ui_storage_page(username: str, storages: list, request: Request) -> HT
'''
if not storages:
storage_rows = '<p class="text-gray-400 text-center py-8">No storage providers configured.</p>'
storage_rows = f'<p class="text-gray-400 text-center py-8">No {info["name"]} configs yet. Add one below.</p>'
# Build form fields based on provider type
form_fields = ""
if provider_type == "pinata":
form_fields = '''
<div>
<label class="block text-sm text-gray-400 mb-1">API Key *</label>
<input type="text" name="api_key" required class="w-full px-3 py-2 bg-dark-700 border border-dark-500 rounded text-white">
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">Secret Key *</label>
<input type="password" name="secret_key" required class="w-full px-3 py-2 bg-dark-700 border border-dark-500 rounded text-white">
</div>
'''
elif provider_type in ("web3storage", "nftstorage"):
form_fields = '''
<div>
<label class="block text-sm text-gray-400 mb-1">API Token *</label>
<input type="password" name="api_token" required class="w-full px-3 py-2 bg-dark-700 border border-dark-500 rounded text-white">
</div>
'''
elif provider_type == "infura":
form_fields = '''
<div>
<label class="block text-sm text-gray-400 mb-1">Project ID *</label>
<input type="text" name="project_id" required class="w-full px-3 py-2 bg-dark-700 border border-dark-500 rounded text-white">
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">Project Secret *</label>
<input type="password" name="project_secret" required class="w-full px-3 py-2 bg-dark-700 border border-dark-500 rounded text-white">
</div>
'''
elif provider_type in ("filebase", "storj"):
form_fields = '''
<div>
<label class="block text-sm text-gray-400 mb-1">Access Key *</label>
<input type="text" name="access_key" required class="w-full px-3 py-2 bg-dark-700 border border-dark-500 rounded text-white">
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">Secret Key *</label>
<input type="password" name="secret_key" required class="w-full px-3 py-2 bg-dark-700 border border-dark-500 rounded text-white">
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">Bucket Name *</label>
<input type="text" name="bucket" required placeholder="my-bucket" class="w-full px-3 py-2 bg-dark-700 border border-dark-500 rounded text-white">
</div>
'''
elif provider_type == "local":
form_fields = '''
<div>
<label class="block text-sm text-gray-400 mb-1">Storage Path *</label>
<input type="text" name="path" required placeholder="/path/to/storage" class="w-full px-3 py-2 bg-dark-700 border border-dark-500 rounded text-white">
</div>
'''
content = f'''
<div class="max-w-4xl mx-auto">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-white">Storage Providers</h1>
<div class="flex items-center gap-4 mb-6">
<a href="/storage" class="text-gray-400 hover:text-white">&larr; Back</a>
<h1 class="text-2xl font-bold text-white">{info["name"]} Storage</h1>
</div>
<div class="bg-dark-700 rounded-lg p-6 mb-6">
<p class="text-gray-400 mb-4">
Attach your own storage to help power the network. 50% of your capacity is donated to store
shared content, making popular assets more resilient.
</p>
<h2 class="text-lg font-semibold text-white mb-4">Add Storage Provider</h2>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
<button onclick="showAddForm('pinata')"
class="p-3 bg-dark-800 hover:bg-dark-600 border-2 border-blue-600 hover:border-blue-400 rounded-lg text-left transition-colors">
<div class="font-semibold text-white text-sm">Pinata</div>
<div class="text-xs text-gray-400">1GB free, IPFS</div>
</button>
<button onclick="showAddForm('web3storage')"
class="p-3 bg-dark-800 hover:bg-dark-600 border-2 border-green-600 hover:border-green-400 rounded-lg text-left transition-colors">
<div class="font-semibold text-white text-sm">web3.storage</div>
<div class="text-xs text-gray-400">IPFS + Filecoin</div>
</button>
<button onclick="showAddForm('nftstorage')"
class="p-3 bg-dark-800 hover:bg-dark-600 border-2 border-pink-600 hover:border-pink-400 rounded-lg text-left transition-colors">
<div class="font-semibold text-white text-sm">NFT.Storage</div>
<div class="text-xs text-gray-400">Free for NFTs</div>
</button>
<button onclick="showAddForm('infura')"
class="p-3 bg-dark-800 hover:bg-dark-600 border-2 border-orange-600 hover:border-orange-400 rounded-lg text-left transition-colors">
<div class="font-semibold text-white text-sm">Infura IPFS</div>
<div class="text-xs text-gray-400">5GB free</div>
</button>
<button onclick="showAddForm('filebase')"
class="p-3 bg-dark-800 hover:bg-dark-600 border-2 border-cyan-600 hover:border-cyan-400 rounded-lg text-left transition-colors">
<div class="font-semibold text-white text-sm">Filebase</div>
<div class="text-xs text-gray-400">5GB free, S3+IPFS</div>
</button>
<button onclick="showAddForm('storj')"
class="p-3 bg-dark-800 hover:bg-dark-600 border-2 border-indigo-600 hover:border-indigo-400 rounded-lg text-left transition-colors">
<div class="font-semibold text-white text-sm">Storj</div>
<div class="text-xs text-gray-400">25GB free</div>
</button>
<button onclick="showAddForm('local')"
class="p-3 bg-dark-800 hover:bg-dark-600 border-2 border-purple-600 hover:border-purple-400 rounded-lg text-left transition-colors">
<div class="font-semibold text-white text-sm">Local Storage</div>
<div class="text-xs text-gray-400">Your own disk</div>
</button>
</div>
<div id="add-form" class="hidden bg-dark-600 rounded-lg p-4">
<form id="storage-form" hx-post="/storage/add" hx-target="#add-result" hx-swap="innerHTML">
<input type="hidden" name="provider_type" id="provider_type">
<div id="pinata-fields" class="hidden space-y-4">
<div>
<label class="block text-sm text-gray-400 mb-1">API Key</label>
<input type="text" name="api_key" class="w-full px-3 py-2 bg-dark-700 border border-dark-500 rounded text-white">
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">Secret Key</label>
<input type="password" name="secret_key" class="w-full px-3 py-2 bg-dark-700 border border-dark-500 rounded text-white">
</div>
</div>
<div id="web3storage-fields" class="hidden space-y-4">
<div>
<label class="block text-sm text-gray-400 mb-1">API Token</label>
<input type="password" name="api_token" class="w-full px-3 py-2 bg-dark-700 border border-dark-500 rounded text-white">
</div>
</div>
<div id="nftstorage-fields" class="hidden space-y-4">
<div>
<label class="block text-sm text-gray-400 mb-1">API Token</label>
<input type="password" name="api_token" class="w-full px-3 py-2 bg-dark-700 border border-dark-500 rounded text-white">
</div>
</div>
<div id="infura-fields" class="hidden space-y-4">
<div>
<label class="block text-sm text-gray-400 mb-1">Project ID</label>
<input type="text" name="project_id" class="w-full px-3 py-2 bg-dark-700 border border-dark-500 rounded text-white">
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">Project Secret</label>
<input type="password" name="project_secret" class="w-full px-3 py-2 bg-dark-700 border border-dark-500 rounded text-white">
</div>
</div>
<div id="filebase-fields" class="hidden space-y-4">
<div>
<label class="block text-sm text-gray-400 mb-1">Access Key</label>
<input type="text" name="access_key" class="w-full px-3 py-2 bg-dark-700 border border-dark-500 rounded text-white">
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">Secret Key</label>
<input type="password" name="secret_key" class="w-full px-3 py-2 bg-dark-700 border border-dark-500 rounded text-white">
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">Bucket Name</label>
<input type="text" name="bucket" placeholder="my-bucket" class="w-full px-3 py-2 bg-dark-700 border border-dark-500 rounded text-white">
</div>
</div>
<div id="storj-fields" class="hidden space-y-4">
<div>
<label class="block text-sm text-gray-400 mb-1">Access Key</label>
<input type="text" name="access_key" class="w-full px-3 py-2 bg-dark-700 border border-dark-500 rounded text-white">
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">Secret Key</label>
<input type="password" name="secret_key" class="w-full px-3 py-2 bg-dark-700 border border-dark-500 rounded text-white">
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">Bucket Name</label>
<input type="text" name="bucket" placeholder="my-bucket" class="w-full px-3 py-2 bg-dark-700 border border-dark-500 rounded text-white">
</div>
</div>
<div id="local-fields" class="hidden space-y-4">
<div>
<label class="block text-sm text-gray-400 mb-1">Storage Path</label>
<input type="text" name="path" placeholder="/path/to/storage" class="w-full px-3 py-2 bg-dark-700 border border-dark-500 rounded text-white">
</div>
</div>
<div class="space-y-4 mt-4">
<div>
<label class="block text-sm text-gray-400 mb-1">Provider Name (optional)</label>
<input type="text" name="provider_name" placeholder="My Storage" class="w-full px-3 py-2 bg-dark-700 border border-dark-500 rounded text-white">
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">Capacity (GB)</label>
<input type="number" name="capacity_gb" value="5" min="1" class="w-full px-3 py-2 bg-dark-700 border border-dark-500 rounded text-white">
</div>
</div>
<div class="flex justify-end gap-3 mt-4">
<button type="button" onclick="hideAddForm()" class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded">Cancel</button>
<button type="submit" class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded">Add Storage</button>
</div>
</form>
<div id="add-result" class="mt-4"></div>
<h2 class="text-lg font-semibold text-white mb-4">Your {info["name"]} Configs</h2>
<div id="storage-list">
{storage_rows}
</div>
</div>
<h2 class="text-lg font-semibold text-white mb-4">Your Storage Providers</h2>
<div id="storage-list">
{storage_rows}
<div class="bg-dark-700 rounded-lg p-6">
<h2 class="text-lg font-semibold text-white mb-4">Add New {info["name"]} Config</h2>
<form hx-post="/storage/add" hx-target="#add-result" hx-swap="innerHTML" class="space-y-4">
<input type="hidden" name="provider_type" value="{provider_type}">
{form_fields}
<div>
<label class="block text-sm text-gray-400 mb-1">Name (optional)</label>
<input type="text" name="provider_name" placeholder="My {info["name"]}" class="w-full px-3 py-2 bg-dark-700 border border-dark-500 rounded text-white">
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">Description (optional)</label>
<input type="text" name="description" placeholder="e.g., Work account, Personal, etc." class="w-full px-3 py-2 bg-dark-700 border border-dark-500 rounded text-white">
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">Capacity (GB)</label>
<input type="number" name="capacity_gb" value="5" min="1" class="w-full px-3 py-2 bg-dark-700 border border-dark-500 rounded text-white">
</div>
<div class="flex justify-end">
<button type="submit" class="px-4 py-2 bg-{info["color"]}-600 hover:bg-{info["color"]}-700 text-white rounded">Add {info["name"]}</button>
</div>
</form>
<div id="add-result" class="mt-4"></div>
</div>
</div>
<script>
function showAddForm(type) {{
document.getElementById('add-form').classList.remove('hidden');
document.getElementById('provider_type').value = type;
// Hide all field groups
const fieldGroups = ['pinata', 'web3storage', 'nftstorage', 'infura', 'filebase', 'storj', 'local'];
fieldGroups.forEach(g => document.getElementById(g + '-fields').classList.add('hidden'));
// Show the relevant fields
document.getElementById(type + '-fields').classList.remove('hidden');
}}
function hideAddForm() {{
document.getElementById('add-form').classList.add('hidden');
}}
</script>
'''
return HTMLResponse(base_html("Storage", content, username))
return HTMLResponse(base_html(f"{info['name']} Storage", content, username))
# ============ Client Download ============