flask_apps/server.py

303 lines
9 KiB
Python

import importlib.util
import sys
from pathlib import Path
from flask import Flask, render_template_string
from werkzeug.middleware.dispatcher import DispatcherMiddleware
from werkzeug.serving import run_simple
sys.path.insert(0, str(Path(__file__).parent))
CATEGORIES = ["personal", "home", "work"]
root = Flask(__name__)
def _discover_apps():
apps = []
base = Path(__file__).parent
for category in CATEGORIES:
cat_dir = base / category
if not cat_dir.is_dir():
continue
for app_dir in sorted(cat_dir.iterdir()):
if app_dir.is_dir() and (app_dir / "app.py").exists():
apps.append((category, app_dir.name))
return apps
@root.route("/")
def index():
apps = _discover_apps()
grouped: dict[str, list[tuple[str, str]]] = {}
for category, app_name in apps:
grouped.setdefault(category, []).append((category, app_name))
sections = ""
delay = 0
for category in CATEGORIES:
if category not in grouped:
continue
items_html = ""
for cat, name in grouped[category]:
items_html += (
f'<a href="/{cat}/{name}" class="app-item" style="animation-delay:{delay * 50}ms">'
f'<span class="app-arrow">\u203a</span>'
f'<div class="app-name">{name.replace("_", " ").replace("-", " ").title()}</div>'
f'<div class="app-path">/{cat}/{name}</div>'
f"</a>"
)
delay += 1
sections += f"""
<div class="category">
<h2 class="category-title">{category.title()}</h2>
<div class="app-list">{items_html}</div>
</div>"""
return render_template_string(f"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tools</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
*, *::before, *::after {{ box-sizing: border-box; margin: 0; padding: 0; }}
:root {{
--bg: #0f1117;
--surface: #1a1d27;
--surface-hover: #242836;
--border: rgba(255,255,255,0.08);
--text: #e4e6ed;
--text-muted: #8b8fa3;
--accent: #6c5ce7;
--accent-glow: rgba(108,92,231,0.25);
--accent-light: #a29bfe;
--success: #00cec9;
--radius: 12px;
}}
body {{
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100dvh;
line-height: 1.5;
}}
.container {{
max-width: 720px;
margin: 0 auto;
padding: 24px 16px 48px;
}}
.header {{
text-align: center;
margin-bottom: 28px;
}}
.header h1 {{
font-size: 1.5rem;
font-weight: 700;
letter-spacing: -0.02em;
margin-bottom: 4px;
background: linear-gradient(135deg, var(--accent-light), var(--success));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}}
.header .subtitle {{
font-size: 0.82rem;
color: var(--text-muted);
}}
.search-wrap {{
position: relative;
margin-bottom: 24px;
}}
.search-wrap input {{
width: 100%;
padding: 12px 16px 12px 42px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-family: inherit;
font-size: 0.92rem;
outline: none;
transition: border-color 0.2s;
}}
.search-wrap input:focus {{
border-color: rgba(108,92,231,0.5);
}}
.search-wrap input::placeholder {{
color: var(--text-muted);
}}
.search-wrap svg {{
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
color: var(--text-muted);
pointer-events: none;
}}
.category {{
margin-bottom: 28px;
}}
.category-title {{
font-size: 0.82rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 10px;
padding-left: 4px;
}}
.app-list {{
display: flex;
flex-direction: column;
gap: 6px;
}}
.app-item {{
display: block;
padding: 14px 16px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
text-decoration: none;
transition: background 0.2s, border-color 0.2s, transform 0.15s;
opacity: 0;
animation: fadeSlideIn 0.3s ease forwards;
}}
.app-item:hover {{
background: var(--surface-hover);
border-color: rgba(108,92,231,0.3);
transform: translateX(3px);
}}
.app-item:active {{
transform: translateX(1px);
}}
.app-name {{
font-size: 0.92rem;
font-weight: 500;
color: var(--text);
}}
.app-path {{
margin-top: 2px;
font-size: 0.78rem;
color: var(--text-muted);
}}
.app-arrow {{
float: right;
color: var(--text-muted);
font-size: 0.8rem;
opacity: 0;
transition: opacity 0.2s;
}}
.app-item:hover .app-arrow {{ opacity: 1; }}
@keyframes fadeSlideIn {{
from {{ opacity: 0; transform: translateY(8px); }}
to {{ opacity: 1; transform: translateY(0); }}
}}
@media (max-width: 400px) {{
.container {{ padding: 16px 12px 40px; }}
.header h1 {{ font-size: 1.25rem; }}
.app-item {{ padding: 12px 14px; }}
}}
</style>
</head>
<body>
<div class="container">
<header class="header">
<h1>Tools</h1>
<p class="subtitle">Flask applications</p>
</header>
<div class="search-wrap">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" id="search" placeholder="Filter tools..." autocomplete="off" autofocus>
</div>
{sections}
</div>
<script>
const input = document.getElementById('search');
const items = document.querySelectorAll('.app-item');
const categories = document.querySelectorAll('.category');
window.addEventListener('pageshow', () => {{
input.value = '';
categories.forEach(cat => {{
cat.style.display = '';
cat.querySelectorAll('.app-item').forEach(item => item.style.display = '');
}});
input.focus();
}});
input.addEventListener('input', () => {{
const q = input.value.toLowerCase().trim();
let visible = [];
categories.forEach(cat => {{
let catHasVisible = false;
cat.querySelectorAll('.app-item').forEach(item => {{
const name = item.querySelector('.app-name').textContent.toLowerCase();
const show = !q || name.includes(q);
item.style.display = show ? '' : 'none';
if (show) catHasVisible = true, visible.push(item);
}});
cat.style.display = catHasVisible ? '' : 'none';
}});
if (visible.length === 1) {{
window.location.href = visible[0].getAttribute('href');
}}
}});
</script>
</body>
</html>
""")
def load_sub_apps():
mounts = {}
base = Path(__file__).parent
for category in CATEGORIES:
cat_dir = base / category
if not cat_dir.is_dir():
continue
for app_dir in sorted(cat_dir.iterdir()):
app_file = app_dir / "app.py"
if not app_dir.is_dir() or not app_file.exists():
continue
module_name = f"{category}.{app_dir.name}"
spec = importlib.util.spec_from_file_location(module_name, app_file)
mod = importlib.util.module_from_spec(spec)
sys.modules[module_name] = mod
spec.loader.exec_module(mod)
mounts[f"/{category}/{app_dir.name}"] = mod.app
print(f" mounted /{category}/{app_dir.name}")
return mounts
application = DispatcherMiddleware(root, load_sub_apps())
if __name__ == "__main__":
run_simple("0.0.0.0", 8080, application, use_reloader=False)