Update UI to use Tailwind CSS with dark theme

- Replace custom CSS with Tailwind CSS via CDN
- Dark theme matching L1 server styling
- Responsive layouts for all pages
- Updated: home, login/register, registry, activities, users pages
- Modern form styling and table layouts

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
gilesb
2026-01-07 20:44:19 +00:00
parent 4155427f03
commit 231cd1653f

489
server.py
View File

@@ -218,200 +218,71 @@ def sign_activity(activity: dict, username: str) -> dict:
# ============ HTML Templates ============
# Tailwind CSS config for L2 - dark theme to match L1
TAILWIND_CONFIG = '''
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
dark: { 900: '#0a0a0a', 800: '#111', 700: '#1a1a1a', 600: '#222', 500: '#333' }
}
}
}
}
</script>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
'''
def base_html(title: str, content: str, username: str = None) -> str:
"""Base HTML template."""
"""Base HTML template with Tailwind CSS dark theme."""
user_section = f'''
<div class="user-info">
Logged in as <strong>{username}</strong>
<button hx-post="/ui/logout" hx-swap="innerHTML" hx-target="body">Logout</button>
<div class="flex items-center gap-4 text-sm text-gray-400">
Logged in as <strong class="text-white">{username}</strong>
<button hx-post="/ui/logout" hx-swap="innerHTML" hx-target="body"
class="px-3 py-1 bg-dark-500 hover:bg-dark-600 rounded text-blue-400 hover:text-blue-300 transition-colors">
Logout
</button>
</div>
''' if username else '''
<div class="auth-links">
<a href="/ui/login">Login</a> | <a href="/ui/register">Register</a>
<div class="flex items-center gap-4 text-sm">
<a href="/ui/login" class="text-blue-400 hover:text-blue-300">Login</a>
<span class="text-gray-500">|</span>
<a href="/ui/register" class="text-blue-400 hover:text-blue-300">Register</a>
</div>
'''
return f'''<!DOCTYPE html>
<html lang="en">
<html lang="en" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title} - Art DAG L2</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<style>
* {{ box-sizing: border-box; }}
body {{
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
max-width: 900px;
margin: 0 auto;
padding: 20px;
background: #f5f5f5;
color: #333;
}}
header {{
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
border-bottom: 2px solid #333;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 10px;
}}
header h1 {{
margin: 0;
font-size: 1.5rem;
}}
header h1 a {{
color: inherit;
text-decoration: none;
}}
.user-info, .auth-links {{
font-size: 0.9rem;
}}
.auth-links a {{
color: #0066cc;
}}
.user-info button {{
margin-left: 10px;
padding: 5px 10px;
cursor: pointer;
}}
nav {{
margin-bottom: 20px;
padding: 10px 0;
border-bottom: 1px solid #ddd;
}}
nav a {{
margin-right: 15px;
color: #0066cc;
text-decoration: none;
}}
nav a:hover {{
text-decoration: underline;
}}
main {{
background: white;
padding: 20px;
border-radius: 5px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}}
.form-group {{
margin-bottom: 15px;
}}
.form-group label {{
display: block;
margin-bottom: 5px;
font-weight: bold;
}}
.form-group input {{
width: 100%;
max-width: 300px;
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 1rem;
}}
button[type="submit"], .btn {{
background: #0066cc;
color: white;
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}}
button[type="submit"]:hover, .btn:hover {{
background: #0055aa;
}}
.error {{
color: #cc0000;
padding: 10px;
background: #ffeeee;
border-radius: 4px;
margin-bottom: 15px;
}}
.success {{
color: #006600;
padding: 10px;
background: #eeffee;
border-radius: 4px;
margin-bottom: 15px;
}}
/* README styling */
.readme h1 {{ font-size: 1.8rem; margin-top: 0; }}
.readme h2 {{ font-size: 1.4rem; margin-top: 1.5rem; border-bottom: 1px solid #eee; padding-bottom: 5px; }}
.readme h3 {{ font-size: 1.1rem; }}
.readme pre {{
background: #f4f4f4;
padding: 10px;
border-radius: 4px;
overflow-x: auto;
}}
.readme code {{
background: #f4f4f4;
padding: 2px 5px;
border-radius: 3px;
font-family: monospace;
}}
.readme pre code {{
background: none;
padding: 0;
}}
.readme table {{
border-collapse: collapse;
width: 100%;
margin: 1rem 0;
}}
.readme th, .readme td {{
border: 1px solid #ddd;
padding: 8px;
text-align: left;
}}
.readme th {{
background: #f4f4f4;
}}
.stats {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 15px;
margin-bottom: 20px;
}}
.stat-box {{
background: #f9f9f9;
padding: 15px;
border-radius: 5px;
text-align: center;
}}
.stat-box .number {{
font-size: 2rem;
font-weight: bold;
color: #0066cc;
}}
.stat-box .label {{
color: #666;
font-size: 0.9rem;
}}
@media (max-width: 600px) {{
body {{ padding: 10px; }}
header {{ flex-direction: column; align-items: flex-start; }}
}}
</style>
{TAILWIND_CONFIG}
</head>
<body>
<header>
<h1><a href="/ui">Art DAG L2</a></h1>
{user_section}
</header>
<nav>
<a href="/ui">Home</a>
<a href="/ui/registry">Registry</a>
<a href="/ui/activities">Activities</a>
<a href="/ui/users">Users</a>
</nav>
<main>
{content}
</main>
<body class="bg-dark-900 text-gray-100 min-h-screen">
<div class="max-w-5xl mx-auto px-4 py-6 sm:px-6 lg:px-8">
<header class="flex flex-wrap items-center justify-between gap-4 mb-6 pb-4 border-b border-dark-500">
<h1 class="text-2xl font-bold">
<a href="/ui" class="text-white hover:text-gray-200">Art DAG L2</a>
</h1>
{user_section}
</header>
<nav class="flex flex-wrap gap-6 mb-6 pb-4 border-b border-dark-500">
<a href="/ui" class="text-gray-400 hover:text-white transition-colors">Home</a>
<a href="/ui/registry" class="text-gray-400 hover:text-white transition-colors">Registry</a>
<a href="/ui/activities" class="text-gray-400 hover:text-white transition-colors">Activities</a>
<a href="/ui/users" class="text-gray-400 hover:text-white transition-colors">Users</a>
</nav>
<main class="bg-dark-700 rounded-lg p-6">
{content}
</main>
</div>
</body>
</html>'''
@@ -437,21 +308,30 @@ async def ui_home(request: Request):
readme_html = markdown.markdown(README_CONTENT, extensions=['tables', 'fenced_code'])
content = f'''
<div class="stats">
<div class="stat-box">
<div class="number">{len(registry.get("assets", {}))}</div>
<div class="label">Assets</div>
<div class="grid gap-4 sm:grid-cols-3 mb-8">
<div class="bg-dark-600 rounded-lg p-6 text-center">
<div class="text-3xl font-bold text-blue-400">{len(registry.get("assets", {}))}</div>
<div class="text-sm text-gray-400 mt-1">Assets</div>
</div>
<div class="stat-box">
<div class="number">{len(activities)}</div>
<div class="label">Activities</div>
<div class="bg-dark-600 rounded-lg p-6 text-center">
<div class="text-3xl font-bold text-blue-400">{len(activities)}</div>
<div class="text-sm text-gray-400 mt-1">Activities</div>
</div>
<div class="stat-box">
<div class="number">{len(users)}</div>
<div class="label">Users</div>
<div class="bg-dark-600 rounded-lg p-6 text-center">
<div class="text-3xl font-bold text-blue-400">{len(users)}</div>
<div class="text-sm text-gray-400 mt-1">Users</div>
</div>
</div>
<div class="readme">
<div class="prose prose-invert max-w-none
prose-headings:text-white prose-headings:border-b prose-headings:border-dark-500 prose-headings:pb-2
prose-h1:text-2xl prose-h1:mt-0
prose-h2:text-xl prose-h2:mt-6
prose-a:text-blue-400 hover:prose-a:text-blue-300
prose-pre:bg-dark-600 prose-pre:border prose-pre:border-dark-500
prose-code:bg-dark-600 prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-code:text-green-300
prose-table:border-collapse
prose-th:bg-dark-600 prose-th:border prose-th:border-dark-500 prose-th:px-4 prose-th:py-2
prose-td:border prose-td:border-dark-500 prose-td:px-4 prose-td:py-2">
{readme_html}
</div>
'''
@@ -464,25 +344,31 @@ async def ui_login_page(request: Request):
username = get_user_from_cookie(request)
if username:
return HTMLResponse(base_html("Already Logged In", f'''
<div class="success">You are already logged in as <strong>{username}</strong></div>
<p><a href="/ui">Go to home page</a></p>
<div class="bg-green-900/50 border border-green-700 text-green-300 px-4 py-3 rounded-lg mb-4">
You are already logged in as <strong>{username}</strong>
</div>
<p><a href="/ui" class="text-blue-400 hover:text-blue-300">Go to home page</a></p>
''', username))
content = '''
<h2>Login</h2>
<h2 class="text-xl font-semibold text-white mb-6">Login</h2>
<div id="login-result"></div>
<form hx-post="/ui/login" hx-target="#login-result" hx-swap="innerHTML">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" required>
<form hx-post="/ui/login" hx-target="#login-result" hx-swap="innerHTML" class="space-y-4 max-w-md">
<div>
<label for="username" class="block text-sm font-medium text-gray-300 mb-2">Username</label>
<input type="text" id="username" name="username" required
class="w-full px-4 py-3 bg-dark-600 border border-dark-500 rounded-lg text-white placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required>
<div>
<label for="password" class="block text-sm font-medium text-gray-300 mb-2">Password</label>
<input type="password" id="password" name="password" required
class="w-full px-4 py-3 bg-dark-600 border border-dark-500 rounded-lg text-white placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
</div>
<button type="submit">Login</button>
<button type="submit" class="w-full px-4 py-3 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors">
Login
</button>
</form>
<p style="margin-top: 20px;">Don't have an account? <a href="/ui/register">Register</a></p>
<p class="mt-6 text-gray-400">Don't have an account? <a href="/ui/register" class="text-blue-400 hover:text-blue-300">Register</a></p>
'''
return HTMLResponse(base_html("Login", content))
@@ -495,16 +381,16 @@ async def ui_login_submit(request: Request):
password = form.get("password", "")
if not username or not password:
return HTMLResponse('<div class="error">Username and password are required</div>')
return HTMLResponse('<div class="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg mb-4">Username and password are required</div>')
user = authenticate_user(DATA_DIR, username, password)
if not user:
return HTMLResponse('<div class="error">Invalid username or password</div>')
return HTMLResponse('<div class="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg mb-4">Invalid username or password</div>')
token = create_access_token(user.username)
response = HTMLResponse(f'''
<div class="success">Login successful! Redirecting...</div>
<div class="bg-green-900/50 border border-green-700 text-green-300 px-4 py-3 rounded-lg mb-4">Login successful! Redirecting...</div>
<script>window.location.href = "/ui";</script>
''')
response.set_cookie(
@@ -523,33 +409,41 @@ async def ui_register_page(request: Request):
username = get_user_from_cookie(request)
if username:
return HTMLResponse(base_html("Already Logged In", f'''
<div class="success">You are already logged in as <strong>{username}</strong></div>
<p><a href="/ui">Go to home page</a></p>
<div class="bg-green-900/50 border border-green-700 text-green-300 px-4 py-3 rounded-lg mb-4">
You are already logged in as <strong>{username}</strong>
</div>
<p><a href="/ui" class="text-blue-400 hover:text-blue-300">Go to home page</a></p>
''', username))
content = '''
<h2>Register</h2>
<h2 class="text-xl font-semibold text-white mb-6">Register</h2>
<div id="register-result"></div>
<form hx-post="/ui/register" hx-target="#register-result" hx-swap="innerHTML">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" required pattern="[a-zA-Z0-9_-]+" title="Letters, numbers, underscore, dash only">
<form hx-post="/ui/register" hx-target="#register-result" hx-swap="innerHTML" class="space-y-4 max-w-md">
<div>
<label for="username" class="block text-sm font-medium text-gray-300 mb-2">Username</label>
<input type="text" id="username" name="username" required pattern="[a-zA-Z0-9_-]+" title="Letters, numbers, underscore, dash only"
class="w-full px-4 py-3 bg-dark-600 border border-dark-500 rounded-lg text-white placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
</div>
<div class="form-group">
<label for="email">Email (optional)</label>
<input type="email" id="email" name="email">
<div>
<label for="email" class="block text-sm font-medium text-gray-300 mb-2">Email (optional)</label>
<input type="email" id="email" name="email"
class="w-full px-4 py-3 bg-dark-600 border border-dark-500 rounded-lg text-white placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required minlength="6">
<div>
<label for="password" class="block text-sm font-medium text-gray-300 mb-2">Password</label>
<input type="password" id="password" name="password" required minlength="6"
class="w-full px-4 py-3 bg-dark-600 border border-dark-500 rounded-lg text-white placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
</div>
<div class="form-group">
<label for="password2">Confirm Password</label>
<input type="password" id="password2" name="password2" required minlength="6">
<div>
<label for="password2" class="block text-sm font-medium text-gray-300 mb-2">Confirm Password</label>
<input type="password" id="password2" name="password2" required minlength="6"
class="w-full px-4 py-3 bg-dark-600 border border-dark-500 rounded-lg text-white placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
</div>
<button type="submit">Register</button>
<button type="submit" class="w-full px-4 py-3 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors">
Register
</button>
</form>
<p style="margin-top: 20px;">Already have an account? <a href="/ui/login">Login</a></p>
<p class="mt-6 text-gray-400">Already have an account? <a href="/ui/login" class="text-blue-400 hover:text-blue-300">Login</a></p>
'''
return HTMLResponse(base_html("Register", content))
@@ -564,23 +458,23 @@ async def ui_register_submit(request: Request):
password2 = form.get("password2", "")
if not username or not password:
return HTMLResponse('<div class="error">Username and password are required</div>')
return HTMLResponse('<div class="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg mb-4">Username and password are required</div>')
if password != password2:
return HTMLResponse('<div class="error">Passwords do not match</div>')
return HTMLResponse('<div class="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg mb-4">Passwords do not match</div>')
if len(password) < 6:
return HTMLResponse('<div class="error">Password must be at least 6 characters</div>')
return HTMLResponse('<div class="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg mb-4">Password must be at least 6 characters</div>')
try:
user = create_user(DATA_DIR, username, password, email)
except ValueError as e:
return HTMLResponse(f'<div class="error">{str(e)}</div>')
return HTMLResponse(f'<div class="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg mb-4">{str(e)}</div>')
token = create_access_token(user.username)
response = HTMLResponse(f'''
<div class="success">Registration successful! Redirecting...</div>
<div class="bg-green-900/50 border border-green-700 text-green-300 px-4 py-3 rounded-lg mb-4">Registration successful! Redirecting...</div>
<script>window.location.href = "/ui";</script>
''')
response.set_cookie(
@@ -611,34 +505,39 @@ async def ui_registry_page(request: Request):
assets = registry.get("assets", {})
if not assets:
content = '<h2>Registry</h2><p>No assets registered yet.</p>'
content = '''
<h2 class="text-xl font-semibold text-white mb-4">Registry</h2>
<p class="text-gray-400">No assets registered yet.</p>
'''
else:
rows = ""
for name, asset in sorted(assets.items(), key=lambda x: x[1].get("created_at", ""), reverse=True):
hash_short = asset.get("content_hash", "")[:16] + "..."
rows += f'''
<tr>
<td><strong>{name}</strong></td>
<td>{asset.get("asset_type", "")}</td>
<td><code>{hash_short}</code></td>
<td>{", ".join(asset.get("tags", []))}</td>
<tr class="border-b border-dark-500">
<td class="py-3 px-4"><strong class="text-white">{name}</strong></td>
<td class="py-3 px-4 text-gray-400">{asset.get("asset_type", "")}</td>
<td class="py-3 px-4"><code class="text-xs bg-dark-600 px-2 py-1 rounded text-green-300">{hash_short}</code></td>
<td class="py-3 px-4 text-gray-400">{", ".join(asset.get("tags", []))}</td>
</tr>
'''
content = f'''
<h2>Registry ({len(assets)} assets)</h2>
<table>
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Content Hash</th>
<th>Tags</th>
</tr>
</thead>
<tbody>
{rows}
</tbody>
</table>
<h2 class="text-xl font-semibold text-white mb-6">Registry ({len(assets)} assets)</h2>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="bg-dark-600 text-left">
<th class="py-3 px-4 font-medium text-gray-300">Name</th>
<th class="py-3 px-4 font-medium text-gray-300">Type</th>
<th class="py-3 px-4 font-medium text-gray-300">Content Hash</th>
<th class="py-3 px-4 font-medium text-gray-300">Tags</th>
</tr>
</thead>
<tbody>
{rows}
</tbody>
</table>
</div>
'''
return HTMLResponse(base_html("Registry", content, username))
@@ -650,34 +549,41 @@ async def ui_activities_page(request: Request):
activities = load_activities()
if not activities:
content = '<h2>Activities</h2><p>No activities yet.</p>'
content = '''
<h2 class="text-xl font-semibold text-white mb-4">Activities</h2>
<p class="text-gray-400">No activities yet.</p>
'''
else:
rows = ""
for activity in reversed(activities):
obj = activity.get("object_data", {})
activity_type = activity.get("activity_type", "")
type_color = "bg-green-600" if activity_type == "Create" else "bg-yellow-600" if activity_type == "Update" else "bg-gray-600"
rows += f'''
<tr>
<td>{activity.get("activity_type", "")}</td>
<td>{obj.get("name", "")}</td>
<td><code>{obj.get("contentHash", {}).get("value", "")[:16]}...</code></td>
<td>{activity.get("published", "")[:10]}</td>
<tr class="border-b border-dark-500">
<td class="py-3 px-4"><span class="px-2 py-1 {type_color} text-white text-xs font-medium rounded">{activity_type}</span></td>
<td class="py-3 px-4 text-white">{obj.get("name", "")}</td>
<td class="py-3 px-4"><code class="text-xs bg-dark-600 px-2 py-1 rounded text-green-300">{obj.get("contentHash", {}).get("value", "")[:16]}...</code></td>
<td class="py-3 px-4 text-gray-400">{activity.get("published", "")[:10]}</td>
</tr>
'''
content = f'''
<h2>Activities ({len(activities)} total)</h2>
<table>
<thead>
<tr>
<th>Type</th>
<th>Object</th>
<th>Content Hash</th>
<th>Published</th>
</tr>
</thead>
<tbody>
{rows}
</tbody>
</table>
<h2 class="text-xl font-semibold text-white mb-6">Activities ({len(activities)} total)</h2>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="bg-dark-600 text-left">
<th class="py-3 px-4 font-medium text-gray-300">Type</th>
<th class="py-3 px-4 font-medium text-gray-300">Object</th>
<th class="py-3 px-4 font-medium text-gray-300">Content Hash</th>
<th class="py-3 px-4 font-medium text-gray-300">Published</th>
</tr>
</thead>
<tbody>
{rows}
</tbody>
</table>
</div>
'''
return HTMLResponse(base_html("Activities", content, username))
@@ -689,34 +595,39 @@ async def ui_users_page(request: Request):
users = load_users(DATA_DIR)
if not users:
content = '<h2>Users</h2><p>No users registered yet.</p>'
content = '''
<h2 class="text-xl font-semibold text-white mb-4">Users</h2>
<p class="text-gray-400">No users registered yet.</p>
'''
else:
rows = ""
for username, user_data in sorted(users.items()):
actor_url = f"https://{DOMAIN}/users/{username}"
webfinger = f"@{username}@{DOMAIN}"
rows += f'''
<tr>
<td><a href="{actor_url}" target="_blank"><strong>{username}</strong></a></td>
<td><code>{webfinger}</code></td>
<td>{user_data.get("created_at", "")[:10]}</td>
<tr class="border-b border-dark-500">
<td class="py-3 px-4"><a href="{actor_url}" target="_blank" class="text-blue-400 hover:text-blue-300 font-medium">{username}</a></td>
<td class="py-3 px-4"><code class="text-xs bg-dark-600 px-2 py-1 rounded text-gray-300">{webfinger}</code></td>
<td class="py-3 px-4 text-gray-400">{user_data.get("created_at", "")[:10]}</td>
</tr>
'''
content = f'''
<h2>Users ({len(users)} registered)</h2>
<p>Each user has their own ActivityPub actor that can be followed from Mastodon and other federated platforms.</p>
<table>
<thead>
<tr>
<th>Username</th>
<th>ActivityPub Handle</th>
<th>Registered</th>
</tr>
</thead>
<tbody>
{rows}
</tbody>
</table>
<h2 class="text-xl font-semibold text-white mb-4">Users ({len(users)} registered)</h2>
<p class="text-gray-400 mb-6">Each user has their own ActivityPub actor that can be followed from Mastodon and other federated platforms.</p>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="bg-dark-600 text-left">
<th class="py-3 px-4 font-medium text-gray-300">Username</th>
<th class="py-3 px-4 font-medium text-gray-300">ActivityPub Handle</th>
<th class="py-3 px-4 font-medium text-gray-300">Registered</th>
</tr>
</thead>
<tbody>
{rows}
</tbody>
</table>
</div>
'''
return HTMLResponse(base_html("Users", content, current_user))