iurbinah commited on
Commit
e2c12e7
·
0 Parent(s):

Initial lab-setup portal: Flask + dark theme + oTree session page

Browse files
Files changed (10) hide show
  1. .env +2 -0
  2. Dockerfile +10 -0
  3. Makefile +3 -0
  4. README.md +9 -0
  5. app.py +101 -0
  6. requirements.txt +3 -0
  7. static/favicon.svg +4 -0
  8. templates/base.html +182 -0
  9. templates/login.html +112 -0
  10. templates/pages/session.html +110 -0
.env ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ OTREE_SESSION_URL=http://otree-lab-games-790d4693d333.herokuapp.com/room/bpel_lab
2
+ ADMIN_PASSWORD=bpel123
Dockerfile ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+ COPY requirements.txt .
5
+ RUN pip install --no-cache-dir -r requirements.txt
6
+
7
+ COPY . .
8
+ EXPOSE 7860
9
+
10
+ CMD ["gunicorn", "--bind", "0.0.0.0:7860", "--workers", "2", "app:app"]
Makefile ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ .PHONY: run
2
+ run:
3
+ . venv/Scripts/activate && python app.py
README.md ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Lab Setup Portal
3
+ emoji: 🖥️
4
+ colorFrom: indigo
5
+ colorTo: purple
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ ---
app.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Lab-Setup – Multi-page lab portal (Hugging Face Space)
3
+ Stack: Flask · Jinja2 · vanilla JS/CSS · gunicorn
4
+ """
5
+
6
+ import os
7
+ from flask import (
8
+ Flask, render_template, request, redirect,
9
+ url_for, session, flash, jsonify,
10
+ )
11
+ from werkzeug.middleware.proxy_fix import ProxyFix
12
+ from dotenv import load_dotenv
13
+
14
+ load_dotenv()
15
+
16
+ app = Flask(__name__)
17
+ app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1)
18
+
19
+ app.secret_key = os.getenv("FLASK_SECRET", "change-me-in-prod")
20
+ app.config.update(
21
+ SESSION_COOKIE_HTTPONLY=True,
22
+ SESSION_COOKIE_SECURE=True,
23
+ SESSION_COOKIE_SAMESITE="None",
24
+ )
25
+
26
+ ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "bpel123")
27
+
28
+ # ── oTree configuration ────────────────────────────────────────────
29
+ OTREE_SESSION_URL = os.getenv(
30
+ "OTREE_SESSION_URL",
31
+ "http://otree-lab-games-790d4693d333.herokuapp.com/room/bpel_lab",
32
+ )
33
+
34
+ # ─────────────────────────────────────────────────────────────────
35
+ # Sidebar page registry – add entries here to create new pages
36
+ # Each tuple: (endpoint, icon, label)
37
+ # The endpoint must match a route function name.
38
+ # ─────────────────────────────────────────────────────────────────
39
+ SIDEBAR_PAGES = [
40
+ ("page_session", "🖥️", "Session"),
41
+ # ("page_example", "📊", "Example"), # ← uncomment to add pages
42
+ ]
43
+
44
+
45
+ # ── Auth guard ──────────────────────────────────────────────────────
46
+ @app.before_request
47
+ def require_login():
48
+ allowed = ("login", "static")
49
+ if request.endpoint in allowed or (request.endpoint and request.endpoint.startswith("static")):
50
+ return
51
+ if not session.get("authenticated"):
52
+ return redirect(url_for("login"))
53
+
54
+
55
+ # ── Auth routes ─────────────────────────────────────────────────────
56
+ @app.route("/login", methods=["GET", "POST"])
57
+ def login():
58
+ if request.method == "POST":
59
+ if request.form.get("password") == ADMIN_PASSWORD:
60
+ session["authenticated"] = True
61
+ return redirect(url_for("index"))
62
+ flash("Incorrect password.", "error")
63
+ return render_template("login.html")
64
+
65
+
66
+ @app.route("/logout")
67
+ def logout():
68
+ session.clear()
69
+ return redirect(url_for("login"))
70
+
71
+
72
+ # ── Context processor – injects sidebar into every template ─────────
73
+ @app.context_processor
74
+ def inject_sidebar():
75
+ return dict(sidebar_pages=SIDEBAR_PAGES)
76
+
77
+
78
+ # ── Pages ───────────────────────────────────────────────────────────
79
+ @app.route("/")
80
+ def index():
81
+ return redirect(url_for("page_session"))
82
+
83
+
84
+ @app.route("/session")
85
+ def page_session():
86
+ return render_template(
87
+ "pages/session.html",
88
+ active_page="page_session",
89
+ otree_url=OTREE_SESSION_URL,
90
+ )
91
+
92
+
93
+ # ── API helpers (expand as needed) ──────────────────────────────────
94
+ @app.route("/api/health")
95
+ def api_health():
96
+ return jsonify(status="ok")
97
+
98
+
99
+ # ── Dev server ──────────────────────────────────────────────────────
100
+ if __name__ == "__main__":
101
+ app.run(host="0.0.0.0", port=5111, debug=True)
requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ flask==3.1.3
2
+ gunicorn==23.0.0
3
+ python-dotenv==1.1.0
static/favicon.svg ADDED
templates/base.html ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>{% block title %}Lab Portal{% endblock %}</title>
7
+ <link rel="icon" href="{{ url_for('static', filename='favicon.svg') }}" type="image/svg+xml" />
8
+ <style>
9
+ /* ── CSS variables (dark theme – matches csv-viewer) ── */
10
+ :root {
11
+ --bg: #0e0e10;
12
+ --surface: #1a1a2e;
13
+ --surface2: #22223a;
14
+ --border: #2a2a4a;
15
+ --text: #e2e2e8;
16
+ --text-dim: #9090a8;
17
+ --accent: #6c63ff;
18
+ --accent-h: #8b83ff;
19
+ --danger: #ff6b6b;
20
+ --success: #51cf66;
21
+ --sidebar-w: 220px;
22
+ --topbar-h: 0px;
23
+ }
24
+
25
+ /* ── Reset ── */
26
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
27
+ html, body { height: 100%; }
28
+ body {
29
+ font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
30
+ background: var(--bg);
31
+ color: var(--text);
32
+ display: flex;
33
+ }
34
+
35
+ /* ── Sidebar ── */
36
+ .sidebar {
37
+ width: var(--sidebar-w);
38
+ min-height: 100vh;
39
+ background: var(--surface);
40
+ border-right: 1px solid var(--border);
41
+ display: flex;
42
+ flex-direction: column;
43
+ padding: 1.25rem 0;
44
+ flex-shrink: 0;
45
+ }
46
+ .sidebar-brand {
47
+ padding: 0 1.25rem 1.25rem;
48
+ font-size: 1.1rem;
49
+ font-weight: 700;
50
+ letter-spacing: .03em;
51
+ color: var(--accent);
52
+ border-bottom: 1px solid var(--border);
53
+ margin-bottom: .75rem;
54
+ }
55
+ .sidebar nav { flex: 1; display: flex; flex-direction: column; gap: 2px; padding: 0 .5rem; }
56
+ .sidebar a {
57
+ display: flex;
58
+ align-items: center;
59
+ gap: .65rem;
60
+ padding: .6rem .85rem;
61
+ border-radius: 8px;
62
+ color: var(--text-dim);
63
+ text-decoration: none;
64
+ font-size: .92rem;
65
+ transition: background .15s, color .15s;
66
+ }
67
+ .sidebar a:hover { background: var(--surface2); color: var(--text); }
68
+ .sidebar a.active { background: var(--accent); color: #fff; }
69
+ .sidebar a .icon { font-size: 1.15rem; }
70
+ .sidebar-footer {
71
+ padding: .75rem 1.25rem 0;
72
+ border-top: 1px solid var(--border);
73
+ margin-top: auto;
74
+ }
75
+ .sidebar-footer a {
76
+ color: var(--text-dim);
77
+ text-decoration: none;
78
+ font-size: .82rem;
79
+ }
80
+ .sidebar-footer a:hover { color: var(--danger); }
81
+
82
+ /* ── Main content ── */
83
+ .main {
84
+ flex: 1;
85
+ display: flex;
86
+ flex-direction: column;
87
+ min-height: 100vh;
88
+ overflow: hidden;
89
+ }
90
+ .page-header {
91
+ padding: 1.25rem 2rem;
92
+ border-bottom: 1px solid var(--border);
93
+ background: var(--surface);
94
+ }
95
+ .page-header h1 {
96
+ font-size: 1.25rem;
97
+ font-weight: 600;
98
+ }
99
+ .page-header p {
100
+ color: var(--text-dim);
101
+ font-size: .85rem;
102
+ margin-top: .25rem;
103
+ }
104
+ .page-body {
105
+ flex: 1;
106
+ padding: 2rem;
107
+ overflow-y: auto;
108
+ }
109
+
110
+ /* ── Utility classes ── */
111
+ .card {
112
+ background: var(--surface);
113
+ border: 1px solid var(--border);
114
+ border-radius: 12px;
115
+ padding: 1.5rem;
116
+ }
117
+ .btn {
118
+ display: inline-flex;
119
+ align-items: center;
120
+ gap: .5rem;
121
+ padding: .55rem 1.3rem;
122
+ border-radius: 8px;
123
+ font-size: .9rem;
124
+ font-weight: 600;
125
+ border: none;
126
+ cursor: pointer;
127
+ text-decoration: none;
128
+ transition: background .15s, transform .1s;
129
+ }
130
+ .btn:active { transform: scale(.97); }
131
+ .btn-primary { background: var(--accent); color: #fff; }
132
+ .btn-primary:hover { background: var(--accent-h); }
133
+ .btn-outline {
134
+ background: transparent;
135
+ border: 1px solid var(--border);
136
+ color: var(--text);
137
+ }
138
+ .btn-outline:hover { border-color: var(--accent); color: var(--accent); }
139
+
140
+ /* ── Responsive ── */
141
+ @media (max-width: 768px) {
142
+ .sidebar { width: 56px; }
143
+ .sidebar-brand, .sidebar a span, .sidebar-footer span { display: none; }
144
+ .sidebar a { justify-content: center; padding: .6rem; }
145
+ .page-body { padding: 1rem; }
146
+ }
147
+
148
+ /* ── Block: page-specific CSS injected here ── */
149
+ {% block extra_css %}{% endblock %}
150
+ </style>
151
+ </head>
152
+ <body>
153
+
154
+ <!-- Sidebar -->
155
+ <aside class="sidebar">
156
+ <div class="sidebar-brand">Lab Portal</div>
157
+ <nav>
158
+ {% for endpoint, icon, label in sidebar_pages %}
159
+ <a href="{{ url_for(endpoint) }}"
160
+ class="{{ 'active' if active_page == endpoint else '' }}">
161
+ <span class="icon">{{ icon }}</span>
162
+ <span>{{ label }}</span>
163
+ </a>
164
+ {% endfor %}
165
+ </nav>
166
+ <div class="sidebar-footer">
167
+ <a href="{{ url_for('logout') }}"><span>Sign out</span></a>
168
+ </div>
169
+ </aside>
170
+
171
+ <!-- Main content area -->
172
+ <div class="main">
173
+ {% block header %}{% endblock %}
174
+
175
+ <div class="page-body">
176
+ {% block content %}{% endblock %}
177
+ </div>
178
+ </div>
179
+
180
+ {% block extra_js %}{% endblock %}
181
+ </body>
182
+ </html>
templates/login.html ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Lab Portal — Login</title>
7
+ <link rel="icon" href="{{ url_for('static', filename='favicon.svg') }}" type="image/svg+xml" />
8
+ <style>
9
+ :root {
10
+ --bg: #0e0e10;
11
+ --surface: #1a1a2e;
12
+ --border: #2a2a4a;
13
+ --text: #e2e2e8;
14
+ --text-dim: #9090a8;
15
+ --accent: #6c63ff;
16
+ --accent-h: #8b83ff;
17
+ --danger: #ff6b6b;
18
+ }
19
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
20
+ html, body { height: 100%; }
21
+ body {
22
+ font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
23
+ background: var(--bg);
24
+ color: var(--text);
25
+ display: flex;
26
+ align-items: center;
27
+ justify-content: center;
28
+ }
29
+ .login-card {
30
+ background: var(--surface);
31
+ border: 1px solid var(--border);
32
+ border-radius: 16px;
33
+ padding: 2.5rem 2rem;
34
+ width: 100%;
35
+ max-width: 380px;
36
+ text-align: center;
37
+ }
38
+ .login-card h1 {
39
+ font-size: 1.4rem;
40
+ margin-bottom: .35rem;
41
+ }
42
+ .login-card p {
43
+ color: var(--text-dim);
44
+ font-size: .85rem;
45
+ margin-bottom: 1.75rem;
46
+ }
47
+ .field { margin-bottom: 1.25rem; text-align: left; }
48
+ .field label {
49
+ display: block;
50
+ font-size: .82rem;
51
+ color: var(--text-dim);
52
+ margin-bottom: .35rem;
53
+ }
54
+ .field input {
55
+ width: 100%;
56
+ padding: .6rem .85rem;
57
+ border-radius: 8px;
58
+ border: 1px solid var(--border);
59
+ background: var(--bg);
60
+ color: var(--text);
61
+ font-size: .95rem;
62
+ outline: none;
63
+ transition: border-color .15s;
64
+ }
65
+ .field input:focus { border-color: var(--accent); }
66
+ .btn {
67
+ width: 100%;
68
+ padding: .65rem;
69
+ border-radius: 8px;
70
+ border: none;
71
+ background: var(--accent);
72
+ color: #fff;
73
+ font-size: .95rem;
74
+ font-weight: 600;
75
+ cursor: pointer;
76
+ transition: background .15s;
77
+ }
78
+ .btn:hover { background: var(--accent-h); }
79
+ .flash {
80
+ background: rgba(255,107,107,.12);
81
+ border: 1px solid var(--danger);
82
+ color: var(--danger);
83
+ padding: .5rem .75rem;
84
+ border-radius: 8px;
85
+ font-size: .85rem;
86
+ margin-bottom: 1rem;
87
+ }
88
+ </style>
89
+ </head>
90
+ <body>
91
+ <div class="login-card">
92
+ <h1>Lab Portal</h1>
93
+ <p>Enter the supervisor password to continue.</p>
94
+
95
+ {% with messages = get_flashed_messages() %}
96
+ {% if messages %}
97
+ {% for msg in messages %}
98
+ <div class="flash">{{ msg }}</div>
99
+ {% endfor %}
100
+ {% endif %}
101
+ {% endwith %}
102
+
103
+ <form method="POST" action="{{ url_for('login') }}">
104
+ <div class="field">
105
+ <label for="password">Password</label>
106
+ <input type="password" id="password" name="password" autofocus required />
107
+ </div>
108
+ <button class="btn" type="submit">Sign in</button>
109
+ </form>
110
+ </div>
111
+ </body>
112
+ </html>
templates/pages/session.html ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}Session — Lab Portal{% endblock %}
4
+
5
+ {% block header %}
6
+ <div class="page-header">
7
+ <h1>Experiment Session</h1>
8
+ <p>Join the current oTree session from this machine.</p>
9
+ </div>
10
+ {% endblock %}
11
+
12
+ {% block extra_css %}
13
+ <style>
14
+ .session-grid {
15
+ display: grid;
16
+ grid-template-columns: 1fr 1fr;
17
+ gap: 1.5rem;
18
+ margin-bottom: 1.5rem;
19
+ }
20
+ @media (max-width: 900px) { .session-grid { grid-template-columns: 1fr; } }
21
+
22
+ .session-link-card {
23
+ grid-column: 1 / -1;
24
+ }
25
+ .session-link-card .url-bar {
26
+ display: flex;
27
+ align-items: center;
28
+ gap: .75rem;
29
+ margin-top: 1rem;
30
+ }
31
+ .url-bar code {
32
+ flex: 1;
33
+ padding: .55rem .85rem;
34
+ background: var(--bg);
35
+ border: 1px solid var(--border);
36
+ border-radius: 8px;
37
+ font-size: .88rem;
38
+ color: var(--accent);
39
+ word-break: break-all;
40
+ }
41
+ .url-bar .btn { white-space: nowrap; }
42
+
43
+ .iframe-wrap {
44
+ margin-top: 1.5rem;
45
+ border: 1px solid var(--border);
46
+ border-radius: 12px;
47
+ overflow: hidden;
48
+ background: #fff;
49
+ }
50
+ .iframe-wrap iframe {
51
+ width: 100%;
52
+ height: calc(100vh - 260px);
53
+ min-height: 500px;
54
+ border: none;
55
+ display: block;
56
+ }
57
+ .status-dot {
58
+ display: inline-block;
59
+ width: 8px; height: 8px;
60
+ border-radius: 50%;
61
+ background: var(--success);
62
+ margin-right: .4rem;
63
+ vertical-align: middle;
64
+ }
65
+ </style>
66
+ {% endblock %}
67
+
68
+ {% block content %}
69
+ <div class="session-grid">
70
+ <!-- Link card -->
71
+ <div class="card session-link-card">
72
+ <h2 style="font-size:1rem; margin-bottom:.25rem;">
73
+ <span class="status-dot"></span> oTree Room Link
74
+ </h2>
75
+ <p style="color:var(--text-dim); font-size:.84rem;">
76
+ Click <strong>Open</strong> to join in a new tab, or use the embedded
77
+ view below.
78
+ </p>
79
+ <div class="url-bar">
80
+ <code id="otree-url">{{ otree_url }}</code>
81
+ <button class="btn btn-outline" onclick="copyUrl()">Copy</button>
82
+ <a class="btn btn-primary" href="{{ otree_url }}" target="_blank" rel="noopener">Open</a>
83
+ </div>
84
+ </div>
85
+ </div>
86
+
87
+ <!-- Embedded oTree view -->
88
+ <div class="iframe-wrap">
89
+ <iframe
90
+ id="otree-frame"
91
+ src="{{ otree_url }}"
92
+ loading="lazy"
93
+ sandbox="allow-same-origin allow-scripts allow-forms allow-popups"
94
+ title="oTree session"
95
+ ></iframe>
96
+ </div>
97
+ {% endblock %}
98
+
99
+ {% block extra_js %}
100
+ <script>
101
+ function copyUrl() {
102
+ const url = document.getElementById("otree-url").textContent;
103
+ navigator.clipboard.writeText(url).then(() => {
104
+ const btn = event.currentTarget;
105
+ btn.textContent = "Copied!";
106
+ setTimeout(() => btn.textContent = "Copy", 1500);
107
+ });
108
+ }
109
+ </script>
110
+ {% endblock %}