292 lines
8.7 KiB
Python
292 lines
8.7 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
|
|
|
|
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');
|
|
|
|
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)
|